Concurrency Is Hard — Here's How to Tame It
Multithreaded Java applications can deliver dramatic performance improvements, but they introduce a class of bugs — race conditions, deadlocks, and visibility issues — that are intermittent, environment-sensitive, and notoriously difficult to reproduce. The best strategy is to design for correctness first and optimise only when you have measured a genuine bottleneck.
Prefer Immutability Wherever Possible
An immutable object can be shared freely between threads with zero synchronisation overhead. In Java, make fields final, avoid setters, and return new objects instead of modifying existing state:
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
return new Money(this.amount.add(other.amount), this.currency);
}
}
Records (introduced in Java 16) make this pattern even more concise for simple value types.
Use High-Level Concurrency Utilities Over Raw Threads
The java.util.concurrent package provides battle-tested abstractions:
ExecutorService: Manage thread pools instead of spawning threads manually. UseExecutors.newFixedThreadPool(n)orExecutors.newVirtualThreadPerTaskExecutor()(Java 21+).CompletableFuture: Compose async operations in a readable, non-blocking pipeline without callback hell.ConcurrentHashMap: Use instead ofCollections.synchronizedMap()for far better concurrent read performance.BlockingQueue: Ideal for producer-consumer patterns without manual wait/notify loops.- Atomic classes (
AtomicInteger,AtomicReference): Lock-free thread-safe updates to single variables.
Understand synchronized vs volatile
These two keywords solve different problems:
| Keyword | Solves | Does NOT Solve |
|---|---|---|
volatile | Visibility (all threads see latest write) | Atomicity (check-then-act sequences) |
synchronized | Visibility + Atomicity (mutual exclusion) | Nothing — but adds contention overhead |
Use volatile only for simple flags or single-variable state. For compound actions (read-modify-write), you need synchronized or an atomic class.
Avoiding Deadlocks
Deadlocks occur when two or more threads each hold a lock and wait for the other's lock. Practical prevention strategies:
- Lock ordering: Always acquire locks in a globally consistent order across all code paths.
- Lock timeouts: Use
tryLock(timeout, unit)fromReentrantLockto fail gracefully instead of blocking forever. - Reduce lock scope: Hold locks for the shortest time necessary — do not call external methods while holding a lock.
- Prefer higher-level abstractions: If you're not writing locks manually, deadlocks are much less likely.
Virtual Threads (Java 21+)
Project Loom's virtual threads are lightweight threads managed by the JVM, not the OS. They allow you to write simple blocking code at massive scale without thread pool exhaustion — a paradigm shift for I/O-bound server applications. Most existing java.util.concurrent code works transparently with virtual threads.
Testing Concurrent Code
- Use stress tests with many threads and iterations to surface race conditions.
- The jcstress framework (from OpenJDK) is purpose-built for concurrency correctness testing.
- Enable ThreadSanitizer or run under a race detector when using native agents.
- Review code under a concurrency lens separately from regular code review — it deserves dedicated attention.
Summary
Write immutable data structures by default, leverage java.util.concurrent abstractions, understand the visibility vs. atomicity distinction, and test thoroughly under concurrency. These habits will take you from "it works most of the time" to genuinely thread-safe Java code.