Java Concurrency Reference Guide
Status: seedling · Last updated:
Java Concurrency Reference Guide
A comprehensive, interview-focused reference for Java concurrency. Each section covers what it is, when to use it, a complete runnable example, and common gotchas.
Table of Contents
- The Java Memory Model
- Thread Fundamentals
- synchronized — The Foundation
- Explicit Locks
- Atomic Variables
- Concurrent Collections
- Coordination Primitives
- Executors and Thread Pools
- CompletableFuture
- Virtual Threads
- Structured Concurrency
- Common Concurrency Patterns
- Common Concurrency Bugs
- Decision Framework
1. The Java Memory Model
What It Is
The JMM defines rules for when changes made by one thread become visible to other threads. Without proper synchronization, Thread A might never see Thread B’s writes.
Happens-Before Rules (The 5 You Need)
- Program order: Within a thread, each action happens-before the next
- Monitor lock: Unlock of a monitor happens-before subsequent lock of that monitor
- Volatile: Write to a volatile field happens-before subsequent read of that field
- Thread start:
thread.start()happens-before any action in the started thread - Thread join: All actions in a thread happen-before
thread.join()returns
volatile — Visibility Without Atomicity
// volatile guarantees VISIBILITY but NOT ATOMICITY
public class GracefulShutdown {
private volatile boolean running = true;
public void start() {
Thread worker = new Thread(() -> {
while (running) { // Reads latest value — guaranteed by volatile
doWork();
}
System.out.println("Worker stopped cleanly.");
});
worker.start();
}
public void stop() {
running = false; // Write is immediately visible to the worker thread
}
}
// BUT: volatile does NOT make compound operations safe:
private volatile int counter = 0;
counter++; // This is READ + INCREMENT + WRITE — not atomic!
// Two threads can both read 5, increment to 6, write 6. One increment is lost.
// Use AtomicInteger instead.
GOTCHA:
volatileonly helps with simple reads and writes. For read-modify-write operations (like increment), useAtomicIntegeror synchronization.
2. Thread Fundamentals
Thread Creation
// Option 1: Extend Thread (rarely used — can't extend another class)
class MyThread extends Thread {
@Override
public void run() { System.out.println("Running in: " + getName()); }
}
new MyThread().start();
// Option 2: Runnable (preferred — separates task from execution mechanism)
Runnable task = () -> System.out.println("Running in: " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker-1"); // Optional: name the thread
t.start();
t.join(); // Wait for thread to finish (throws InterruptedException)
// Option 3: Callable<V> (returns a value, can throw checked exceptions)
Callable<String> callable = () -> {
Thread.sleep(100);
return "result";
};
// Callable can't be passed to Thread directly — submit to an ExecutorService:
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(callable);
String result = future.get(); // Blocks until result is ready
executor.shutdown();
Thread Lifecycle States
NEW → RUNNABLE → BLOCKED / WAITING / TIMED_WAITING → TERMINATED
- NEW: Created but
start()not yet called - RUNNABLE: Executing or ready to execute (OS may not have scheduled it yet)
- BLOCKED: Waiting to acquire a monitor lock (entering a
synchronizedblock) - WAITING:
Object.wait(),Thread.join(),LockSupport.park() - TIMED_WAITING: Same as WAITING but with a timeout (e.g.,
Thread.sleep(ms)) - TERMINATED:
run()completed (normally or via exception)
Thread Interruption
// Interruption is a COOPERATIVE mechanism — the thread must check for it.
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
// Blocking methods (sleep, wait, take, etc.) throw InterruptedException
Thread.sleep(1000);
doWork();
} catch (InterruptedException e) {
// IMPORTANT: Either re-interrupt or propagate!
Thread.currentThread().interrupt(); // Restore the flag
break; // Exit the loop
// If you swallow it (do nothing), the thread can never be stopped.
}
}
System.out.println("Thread interrupted, cleaning up...");
});
worker.start();
Thread.sleep(3000); // Let it run for 3 seconds
worker.interrupt(); // Request the thread to stop
worker.join(); // Wait for it to actually finish
GOTCHA: NEVER swallow
InterruptedExceptionsilently. Either propagate it (throws InterruptedException) or restore the interrupt flag (Thread.currentThread().interrupt()). Swallowing it means the thread can never be properly shut down.
3. synchronized — The Foundation
How It Works
Every Java object has an intrinsic lock (monitor). synchronized acquires this lock.
// Synchronized method — locks on `this`
public synchronized void increment() {
count++;
}
// Synchronized block — locks on specific object (preferred — finer control)
private final Object lock = new Object(); // Dedicated lock object
public void increment() {
synchronized (lock) {
count++;
}
}
// Static synchronized — locks on the Class object
public static synchronized void staticMethod() { /* ... */ }
wait() / notify() / notifyAll() — Complete Example
// Thread-safe bounded buffer using wait/notify
public class SynchronizedBuffer<T> {
private final Object[] items;
private int count = 0, putIndex = 0, takeIndex = 0;
public SynchronizedBuffer(int capacity) {
items = new Object[capacity];
}
public synchronized void put(T item) throws InterruptedException {
while (count == items.length) { // MUST be while, not if (spurious wakeups!)
wait(); // Releases the lock, blocks this thread
}
items[putIndex] = item;
putIndex = (putIndex + 1) % items.length;
count++;
notifyAll(); // Wake ALL waiting threads (consumers + producers)
}
@SuppressWarnings("unchecked")
public synchronized T take() throws InterruptedException {
while (count == 0) {
wait();
}
T item = (T) items[takeIndex];
items[takeIndex] = null;
takeIndex = (takeIndex + 1) % items.length;
count--;
notifyAll();
return item;
}
}
// Usage:
SynchronizedBuffer<String> buffer = new SynchronizedBuffer<>(10);
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 100; i++) {
buffer.put("item-" + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 100; i++) {
String item = buffer.take();
System.out.println("Got: " + item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
GOTCHA: Always use
while(notif) aroundwait()— spurious wakeups can happen! The thread can wake up even though no one callednotify().
GOTCHA: Don’t synchronize on a boxed type like
Integer— boxing can return shared instances (Integer.valueOf(5)returns a cached object), so you might lock on the same object as unrelated code.
GOTCHA: Double-checked locking is broken without volatile:
// BROKEN: if (instance == null) { synchronized (lock) { if (instance == null) { instance = new Singleton(); // Can be seen partially constructed! } } } // FIX: make instance volatile, or use the Holder idiom (section 10)
4. Explicit Locks
ReentrantLock
More flexible than synchronized: supports tryLock, timed locking, fairness, multiple conditions.
private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock(); // Blocks until lock is available
try {
// critical section
} finally {
lock.unlock(); // ALWAYS in finally — even if an exception is thrown!
}
}
// Try-lock with timeout (avoids deadlock — gives up instead of waiting forever)
public boolean tryDoWork() throws InterruptedException {
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// got the lock within 1 second
return true;
} finally {
lock.unlock();
}
} else {
// couldn't get lock in time — handle gracefully (log, retry, fail)
return false;
}
}
// "Reentrant" means the same thread can acquire it again without deadlocking:
lock.lock(); // hold count = 1
lock.lock(); // hold count = 2 (same thread — OK)
lock.unlock(); // hold count = 1
lock.unlock(); // hold count = 0, lock released
ReentrantReadWriteLock
Optimizes for read-heavy workloads: multiple readers can hold the lock concurrently, but a writer needs exclusive access.
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private final Map<String, String> cache = new HashMap<>();
public String read(String key) {
readLock.lock();
try {
return cache.get(key); // Multiple readers can be here simultaneously
} finally {
readLock.unlock();
}
}
public void write(String key, String value) {
writeLock.lock();
try {
cache.put(key, value); // Exclusive access — no readers or other writers
} finally {
writeLock.unlock();
}
}
// computeIfAbsent pattern (read lock → write lock, with double-check):
public String computeIfAbsent(String key, Function<String, String> loader) {
readLock.lock();
try {
if (cache.containsKey(key)) return cache.get(key);
} finally {
readLock.unlock(); // MUST release read lock before acquiring write lock
}
// Cannot upgrade read → write. Must release first, then acquire write.
writeLock.lock();
try {
// Double-check: another thread may have written between our unlock and lock
if (cache.containsKey(key)) return cache.get(key);
String value = loader.apply(key);
cache.put(key, value);
return value;
} finally {
writeLock.unlock();
}
}
GOTCHA: You CANNOT upgrade a read lock to a write lock. This deadlocks:
readLock.lock(); writeLock.lock(); // DEADLOCK! This thread holds the read lock, preventing // any writer (including itself) from acquiring the write lock.You must: release read lock → acquire write lock → double-check → do work.
Condition Objects
Like wait/notify but more powerful: multiple conditions per lock. Each condition has its own wait queue, so you can wake only the right type of thread.
// Bounded buffer using Condition objects — more efficient than notifyAll()
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // Producers wait here
private final Condition notEmpty = lock.newCondition(); // Consumers wait here
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (isFull()) {
notFull.await(); // Release lock, wait. Only producers are on this queue.
}
addItem(item);
notEmpty.signal(); // Wake ONE consumer. signal() is safe here because
// only consumers wait on notEmpty.
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (isEmpty()) {
notEmpty.await(); // Release lock, wait. Only consumers are on this queue.
}
T item = removeItem();
notFull.signal(); // Wake ONE producer.
return item;
} finally {
lock.unlock();
}
}
// Why this is better than notifyAll():
// - notifyAll() wakes ALL threads (producers + consumers), most go back to sleep.
// - Condition.signal() wakes exactly ONE thread of the RIGHT type.
// - With separate conditions, signal() is safe. With a single monitor, you
// must use notifyAll() because notify() might wake the wrong type of thread.
StampedLock (Java 8+)
Supports optimistic reads — even faster for read-heavy workloads.
private final StampedLock sl = new StampedLock();
private double x, y;
// Optimistic read: no lock acquired! Just check afterward if a write happened.
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // Returns a stamp, does NOT lock
double currentX = x, currentY = y; // Read fields into locals
if (!sl.validate(stamp)) { // Did a write happen since our stamp?
stamp = sl.readLock(); // Yes — fall back to a real read lock
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
public void move(double deltaX, double deltaY) {
long stamp = sl.writeLock(); // Exclusive lock
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
GOTCHA: StampedLock is NOT reentrant. If a thread holding a write lock tries to acquire it again, it will deadlock. This is the #1 StampedLock pitfall. Use ReentrantReadWriteLock if you need reentrancy.
GOTCHA: StampedLock does not support
Conditionobjects, and stamps must be used correctly — callingunlockWritewith a read stamp (or vice versa) throwsIllegalMonitorStateException.
5. Atomic Variables
Core Concept: Compare-And-Swap (CAS)
CAS is a hardware instruction: “if the current value is X, set it to Y, atomically.” All atomic classes use CAS internally — no locking needed (lock-free).
// AtomicInteger — thread-safe counter without locks
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // Atomically: read + increment + write. Returns new value.
count.decrementAndGet(); // Atomically decrement.
count.addAndGet(5); // Atomically add 5.
count.compareAndSet(5, 10); // If value is 5, set to 10. Returns true if successful.
count.get(); // Just read the current value.
// AtomicReference — thread-safe reference swaps
AtomicReference<Config> configRef = new AtomicReference<>(initialConfig);
// Thread-safe config reload:
Config oldConfig = configRef.get();
Config newConfig = loadNewConfig();
configRef.compareAndSet(oldConfig, newConfig); // Only swaps if no one else changed it
// AtomicBoolean — thread-safe flag, great for "do this exactly once"
AtomicBoolean initialized = new AtomicBoolean(false);
if (initialized.compareAndSet(false, true)) {
// I'm the one thread that gets to initialize — everyone else gets false
performOneTimeInit();
}
LongAdder (Java 8+) — High-Contention Counter
// Better than AtomicLong when MANY threads are incrementing concurrently.
// Internally stripes across multiple cells to reduce CAS contention.
LongAdder counter = new LongAdder();
counter.increment(); // Fast: updates a thread-local cell, not a single variable
counter.add(5); // Add arbitrary amount
long total = counter.sum(); // Read the total (sums all cells)
// Note: sum() is eventually consistent during concurrent writes.
// LongAccumulator — generalized version with custom operation
LongAccumulator maxFinder = new LongAccumulator(Long::max, Long.MIN_VALUE);
maxFinder.accumulate(42); // Thread-safe running maximum
maxFinder.get(); // Returns the current max
// When to use which:
// - AtomicInteger/Long: single variable, moderate contention, need exact reads
// - LongAdder: counter only (sum), high contention, reads can be approximate
// - LongAccumulator: custom reduction (max, min, etc.), high contention
GOTCHA:
AtomicReferencemakes the reference atomic — NOT the referenced object. If you haveAtomicReference<List>, swapping the reference is atomic, but the list itself is not thread-safe. Don’t mutate the object inside — swap to a new one.
6. Concurrent Collections
ConcurrentHashMap
The workhorse of concurrent Java. Since Java 8, uses CAS operations for reads and
synchronized on the first node of each bucket for writes — no separate lock objects,
and reads are entirely lock-free. (Pre-Java 8 used segment-based locking.)
ConcurrentHashMap<String, Long> wordCounts = new ConcurrentHashMap<>();
// Atomic compound operations (THE KEY FEATURE):
wordCounts.merge("hello", 1L, Long::sum); // Increment: if absent put 1, else add 1
wordCounts.compute("hello", (k, v) -> v == null ? 1L : v + 1); // Same thing
wordCounts.putIfAbsent("hello", 0L); // Only puts if key is absent
wordCounts.computeIfAbsent("hello", k -> expensiveCompute(k)); // Lazy init
// These compound operations are atomic PER KEY — no external locking needed.
// But operations spanning multiple keys are NOT atomic.
// Bulk operations (Java 8+):
long total = wordCounts.reduceValuesToLong(
1, // parallelism threshold (1 = always parallel)
Long::sum, // reducer
0L // identity
);
wordCounts.forEach(1, (key, value) -> System.out.println(key + ": " + value));
// Thread-safe Set backed by ConcurrentHashMap:
Set<String> concurrentSet = ConcurrentHashMap.newKeySet();
concurrentSet.add("item");
GOTCHA:
size()is an ESTIMATE under contention. UsemappingCount()for a long return type, but it’s still an estimate. This is by design — exact counts would require locking the entire map.
GOTCHA:
ConcurrentHashMapdoes NOT allow null keys or values (unlike HashMap).map.get(key)returningnullunambiguously means “not present.”
BlockingQueue Family
Purpose-built for producer-consumer. The put()/take() methods block automatically.
| Implementation | Bounded? | Notes |
|---|---|---|
ArrayBlockingQueue | Yes (fixed) | Fair ordering option. Most common choice. |
LinkedBlockingQueue | Optional | Optionally bounded. Higher throughput than ABQ. |
PriorityBlockingQueue | No (unbounded) | Elements ordered by priority. |
SynchronousQueue | Zero capacity | Direct handoff: put blocks until take is called. |
DelayQueue | No (unbounded) | Elements available only after a delay expires. |
// Complete producer-consumer with ArrayBlockingQueue
BlockingQueue<String> queue = new ArrayBlockingQueue<>(100); // Capacity 100
// Producer thread
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 1000; i++) {
queue.put("task-" + i); // Blocks if queue is full (back-pressure!)
}
queue.put("POISON_PILL"); // Signal consumer to stop
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Consumer thread
Thread consumer = new Thread(() -> {
try {
while (true) {
String task = queue.take(); // Blocks if queue is empty
if ("POISON_PILL".equals(task)) break;
process(task);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
// Non-blocking variants:
queue.offer("task"); // Returns false if full (no blocking)
queue.poll(); // Returns null if empty (no blocking)
queue.offer("task", 1, TimeUnit.SECONDS); // Blocks up to 1 second, then returns false
queue.poll(1, TimeUnit.SECONDS); // Blocks up to 1 second, then returns null
CopyOnWriteArrayList / CopyOnWriteArraySet
For read-heavy, write-rare scenarios. Every write copies the entire internal array.
Iteration never throws ConcurrentModificationException.
// Good for: listener lists, configuration that rarely changes
List<EventListener> listeners = new CopyOnWriteArrayList<>();
listeners.add(listener); // Copies the array (expensive write)
// Iteration is lock-free and sees a snapshot:
for (var l : listeners) {
l.onEvent(e); // Safe even if another thread adds/removes during iteration
}
7. Coordination Primitives
CountDownLatch — “Wait for N events”
One-shot: count down from N, threads wait until it reaches 0. Cannot be reset.
// Example: Start 5 worker threads simultaneously, wait for all to finish
int workerCount = 5;
CountDownLatch startGate = new CountDownLatch(1); // Workers wait for "go"
CountDownLatch doneLatch = new CountDownLatch(workerCount); // Main waits for workers
for (int i = 0; i < workerCount; i++) {
final int id = i;
new Thread(() -> {
try {
startGate.await(); // All workers block here until main says "go"
System.out.println("Worker " + id + " started");
Thread.sleep(100 * id); // Simulate varying work
System.out.println("Worker " + id + " done");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneLatch.countDown(); // Signal: "I'm done" (N → N-1 → ... → 0)
}
}).start();
}
System.out.println("All workers created. Starting...");
startGate.countDown(); // Release all workers simultaneously
doneLatch.await(10, TimeUnit.SECONDS); // Wait for all workers (with timeout)
System.out.println("All workers finished!");
// Key properties:
// - countDown() never blocks
// - await() blocks until count reaches 0
// - One-shot: once count hits 0, await() returns immediately forever after
// - countDown() when count is already 0 has no effect (count never goes negative)
CyclicBarrier — “N threads rendezvous, then proceed together”
All N threads must call await() before ANY can proceed. Resets automatically after
each use — great for phased/iterative algorithms.
// Example: 3 worker threads process data in phases. All must finish phase N
// before any can start phase N+1.
int threadCount = 3;
int phases = 4;
// Optional barrier action runs ONCE when all threads arrive (by the last arriving thread)
CyclicBarrier barrier = new CyclicBarrier(threadCount, () -> {
System.out.println("--- All threads arrived. Phase complete. ---");
});
for (int t = 0; t < threadCount; t++) {
final int threadId = t;
new Thread(() -> {
try {
for (int phase = 0; phase < phases; phase++) {
// Each thread does its own work for this phase
System.out.println("Thread " + threadId + " working on phase " + phase);
Thread.sleep(100 * (threadId + 1)); // Varying work times
// Block until ALL threads reach this point
barrier.await();
// All threads released simultaneously. Barrier auto-resets for next phase.
}
} catch (InterruptedException | BrokenBarrierException e) {
// BrokenBarrierException: thrown if any thread is interrupted or times out
// while others are waiting. The barrier is now "broken" — all waiting
// threads get this exception. Must create a new barrier to continue.
Thread.currentThread().interrupt();
}
}).start();
}
// Output:
// Thread 0 working on phase 0
// Thread 1 working on phase 0
// Thread 2 working on phase 0
// --- All threads arrived. Phase complete. ---
// Thread 0 working on phase 1
// ... (all threads proceed together through each phase)
// Key differences from CountDownLatch:
// - CyclicBarrier: ALL threads wait, ALL threads proceed. Reusable.
// - CountDownLatch: Some threads count down, other threads wait. One-shot.
Semaphore — “Limit concurrent access to N”
Controls how many threads can access a resource concurrently. Like a bouncer at a club with a fixed capacity.
// Example: Connection pool that allows at most 5 concurrent connections
Semaphore permits = new Semaphore(5); // 5 permits = 5 concurrent users max
// Each thread that wants a connection:
public Connection getConnection() throws InterruptedException, TimeoutException {
if (!permits.tryAcquire(5, TimeUnit.SECONDS)) {
// Waited 5 seconds and no permit became available
throw new TimeoutException("No connection available");
}
// Got a permit — we're one of at most 5 threads in this section
return pool.borrowConnection();
}
public void releaseConnection(Connection conn) {
pool.returnConnection(conn);
permits.release(); // Return the permit so another thread can enter
// IMPORTANT: always release in a finally block in real code!
}
// Full usage pattern:
permits.acquire(); // Blocks until a permit is available (or thread is interrupted)
try {
useSharedResource();
} finally {
permits.release(); // ALWAYS release — even if an exception occurs
}
// Fair semaphore (FIFO order — longest-waiting thread gets next permit):
Semaphore fair = new Semaphore(5, true); // true = fair
// Binary semaphore (permits=1) acts like a mutex, but unlike a lock,
// any thread can release it (not just the one that acquired it).
Phaser — Flexible Multi-Phase Coordination
Like CyclicBarrier but supports dynamic registration/deregistration of parties, and can terminate after a specific phase.
// Example: Dynamic number of workers, 3 phases, some workers drop out early
Phaser phaser = new Phaser(1); // Register self (the main thread) as a party
for (int t = 0; t < 3; t++) {
phaser.register(); // Dynamically register each worker
final int threadId = t;
new Thread(() -> {
try {
for (int phase = 0; phase < 3; phase++) {
System.out.println("Thread " + threadId + " phase " + phase);
Thread.sleep(100);
if (threadId == 1 && phase == 1) {
// Thread 1 drops out after phase 1
System.out.println("Thread 1 deregistering");
phaser.arriveAndDeregister(); // Leave the phaser
return;
}
phaser.arriveAndAwaitAdvance(); // Like barrier.await()
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
phaser.arriveAndDeregister(); // Main thread deregisters, workers continue on their own
Exchanger — Two Threads Swap Data
// Two threads rendezvous and swap objects. Useful for pipeline stages.
Exchanger<List<String>> exchanger = new Exchanger<>();
// Thread A fills a buffer, swaps it for an empty one
Thread filler = new Thread(() -> {
try {
List<String> buffer = new ArrayList<>();
while (running) {
buffer.add(generateItem());
if (buffer.size() == BATCH_SIZE) {
buffer = exchanger.exchange(buffer); // Swap full buffer for empty one
buffer.clear();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Thread B processes a full buffer, gives back an empty one
Thread processor = new Thread(() -> {
try {
List<String> buffer = new ArrayList<>();
while (running) {
buffer = exchanger.exchange(buffer); // Swap empty buffer for full one
processAll(buffer);
buffer.clear();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
When to Use What
| Need | Use | Reusable? |
|---|---|---|
| Wait for N tasks to complete | CountDownLatch | No (one-shot) |
| All N threads synchronize at a point, then proceed | CyclicBarrier | Yes (auto-reset) |
| Limit concurrent access to a resource (like a pool) | Semaphore | N/A |
| Dynamic party count, optional termination | Phaser | Yes |
| Two threads exchange data at a rendezvous point | Exchanger | Yes |
8. Executors and Thread Pools
The Executor Framework
// DON'T create threads manually for production code. Use executors.
// Fixed pool: exactly N threads, unbounded task queue
ExecutorService fixed = Executors.newFixedThreadPool(4);
// Cached pool: creates threads as needed, reuses idle ones (60s timeout)
ExecutorService cached = Executors.newCachedThreadPool();
// Single thread: guarantees tasks execute sequentially, in submission order
ExecutorService single = Executors.newSingleThreadExecutor();
// Scheduled: for delayed or periodic tasks
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);
scheduled.scheduleAtFixedRate(() -> cleanup(), 0, 1, TimeUnit.MINUTES);
Submitting Tasks and Getting Results
ExecutorService executor = Executors.newFixedThreadPool(4);
// submit(Runnable) — fire and forget (Future<?> for completion tracking)
Future<?> f1 = executor.submit(() -> System.out.println("hello"));
f1.get(); // Blocks until done. Returns null for Runnable.
// submit(Callable<V>) — returns a result
Future<String> f2 = executor.submit(() -> {
Thread.sleep(1000);
return "computed result";
});
String result = f2.get(); // Blocks until result is ready
String resultWithTimeout = f2.get(5, TimeUnit.SECONDS); // Blocks up to 5 seconds
// Throws TimeoutException if not done in time.
// Throws ExecutionException if the task threw an exception.
// invokeAll — submit multiple tasks, wait for ALL to complete
List<Callable<String>> tasks = List.of(
() -> "task1",
() -> "task2",
() -> "task3"
);
List<Future<String>> futures = executor.invokeAll(tasks); // Blocks until all done
for (Future<String> future : futures) {
System.out.println(future.get()); // Already complete — get() returns immediately
}
// invokeAll with timeout — uncompleted tasks are CANCELLED
List<Future<String>> futures = executor.invokeAll(tasks, 5, TimeUnit.SECONDS);
for (Future<String> future : futures) {
if (future.isCancelled()) {
System.out.println("Task timed out");
} else {
System.out.println(future.get());
}
}
// invokeAny — returns the result of the FIRST task that completes successfully
// Cancels all remaining tasks.
String fastest = executor.invokeAny(tasks); // Returns "task1" (or whichever finishes first)
CompletionService — Process Results as They Complete
// Problem: invokeAll waits for ALL tasks. What if you want to process results
// as they arrive, in completion order?
ExecutorService executor = Executors.newFixedThreadPool(4);
CompletionService<String> cs = new ExecutorCompletionService<>(executor);
// Submit tasks
for (int i = 0; i < 10; i++) {
final int id = i;
cs.submit(() -> {
Thread.sleep(100 * (10 - id)); // Task 9 finishes first
return "result-" + id;
});
}
// Process results in completion order (not submission order)
for (int i = 0; i < 10; i++) {
Future<String> completed = cs.poll(30, TimeUnit.SECONDS); // Next completed task
if (completed == null) break; // Timeout — no more results
try {
System.out.println(completed.get()); // Already done — returns immediately
} catch (ExecutionException e) {
System.err.println("Task failed: " + e.getCause());
}
}
executor.shutdown();
ThreadPoolExecutor — Full Control
// The factory methods above hide important details. Prefer direct construction:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize: minimum threads kept alive
8, // maxPoolSize: maximum threads under load
60L, TimeUnit.SECONDS, // keepAliveTime: idle excess threads die after this
new ArrayBlockingQueue<>(100), // BOUNDED work queue (prevents OOM)
new ThreadPoolExecutor.CallerRunsPolicy() // Rejection policy (see below)
);
Rejection Policies (when queue is full AND max threads reached)
| Policy | Behavior |
|---|---|
AbortPolicy (default) | Throws RejectedExecutionException |
CallerRunsPolicy | Caller’s thread executes the task (natural back-pressure!) |
DiscardPolicy | Silently drops the task |
DiscardOldestPolicy | Drops oldest queued task, retries |
Proper Shutdown
executor.shutdown(); // Stop accepting new tasks, finish queued ones
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow(); // Interrupt running tasks
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
}
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
GOTCHA:
Executors.newFixedThreadPool()uses an UNBOUNDEDLinkedBlockingQueue. Under load, tasks pile up → OOM. UseThreadPoolExecutorwith a bounded queue.
GOTCHA:
Executors.newCachedThreadPool()can create UNLIMITED threads. A burst of tasks can create thousands of threads → OOM or thrashing.
ForkJoinPool — Divide and Conquer
Work-stealing pool: idle threads steal work from busy threads’ queues.
// RecursiveTask<V> returns a value. RecursiveAction is void.
class SumTask extends RecursiveTask<Long> {
private final int[] array;
private final int start, end;
private static final int THRESHOLD = 1000;
SumTask(int[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
// Base case: small enough to compute directly
long sum = 0;
for (int i = start; i < end; i++) sum += array[i];
return sum;
}
// Split in half
int mid = (start + end) / 2;
SumTask left = new SumTask(array, start, mid);
SumTask right = new SumTask(array, mid, end);
left.fork(); // Submit left half to the pool
long rightResult = right.compute(); // Compute right half in current thread
long leftResult = left.join(); // Wait for left half's result
return leftResult + rightResult;
}
}
// Usage:
ForkJoinPool pool = new ForkJoinPool(4);
int[] data = new int[1_000_000];
long total = pool.invoke(new SumTask(data, 0, data.length));
// Or use the common pool (shared across the JVM):
long total = ForkJoinPool.commonPool().invoke(new SumTask(data, 0, data.length));
9. CompletableFuture
Basic Chaining
ExecutorService executor = Executors.newFixedThreadPool(4);
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> {
// Runs on the executor thread pool
return fetchDataFromDb();
}, executor)
.thenApply(data -> {
// Synchronous transform — runs on the same thread that completed the previous stage
return data.toUpperCase();
})
.thenApplyAsync(data -> {
// Async transform — runs on executor (specify it!)
return enrichData(data);
}, executor)
.exceptionally(ex -> {
// Recover from any error in the chain
System.err.println("Failed: " + ex.getMessage());
return "default-value";
});
// Block and get the result (only do this at the top level — don't block inside a chain)
String result = future.get(10, TimeUnit.SECONDS);
executor.shutdown();
Parallel Composition
// Run two independent tasks in parallel, combine their results
CompletableFuture<User> userFuture = CompletableFuture
.supplyAsync(() -> fetchUser(userId), executor);
CompletableFuture<List<Order>> ordersFuture = CompletableFuture
.supplyAsync(() -> fetchOrders(userId), executor);
// Combine when both are done:
CompletableFuture<Profile> profileFuture = userFuture
.thenCombineAsync(ordersFuture, (user, orders) -> new Profile(user, orders), executor);
// Wait for ALL of several futures:
CompletableFuture<Void> all = CompletableFuture.allOf(future1, future2, future3);
all.thenRun(() -> {
// All three are done. Get individual results:
String r1 = future1.join(); // join() is like get() but throws unchecked exceptions
String r2 = future2.join();
String r3 = future3.join();
});
// Wait for FIRST to complete (any of them):
CompletableFuture<Object> first = CompletableFuture.anyOf(future1, future2, future3);
first.thenAccept(result -> System.out.println("First result: " + result));
Error Handling
// exceptionally: recover from errors — returns a replacement value
future.exceptionally(ex -> {
log.error("Failed", ex);
return defaultValue;
});
// handle: access both result and error
// If ex is non-null, the future completed exceptionally. If ex is null, it completed
// normally (but result may still be null — so DON'T assume "exactly one is non-null").
future.handle((result, ex) -> {
if (ex != null) {
log.error("Failed", ex);
return fallbackValue;
}
return process(result);
});
// whenComplete: side-effects only (logging, metrics) — does NOT change the result
future.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Failed", ex);
} else {
metrics.record(result);
}
});
// The returned future still holds the original result (or error).
thenCompose vs thenApply
// thenApply: T → U (synchronous transform, like .map())
CompletableFuture<String> upper = future.thenApply(s -> s.toUpperCase());
// thenCompose: T → CompletableFuture<U> (async chain, like .flatMap())
// Use when the next step is ITSELF asynchronous:
CompletableFuture<Order> orderFuture = userFuture
.thenCompose(user -> fetchOrderAsync(user.getId()));
// thenApply would give you CompletableFuture<CompletableFuture<Order>> — wrong!
Key Methods Summary
| Method | Input → Output | Async? | Notes |
|---|---|---|---|
thenApply | T → U | No | Sync transform (like map) |
thenApplyAsync | T → U | Yes | Async transform |
thenCompose | T → CF<U> | No | Flat-map (chain async operations) |
thenComposeAsync | T → CF<U> | Yes | Async flat-map |
thenCombine | (T, U) → V | No | Join two independent futures |
thenAccept | T → void | No | Consume result (side-effect) |
thenRun | () → void | No | Run after completion (ignores result) |
exceptionally | Throwable → T | No | Error recovery |
handle | (T, Throwable) → U | No | Handle both success and error |
whenComplete | (T, Throwable) → void | No | Side-effects only, doesn’t change result |
GOTCHA: By default,
supplyAsyncusesForkJoinPool.commonPool(). In production/interviews, always specify your own executor:supplyAsync(supplier, myExecutor)
GOTCHA:
thenApplyvsthenComposeis a common interview question. Think of it likeOptional.map()vsOptional.flatMap()— usethenComposewhen your function itself returns aCompletableFutureto avoid wrapping.
10. Virtual Threads
What They Are (Java 21+)
Virtual threads are lightweight threads managed by the JVM, not the OS. A single JVM can run millions of virtual threads, because they are not mapped 1:1 to OS threads. They are scheduled onto a pool of carrier (platform) threads by the JVM’s scheduler.
Creating Virtual Threads
// Option 1: Thread.ofVirtual()
Thread vt = Thread.ofVirtual().name("worker").start(() -> {
System.out.println("Running on: " + Thread.currentThread());
// During a blocking call (sleep, I/O, lock), the virtual thread is
// unmounted from its carrier — the carrier can run other virtual threads.
try { Thread.sleep(1000); } catch (InterruptedException e) { /* ... */ }
});
vt.join();
// Option 2: Virtual-thread-per-task executor (the most common pattern)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Submit 10,000 tasks — each gets its own virtual thread. No pool sizing needed.
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
final int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // Simulates I/O
return "result-" + id;
}));
}
for (var f : futures) {
System.out.println(f.get());
}
} // AutoCloseable: shuts down and awaits termination
// Option 3: Thread.startVirtualThread (quick one-off)
Thread.startVirtualThread(() -> System.out.println("Hello from virtual thread"));
When to Use Virtual Threads
Is the workload I/O-bound? (network calls, DB queries, file I/O)
├── Yes → Virtual threads are ideal. Use newVirtualThreadPerTaskExecutor().
│ No pool sizing, no queueing, scales to millions of concurrent tasks.
└── No (CPU-bound: computation, hashing, compression, etc.)
└── Use platform threads with a fixed-size pool (ForkJoinPool, ThreadPoolExecutor).
Virtual threads gain nothing here — the bottleneck is CPU, not waiting.
Pinning — The Main Pitfall
A virtual thread is pinned to its carrier (cannot unmount) when:
- Inside a
synchronizedblock or method - Inside a native method or foreign function
While pinned, the carrier thread is blocked — other virtual threads can’t use it. This defeats the purpose and can cause throughput collapse.
// BAD: synchronized pins the virtual thread to its carrier
private final Object lock = new Object();
public void fetchData() {
synchronized (lock) { // PINNED — carrier thread is stuck
httpClient.send(request); // Long I/O while pinned = wasted carrier
}
}
// FIX: Use ReentrantLock — virtual threads unmount normally while waiting
private final ReentrantLock lock = new ReentrantLock();
public void fetchData() {
lock.lock(); // Virtual thread can unmount while waiting
try {
httpClient.send(request); // If this blocks, virtual thread unmounts
} finally {
lock.unlock();
}
}
GOTCHA: Don’t pool virtual threads. The whole point is one-thread-per-task — create them freely and let them be GC’d. Using a bounded pool of virtual threads is an anti-pattern that limits concurrency for no benefit.
GOTCHA:
ThreadLocalworks with virtual threads but can cause memory issues at scale (millions of threads × per-thread state). Prefer passing context explicitly or using scoped values (ScopedValue, preview in Java 21, stable in Java 25).
GOTCHA:
-Djdk.tracePinnedThreads=shortis your best friend for finding pinning issues. It prints a stack trace whenever a virtual thread is pinned.
11. Structured Concurrency
What It Is (Preview in Java 21–24, Stable in Java 25)
Structured concurrency treats concurrent tasks as a unit — if a parent task spawns subtasks, all subtasks are guaranteed to complete (or be cancelled) before the parent continues. No more fire-and-forget threads leaking after an exception.
StructuredTaskScope — The Core API
// Run two tasks in parallel, wait for both, handle errors as a unit
try (var scope = StructuredTaskScope.open()) {
// Fork subtasks — each runs in its own virtual thread
Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
// Wait for all subtasks to complete (or fail)
scope.join();
// Get results — throws if the subtask failed
User user = userTask.get();
List<Order> orders = ordersTask.get();
return new Profile(user, orders);
} // AutoCloseable: if we exit early (exception), all subtasks are cancelled
// Compare with CompletableFuture — structured concurrency is simpler:
// - No need to manage an executor
// - Automatic cancellation on failure
// - Exception from a subtask propagates naturally
// - Virtual threads are used automatically
Joiner Policies
// Wait for ALL subtasks (default):
try (var scope = StructuredTaskScope.open()) {
var a = scope.fork(() -> taskA());
var b = scope.fork(() -> taskB());
scope.join(); // Waits until both complete (success or failure)
}
// Short-circuit on first SUCCESS (cancel the rest):
try (var scope = StructuredTaskScope.open(Joiner.anySuccessfulResultOrThrow())) {
scope.fork(() -> queryMirror1());
scope.fork(() -> queryMirror2());
scope.fork(() -> queryMirror3());
String fastest = scope.join(); // Returns the first successful result
// Remaining subtasks are cancelled automatically
}
// Short-circuit on first FAILURE (cancel the rest — fail fast):
try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
var a = scope.fork(() -> validateInput());
var b = scope.fork(() -> checkPermissions());
scope.join(); // If either fails, the other is cancelled and exception is thrown
}
When to Use What
| Pattern | Use |
|---|---|
| Independent I/O tasks, need all results | StructuredTaskScope (default joiner) |
| Race multiple sources, take first success | Joiner.anySuccessfulResultOrThrow() |
| All must succeed or fail fast | Joiner.allSuccessfulOrThrow() |
| Complex async pipelines with transforms | CompletableFuture (more flexible chaining) |
| CPU-bound parallel decomposition | ForkJoinPool / parallel streams |
GOTCHA: You cannot fork subtasks after calling
join(). The scope is a one-shot lifecycle: fork → join → get results → close.
GOTCHA: Subtasks MUST be virtual threads (the default). Structured concurrency is designed around the virtual thread model — you can’t use it with platform thread pools.
12. Common Concurrency Patterns
Producer-Consumer with Multiple Consumers
See section 6 for the basic single-consumer pattern. Here’s the multi-consumer variant:
// Multiple consumers: need one poison pill PER consumer
BlockingQueue<String> queue = new ArrayBlockingQueue<>(100);
String POISON = "DONE";
int consumerCount = 4;
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 10_000; i++) {
queue.put("task-" + i);
}
// Send one poison pill per consumer — each consumer takes one and stops
for (int i = 0; i < consumerCount; i++) {
queue.put(POISON);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
List<Thread> consumers = new ArrayList<>();
for (int c = 0; c < consumerCount; c++) {
final int id = c;
Thread consumer = new Thread(() -> {
try {
while (true) {
String task = queue.take();
if (POISON.equals(task)) break;
process(task);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "consumer-" + id);
consumers.add(consumer);
}
producer.start();
consumers.forEach(Thread::start);
producer.join();
for (Thread c : consumers) c.join();
Thread-Safe Lazy Initialization (Holder Idiom)
// The best way to do lazy singletons in Java — no synchronization needed!
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
// Initialized when Holder class is first accessed (JLS guarantees thread safety)
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
Thread Confinement (ThreadLocal)
// Each thread gets its own instance — no sharing, no synchronization needed
private static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String date = dateFormat.get().format(new Date()); // Each thread uses its own formatter
// IMPORTANT: Clean up in thread pools to prevent memory leaks!
// Thread pool threads are reused — ThreadLocal values stick around!
executor.submit(() -> {
try {
dateFormat.get().format(new Date());
} finally {
dateFormat.remove(); // ALWAYS clean up in thread pools
}
});
Safe Publication
An object is safely published when both the reference and the object’s state are visible.
Safe publication methods:
- Static initializer:
static final Map<K,V> MAP = new HashMap<>(); - volatile field:
volatile Config config = loadConfig(); - AtomicReference:
AtomicReference<Config> config = new AtomicReference<>(...); - Final field:
final List<String> items = new ArrayList<>();(in constructor) — But only ifthisdoes not escape during construction (e.g., don’t passthisto another thread or store it in a static field inside the constructor). - Synchronized: Store via
synchronizedblock
Immutability as a Concurrency Strategy
// Immutable objects are inherently thread-safe — no synchronization needed!
// Java records are immutable by default:
public record Config(String host, int port, boolean ssl) {}
// Swap atomically with volatile:
private volatile Config currentConfig;
public void reload() {
Config newConfig = loadFromFile(); // Build a completely new immutable object
currentConfig = newConfig; // Single volatile write — atomic swap
}
public Config getConfig() {
return currentConfig; // Always returns a consistent, immutable snapshot
}
13. Common Concurrency Bugs
Race Condition: Check-Then-Act (TOCTOU)
// BROKEN: Two threads can both see null and both create
if (map.get(key) == null) { // Check ← Thread B reads null here too
map.put(key, createValue()); // Act ← Both threads put, one overwrites the other
}
// FIX: Use ConcurrentHashMap.computeIfAbsent() — atomic check-and-put
map.computeIfAbsent(key, k -> createValue());
// Same bug with a counter check:
// BROKEN: Multiple threads pass the check, all call take(), some block forever
while (count.get() < total) { // Check ← Multiple threads see count < total
item = buffer.take(); // Act ← Not all of them will get an item
}
// FIX: Use atomic claim: while (remaining.decrementAndGet() >= 0) { buffer.take(); }
Race Condition: Read-Modify-Write
// BROKEN: count++ is not atomic (read → increment → write)
private int count;
public void increment() { count++; }
// Thread A reads 5, Thread B reads 5, both write 6. One increment is lost.
// FIX: AtomicInteger
private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); }
Deadlock: Circular Lock Ordering
// BROKEN: Thread 1 locks A then B, Thread 2 locks B then A
// Thread 1: // Thread 2:
synchronized(A) { synchronized(B) {
synchronized(B) { synchronized(A) { // DEADLOCK!
} }
} }
// FIX: Always lock in a consistent global order (e.g., by object ID)
BankAccount first = (a.id < b.id) ? a : b;
BankAccount second = (a.id < b.id) ? b : a;
synchronized(first) {
synchronized(second) { /* safe — always same order */ }
}
Deadlock: Lock Upgrade (ReadWriteLock)
ReadWriteLock rwl = new ReentrantReadWriteLock();
rwl.readLock().lock();
rwl.writeLock().lock(); // DEADLOCK — this thread holds the read lock, which blocks
// ALL writers, including itself.
// FIX: Release read lock first, then acquire write lock, then double-check.
Memory Leak: ThreadLocal in Thread Pools
// Thread pool threads are reused — ThreadLocal values stick around forever!
private static final ThreadLocal<HeavyObject> cache = ThreadLocal.withInitial(HeavyObject::new);
executor.submit(() -> {
try {
cache.get().doWork();
} finally {
cache.remove(); // ALWAYS clean up in thread pools
}
});
Lost Signal: notify() vs notifyAll()
// DANGEROUS: notify() only wakes ONE thread. If the wrong thread wakes up
// (e.g., a producer wakes another producer instead of a consumer), the
// signal is lost — the consumer that should have woken up stays sleeping.
//
// FIX options:
// 1. Use notifyAll() — wakes all threads, each re-checks its condition
// 2. Use Condition objects with separate conditions per thread type:
// Condition notFull (producers wait here), Condition notEmpty (consumers wait here)
// Then signal() is safe because it only wakes the right type of thread.
14. Decision Framework
”Do I Need Synchronization?"
Is the data shared between threads?
├── No → No synchronization needed (thread confinement)
└── Yes → Is the data immutable?
├── Yes → No synchronization needed (immutable + safe publication)
└── No → Is it a single variable?
├── Yes → Is it a simple read/write?
│ ├── Yes → volatile
│ └── No (read-modify-write) → AtomicInteger/AtomicReference
└── No → Is it a standard data structure?
├── Yes → Use concurrent collection (ConcurrentHashMap, etc.)
└── No → Is it read-heavy?
├── Yes → ReadWriteLock or StampedLock
└── No → synchronized or ReentrantLock
"Which Coordination Primitive?"
What are you coordinating?
├── "Wait for N tasks to finish" → CountDownLatch
├── "N threads synchronize, then all proceed" → CyclicBarrier
├── "Limit concurrent access to N" → Semaphore
├── "Dynamic parties / optional termination" → Phaser
├── "Producer-consumer pipeline" → BlockingQueue
├── "One thread produces, one consumes" → SynchronousQueue
├── "Two threads swap data" → Exchanger
├── "Async pipeline with composition" → CompletableFuture
├── "Parallel I/O tasks with cancellation" → StructuredTaskScope (Java 25+)
└── "Thread-safe lazy initialization" → Holder idiom or computeIfAbsent
"Which Thread Model?"
Is the workload I/O-bound? (HTTP calls, DB queries, file reads)
├── Yes → Virtual threads (newVirtualThreadPerTaskExecutor)
│ Need structured cancellation? → StructuredTaskScope
└── No (CPU-bound: computation, hashing, sorting)
└── Platform threads with fixed pool (ThreadPoolExecutor / ForkJoinPool)
"Which Lock?”
├── Simple mutual exclusion → synchronized (simplest)
├── Need tryLock / timeout → ReentrantLock
├── Need multiple wait conditions → ReentrantLock + Condition
├── Read-heavy workload → ReentrantReadWriteLock
├── Read-heavy + performance critical → StampedLock (optimistic reads)
└── No lock needed → AtomicReference + CAS (lock-free)
Performance Considerations
- Lock contention is the #1 performance killer. Minimize time spent holding locks.
- False sharing: Two threads writing to adjacent memory locations (same cache line)
cause cache invalidation. Use
@Contendedor padding. - Context switching: Thousands of threads = lots of switching. Use thread pools.
- Lock-free > fine-grained locking > coarse-grained locking (for performance) but also: more complex > less complex (for correctness). Choose wisely.
Interview Rule of Thumb
- Start with the simplest correct solution
- Mention that you could optimize with [specific technique]
- Only optimize if the interviewer asks you to
- A correct solution with
synchronizedbeats a buggy lock-free solution every time
#java #coding #guide #concurrency