Never Use This in Java Production
Java is an incredible ecosystem, offering a vast standard library that has evolved over three decades. But this rich history comes with a catch: not everything available in the JDK is suitable for a high-performance backend system. Certain legacy classes, naive patterns, and convenient shortcuts can quickly turn into crippling bottlenecks, bugs, or even OutOfMemoryError incidents when deployed at scale.
Let’s break down the most common “bad habits” seen in Java development. For each case, we will look at why it’s harmful in production, followed by the correct modern approach.
1. Using System.out.println() and e.printStackTrace()
When practicing Java, System.out.println() is every developer’s best friend. But in a backend environment handling hundreds of requests per second, standard output is your worst enemy.
Why it’s a problem:
- Blocking I/O operations: Both
System.outandSystem.errinvolve synchronized, blocking I/O calls. Under heavy load, threads will queue up just to print a message, severely degrading your application’s throughput. - No rotation or management: These logs just dump to the console. You lack the ability to rotate files, manage sizes, send logs to a central system (like ELK), or filter by severity (
INFO,DEBUG,ERROR). - Context loss:
e.printStackTrace()sends the error to standard error without any application context, making it a nightmare to trace what user or process triggered it.
The Correct Way:
Use a robust logging framework like SLF4J coupled with Logback or Log4j2.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PaymentService {
// 1. Initialize the logger
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
public void processPayment(String transactionId) {
// 2. Log important execution events
log.info("Processing payment for transaction: {}", transactionId);
try {
// ... business logic ...
} catch (Exception e) {
// 3. Log errors properly, passing the exception to retain the stack trace
log.error("Failed to process payment for transaction: {}", transactionId, e);
}
}
}
2. Using SimpleDateFormat for Dates
The java.text.SimpleDateFormat class has been the go-to date formatting tool since Java 1.1. However, it harbors a dangerous secret.
Why it’s a problem:
It is not thread-safe. If you define a static final SimpleDateFormat and multiple threads try to format or parse dates simultaneously, the internal calendar state gets corrupted. This results in silently incorrect dates being saved to your database, or unpredictable NumberFormatExceptions throwing at runtime.
The Correct Way:
With the introduction of the java.time API in Java 8, you should use DateTimeFormatter, which is completely immutable and thread-safe.
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class DateUtils {
// Thread-safe and immutable!
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String formatNow() {
return LocalDateTime.now().format(FORMATTER);
}
}
3. String Concatenation (+) in Loops
Java makes it incredibly easy to append strings using the + operator.
Why it’s a problem:
Strings in Java are immutable. When you concatenate strings inside a loop using +, you aren’t just joining text; you are instantiating a new String object on every iteration, throwing the old one away to be garbage collected. This leads to massive CPU overhead and memory churn (an O(n²) operation).
The Correct Way:
Use StringBuilder to accumulate characters dynamically without throwing away intermediate objects.
import java.util.List;
public class CsvBuilder {
// Terrible performance
public String buildCsvBad(List<String> values) {
String csv = "";
for (String val : values) {
csv += val + ","; // Creates a new String every time!
}
return csv;
}
// Production ready
public String buildCsvGood(List<String> values) {
StringBuilder csv = new StringBuilder();
for (String val : values) {
csv.append(val).append(","); // Modifies the internal array
}
return csv.toString();
}
// Even better for joining (Java 8+)
public String buildCsvBest(List<String> values) {
return String.join(",", values);
}
}
4. java.util.Random in Multithreaded Environments
When you need a random number, java.util.Random or Math.random() (which uses Random under the hood) are usually the first pick.
Why it’s a problem:
java.util.Random is thread-safe, but it achieves this by using an AtomicLong to manage its internal seed. When multiple threads try to generate random numbers concurrently, they all contend for the exact same atomic variable. This causes extremely high thread contention and kills performance.
The Correct Way:
Use ThreadLocalRandom, which maintains a separate seed for each thread, eliminating contention entirely.
import java.util.concurrent.ThreadLocalRandom;
public class RandomPinGenerator {
public String generatePin() {
int pin = ThreadLocalRandom.current().nextInt(1000, 10000);
return String.valueOf(pin);
}
}
Critical caveat: ThreadLocalRandom is optimized for speed, not cryptographic unpredictability. For anything security-sensitive — session tokens, password-reset links, API keys — use java.security.SecureRandom, which draws entropy from the OS’s cryptographically strong random source.
import java.security.SecureRandom;
import java.util.Base64;
public class SecurityTokenGenerator {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
public String generateToken() {
byte[] tokenBytes = new byte[32];
SECURE_RANDOM.nextBytes(tokenBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
}
}
5. Executors.newFixedThreadPool() or newCachedThreadPool()
The Executors factory class gives you thread pools with a single method call.
Why it’s a problem:
If you look at the source code, newFixedThreadPool(n) uses an unbounded queue (LinkedBlockingQueue). If your consumers are slower than your producers, this queue will grow endlessly until it crashes the JVM with an OutOfMemoryError.
On the other hand, newCachedThreadPool() has no maximum thread limit. If you get a spike in traffic, it can spawn tens of thousands of threads, collapsing the operating system.
The Correct Way:
Create a ThreadPoolExecutor manually. Define the exact core pool size, maximum pool size, a bounded queue to limit memory, and a Rejection Policy to handle overflow.
import java.util.concurrent.*;
public class ResilientExecutor {
// Core threads: 10
// Max threads: 50
// Keep-alive time: 60s
// Queue capacity: 100 tasks (Bounded!)
private final ExecutorService executor = new ThreadPoolExecutor(
10, 50, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy() // Handle overload gracefully
);
public void submitTask(Runnable task) {
executor.submit(task);
}
}
6. Using Vector and Hashtable
These data structures are relics from Java 1.0.
Why it’s a problem:
Every single method inside Vector and Hashtable is marked with the synchronized keyword. Even if you are just reading from a synchronized collection, other reading threads will be blocked, destroying concurrent read performance.
The Correct Way:
- If dealing with a single thread: Use
ArrayListinstead ofVector, andHashMapinstead ofHashtable. - If dealing with multithreading: Use
ConcurrentHashMapfor maps, andCopyOnWriteArrayList(or collections synchronized byCollections.synchronizedList) depending on whether you are read-heavy or write-heavy.
import java.util.Map;
import java.util.Hashtable;
import java.util.concurrent.ConcurrentHashMap;
public class CacheData {
// Bad: Blocks all other operations
private final Map<String, Object> badCache = new Hashtable<>();
// Good: Uses lock-striping for highly concurrent access
private final Map<String, Object> goodCache = new ConcurrentHashMap<>();
}
7. java.net.URL.equals()
java.net.URL represents a Uniform Resource Locator.
Why it’s a problem:
If you call .equals() or .hashCode() on a java.net.URL object, Java will actually try to resolve the domain name through your operating system’s DNS to compare the IP addresses!
This means that calling .equals() can block the execution thread for several seconds, can fail without a network connection, and can even yield different results on different environments if DNS caching is involved.
The Correct Way:
To compare resource identifiers logically, always use java.net.URI instead.
import java.net.URI;
import java.net.URL;
public class UrlComparator {
public static void main(String[] args) throws Exception {
URL url1 = new URL("http://example.com");
URL url2 = new URL("http://example.com");
// DANGEROUS! Will trigger DNS resolution
// boolean isSame = url1.equals(url2);
URI uri1 = url1.toURI();
URI uri2 = url2.toURI();
// FAST! Performs pure lexical comparison
boolean isSafeSame = uri1.equals(uri2);
}
}
8. Money calculations with float or double
Floating point numbers represent values as fractions of base-2 (binary).
Why it’s a problem:
Certain decimal numbers simply cannot be represented exactly in binary floating-point. Calculating 0.1 + 0.2 in double yields 0.30000000000000004. If your backend deals with transactions, using floating points will eventually lead you to roundoff accumulation, incorrect balances, and severely angry customers.
The Correct Way:
Whenever handling monetary values or scenarios where exact precision is non-negotiable, use either BigDecimal or store the amounts as long or int (counting the smallest denomination, like cents).
import java.math.BigDecimal;
public class FinancialTransaction {
// Bad
public double calculateTotalBad(double initial, double fee) {
return initial + fee;
}
// Good: Precision is guaranteed
public BigDecimal calculateTotalGood(BigDecimal initial, BigDecimal fee) {
return initial.add(fee);
}
}
9. Swallowing or Ignoring Exceptions
Silencing exceptions — with an empty catch block, a bare e.printStackTrace(), or by catching and not re-throwing — is one of the most destructive habits in production Java code.
Why it’s a problem:
- Silent corruption: An empty
catchblock hides failures completely. Your system enters a broken, inconsistent state while every health-check metric shows green. - Lost context: Printing the stack trace and continuing execution means the calling code has no idea the operation failed, allowing the error to propagate in unpredictable ways.
InterruptedExceptionis special: CatchingInterruptedExceptionand doing nothing permanently clears the thread’s interrupt flag, silently disabling the cooperative-interruption mechanism that frameworks rely on for orderly shutdown.
The Correct Way:
// BAD: Failure silently swallowed
try {
connection = dataSource.getConnection();
} catch (SQLException e) {}
// BAD: Logged but execution blindly continues in a broken state
try {
processPayment(order);
} catch (Exception e) {
e.printStackTrace();
}
// GOOD: Log with full context and re-throw as a typed domain exception
try {
processPayment(order);
} catch (PaymentGatewayException e) {
log.error("Payment failed for order {}", order.getId(), e);
throw new PaymentProcessingException("Payment failed for order " + order.getId(), e);
}
// GOOD: Restore the interrupt flag when catching InterruptedException
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Preserve the interrupt status for callers
throw new RuntimeException("Thread interrupted", e);
}
10. Using a Plain HashMap as an In-Memory Cache
Caching computed results in a HashMap is a natural reflex. But a raw HashMap has no concept of eviction, expiration, or size limits — making it a production time bomb.
Why it’s a problem:
Every entry added stays forever. Under normal load, the map grows without bound: every unique user ID, product SKU, or request parameter that passes through adds another entry. The result is an invisible memory leak that eventually terminates the JVM with an OutOfMemoryError — typically during a traffic spike, at the worst possible moment.
The Correct Way:
For production in-process caches, use Caffeine — the standard choice for in-JVM caching. It provides bounded size, time-based eviction, and access statistics with negligible overhead.
// BAD: Unbounded map grows forever under real traffic
private final Map<String, Product> cache = new HashMap<>();
public Product getProduct(String id) {
return cache.computeIfAbsent(id, this::loadFromDatabase);
}
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
// GOOD: Bounded cache with automatic eviction
private final Cache<String, Product> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats()
.build();
public Product getProduct(String id) {
return cache.get(id, this::loadFromDatabase);
}
If adding a dependency is not an option, overriding removeEldestEntry on a LinkedHashMap provides basic LRU eviction as a lightweight fallback.
Final Thoughts
Building resilient enterprise Java applications requires understanding not just what the language can do, but what it does under the hood. The JDK prioritizes backward compatibility, meaning legacy classes and naive implementations from the late 1990s still sit side-by-side with modern, performant APIs.
The patterns in this guide share a common root cause: they work perfectly in isolation during development but fail under production load or at the edges. A single System.out.println, a swallowed exception, or an unbounded cache is invisible in a unit test — but each one is a liability waiting for the right conditions to surface.
Before shipping your backend, review your codebase with this list in hand. The fixes are almost always a one-line change; the cost of skipping them can be measured in production incidents.