Chapter 10: Concurrency
effective-java concurrency java best-practices threading
Book: Effective Java, 3rd Edition — Joshua Bloch
Status: 🟩 Complete
Difficulty: Hard
Items: 78-84 (7 items)
Time to complete: ~90 min
Overview
Concurrency is the most technically demanding chapter in Effective Java. Getting concurrent code correct is hard; getting it performant is harder still. The traps are subtle — races that manifest once in a million runs, deadlocks that appear only under load, visibility bugs that vanish when you add a System.out.println. This chapter provides a practical engineering guide to Java concurrency: when to synchronize, what synchronization costs, what to use instead, and how to document thread safety contracts.
Since the 3rd edition (2018), two major developments have reshaped Java concurrency. Virtual threads (Project Loom, JEP 425, previewed in Java 19-20, production in Java 21) fundamentally change the cost model for blocking I/O — millions of cheap virtual threads can replace complex async code. Structured concurrency (Java 21+, JEP 453) provides a framework for managing the lifecycle of groups of concurrent tasks. These are covered in the Java 17+ notes throughout.
This chapter connects to ch04-classes-and-interfaces (immutability), ch08-methods (API contracts), and ch09-exceptions (failure handling in concurrent code).
Items
Item 78: Synchronize Access to Shared Mutable Data
The Problem
Mutual exclusion is the first reason for synchronization (preventing partial reads/writes), but visibility is equally important and more often missed. Without synchronization, changes made by one thread are not guaranteed to be visible to other threads — this is not a theoretical concern, it is guaranteed by the Java Memory Model (JMM).
A classic example — the “stop-me” flag race condition:
// BAD: Broken -- program may never terminate!
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested) // may read stale cached value forever
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true; // write may never be seen by background thread
}
}This program may run forever. The JIT compiler may hoist the read of stopRequested out of the loop (perfectly legal per JMM without synchronization):
// What the JIT may effectively do:
if (!stopRequested)
while (true) i++;The Solution
Option A: Synchronize both read and write
// GOOD: Both read AND write synchronized
public class StopThread {
private static boolean stopRequested;
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized boolean stopRequested() {
return stopRequested;
}
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested())
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}Option B: volatile for visibility-only (no mutual exclusion needed here)
// GOOD: volatile ensures visibility without the overhead of synchronization
public class StopThread {
private static volatile boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}Realistic race condition — shared counter (read-modify-write is NOT atomic):
// BAD: Race condition on increment
public class Counter {
private static volatile int count = 0; // volatile alone is NOT enough!
public static void increment() {
count++; // NOT atomic: read, increment, write — three steps!
}
}
// Two threads call increment() simultaneously:
// T1 reads count (0), T2 reads count (0)
// T1 writes 1, T2 writes 1 — result is 1, not 2!
// GOOD option 1: synchronized
public class Counter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static synchronized int get() {
return count;
}
}
// GOOD option 2: AtomicInteger (preferred for simple counters)
public class Counter {
private static final AtomicInteger count = new AtomicInteger(0);
public static void increment() {
count.incrementAndGet(); // atomic compare-and-swap
}
public static int get() {
return count.get();
}
}
// GOOD option 3: VarHandle for fine-grained atomic operations (Java 9+)
public class Counter {
private static volatile int count = 0;
private static final VarHandle COUNT;
static {
try {
COUNT = MethodHandles.lookup()
.findStaticVarHandle(Counter.class, "count", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
public static void increment() {
COUNT.getAndAdd(Counter.class, 1); // VarHandle atomic add
}
}Why This Works
The JMM guarantees that synchronized blocks and volatile reads/writes establish happens-before relationships. A write by Thread A that happens-before a read by Thread B guarantees Thread B sees Thread A’s write. Without this relationship, the JMM makes no such guarantee.
Key rules:
volatile: guarantees visibility only. A volatile read always sees the most recent write. Does NOT guarantee atomicity of compound operations (read-modify-write).synchronized: guarantees both visibility and atomicity (mutual exclusion).AtomicXxx: provides atomic compound operations using hardware CAS (compare-and-swap). No lock overhead.
When to Apply / When NOT to Apply
- Apply: for every shared mutable variable accessed by more than one thread.
- Key insight: synchronization must be applied at both the write AND the read — synchronizing only the write is not sufficient.
- Do NOT use
volatilefor read-modify-write operations (increment, check-then-set, etc.) — it does not guarantee atomicity. - Prefer immutable objects when possible — they require no synchronization at all.
Java 17 Update
VarHandle (Java 9+) provides getAndSet, compareAndSet, getAndAdd, and fence operations (fullFence, acquireFence, releaseFence) that were previously only available via sun.misc.Unsafe. VarHandle is the standard replacement for Unsafe in library code. For application code, AtomicInteger, AtomicReference, etc. remain the preferred tools.
Item 79: Avoid Excessive Synchronization
The Problem
Excessive synchronization causes three problems: (1) deadlock — if you call an alien method (external/overridable) while holding a lock, that method may acquire another lock in a different order, creating a cycle; (2) performance degradation — contended locks are expensive, and holding locks for long periods limits throughput; (3) liveness failures — starvation, thread hogging.
// BAD: Calling an alien method (observer) while holding the lock
public class ObservableSet<E> extends ForwardingSet<E> {
private final List<SetObserver<E>> observers = new ArrayList<>();
public synchronized void addObserver(SetObserver<E> observer) {
observers.add(observer);
}
public synchronized boolean removeObserver(SetObserver<E> observer) {
return observers.remove(observer);
}
private synchronized void notifyElementAdded(E element) {
for (SetObserver<E> observer : observers)
observer.added(this, element); // ALIEN METHOD called with lock held!
}
@Override
public boolean add(E element) {
boolean added = super.add(element);
if (added)
notifyElementAdded(element);
return added;
}
}
// Observer that tries to unsubscribe itself on notification:
set.addObserver((s, e) -> {
System.out.println(e);
if (e.equals(23))
s.removeObserver(s.getLastObserver()); // acquires lock while lock held = DEADLOCK
});Another problem: observer callbacks should not hold locks long enough to cause throughput issues in high-contention scenarios.
The Solution
Move alien method calls outside the synchronized block. Use snapshot copy within the synchronized block, then call the alien method outside:
// GOOD: Copy observers under lock, notify outside lock
private void notifyElementAdded(E element) {
List<SetObserver<E>> snapshot;
synchronized (this) {
snapshot = new ArrayList<>(observers); // copy under lock (fast)
}
for (SetObserver<E> observer : snapshot)
observer.added(this, element); // call outside lock — no deadlock risk
}Better still: use CopyOnWriteArrayList for the observers list — it is designed exactly for this use case (rare writes, frequent reads):
// BEST: CopyOnWriteArrayList eliminates the need for explicit synchronization
public class ObservableSet<E> extends ForwardingSet<E> {
private final List<SetObserver<E>> observers =
new CopyOnWriteArrayList<>();
public void addObserver(SetObserver<E> observer) {
observers.add(observer); // no explicit synchronization needed
}
public boolean removeObserver(SetObserver<E> observer) {
return observers.remove(observer);
}
private void notifyElementAdded(E element) {
for (SetObserver<E> observer : observers) // snapshot is automatic
observer.added(this, element);
}
}Performance: StampedLock vs. ReentrantReadWriteLock
// ReentrantReadWriteLock: better than synchronized for read-heavy workloads
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public double getValue() {
readLock.lock();
try { return value; }
finally { readLock.unlock(); }
}
public void setValue(double v) {
writeLock.lock();
try { value = v; }
finally { writeLock.unlock(); }
}
// StampedLock (Java 8+): optimistic reads — zero lock acquisition if no write in progress
private final StampedLock sl = new StampedLock();
public double getValue() {
long stamp = sl.tryOptimisticRead(); // non-blocking!
double v = value;
if (!sl.validate(stamp)) { // check for concurrent write
stamp = sl.readLock(); // fall back to read lock
try { v = value; }
finally { sl.unlockRead(stamp); }
}
return v;
}
public void setValue(double newValue) {
long stamp = sl.writeLock();
try { value = newValue; }
finally { sl.unlockWrite(stamp); }
}
// StampedLock is significantly faster for read-heavy, write-rare workloads
// but does NOT support reentrance and has more complex APIWhy This Works
Minimizing the synchronized region to only the data access (not the side-effect-producing calls) reduces both lock contention and the risk of alien-method-caused deadlocks. The rule: do as little as possible inside synchronized blocks.
When to Apply / When NOT to Apply
- Apply: always move alien method calls out of synchronized blocks.
- Prefer
java.util.concurrentclasses over manual synchronization for standard data structures. - Do NOT use
ReentrantReadWriteLockoversynchronizedunless profiling shows read-heavy contention — the overhead of RRWL is higher thansynchronizedfor low-contention scenarios. StampedLockis for performance-critical, expert-level code. It does not support condition variables and requires careful use.
Java 17 Update
StampedLock (Java 8+) remains underused despite excellent performance for read-dominant workloads. No new locking primitives in Java 17. Virtual threads (Java 21) change the cost model: with virtual threads, a thread blocked on a monitor does NOT block the underlying OS thread — but it does “pin” it if the synchronized block is entered from a virtual thread running on a carrier thread. Prefer ReentrantLock over synchronized in virtual-thread-heavy code (Java 21+) to avoid carrier thread pinning.
Item 80: Prefer Executors, Tasks, and Streams to Threads
The Problem
Creating threads directly gives you no lifecycle management, no work queue, no retry, and no resource bounding. When the system is overloaded, an unbounded thread-per-request model crashes the JVM.
// BAD: Raw thread creation — no lifecycle management
public void handleRequest(Request req) {
new Thread(() -> {
process(req);
}).start(); // uncontrolled thread creation — OOM under load
}
// BAD: No resource bounding
List<Thread> threads = new ArrayList<>();
for (Task task : tasks) {
Thread t = new Thread(task::execute);
threads.add(t);
t.start(); // creates as many threads as tasks — catastrophic at scale
}The Solution
Use the Executor framework. Choose the appropriate executor type for the workload:
// GOOD: Single-threaded executor for sequential background work
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.submit(() -> doBackgroundWork());
exec.shutdown();
// GOOD: Fixed thread pool — bounded resource usage
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
try {
List<Future<Result>> futures = pool.invokeAll(tasks);
for (Future<Result> f : futures) {
Result r = f.get(); // blocks until result available
}
} finally {
pool.shutdown();
}
// GOOD: Cached thread pool — for short-lived async tasks (I/O bound)
ExecutorService cached = Executors.newCachedThreadPool();
// GOOD: Scheduled executor — for periodic tasks
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(this::runHealthCheck, 0, 30, TimeUnit.SECONDS);CompletableFuture for async pipelines (Java 8+, enhanced in Java 9+):
// GOOD: CompletableFuture for non-blocking async composition
CompletableFuture<User> userFuture = CompletableFuture
.supplyAsync(() -> fetchUser(userId), pool) // async fetch
.thenApply(user -> enrich(user)) // transform result
.thenCompose(user -> // chain another async op
CompletableFuture.supplyAsync(() -> addPermissions(user), pool));
// Java 9+ additions:
CompletableFuture<User> withTimeout = userFuture
.orTimeout(5, TimeUnit.SECONDS) // fail if not done in 5s
.exceptionally(ex -> User.anonymous()); // fallback on timeout/error
CompletableFuture<User> withDefault = userFuture
.completeOnTimeout(User.anonymous(), 3, TimeUnit.SECONDS); // complete with default
// Await multiple futures:
CompletableFuture.allOf(f1, f2, f3).thenRun(() -> System.out.println("All done"));
CompletableFuture.anyOf(f1, f2, f3).thenAccept(result -> System.out.println("First: " + result));Parallel streams for CPU-bound data parallelism:
// GOOD: Parallel streams for CPU-bound, side-effect-free operations
long count = IntStream.range(0, 1_000_000)
.parallel()
.filter(n -> isPrime(n))
.count();
// For custom parallelism level, use a custom ForkJoinPool
ForkJoinPool customPool = new ForkJoinPool(4);
long result = customPool.submit(() ->
IntStream.range(0, 1_000_000)
.parallel()
.filter(n -> isPrime(n))
.count()
).get();
customPool.shutdown();Virtual threads (Java 21) — the paradigm shift:
// GOOD: Virtual thread executor — millions of cheap blocking threads
// (Java 21+, the modern replacement for async/reactive for I/O-bound work)
ExecutorService virtualExec = Executors.newVirtualThreadPerTaskExecutor();
// OR: per-thread factory
Thread.Builder.OfVirtual factory = Thread.ofVirtual().name("worker-", 0);
ExecutorService virtualExec2 = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().factory());
// Virtual threads make blocking I/O cheap:
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
int id = i;
exec.submit(() -> {
// This blocks — with virtual threads, this is FINE
// The carrier thread is freed while this virtual thread waits
String result = httpClient.get("https://api.example.com/users/" + id);
process(result);
});
}
} // auto-shutdown: exec.awaitTermination(...)Why This Works
The Executor framework decouples task submission from task execution. Work queues buffer tasks under load. Thread pools bound resource usage. CompletableFuture enables non-blocking composition. Virtual threads (Java 21) eliminate the need for reactive/async patterns for I/O-bound work.
When to Apply / When NOT to Apply
- Apply: always — never use
new Thread(...)directly in production code. newCachedThreadPool: good for I/O-bound tasks where most threads spend time waiting. Bad for CPU-bound tasks (creates too many threads).newFixedThreadPool(Runtime.getRuntime().availableProcessors()): ideal for CPU-bound work.- Parallel streams: ideal for CPU-bound, independent, side-effect-free operations. Do NOT use for I/O-bound work — shared ForkJoinPool is sized for CPU count, not blocking.
- Virtual threads (Java 21+): ideal for I/O-bound work. Do NOT use for CPU-bound work — virtual threads still run on carrier threads (OS threads), and a CPU-bound virtual thread holds the carrier thread.
Java 17 Update
Java 9 added orTimeout and completeOnTimeout to CompletableFuture. Java 21 (important even for Java 17 context): Virtual Threads (JEP 444) are production-ready. They replace thread pools for I/O-bound work. Key: virtual threads are cheap (few hundred bytes vs. ~1MB stack for platform threads). One virtual thread per request — no callbacks, no reactive frameworks — is now a viable high-performance pattern.
Item 81: Prefer Concurrency Utilities to wait and notify
The Problem
wait() and notify() are low-level primitives that are hard to use correctly. They require external synchronization, are prone to missed notifications (if notify() is called before wait()), spurious wakeups, and are difficult to reason about. Most problems they solve are better addressed by java.util.concurrent.
// BAD: Manual wait/notify — fragile, verbose, error-prone
public class BoundedQueue<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
public synchronized void put(T item) throws InterruptedException {
while (queue.size() >= capacity)
wait(); // must be in loop for spurious wakeups
queue.add(item);
notifyAll(); // must use notifyAll, not notify — subtle
}
public synchronized T take() throws InterruptedException {
while (queue.isEmpty())
wait();
T item = queue.remove();
notifyAll();
return item;
}
}The Solution
Use java.util.concurrent equivalents:
// GOOD: ArrayBlockingQueue replaces the above entirely
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100);
// Producer
queue.put(task); // blocks if full, handles wait/notify internally
queue.offer(task, 5, TimeUnit.SECONDS); // timed version
// Consumer
Task task = queue.take(); // blocks if empty
Task task2 = queue.poll(5, TimeUnit.SECONDS); // timed versionCountDownLatch — one-time start/stop coordination:
// GOOD: CountDownLatch for "all threads ready, then go" pattern
public static long time(Executor executor, int concurrency, Runnable action)
throws InterruptedException {
CountDownLatch ready = new CountDownLatch(concurrency); // workers signal ready
CountDownLatch start = new CountDownLatch(1); // timer signals go
CountDownLatch done = new CountDownLatch(concurrency); // workers signal done
for (int i = 0; i < concurrency; i++) {
executor.execute(() -> {
ready.countDown(); // tell timer we're ready
try {
start.await(); // wait for "go"
action.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown(); // tell timer we're done
}
});
}
ready.await(); // wait for all workers to be ready
long startNanos = System.nanoTime();
start.countDown(); // fire starting gun
done.await(); // wait for all workers to finish
return System.nanoTime() - startNanos;
}CyclicBarrier — repeatable rendezvous:
// GOOD: CyclicBarrier for multi-phase parallel algorithms
CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT, () -> {
// Runs between each phase — single-threaded
mergePhaseResults();
prepareNextPhase();
});
for (int t = 0; t < THREAD_COUNT; t++) {
final int threadId = t;
executor.submit(() -> {
for (int phase = 0; phase < NUM_PHASES; phase++) {
doPhaseWork(threadId, phase);
try {
barrier.await(); // wait for all threads, then barrier action runs
} catch (InterruptedException | BrokenBarrierException e) {
Thread.currentThread().interrupt();
}
}
});
}Semaphore — resource bounding:
// GOOD: Semaphore to limit concurrent database connections
Semaphore dbPermits = new Semaphore(MAX_DB_CONNECTIONS);
public Result query(String sql) throws InterruptedException {
dbPermits.acquire(); // block if no permits available
try {
return executeQuery(sql);
} finally {
dbPermits.release();
}
}ConcurrentHashMap — concurrent map patterns:
// GOOD: Atomic get-or-compute (avoids double-initialization)
ConcurrentMap<String, List<String>> map = new ConcurrentHashMap<>();
// Wrong: not atomic
List<String> list = map.get(key);
if (list == null) {
list = new ArrayList<>();
map.put(key, list); // race! Two threads may create two lists
}
// GOOD: computeIfAbsent is atomic
List<String> list = map.computeIfAbsent(key, k -> new ArrayList<>());
// GOOD: merge for frequency counting
map.merge(word, 1L, Long::sum);
// GOOD: compute for conditional update
map.compute(key, (k, existing) -> existing == null ? 1 : existing + 1);If you must use wait/notify (legacy code):
// GOOD: Correct wait/notify pattern (for legacy code only)
synchronized (obj) {
while (!condition) // ALWAYS loop — spurious wakeups are real
obj.wait();
// condition is now true — do work
}
// Prefer notifyAll() over notify()
// notify() wakes one thread; if it's the wrong one, progress stalls
synchronized (obj) {
condition = true;
obj.notifyAll(); // wake all waiters; correct one will proceed
}Why This Works
java.util.concurrent classes are implemented by concurrency experts with fine-grained internal synchronization, hardware-level CAS, and careful avoidance of common pitfalls (spurious wakeups, missed notifications, starvation). They are also well-tested and documented.
When to Apply / When NOT to Apply
- Apply: always use
java.util.concurrentfor new code. CountDownLatch: one-shot, cannot be reset. Use for start-gun and completion-gate patterns.CyclicBarrier: reusable rendezvous. Use for multi-phase parallel algorithms.Semaphore: resource bounding. Use when you need to limit concurrent access to a resource.- Do NOT use
wait/notifyin new code. Only touch them when maintaining legacy code.
Java 17 Update
No new high-level primitives in Java 17. Java 21 adds Structured Concurrency (StructuredTaskScope, JEP 453) which provides a higher-level framework for managing groups of tasks that should succeed or fail as a unit — a better alternative to CompletableFuture composition for many use cases. Java 21 also adds Scoped Values (JEP 446) as a replacement for ThreadLocal in virtual-thread-heavy code.
Item 82: Document Thread Safety
The Problem
Without documented thread safety guarantees, users of a class face an impossible choice: assume it’s thread-safe and risk data corruption, or assume it’s not and add unnecessary synchronization.
// BAD: No thread safety documentation
public class UserCache {
private final Map<Long, User> cache = new HashMap<>();
public User get(long id) { return cache.get(id); }
public void put(long id, User user) { cache.put(id, user); }
// Is this thread-safe? HashMap is not. But callers don't know.
}The Solution
Document the thread safety level of every class. Levels (from most to least safe):
| Level | Meaning | Example |
|---|---|---|
| Immutable | No mutable state; thread-safe without synchronization | String, Long, BigDecimal |
| Unconditionally thread-safe | Mutable but internally synchronized; safe without external sync | ConcurrentHashMap, AtomicLong |
| Conditionally thread-safe | Thread-safe if specific conditions are met (document them) | Collections.synchronizedList |
| Not thread-safe | Clients must synchronize externally | ArrayList, HashMap |
| Thread-hostile | Unsafe even with external synchronization | Classes that modify static state without sync |
/**
* A cache mapping user IDs to User objects.
*
* <p>This class is <i>unconditionally thread-safe</i>. All operations are
* atomic and may be safely called from multiple threads without external
* synchronization.
*
* @ThreadSafe
*/
public class UserCache {
private final ConcurrentHashMap<Long, User> cache = new ConcurrentHashMap<>();
public User get(long id) { return cache.get(id); }
public void put(long id, User user) { cache.put(id, user); }
}
/**
* A list wrapper that is conditionally thread-safe.
*
* <p>Individual method calls are thread-safe. Compound operations (e.g.,
* iterate and remove) require external synchronization on the list object:
* <pre>
* List<Foo> list = Collections.synchronizedList(new ArrayList<>());
* synchronized (list) {
* for (Foo f : list) { ... }
* }
* </pre>
*
* @ThreadSafe (conditionally)
*/Using annotations (JSR-305 / Google Guava / SpotBugs):
import javax.annotation.concurrent.ThreadSafe;
import javax.annotation.concurrent.NotThreadSafe;
import javax.annotation.concurrent.Immutable;
import net.jcip.annotations.GuardedBy;
@ThreadSafe
public class SafeCounter {
@GuardedBy("this")
private int count;
public synchronized void increment() { count++; }
public synchronized int get() { return count; }
}
@Immutable
public final class Point {
private final double x, y;
public Point(double x, double y) { this.x = x; this.y = y; }
public double x() { return x; }
public double y() { return y; }
}
@NotThreadSafe
public class FastCounter {
private int count; // no synchronization — for single-threaded use only
public void increment() { count++; }
public int get() { return count; }
}Lock documentation:
// Document WHICH lock guards WHICH field
@ThreadSafe
public class AccountManager {
@GuardedBy("this")
private Map<Long, Account> accounts = new HashMap<>();
@GuardedBy("transactionLock")
private List<Transaction> pendingTransactions = new ArrayList<>();
private final Object transactionLock = new Object();
public synchronized Account getAccount(long id) {
return accounts.get(id);
}
public void addTransaction(Transaction tx) {
synchronized (transactionLock) {
pendingTransactions.add(tx);
}
}
}Why This Works
Thread safety is a contract. Without documentation, every client either under-synchronizes (races) or over-synchronizes (performance). Documentation lets clients use the class correctly and efficiently.
When to Apply / When NOT to Apply
- Apply: to every class that can be used in a multithreaded context — which is almost all classes in a server application.
- The
synchronizedmodifier on a public method is NOT documentation — it is an implementation detail. Clients should not depend on it being present. - Private lock objects are better than synchronizing on
thisfor public classes, because they cannot be acquired externally (preventing clients from accidentally causing deadlocks by synchronizing on your object).
// GOOD: Private lock object — prevents external lock acquisition
@ThreadSafe
public class Foo {
private final Object lock = new Object(); // private lock object
public void bar() {
synchronized (lock) { ... }
}
}Java 17 Update
@ThreadSafe, @NotThreadSafe, @Immutable, and @GuardedBy are from JCIP (Java Concurrency in Practice) and JSR-305. They are not in the JDK standard library but are available via the com.google.code.findbugs:jsr305 or net.jcip:jcip-annotations dependency. SpotBugs and IntelliJ IDEA recognize these annotations for static analysis.
Item 83: Use Lazy Initialization Judiciously
The Problem
Lazy initialization delays object creation until first use. It seems simple but is full of concurrency traps. The naive approach has a race condition.
// BAD: Not thread-safe lazy initialization
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // CHECK
instance = new Singleton(); // SET — race! Two threads may both see null
}
return instance;
}
}
// Two threads can both see instance == null and create two instances
// BAD: Broken double-checked locking (without volatile) — DO NOT USE
public class Singleton {
private static Singleton instance; // missing volatile!
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
// Without volatile, another thread may see a partially
// constructed object — instance reference written before
// constructor completes (JMM allows this without volatile)
}
}
}
return instance;
}
}The Solution
Four correct patterns depending on use case:
Pattern 1: Eager initialization (simplest, usually preferred)
// GOOD: Eager — if initialization cost is acceptable
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }
}Pattern 2: Initialization-on-demand holder idiom (lazy, no synchronization overhead)
// GOOD: Initialization-on-demand holder — lazy, thread-safe, no volatile/synchronized needed
// Works because class loading is guaranteed to be single-threaded by the JVM
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton(); // initialized once on class load
}
public static Singleton getInstance() {
return Holder.INSTANCE; // triggers Holder class load on first call
}
}
// The JVM guarantees that Holder is initialized only once, even under contention
// This is the preferred pattern for lazy singleton initializationPattern 3: Correct double-checked locking with volatile (for instance fields)
// GOOD: Double-checked locking with volatile (works in Java 5+ memory model)
public class FieldHolder {
private volatile FieldType field; // MUST be volatile
public FieldType getField() {
if (field == null) { // first check (no lock)
synchronized (this) {
if (field == null) { // second check (with lock)
field = computeField(); // safe: volatile write
}
}
}
return field; // safe: volatile read happens-before return
}
}
// The volatile ensures that the write to field (completing initialization)
// happens-before any subsequent read of field.
// Without volatile, a thread may see a non-null but partially initialized field.Pattern 4: Single-check idiom for non-critical lazy fields (tolerates redundant initialization)
// GOOD: Single-check — for fields where reinitializing on every thread is acceptable
// Use only for types where multiple initializations are harmless (idempotent)
public class LazyField {
private volatile FieldType field;
public FieldType getField() {
FieldType result = field;
if (result == null) {
field = result = computeField(); // no synchronization — may run multiple times
}
return result;
}
}When to apply which pattern:
// Static fields: Use initialization-on-demand holder
// Instance fields with need for lazy: Use double-checked locking with volatile
// Instance fields where repeated init is fine: Use single-check idiom
// Performance-critical hot path: Use holder idiom (zero overhead after first access)Why This Works
- Holder idiom: class loading is synchronized by the JVM class loader — no application-level locking needed.
- Double-checked locking with
volatile: thevolatilewrite tofieldestablishes a happens-before relationship. Any thread that reads a non-nullfieldis guaranteed to see the complete, fully constructed object. - Without
volatile: the JMM allows reordering of the constructor completion and the reference assignment — a thread can see a non-null reference to a partially constructed object.
When to Apply / When NOT to Apply
- Prefer eager initialization for most cases — lazy initialization is an optimization, not a default.
- Apply lazy initialization when: the field is rarely needed, initialization is expensive, and the cost can be deferred without harming correctness.
- Apply the holder idiom for static fields; double-checked locking with volatile for instance fields.
- Do NOT apply to primitive fields (volatility guarantees for primitives are simpler — but synchronized or AtomicXxx is more readable).
- In concurrent contexts: consider
ConcurrentHashMap.computeIfAbsentas an alternative to manual double-checked locking for map-based lazy values.
Java 17 Update
No new language-level lazy initialization primitives. Records (Java 16+) and sealed types (Java 17) encourage immutability, which eliminates lazy initialization concerns for those types. For caches (the most common real use case), ConcurrentHashMap.computeIfAbsent is the preferred tool:
// BEST for cached computed values (avoids DIY lazy init)
private final ConcurrentHashMap<Key, Value> cache = new ConcurrentHashMap<>();
public Value get(Key key) {
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
}
// Atomic, correct, no boilerplate — prefer this over double-checked locking for cachesItem 84: Don’t Depend on the Thread Scheduler
The Problem
Any program whose correctness or performance depends on the thread scheduler’s behavior is non-portable. Thread scheduling is OS-dependent, load-dependent, and inherently non-deterministic. Programs that rely on Thread.sleep() for timing, Thread.yield() for “cooperation”, or thread priorities for correctness are fragile.
// BAD: Busy waiting — wastes CPU and depends on scheduler to preempt
public void waitForCondition() {
while (!condition) {
// spin — burns CPU, starves other threads
}
}
// BAD: Using Thread.sleep() for synchronization
public void produce() throws InterruptedException {
item = createItem();
Thread.sleep(100); // "give consumer time to read" — WRONG
}
// BAD: Using Thread.yield() to "be nice" — behavior is undefined on modern JVMs
// yield() is a hint that the JVM may ignore entirely
while (!done) {
Thread.yield(); // platform-dependent, does nothing useful on many JVMs
}
// BAD: Using thread priorities for correctness
thread.setPriority(Thread.MAX_PRIORITY); // cannot guarantee orderingThe Solution
Write programs that are correct regardless of scheduling. Use proper synchronization mechanisms:
// GOOD: Use blocking primitives instead of busy-waiting
// BlockingQueue.take() sleeps efficiently until an item is available
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
// Consumer blocks properly — no CPU waste
public Task consume() throws InterruptedException {
return queue.take(); // efficient blocking, woken by producer
}
// Producer — signals consumer correctly
public void produce(Task task) throws InterruptedException {
queue.put(task); // blocks if full, signals consumer when added
}
// GOOD: CountDownLatch instead of sleep-based timing
CountDownLatch latch = new CountDownLatch(1);
Thread worker = new Thread(() -> {
doWork();
latch.countDown(); // signal when done
});
worker.start();
latch.await(); // wait properly — no sleep guessing
// GOOD: Condition variables for signaling
Lock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
private final Queue<Task> queue = new LinkedList<>();
public void produce(Task task) throws InterruptedException {
lock.lock();
try {
queue.add(task);
notEmpty.signal(); // signal waiting consumer
} finally {
lock.unlock();
}
}
public Task consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty())
notEmpty.await(); // efficient wait — no spin
return queue.poll();
} finally {
lock.unlock();
}
}
// GOOD: Number of threads <= number of runnable threads
// Keep threads doing real work or blocking — minimize runnable threads
// that aren't actually making progressThread count guidelines:
// Runnable thread count should not significantly exceed the number of processors
// Rule: keep the ratio of runnable threads to processors close to 1 for CPU-bound work
int processors = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(processors);
// Threads doing I/O can be more: they spend most time blocked, not runnableWhy This Works
When threads block (on queues, locks, latches, semaphores), the OS parks them efficiently — no CPU wasted. When a condition is met, the blocked thread is woken precisely. This is independent of scheduler policy, load, or platform.
When to Apply / When NOT to Apply
- Apply: all production code — never use
Thread.sleep()for synchronization,Thread.yield()for correctness. Thread.sleep()in tests is a code smell — replace withCountDownLatchorAwaitility.- Thread priorities: may be useful as a hint for performance tuning but never for correctness.
Thread.onSpinWait()(Java 9+): a hint for extremely short busy-wait loops (spin locks). Use only in fine-grained low-latency lock implementations, never in application code.
Java 17 Update
Virtual threads (Java 21) significantly change thread scheduling semantics. Virtual threads are multiplexed onto a small pool of platform (OS/carrier) threads. The virtual thread scheduler is controlled by the JDK, not the OS. Key consequences:
Thread.sleep()on a virtual thread is efficient — the carrier thread is released to other virtual threads.synchronizedblocks on a virtual thread may “pin” the carrier thread (if a blocking operation happens insidesynchronized) — this is a known limitation and the JDK team recommends replacingsynchronizedwithReentrantLockin virtual-thread-heavy code.- Structured Concurrency (Java 21+, JEP 453): provides
StructuredTaskScopewhich enforces a tree-structured lifetime for concurrent tasks:
// Structured Concurrency (Java 21+)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
Subtask<Orders> ordersTask = scope.fork(() -> fetchOrders(userId));
scope.join(); // wait for all subtasks
scope.throwIfFailed(); // propagate any failure
return new UserProfile(userTask.get(), ordersTask.get());
} // scope ensures all subtasks are done before here
// If either subtask fails, the other is cancelled — no resource leaksStructured concurrency makes the dependency on thread scheduler behavior even less relevant — tasks have explicit, managed lifecycles.
Interview Questions & Exercises
Q1: Race Condition Code Reading — Identify the Bug
Context: Common at Google, Amazon, Meta for senior positions. “What is wrong with this code?”
public class BankAccount {
private int balance;
public void deposit(int amount) {
balance += amount;
}
public void withdraw(int amount) {
if (balance >= amount)
balance -= amount;
}
public int getBalance() {
return balance;
}
}Answer: Three bugs:
balance += amountis NOT atomic — it compiles togetfield,iadd,putfield. Two concurrent deposits can lose one update.withdrawhas a TOCTOU race: afterbalance >= amountis true for Thread A, Thread B may withdraw, making balance < amount before Thread A’sbalance -= amountexecutes — resulting in a negative balance.balancehas novolatileor synchronization, so changes may not be visible across threads at all.
Fix: synchronize all methods, or use AtomicInteger and restructure withdraw to use compareAndSet.
public class BankAccount {
private final AtomicInteger balance = new AtomicInteger(0);
public void deposit(int amount) {
balance.addAndGet(amount);
}
public boolean withdraw(int amount) {
return balance.updateAndGet(b -> b >= amount ? b - amount : b) != balance.get() - amount;
// Cleaner with compareAndSet loop:
}
// Cleaner withdraw with CAS loop
public boolean withdraw(int amount) {
int current;
do {
current = balance.get();
if (current < amount) return false; // not enough funds
} while (!balance.compareAndSet(current, current - amount));
return true;
}
}Follow-up: “What if we need both deposit and withdraw to be part of a larger transaction?” AtomicInteger is not enough — you need synchronized or a lock to make compound operations atomic.
Q2: Explain the Happens-Before Relationship
Context: Asked at senior/principal level; fundamental to JMM understanding.
Answer: A happens-before relationship is a guarantee in the Java Memory Model that one action’s effects are visible to another. If action A happens-before action B, all side effects of A are visible to B. Key happens-before rules: (1) Within a thread, each action happens-before subsequent actions. (2) An unlock on a monitor happens-before every subsequent lock on the same monitor. (3) A write to a volatile field happens-before every subsequent read of that field. (4) Thread.start() happens-before any action in the started thread. (5) All actions in a thread happen-before Thread.join() returns.
Why it matters: Without a happens-before relationship between a write and a read, the reading thread may see stale (cached) data. This is why synchronizing only the write OR only the read is insufficient — you need both.
Follow-up: “Does volatile guarantee atomicity?” No. volatile guarantees visibility (no stale reads) but not atomicity. A volatile long read is atomic (no word tearing), but volatileCounter++ is not atomic — it’s three operations.
Q3: What is the initialization-on-demand holder idiom, and why is it preferred for lazy singletons?
Context: Asked when discussing design patterns or concurrency-safe singleton implementation.
Answer: The initialization-on-demand holder idiom uses a private static nested class to hold the singleton instance. The class is only loaded (and thus the singleton initialized) when getInstance() is first called. Thread safety comes from the JVM class loading guarantee: class initialization is performed by the class loader with a lock, so the initialization happens exactly once, even under concurrent access. No synchronized or volatile is needed at the application level. The pattern has zero synchronization overhead after the first call.
public class Singleton {
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() { return Holder.INSTANCE; }
}Follow-up: “When would you use double-checked locking instead?” When you need lazy initialization of a non-static (instance) field — the holder idiom only works for static fields. In that case, use double-checked locking with volatile.
Q4: What is the difference between CountDownLatch and CyclicBarrier?
Context: Asked when discussing concurrency primitives or parallel algorithms.
Answer:
CountDownLatch: one-shot countdown. Initialized with a count; threads decrement withcountDown(); other threads wait withawait()until count reaches zero. Cannot be reset. Use for “wait until N events have occurred” — startup gates, completion detection.CyclicBarrier: reusable rendezvous point. All N parties must callawait()before any proceed. Optional barrier action runs when the last party arrives. Can be reset and reused. Use for “all threads complete phase K before any start phase K+1” — multi-phase parallel algorithms.
Key difference: CountDownLatch counts down external events; CyclicBarrier synchronizes a fixed set of threads at a rendezvous point.
Follow-up: “What happens if a thread throws an exception while waiting at a CyclicBarrier?” The barrier is “broken” — all waiting threads receive BrokenBarrierException. The barrier is unusable until reset.
Q5: Explain the broken double-checked locking pattern without volatile
Context: Classic interview question about memory model; tests deep JMM knowledge.
Answer: Without volatile, the JIT compiler and CPU are allowed to reorder instructions. In particular, the write to the reference field (instance = new Singleton()) can be made visible to other threads before the constructor has finished executing. This is because “new Singleton()” is NOT atomic: (1) allocate memory, (2) write default values, (3) run constructor, (4) assign reference. The JMM allows (4) to be observed by other threads before (3) completes. A second thread doing the outer if (instance == null) check could see a non-null reference to a partially initialized object and proceed to use it.
Adding volatile establishes a happens-before relationship: the write to the field (step 4) cannot be observed until all writes from the constructor (step 3) are also visible.
Follow-up: “Does synchronized fix the broken DCL without volatile?” Yes — a synchronized read of the field also prevents the partially-constructed object from being observed. But the whole point of DCL is to avoid synchronized on every read. The fix is volatile, not synchronized on the read path.
Q6: What is “excessive synchronization” and how does it lead to deadlock?
Context: Design review question; tests understanding of lock ordering and alien methods.
Answer: Excessive synchronization means holding a lock while calling alien methods — methods that can be overridden or that come from code outside your control. While holding your lock, an alien method may try to acquire another lock (or your lock again if it’s non-reentrant), creating a potential deadlock cycle. The fix: copy the relevant state under the lock, then call the alien method with no lock held.
Deadlock example: Class A holds lock A and calls a method on Class B (which requires lock B). Simultaneously, Class B holds lock B and calls a method on Class A (which requires lock A). Neither can proceed.
Prevention rules: (1) acquire locks in a consistent global order; (2) do not hold a lock while calling external code; (3) minimize lock scope.
Follow-up: “How does CopyOnWriteArrayList solve the observer problem without explicit locking?” It maintains an immutable snapshot of the array. On every write, it copies the entire array, modifies the copy, and atomically replaces the reference. Readers iterate the snapshot — no lock needed.
Q7: When should you use virtual threads vs. platform threads vs. async/reactive?
Context: Architecture question for Java 21+ systems.
Answer:
- Platform threads (OS threads): for CPU-bound work. Each thread runs continuously on an OS thread; no overhead from virtual thread scheduling.
- Virtual threads (Java 21+): for I/O-bound, blocking work. Millions of virtual threads can coexist cheaply. Blocking a virtual thread does NOT block the underlying OS thread. Replace reactive/async programming for most web server use cases.
- Async/reactive (CompletableFuture, RxJava, Reactor): still appropriate for back-pressure management, complex event streaming pipelines, and situations where the reactive model is inherently a good fit (event-driven UIs, streaming data).
Rule of thumb for Java 21+: use Executors.newVirtualThreadPerTaskExecutor() for I/O-bound server work. Use Executors.newFixedThreadPool(cpuCount) for CPU-bound work. Reserve reactive frameworks for genuinely event-driven pipelines.
Follow-up: “What is the ‘pinning’ problem with virtual threads?” If a virtual thread is blocked inside a synchronized block, the carrier (platform) thread is pinned and cannot run other virtual threads. Fix by replacing synchronized with ReentrantLock.
Q8: Given a multithreaded producer-consumer setup, what concurrency primitives would you choose?
Context: System design or coding question at senior level.
Answer: BlockingQueue (specifically ArrayBlockingQueue for bounded or LinkedBlockingQueue for bounded/unbounded) is the canonical solution. Producers call put() (blocks if full), consumers call take() (blocks if empty). All synchronization, wait/notify, and condition variable logic is handled internally.
For more complex scenarios:
- Multiple consumers, single producer: same
BlockingQueue— multiple consumers calltake()safely. - Priority ordering:
PriorityBlockingQueue. - Delay before consumption:
DelayQueue. - Discard old items if full:
LinkedTransferQueueorSynchronousQueuefor immediate handoff.
Follow-up: “How does this change with virtual threads?” With virtual threads, the blocking call to take() is cheap — the carrier thread is released. The solution is the same but scales to many more concurrent consumers without platform thread overhead.
Key Takeaways
- Synchronize reads AND writes — synchronizing only the write is not sufficient. The JMM guarantees visibility only when both sides use synchronization or volatile.
volatileis for visibility, not atomicity — usevolatilefor flags and references; useAtomicXxxorsynchronizedfor read-modify-write operations.VarHandle(Java 9+) replacessun.misc.Unsafefor fine-grained atomic operations in library code. Application code should still useAtomicInteger,AtomicReference, etc.- Minimize synchronized blocks — do only the minimum necessary inside
synchronized. Move alien method calls outside. PreferCopyOnWriteArrayListfor observer patterns. - Prefer
java.util.concurrentover rawwait/notify.BlockingQueue,CountDownLatch,CyclicBarrier,Semaphore, andConcurrentHashMapare more correct, readable, and performant. - Prefer executors and tasks over raw threads — never
new Thread(...)in production. UseCompletableFuturefor async composition. - Document thread safety — use
@ThreadSafe,@NotThreadSafe,@Immutable, and@GuardedBy. Thread safety is a contract, not an implementation detail. - Lazy initialization: prefer eager init; use holder idiom for static fields; use double-checked locking with
volatilefor instance fields. - Do not depend on the thread scheduler — busy-waiting,
Thread.yield(), andThread.sleep()-based synchronization are non-portable and incorrect. - Virtual threads (Java 21+) change the cost model for I/O-bound work — millions of cheap blocking threads replace complex async pipelines. Be aware of the
synchronizedpinning issue. - Immutability eliminates synchronization — wherever possible, make objects immutable. Records (Java 16+) make this easy for data classes.
StampedLockoutperformsReentrantReadWriteLockfor read-dominant, write-rare workloads — but is non-reentrant and more complex. Profile before choosing.
Last Updated: 2026-05-10