๐ŸŒฑSeedlingยทLast tended 2026-03-02

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

  1. The Java Memory Model
  2. Thread Fundamentals
  3. synchronized โ€” The Foundation
  4. Explicit Locks
  5. Atomic Variables
  6. Concurrent Collections
  7. Coordination Primitives
  8. Executors and Thread Pools
  9. CompletableFuture
  10. Virtual Threads
  11. Structured Concurrency
  12. Common Concurrency Patterns
  13. Common Concurrency Bugs
  14. 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)

  1. Program order: Within a thread, each action happens-before the next
  2. Monitor lock: Unlock of a monitor happens-before subsequent lock of that monitor
  3. Volatile: Write to a volatile field happens-before subsequent read of that field
  4. Thread start: thread.start() happens-before any action in the started thread
  5. 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: 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 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 synchronized block)
  • 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 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 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 (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 = 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 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 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: 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 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. 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.

ImplementationBounded?Notes
ArrayBlockingQueueYes (fixed)Fair ordering option. Most common choice.
LinkedBlockingQueueOptionalOptionally bounded. Higher throughput than ABQ.
PriorityBlockingQueueNo (unbounded)Elements ordered by priority.
SynchronousQueueZero capacityDirect handoff: put blocks until take is called.
DelayQueueNo (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

NeedUseReusable?
Wait for N tasks to completeCountDownLatchNo (one-shot)
All N threads synchronize at a point, then proceedCyclicBarrierYes (auto-reset)
Limit concurrent access to a resource (like a pool)SemaphoreN/A
Dynamic party count, optional terminationPhaserYes
Two threads exchange data at a rendezvous pointExchangerYes

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)

PolicyBehavior
AbortPolicy (default)Throws RejectedExecutionException
CallerRunsPolicyCaller's thread executes the task (natural back-pressure!)
DiscardPolicySilently drops the task
DiscardOldestPolicyDrops 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 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 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

MethodInput โ†’ OutputAsync?Notes
thenApplyT โ†’ UNoSync transform (like map)
thenApplyAsyncT โ†’ UYesAsync transform
thenComposeT โ†’ CF<U>NoFlat-map (chain async operations)
thenComposeAsyncT โ†’ CF<U>YesAsync flat-map
thenCombine(T, U) โ†’ VNoJoin two independent futures
thenAcceptT โ†’ voidNoConsume result (side-effect)
thenRun() โ†’ voidNoRun after completion (ignores result)
exceptionallyThrowable โ†’ TNoError recovery
handle(T, Throwable) โ†’ UNoHandle both success and error
whenComplete(T, Throwable) โ†’ voidNoSide-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:

  1. Inside a synchronized block or method
  2. 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: 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 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

PatternUse
Independent I/O tasks, need all resultsStructuredTaskScope (default joiner)
Race multiple sources, take first successJoiner.anySuccessfulResultOrThrow()
All must succeed or fail fastJoiner.allSuccessfulOrThrow()
Complex async pipelines with transformsCompletableFuture (more flexible chaining)
CPU-bound parallel decompositionForkJoinPool / 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:

  1. Static initializer: static final Map<K,V> MAP = new HashMap<>();
  2. volatile field: volatile Config config = loadConfig();
  3. AtomicReference: AtomicReference<Config> config = new AtomicReference<>(...);
  4. 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).
  5. 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 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

  1. Lock contention is the #1 performance killer. Minimize time spent holding locks.
  2. False sharing: Two threads writing to adjacent memory locations (same cache line) cause cache invalidation. Use @Contended or padding.
  3. Context switching: Thousands of threads = lots of switching. Use thread pools.
  4. 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 synchronized beats a buggy lock-free solution every time
javacodingguideconcurrency