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.
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 ATOMICITYpublic 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: volatile only helps with simple reads and writes. For read-modify-write
operations (like increment), use AtomicInteger or 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 threadt.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 readyexecutor.shutdown();
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 secondsworker.interrupt(); // Request the thread to stopworker.join(); // Wait for it to actually finish
GOTCHA: NEVER swallow InterruptedException silently. 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 objectpublic void increment() { synchronized (lock) { count++; }}// Static synchronized โ locks on the Class objectpublic static synchronized void staticMethod() { /* ... */ }
wait() / notify() / notifyAll() โ Complete Example
// Thread-safe bounded buffer using wait/notifypublic 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 (not if) around wait() โ spurious wakeups can happen!
The thread can wake up even though no one called notify().
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 = 1lock.lock(); // hold count = 2 (same thread โ OK)lock.unlock(); // hold count = 1lock.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 hereprivate final Condition notEmpty = lock.newCondition(); // Consumers wait herepublic 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 Condition objects, and stamps must be
used correctly โ calling unlockWrite with a read stamp (or vice versa) throws
IllegalMonitorStateException.
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 locksAtomicInteger 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 swapsAtomicReference<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 variablecounter.add(5); // Add arbitrary amountlong total = counter.sum(); // Read the total (sums all cells)// Note: sum() is eventually consistent during concurrent writes.// LongAccumulator โ generalized version with custom operationLongAccumulator maxFinder = new LongAccumulator(Long::max, Long.MIN_VALUE);maxFinder.accumulate(42); // Thread-safe running maximummaxFinder.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: AtomicReference makes the reference atomic โ NOT the referenced object.
If you have AtomicReference<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 1wordCounts.compute("hello", (k, v) -> v == null ? 1L : v + 1); // Same thingwordCounts.putIfAbsent("hello", 0L); // Only puts if key is absentwordCounts.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. Use mappingCount() for a long
return type, but it's still an estimate. This is by design โ exact counts would
require locking the entire map.
GOTCHA: ConcurrentHashMap does NOT allow null keys or values (unlike HashMap).
map.get(key) returning null unambiguously 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 ArrayBlockingQueueBlockingQueue<String> queue = new ArrayBlockingQueue<>(100); // Capacity 100// Producer threadThread 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 threadThread 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 falsequeue.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 changesList<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 finishint workerCount = 5;CountDownLatch startGate = new CountDownLatch(1); // Workers wait for "go"CountDownLatch doneLatch = new CountDownLatch(workerCount); // Main waits for workersfor (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 simultaneouslydoneLatch.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 connectionsSemaphore 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 earlyPhaser phaser = new Phaser(1); // Register self (the main thread) as a partyfor (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 oneThread 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 oneThread 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 queueExecutorService 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 orderExecutorService single = Executors.newSingleThreadExecutor();// Scheduled: for delayed or periodic tasksScheduledExecutorService 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 resultFuture<String> f2 = executor.submit(() -> { Thread.sleep(1000); return "computed result";});String result = f2.get(); // Blocks until result is readyString 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 completeList<Callable<String>> tasks = List.of( () -> "task1", () -> "task2", () -> "task3");List<Future<String>> futures = executor.invokeAll(tasks); // Blocks until all donefor (Future<String> future : futures) { System.out.println(future.get()); // Already complete โ get() returns immediately}// invokeAll with timeout โ uncompleted tasks are CANCELLEDList<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 tasksfor (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 onestry { 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 UNBOUNDED LinkedBlockingQueue.
Under load, tasks pile up โ OOM. Use ThreadPoolExecutor with 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 resultsCompletableFuture<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 valuefuture.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 resultfuture.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, supplyAsync uses ForkJoinPool.commonPool().
In production/interviews, always specify your own executor:
supplyAsync(supplier, myExecutor)
GOTCHA: thenApply vs thenCompose is a common interview question. Think of it
like Optional.map() vs Optional.flatMap() โ use thenCompose when your function
itself returns a CompletableFuture to 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 synchronized block 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 carrierprivate 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 waitingprivate 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: ThreadLocal works 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=short is 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 unittry (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 consumerBlockingQueue<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 neededprivate 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<>();
AtomicReference: AtomicReference<Config> config = new AtomicReference<>(...);
Final field: final List<String> items = new ArrayList<>(); (in constructor)
โ But only if this does not escape during construction (e.g., don't pass this
to another thread or store it in a static field inside the constructor).
Synchronized: Store via synchronized block
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 createif (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-putmap.computeIfAbsent(key, k -> createValue());// Same bug with a counter check:// BROKEN: Multiple threads pass the check, all call take(), some block foreverwhile (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: AtomicIntegerprivate 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)