Java / Concurrency & Multithreading

Java Concurrency & Multithreading, End to End

From CPU cache lines to CompletableFuture — why concurrency exists in hardware, all six thread states, locks and volatile, thread pools, the full java.util.concurrent toolkit, the memory model, and the bugs that show up when any of it is misused. One page, start to finish.

On this page

Concurrency isn't a language feature bolted onto Java — it's a direct answer to a hardware problem. Every idea below, from volatile to CompletableFuture, exists because of the same underlying fact: CPU cores got fast, memory didn't keep up, and caches created a world where two threads can honestly disagree about the value of a variable. Keep that one sentence in mind and the rest of this guide reads as consequences, not trivia.

00JVM memory architecture

Before threads, you need the map they live on. Each Java program you run is one OS process and one JVM instance — completely isolated from every other JVM instance, even one running the exact same program. Two JVMs never share heap memory; the same virtual address in each points to different physical RAM.

Inside a single JVM process, memory splits into segments that are either shared across every thread or private to one thread:

Fig. 1

Shared vs. private memory inside one JVM process

Shared vs. private memory inside one JVM processOne JVM process holds a shared area (code segment, data segment, heap) that every thread sees, and a private area where each thread has its own stack, registers and program counter. Shared heap objects and static fields need synchronization; local variables on a thread stack never do.JVM instance (1 process)Shared — all threads see thisPrivate — per threadThread 1 stackThread 2 stackThread 3 stackread / writeCode segmentbytecode, read-onlyData segmentstatic / global varsHeapobjects created with newStack (frames, locals)RegistersProgram Counter (PC)Stack (frames, locals)RegistersProgram Counter (PC)Stack (frames, locals)RegistersProgram Counter (PC)Synchronization neededTwo threads reading/writing the SAME heapobject or static fieldNever needs synchronizationLocal variables on a thread's own stack —nobody else can see themno sync neededsync neededsync needed
Code and data are shared and need care; stacks, registers, and the program counter are private and never need synchronization.
Memory areaShared or private?Sync needed?
Code segmentShared (all threads in process)No — read-only
Data segment (statics)SharedYes
HeapSharedYes
StackPrivate per threadNo
RegistersPrivate per threadNo
Program CounterPrivate per threadNo

From bytecode to a CPU register

Source compiles to bytecode (.class), which the JVM interprets at first, then profiles for "hot" methods and compiles to native machine code via the JIT. Each thread has its own register set and Program Counter — the PC tracks exactly which instruction a thread is on, and registers hold the JIT's optimized intermediate values. Both are saved and restored on every context switch.

Fig. 2

CPU cache hierarchy and access latency

CPU cache hierarchy and access latencyRegisters take about 1 cycle, L1 cache about 4 cycles (32–64KB), L2 about 12 cycles (256KB–1MB), shared L3 about 40 cycles (4–32MB) and main memory about 100 cycles (gigabytes).slower& largerRegistersL1 Cache32–64KBL2 Cache256KB–1MBL3 Cache4–32MB, sharedMain Memory (RAM)gigabytes~1 cycle~4 cycles~12 cycles~40 cycles~100 cyclesMemory levelAccess latency (CPU cycles)
Every step outward is roughly an order of magnitude slower — this gap is why caches exist, and why volatile has to fight against them.
Why this matters for everything that follows
Thread A on Core 1 writes counter = 5 — the write lands in Core 1's L1 cache first, not immediately in RAM. Thread B on Core 2 reads counter from its own cache and may still see the old value. This single fact — the CPU cache visibility problem — is the root cause behind volatile, synchronized, and the entire Java Memory Model.

Context switching, briefly

When the OS scheduler moves a core from Thread A to Thread B, it saves A's registers, program counter, and stack pointer to a control block in RAM, then restores B's. This is expensive for two reasons beyond the save/restore cost itself: the cache is now "cold" for the new thread, so its next few memory accesses pay full RAM latency, and — when switching processes rather than threads — the CPU's address-translation cache is flushed too. This is part of why Project Loom's virtual threads, scheduled by the JVM rather than the OS, are cheaper to switch.

01Why concurrency exists

Concurrency is not a software abstraction invented for convenience — it's a consequence of hardware history.

  • Single-core era: the OS faked parallelism via time-slicing; only one instruction ever executed at a time.
  • The memory wall: through the 1990s–2000s, clock speeds grew far faster than RAM access speed. CPU caches were built to bridge that gap.
  • The multi-core turn (~2005): manufacturers hit a power/heat ceiling on clock speed and added cores instead. A modern laptop has 8–16; a server may have 128.

A single-threaded Java program on a 16-core machine uses roughly 6% of available CPU. Concurrency is the tool that unlocks the other 94% — at the cost of introducing race conditions, deadlocks, visibility bugs, and liveness failures that don't exist in single-threaded code. The rest of this guide is about taming exactly those costs.

02Thread life cycle — all six states

Every Java thread moves through exactly six states, defined by Thread.State.

Fig. 3

The six Java thread states and their transitions

The six Java thread states and their transitionsNEW goes to RUNNABLE via start(). RUNNABLE goes to BLOCKED on entering a locked synchronized block, to WAITING via wait() or join(), and to TIMED_WAITING via sleep(n) or wait(n); each returns to RUNNABLE when the lock is released, on notify(), or on timeout. When run() returns the thread is TERMINATED..start()run() returnsenter lockedsynchronizedlock releasedwait() /join()notify()sleep(n) /wait(n)timeout /notify()NEWRUNNABLETERMINATEDBLOCKEDWAITINGTIMED_WAITINGone-waydead end · can't restart
NEW → RUNNABLE is one-way; TERMINATED is a dead end — a finished thread can never restart, you must construct a new one.
StateOS thread exists?Holds a lock?How to exit
NEWNoNo.start()
RUNNABLEYesMaybeVoluntary or blocking op
BLOCKEDYesNoOther thread releases the lock
WAITINGYesNonotify() / notifyAll() / join() returns
TIMED_WAITINGYesMaybe (see sleep)Timeout or signal
TERMINATEDNoNoFinal — cannot leave

Java doesn't distinguish "ready to run" from "actually running on a core" — both are just RUNNABLE. The JVM delegates scheduling entirely to the OS, and tracking the exact sub-state would require expensive, platform-specific calls the abstraction deliberately avoids.

A BLOCKED thread holds no locks
It's easy to mix up BLOCKED and WAITING. BLOCKED is involuntary — the thread wants to proceed but a monitor is held elsewhere. WAITING is voluntary — the thread called wait()/join()/park() and released whatever lock it held before suspending.

03Creating threads

new Thread(() -> ...) does nothing at the OS level. It allocates a plain Java object on the heap, copies thread-group, daemon status, and priority from the calling thread, stores the Runnable in a field, sets an internal threadStatus to 0 (NEW), and assigns an ID. Zero system calls.

Calling .start() is the real event: it's a thin wrapper around a native start0() that asks the OS (pthread_create on Linux, CreateThread on Windows) to create a kernel thread, allocate a native stack (often 512KB–1MB), and register it with the scheduler.

Four constructor shapes
Thread t1 = new Thread(myRunnable);                       // auto-named "Thread-0"
Thread t2 = new Thread(myRunnable, "payment-processor-1"); // always do this in prod
Thread t3 = new Thread(group, myRunnable, "io-worker-1");  // + ThreadGroup
class MyThread extends Thread { public void run(){ /*...*/ } }  // avoid — see §4

Naming threads is not cosmetic. A 2 a.m. thread dump full of Thread-47 tells you nothing; payment-processor-3 tells you exactly where to look.

Daemon vs. non-daemon

The JVM exits once every non-daemon thread has terminated — daemon threads are killed outright, mid-task, without ceremony. Use non-daemon for work that must finish (writing files, committing transactions); daemon for interruptible housekeeping (log flushing, heartbeats). Thread pool threads are non-daemon by default — this is exactly why forgetting executor.shutdown() keeps a JVM alive forever.

04Runnable vs. extending Thread

Both work. Only one scales.

implements Runnable — preferred

  • Leaves your single inheritance slot free for a real base class.
  • Models HAS-A behavior, not IS-A thread.
  • The same task can run on a bare thread, a pool, or a scheduler.
  • Lambda-compatible — Runnable is a functional interface.
  • Callers never get a Thread reference to fiddle with.

extends Thread — rarely justified

  • Burns your one inheritance slot.
  • Exposes setName(), setPriority(), even deprecated stop() to every caller.
  • Only reasonable if you're overriding Thread's own internals — genuinely rare.

05The synchronized keyword

Every Java object carries a monitor (intrinsic lock) as part of its header. At most one thread can own a given monitor at a time; everyone else trying to acquire it goes BLOCKED.

Fig. 4

One monitor, one owner, everyone else blocked

One monitor, one owner, everyone else blockedThread A owns the monitor (object lock); Threads B and C are BLOCKED waiting for it. On release the JVM picks one BLOCKED thread, with no fairness guarantee.Everyone elseownswaitingreleasepicks the next ownerThread Aowns itThread BBLOCKEDThread CBLOCKEDMonitor(object lock)On release: JVM picksone BLOCKED thread —no fairness guarantee
Instance methods lock this; static methods lock the Class object; a synchronized block can lock anything you choose.

What synchronized actually guarantees

  • Atomicity — the block runs as one indivisible unit from every other thread's point of view.
  • Visibility — releasing a monitor flushes writes to main memory; acquiring one invalidates the local cache and re-reads from memory.
  • Happens-before ordering — everything before a release is visible to whoever acquires next.
  • Mutual exclusion — exactly one owner at a time.
  • Reentrancy — the owning thread can re-acquire its own lock; the JVM tracks a hold count.
Prefer a private lock object over `this`
public class SafeCounter {
    private final Object lock = new Object(); // nobody outside can grab this
    private int count = 0;
    public void increment() {
        synchronized (lock) { count++; }
    }
}

Deadlock — the classic setup

// Thread 1: synchronized(lockA) { ... synchronized(lockB) { work(); } }
// Thread 2: synchronized(lockB) { ... synchronized(lockA) { work(); } }
// Each holds what the other wants. Neither proceeds.

Prevent it with a consistent global lock order, tryLock(timeout) instead of blocking forever, the smallest possible lock scope, and by avoiding holding two locks at once wherever you can.

06The volatile keyword

volatile exists to defeat exactly the cache visibility problem from §00: writes go straight through to main memory, reads come straight from it, and a memory barrier stops the compiler/CPU reordering instructions across the access.

What volatile does NOT give you
Atomicity for compound operations. counter++ on a volatile int is still read-modify-write in three separate steps — two threads can both read 5, both compute 6, both write 6, and an increment silently vanishes. For atomic compound updates, use AtomicInteger or synchronized.

The double-checked-locking singleton

public class Singleton {
    private static volatile Singleton instance;   // volatile is load-bearing here
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();   // alloc → init → assign
                }
            }
        }
        return instance;
    }
}

Without volatile, the JVM may reorder "initialize the object" and "assign the reference," letting another thread see a non-null, half-constructed instance. The memory barrier forbids exactly that reordering.

NeedUse
Only visibility (a simple flag)volatile
Atomic compound operationsynchronized or AtomicXxx
Mutual exclusionsynchronized or ReentrantLock
Lazy singleton initvolatile + double-checked locking
Multiple related variablessynchronized (all-or-nothing update)

07wait() and notify()

Polling a condition in a loop with Thread.sleep(10) wastes CPU and adds latency. wait()/notify() suspend a thread completely — zero CPU — until precisely signaled.

Three iron rules

  1. Must be called from inside a synchronized block on the same object — otherwise IllegalMonitorStateException.
  2. wait() atomically releases the monitor and suspends — there is no gap where you'd hold the lock but miss a signal.
  3. After being notified, the thread must re-acquire the monitor before it runs again — it goes BLOCKED first, never straight to RUNNABLE.
Fig. 5

The exact sequence after notifyAll()

The exact sequence after notifyAll()After notifyAll(), a WAITING thread first becomes BLOCKED, then becomes RUNNABLE only when it wins the lock, and then re-checks its while() condition. It never goes from WAITING straight to RUNNABLE.notifyAll()wins lock✕ never WAITING straight to RUNNABLEWAITING1BLOCKEDre-acquire monitor2RUNNABLE3Re-checkwhile() condition4
Never WAITING straight to RUNNABLE — re-acquiring the monitor through BLOCKED is mandatory and not instant.
Producer–consumer, the canonical pattern
public synchronized void produce(T item) throws InterruptedException {
    while (queue.size() == capacity) wait();   // WHILE, never if
    queue.offer(item);
    notifyAll();
}
public synchronized T consume() throws InterruptedException {
    while (queue.isEmpty()) wait();
    T item = queue.poll();
    notifyAll();
    return item;
}
Why while(), never if()
Two reasons compound: spurious wakeups are explicitly permitted by the spec — a thread can wake with no notify() at all — and even a real notification can be stale by the time you actually re-acquire the lock, because another thread may have already consumed the resource while you were BLOCKED. Re-checking the condition on every wake is the only fix for both. Default to notifyAll() unless you can prove every waiter shares one condition and only one thread benefits per signal — notify() picking the wrong thread is a real, if quiet, deadlock.
Deep dive Why wait() and notify() must be called inside synchronized The lost-wakeup race behind the rule, the monitor’s entry set and wait set, and how Lock/Condition inherits the same contract.

08wait() vs. sleep()

PropertyObject.wait()Thread.sleep()
Defined inObjectThread
Releases the monitor?YesNo
Needs synchronized?Yes — throws otherwiseNo restriction
Wakes early vianotify()/notifyAll()interrupt()
Typical useInter-thread coordinationFixed time delay

sleep() inside a synchronized block holds the lock the entire time — every other thread trying to enter is blocked for the full duration. That's a common, easy-to-miss source of accidental serialization. Also worth knowing: Thread.sleep() is static — calling it on a thread reference (t.sleep(1000)) actually sleeps the current thread, not t.

09Thread.join()

t.join() blocks the caller until t reaches TERMINATED. Internally it's implemented with wait() on the Thread object itself — the JVM calls notifyAll() on it when the thread finishes.

Thread t1 = new Thread(() -> sort(data, 0, mid));
Thread t2 = new Thread(() -> sort(data, mid, data.length));
t1.start(); t2.start();     // run in parallel
t1.join();  t2.join();      // barrier — both halves guaranteed sorted
merge(data, mid);

In modern code, prefer CompletableFuture.allOf(...).join(), CountDownLatch.await(), or ExecutorService.invokeAll() — all more flexible than raw join().

10Runnable vs. Callable

Runnable

void run() — no return value, no checked exceptions. Fire-and-forget.

Callable<V>

V call() throws Exception — returns a value, can throw anything. Pair with Future<V>.

Callable<Integer> job = () -> expensiveCalculation();
Future<Integer> future = executor.submit(job);
Integer result = future.get();  // blocks for the result

// Bare Thread only accepts Runnable — bridge with FutureTask
FutureTask<String> task = new FutureTask<>(callable);
new Thread(task).start();   // FutureTask implements Runnable
String r = task.get();      // ...and Future<V>

Exceptions thrown from call() are wrapped in ExecutionException and surface at future.get() — they don't crash the pool thread.

11Stopping a thread, correctly

Never Thread.stop()
Deprecated since 1998, removed in modern JDKs. It throws ThreadDeath at an arbitrary point in the stack — including mid-way through updating a shared data structure — leaving it visibly inconsistent to every other thread, with no way to defend against it.

1. volatile flag

Simple and clean, but only checked between iterations — a thread blocked in I/O won't notice until it unblocks.

volatile boolean running = true;
while (running) { doWork(); }
// caller: running = false;

2. Thread.interrupt()

Sets an interrupt flag and throws InterruptedException out of sleep()/wait()/join() immediately — handles blocking calls the flag approach can't.

catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // restore it!
}

Restoring the flag matters: catching InterruptedException clears it automatically, so if you swallow the exception silently, every caller further up the stack loses the fact that an interrupt ever happened.

For pools, prefer executor.shutdown() (graceful) and, if needed, shutdownNow(), which sends interrupt() to every running task — one more reason interrupt-responsive code matters even when you never touch a raw Thread.

12Thread pools — internals

Creating a thread costs a system call, a native stack allocation, and scheduler registration. At 1,000 requests/second, a thread-per-request model means 1,000 thread creations a second. Pools amortize that cost by reusing a fixed, warm set of threads.

FactoryBehaviorUse for
newFixedThreadPool(n)Exactly n threads; tasks queue when busyCPU-bound work
newCachedThreadPool()Grows on demand, no boundrisky can OOM under load
newSingleThreadExecutor()Exactly 1, sequentialSerializing access to a resource
newScheduledThreadPool(n)Delayed / periodic tasksHeartbeats, scheduled jobs

All four are convenience wrappers around ThreadPoolExecutor, which you should configure explicitly in production:

new ThreadPoolExecutor(
    4, 8, 60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(200),        // bounded — gives back-pressure
    myThreadFactory,                      // name your threads
    new ThreadPoolExecutor.CallerRunsPolicy()  // rejection policy
);
Fig. 6

Where a submitted task actually goes

Where a submitted task actually goesA submitted task starts a new core thread if active threads are below corePoolSize; otherwise it is enqueued if the queue is not full, with no new thread yet; if the queue is full a temporary thread is started while active threads are below maxPoolSize; otherwise the rejection policy runs.yesnoyesno(queue full)yesnoTasksubmittedactive <corePoolSize?1Start newcore threadqueuenot full?2EnqueueNO new thread yetactive <maxPoolSize?3Temp threadlast resortRejectionpolicy
The counter-intuitive part: tasks queue before the pool grows past corePoolSize — extra threads are the last resort, not the first.

The four rejection policies

  • AbortPolicy (default) — throws RejectedExecutionException.
  • CallerRunsPolicy — the submitting thread runs the task itself, a natural, self-throttling back-pressure.
  • DiscardPolicy — silently drops the task.
  • DiscardOldestPolicy — drops the oldest queued task and retries submission.

Sizing rules of thumb

CPU-bound: corePoolSize = maxPoolSize = number of cores — more threads than cores just adds context-switch overhead with no throughput gain. IO-bound: cores × (1 + wait_time / compute_time) — a thread that's 90% waiting on IO needs roughly 10x more threads than cores to keep one core saturated. Either way: estimate, then load-test and tune from real measurements, never a guess.

13corePoolSize vs. maxPoolSize

Spring's ThreadPoolTaskExecutor follows the exact same algorithm as §12 above. With corePoolSize=4, maxPoolSize=8, queue capacity 100: tasks 1–4 start core threads; tasks 5–104 simply queue with no new threads; only task 105 (queue now full) creates the pool's fifth thread; task 109 with 8 threads busy and the queue still full finally triggers the rejection policy.

@Bean
public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("my-service-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}

Expose getPoolSize(), getActiveCount(), and getQueue().size() as metrics — and alert well before the queue nears capacity, not after.

14java.util.concurrent — the full toolkit

Prefer these battle-tested building blocks over hand-rolled synchronized whenever they fit.

Locks

ReentrantLock

Explicit lock: tryLock(), timed acquisition, lockInterruptibly(), optional fairness, multiple Conditions. Always unlock() in finally.

ReadWriteLock

Many concurrent readers, one exclusive writer — ideal when reads vastly outnumber writes.

StampedLock

Adds an optimistic read mode with no lock at all, validated afterward — fastest option for read-heavy workloads (Java 8+).

Semaphore

N permits, N concurrent holders — natural fit for rate limiting and bounded resource pools.

Atomics — compare-and-swap, no locking

AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();                    // atomic i++
counter.compareAndSet(10, 20);                // CAS: only if current == 10
counter.updateAndGet(x -> x * 2);              // atomic functional update

CAS is a single CPU instruction — no OS lock, no thread parking, no context switch — which is why atomics dramatically outperform synchronized for simple counters under contention.

Concurrent collections

TypeBest for
ConcurrentHashMapGeneral-purpose thread-safe map, bucket-level locking
CopyOnWriteArrayListRare writes, frequent reads (e.g. listener lists)
ConcurrentLinkedQueueLock-free, high-throughput producer/consumer
ArrayBlockingQueue(n)Bounded queue that blocks producers/consumers at capacity
SynchronousQueueZero storage — every put is matched instantly by a take

Synchronizers

// CountDownLatch — one-shot barrier
CountDownLatch latch = new CountDownLatch(3);
executor.submit(() -> { doWork(); latch.countDown(); });
latch.await();  // blocks until count hits 0

// CyclicBarrier — reusable rendezvous point for N threads
CyclicBarrier barrier = new CyclicBarrier(4, () -> System.out.println("all arrived"));

// Semaphore — bounded concurrent access
Semaphore semaphore = new Semaphore(3);
semaphore.acquire(); try { useResource(); } finally { semaphore.release(); }

15Mutex patterns in Java

Java has no class literally called Mutex — three things implement the same one-owner-at-a-time semantics, each with a real trade-off.

FeaturesynchronizedReentrantLockSemaphore(1)
Must unlock in finallyNo — JVM handles itYesYes
ReentrantYesYesNo
tryLock() / timeoutNoYesYes (tryAcquire)
Interruptible waitNoYesYes
Multiple conditionsNo (one wait set)Yes (newCondition())No
Release by a different threadNoNoYes

ReentrantLock's biggest structural win over synchronized: multiple named Conditions. A bounded buffer needs to wake producers and consumers separately — synchronized only gives you one wait set and forces a blanket notifyAll(); ReentrantLock lets you notFull.signal() and notEmpty.signal() independently, waking only the threads that can actually proceed.

Fairness is a real trade, not a free upgrade
new ReentrantLock(true) serves waiting threads in FIFO order and prevents starvation — but fair locks run 2–10x slower in benchmarks because the JVM must maintain and check a queue instead of using the unfair fast path. Reach for fairness only once starvation is an observed problem, not by default.

16java.util.concurrent.Future

Future<V> is a handle to a background computation submitted via Callable.

Integer result = future.get();                       // blocks indefinitely
Integer result = future.get(5, TimeUnit.SECONDS);    // bounded wait
boolean done = future.isDone();                      // non-blocking check
future.cancel(true);                                 // interrupt if running

Its real limitation is that get() is blocking with no callback, chaining, or combining story — exactly the gap CompletableFuture was built to close.

17ThreadLocal

Every Thread object owns a private ThreadLocalMap keyed by ThreadLocal instances. Two threads reading the same ThreadLocal get completely independent values — no sharing, no synchronization required.

private static final ThreadLocal<SimpleDateFormat> FORMAT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// each thread gets its own formatter — SimpleDateFormat itself isn't thread-safe
The pooled-thread memory leak
Pool threads are reused. If request 1 sets a ThreadLocal on Thread-5 and never calls remove(), request 2 — served by the same recycled thread — silently sees request 1's stale value, and the original object can never be garbage-collected while the thread lives. Always threadLocal.remove() in a finally block in anything that runs on pooled threads.

InheritableThreadLocal copies a value to a child thread at creation time — which means it does not propagate into thread-pool workers, since those threads are created once at pool startup, not per task. For request-context propagation into a pool, use a framework's TaskDecorator equivalent instead.

18CompletableFuture

CompletableFuture<V> lets you describe what happens when a result becomes available, without blocking anything to wait for it.

Fig. 7

Composing two independent futures

Composing two independent futuressupplyAsync(fetchUser) and supplyAsync(fetchOrder) run in parallel; thenCombine((user, order) -> ...) joins both results once they finish and feeds the Dashboard.Both run in parallelsupplyAsync(fetchUser)supplyAsync(fetchOrder)thenCombine(user, order) -> ...Dashboardruns once both finish
Two independent async calls, combined once both finish — neither one blocks a thread waiting on the other.
CompletableFuture.supplyAsync(() -> fetchUser(id))
    .thenCompose(user -> fetchOrder(user.getId()))   // next step IS async → thenCompose
    .thenApply(order -> order.getTotal())            // pure transform → thenApply
    .exceptionally(ex -> BigDecimal.ZERO)             // fallback on any failure
    .thenAccept(total -> render(total));

// Waiting for several independent futures
CompletableFuture.allOf(cf1, cf2, cf3).join();
thenApply vs. thenCompose — map vs. flatMap
thenApply(fn) is Stream.map — fn returns a plain value. thenCompose(fn) is Stream.flatMap — fn itself returns a CompletableFuture, and the result is flattened rather than nested. Using thenApply where the next step is async gives you the wrong type: CompletableFuture<CompletableFuture<User>>.

19The Java Memory Model

The JMM specifies exactly when a write by one thread becomes visible to another. Its central idea is happens-before: if action A happens-before action B, every effect of A is guaranteed visible to B.

  • Program order — actions within one thread happen-before each other, in order.
  • Monitor release/acquire — releasing a lock happens-before the next thread acquiring that same lock.
  • volatile write/read — a volatile write happens-before any subsequent read of it.
  • Thread start/join — start() happens-before anything in the new thread; everything in a thread happens-before a successful join() on it.
  • Transitivity — A before B, B before C, therefore A before C.
Fig. 8

Happens-before chain across two threads

Happens-before chain across two threadsIn Thread 1, value = 42 is followed in program order by the volatile write ready = true. That volatile write happens-before Thread 2 reading ready in if (ready) use(value), so by transitivity Thread 2 sees value = 42.Thread 1Thread 2program orderhappens-beforesees value = 42 (transitivity)timevalue = 42plain writevolatile ready = truevolatile writeif (ready) use(value)volatile readwithout volatile: may be reordered
The volatile write/read pair is the guarantee — without it, the compiler or CPU may reorder the two writes in Thread 1, and Thread 2 could see ready = true with a stale value.

Safe publication means an object becomes visible to other threads only in a state the JMM guarantees is fully initialized — via final fields, a volatile write after construction, or static initialization (which the class-loading mechanism guarantees is safe on its own).

20Common concurrency bugs

Race condition

Behavior depends on thread interleaving. Classic check-then-act: if (!map.containsKey(k)) map.put(...) — fix with computeIfAbsent.

Data race

Two threads touch the same variable concurrently, at least one a write, with no synchronization — fix with AtomicInteger or a lock.

Deadlock

Circular lock waits — see §5.6. Fixed by consistent lock ordering or tryLock.

Livelock

Threads stay active but keep yielding to each other, never progressing — like two people repeatedly stepping the same way in a hallway.

Starvation

A thread never gets a fair shot at a resource because others monopolize it — fair locks (ReentrantLock(true)) address this directly.

Thread confinement

The actual fix behind most of the above: don't share. Local variables, ThreadLocal, and by-convention ownership all sidestep synchronization entirely.

21Interview questions

Core concepts

What is the difference between BLOCKED and WAITING?

BLOCKED is involuntary — the thread wants to proceed but a monitor is held elsewhere. WAITING is voluntary — the thread called wait(), join(), or park() and released any lock it held. A BLOCKED thread holds nothing; it's competing for something.

Does Thread.sleep() release the lock?

No. It goes to TIMED_WAITING but keeps every monitor it holds — anyone else waiting on that lock is blocked for the whole sleep. wait() is the one that releases the lock, and it's the key difference between the two.

Is i++ thread-safe?

No — it's read, add, write as three separate steps. Two threads can read the same value, both compute the same increment, and both write it back, silently losing an update. Use AtomicInteger.incrementAndGet() or synchronized.

Why must wait() always be in a while loop?

Spurious wakeups are explicitly legal per the JLS, and even a genuine notification can be stale by the time the thread re-acquires the lock — another thread may already have consumed the resource. Re-checking the condition on every wake handles both cases; if handles neither.

Synchronization

volatile vs. synchronized — what's the real difference?

volatile gives visibility and ordering only — no atomicity, no mutual exclusion. synchronized gives visibility, atomicity, ordering, and exclusive access, at the cost of potential blocking. Use volatile for simple flags; synchronized/AtomicXxx for anything compound.

What are the problems with locking on `this`?

External code can call synchronized(myObject) on your instance and unintentionally contend with your own internal locking. A private final Object lock = new Object() keeps the lock encapsulated and safe from outside interference.

Thread pools

Why is newCachedThreadPool() dangerous in production?

Unbounded max pool size plus a SynchronousQueue with zero buffering means every task with no idle thread available spawns a brand-new one. Under sustained load that's thousands of threads and an OOM. Always configure a bounded ThreadPoolExecutor explicitly.

What does CallerRunsPolicy actually buy you?

Rejected tasks run on the submitting thread instead of being thrown away or erroring out. That thread is now busy executing the task instead of submitting more, which naturally throttles the whole pipeline under overload — back-pressure without dropping work.

CompletableFuture & ThreadLocal

thenApply vs. thenCompose?

thenApply is map — a synchronous value transform. thenCompose is flatMap — used when the next step itself returns a CompletableFuture, flattening the result instead of nesting it.

How does ThreadLocal leak memory in a pool?

Pool threads persist for the pool's lifetime. A value set without a matching remove() stays in that thread's ThreadLocalMap indefinitely — visible (wrongly) to the next unrelated task on the same thread, and never eligible for GC while the thread lives. Always remove() in finally.

Advanced

What is the ABA problem?

A thread reads value A, another thread changes it to B and back to A, and the first thread's CAS succeeds because the value looks unchanged — even though it wasn't stable the whole time. Usually harmless; for lock-free structures where it matters (like some stacks), AtomicStampedReference adds a version counter to detect it.

What is ForkJoinPool, and when should you reach for it?

A pool tuned for divide-and-conquer recursion, using work-stealing so idle threads pull tasks from busy threads' queues. CompletableFuture.supplyAsync() uses the common ForkJoinPool by default. Best for CPU-bound recursive work (parallel sort, parallel streams); for IO-bound work, a regular sized ThreadPoolExecutor is the better fit.

Quick reference

Thread statesNEW → RUNNABLE → {BLOCKED, WAITING, TIMED_WAITING} → TERMINATED
sleep() vs wait()sleep keeps the lock · wait releases it
volatileVisibility + ordering only — never atomicity
synchronizedVisibility + atomicity + ordering + mutual exclusion
i++ atomic?No — use AtomicInteger.incrementAndGet()
notify vs notifyAllnotifyAll is the safe default; notify only if exactly one waiter can ever benefit
wait() ruleAlways inside a while loop, never if
Pool submission ordercore threads → queue → extra threads → rejection policy
Stopping a threadvolatile flag or interrupt() — never Thread.stop()
ThreadLocal in poolsAlways remove() in finally
thenApply vs thenComposethenApply = map · thenCompose = flatMap (next step is async)

Resources

For going deeper than any single-page guide can, in roughly the order this page introduces the topics:

Specification & official docs
  • Java Language Specification — Chapter 17, Threads and Locks (the source of truth for happens-before)
  • java.lang.Thread and Thread.State — official Javadoc
  • java.util.concurrent package summary — official Javadoc (locks, atomics, executors, synchronizers)
  • java.util.concurrent.CompletableFuture — official Javadoc
Books
  • Java Concurrency in Practice — Brian Goetz et al. — still the definitive text on the JMM, locking, and safe publication
  • The Art of Multiprocessor Programming — Herlihy & Shavit — deeper theory on lock-free and wait-free algorithms
Deeper dives on specific topics
  • Doug Lea's original java.util.concurrent design notes and papers on ForkJoinPool and work-stealing
  • OpenJDK Project Loom documentation — virtual threads and JVM-scheduled concurrency
  • JEP 444 (Virtual Threads) — for how the thread model is evolving beyond the six-state model above
Practice
  • Thread dumps from your own running services — jstack or a profiler, read against the state table in §2
  • Reproduce the race conditions in §20 yourself in a scratch project — seeing a lost update happen once is worth more than reading about it ten times

Go deeper