Module 11: Deadlock, livelock and starvation
Locks fix races but bring a new failure: threads that wait for each other forever. This module covers deadlocks and their four conditions, the standard ways to prevent them, deadlocks hidden in thread pools and connection pools, livelock and starvation, and how to find a deadlock in a running JVM.
- Thread 1 locks account A then B. Thread 2 locks B then A. What can happen?
Reveal
A deadlock: thread 1 holds A and waits for B while thread 2 holds B and waits for A. Neither can ever continue.
- Does the JVM detect and break Java deadlocks the way a database does?
Reveal
No. A database aborts one transaction to break a deadlock. The JVM leaves deadlocked threads stuck forever; for
synchronizedlocks only a restart frees them (a thread waiting inlockInterruptibly()can at least be interrupted). Thread dumps can detect them, but nothing fixes them automatically. - A task running on a single-thread executor submits a second task to the same executor and calls
get()on it. What happens?Reveal
It waits forever. The only thread is busy waiting, so the second task never runs. This is a thread starvation deadlock, and no lock is involved.
- Two threads keep retrying a
tryLockpair, each backing off for exactly 10 ms and colliding again. Name the problem and the fix.Reveal
Livelock: both are active but neither makes progress. Add random jitter to the back-off so they stop moving in lockstep.
- What is the single most effective rule for preventing lock-ordering deadlocks?
Reveal
Always acquire multiple locks in one global order (for example by account ID), in every code path.
11.1What a deadlock is
Two transfers run at the same time: transfer(a, b, 100) and transfer(b, a, 50). Walk through how they can freeze.
java
void transfer(Account from, Account to, long amount) {
synchronized (from) {
synchronized (to) {
from.debit(amount); to.credit(amount);
}
}
}
Reveal the answer
Thread 1 locks a (its from). Thread 2 locks b (its from). Thread 1 now waits for b, which thread 2 holds; thread 2 waits for a, which thread 1 holds. Each waits for the other forever. Both threads show as BLOCKED in a thread dump.
A deadlock is a cycle of threads, each holding a resource the next one needs, so none of them can move.
Two cars meet on a one-lane bridge from opposite ends. Each holds its half of the bridge and waits for the other to back up. Neither will.
Deadlocked threads never recover. In a service they pin request threads one by one until the pool is empty and the pod stops answering.
A deadlock needs all four of these conditions (the Coffman conditions). Prevention means breaking at least one:
| Condition | Meaning | How to break it |
|---|---|---|
| Mutual exclusion | A resource is held by one thread at a time | Use lock-free structures or immutable data (not always possible) |
| Hold and wait | A thread holds one lock while waiting for another | Take all locks at once, or never call out while holding a lock |
| No preemption | A held lock cannot be taken away | Use tryLock with a timeout and release on failure |
| Circular wait | A cycle of threads waiting on each other | Acquire locks in one global order |
11.2Prevention: a global lock order
How can transfer(a, b) and transfer(b, a) be made to lock the two accounts in the same order, whatever the argument order?
Reveal the answer
Order the locks by something stable and unique, such as the account ID: always lock the smaller ID first. Both transfers then lock a before b, so no cycle can form.
class Transfers {
static void transfer(Account from, Account to, long amount) {
if (from.id == to.id) throw new IllegalArgumentException("same account");
Account first = from.id < to.id ? from : to; // one global order: by id
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
if (from.balance < amount) throw new IllegalStateException("insufficient funds");
from.balance -= amount;
to.balance += amount;
}
}
}
}
class Account {
final long id;
long balance; // guarded by this
Account(long id, long balance) { this.id = id; this.balance = balance; }
}- The order must hold in every code path that takes both locks, including ones written later by someone else. Write it down next to the lock.
- Without a natural key,
System.identityHashCodeworks as an order, with a third "tie-breaker" lock for the rare case of equal hashes (Java Concurrency in Practice, section 10.1.2). - The same idea prevents database deadlocks: update rows in a consistent order (for example, by primary key) inside transactions.
11.3Prevention: timeouts, open calls, fewer locks
A class calls listener.onChange(event) while holding its own lock. The listener, written by another team, takes a lock of its own and calls back into your class from another thread. What is the risk, and the fix?
Reveal the answer
A lock-ordering deadlock you cannot see in either class alone: your lock then theirs on one thread, theirs then yours on another. Fix: make it an open call, meaning copy what you need under the lock, release it, then call the listener.
Timeouts. With ReentrantLock.tryLock(timeout), a thread that cannot get its second lock releases the first and tries again later. Add random jitter to the back-off, or two threads can collide forever in lockstep (livelock, lesson 11.5).
boolean transferWithRetry(Account from, Account to, long amount) throws InterruptedException {
for (int attempt = 0; attempt < 5; attempt++) {
if (from.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (to.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
from.balance -= amount; to.balance += amount;
return true;
} finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); }
}
Thread.sleep(ThreadLocalRandom.current().nextLong(5, 50)); // random back-off breaks lockstep
}
return false; // report "busy, try again" to the caller
}Open calls. Never call code you do not control (listeners, callbacks, overridable methods, other services' clients) while holding a lock. Copy the data, release the lock, then call.
Fewer locks. Every lock you remove is a deadlock you cannot have. Options, roughly from best:
- No shared mutable state: immutable values, thread confinement, one owner thread.
- One lock for the whole related state instead of several fine-grained ones.
- Concurrent collections and atomics, which never hold locks across your code.
- Let the database do it: one transaction with a conditional
UPDATE, which the database protects and, if needed, aborts on deadlock.
Example: opposite transfers that never deadlock. Run it: 20,000 transfers in opposite directions finish, and no money is created or lost. retries is usually small, often 0: collisions are rare, but each one costs a retry instead of a permanent hang.
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockTransfers {
static final class Account { long balance; final ReentrantLock lock = new ReentrantLock(); Account(long b) { balance = b; } }
static final AtomicInteger retries = new AtomicInteger();
static void transfer(Account from, Account to, long amt) throws InterruptedException {
while (true) {
if (from.lock.tryLock(10, TimeUnit.MILLISECONDS)) {
try {
if (to.lock.tryLock(10, TimeUnit.MILLISECONDS)) {
try { from.balance -= amt; to.balance += amt; return; }
finally { to.lock.unlock(); }
}
} finally {
from.lock.unlock(); // on failure, release the first lock too: breaks "no preemption"
}
}
retries.incrementAndGet();
Thread.sleep(ThreadLocalRandom.current().nextLong(1, 5)); // random back-off: no livelock
}
}
public static void main(String[] args) throws Exception {
Account a = new Account(1_000), b = new Account(1_000);
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<?> f1 = pool.submit(() -> { for (int i = 0; i < 10_000; i++) transfer(a, b, 1); return null; });
Future<?> f2 = pool.submit(() -> { for (int i = 0; i < 10_000; i++) transfer(b, a, 1); return null; });
f1.get(60, TimeUnit.SECONDS);
f2.get(60, TimeUnit.SECONDS);
pool.shutdown();
System.out.println("done: 20000 transfers, total = " + (a.balance + b.balance) + ", retries = " + retries.get());
}
}11.4Thread-pool and resource deadlocks
A pool of 10 threads runs report tasks. Each report task submits 3 chart tasks to the same pool and waits for them with get(). Twelve reports arrive at once. What happens?
Reveal the answer
Ten report tasks take all ten threads and each waits for chart tasks that sit in the queue behind the two remaining reports. No thread is free to run a chart, so everything waits forever. No lock is involved; the scarce resource is the threads.
import java.util.concurrent.*;
public class PoolStarvation {
public static void main(String[] args) throws Exception {
ExecutorService single = Executors.newSingleThreadExecutor();
Future<String> outer = single.submit(() -> {
Future<String> inner = single.submit(() -> "inner result"); // queued behind us
return inner.get(1, TimeUnit.SECONDS); // the only thread waits for itself
});
try {
System.out.println(outer.get());
} catch (ExecutionException e) {
System.out.println("outer failed: " + e.getCause().getClass().getSimpleName() + ", inner task could not run while outer waited");
}
single.shutdownNow();
}
}CompletableFuture instead of get()), or use ForkJoinPool, whose join() lets a waiting worker run other tasks.The same shape appears with any bounded resource:
- Database connection pool. Each request holds one connection and, inside the same flow, asks for a second (a nested
REQUIRES_NEWtransaction, or a call to a helper that opens its own connection). With a pool of 10 and 10 such requests in flight, all 10 wait for an 11th connection that does not exist. HikariCP eventually throwsSQLTransientConnectionExceptionafterconnectionTimeout. CompletableFuturecallbacks that calljoin()on another future that needs the same small pool.- Semaphores acquired in nested code paths, twice by the same flow.
Rule: a task must never block waiting for work that needs the same bounded resource it is holding.
11.5Livelock and starvation
Two people meet in a corridor. Both step left, then both step right, then both left again. Nobody is stuck, yet nobody passes. What is the computing equivalent, and how do people (and programs) usually escape it?
Reveal the answer
Livelock: threads are active and keep reacting to each other, but make no progress, such as two transactions that retry with the same fixed delay and collide every time. Randomness breaks it: one person (or thread) waits a random moment longer.
| Problem | What you see | Typical causes | Fixes |
|---|---|---|---|
| Deadlock | Threads BLOCKED or WAITING forever, CPU idle |
Lock cycles, nested waits on a bounded pool | Lock ordering, timeouts, open calls, separate pools |
| Livelock | Threads busy, CPU high, no progress | Retries in lockstep, two components undoing each other's work | Random jitter, retry limits, one side backs off |
| Starvation | Some threads rarely or never get their turn | Unfair locks under heavy contention, very long critical sections, a pool hogged by slow tasks, readers starving a writer | Shorter critical sections, fair locks where needed, separate pools for slow work, bounded task time |
Starvation shows up in services as "the slow endpoint makes the fast ones time out": one pool shared by fast and slow work, where slow tasks occupy every thread. The fix is separate pools (bulkheads, Module 14).
11.6Detecting deadlocks
A service stops responding. CPU is near zero. What do you capture first, and what do you look for?
Reveal the answer
A thread dump (jcmd <pid> Thread.print or jstack <pid>), two or three of them a few seconds apart. Look for the section Found one Java-level deadlock, and for many threads BLOCKED on the same lock or WAITING in the same place. Module 15 walks through reading dumps.
Found one Java-level deadlock:
=============================
"transfer-1":
waiting to lock monitor 0x00007f3c2c003f88 (object 0x000000071a8c2d30, a Account),
which is held by "transfer-2"
"transfer-2":
waiting to lock monitor 0x00007f3c2c006a18 (object 0x000000071a8c2d18, a Account),
which is held by "transfer-1"
Java stack information for the threads listed above:
===================================================
"transfer-1":
at NaiveTransfers.transfer(NaiveTransfers.java:9)
- waiting to lock <0x000000071a8c2d30> (a Account)
- locked <0x000000071a8c2d18> (a Account)The dump names both threads, which lock each one holds, which it wants, and the exact line. You can also check from inside the JVM, for a health check or a test:
import java.lang.management.*;
public class DeadlockDetector {
public static void main(String[] args) throws Exception {
Object a = new Object(), b = new Object();
start("transfer-1", a, b);
start("transfer-2", b, a); // opposite order: deadlock
Thread.sleep(300);
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] ids = mx.findDeadlockedThreads(); // monitors and ReentrantLocks, platform threads only
if (ids != null) {
for (ThreadInfo info : mx.getThreadInfo(ids))
System.out.println("deadlocked: " + info.getThreadName() + " waiting for " + info.getLockName());
}
}
static void start(String name, Object first, Object second) {
Thread t = new Thread(() -> {
synchronized (first) {
sleep(100);
synchronized (second) { System.out.println("never printed"); }
}
}, name);
t.setDaemon(true); // lets the demo JVM exit
t.start();
}
static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
}Both the dump's deadlock section and ThreadMXBean cover platform threads only. Deadlocks between virtual threads are not reported there; use jcmd <pid> Thread.dump_to_file -format=json and JFR, and expect the lock holder to be missing from Thread.print.
Databases handle their own deadlocks: PostgreSQL detects a cycle between transactions and aborts one with ERROR: deadlock detected, so your code must be ready to retry the transaction. The JVM never does that for Java locks, which is why prevention matters more in Java code.
11.7Use cases: where deadlocks come from in real systems
Service A calls service B synchronously and B calls back into A, each with a pool of 20 request threads. Under load both services hang with idle CPUs. What happened?
Reveal the answer
A distributed deadlock: all of A's threads wait for responses from B, and all of B's threads wait for responses from A, which has no free thread to answer. No lock is involved, only bounded thread pools. Fix: timeouts on every call, avoid synchronous call cycles (make one direction asynchronous), and bulkheads.
Typical with listeners and callbacks. Copy, release, then call.
In the database: update the rows in id order, and retry on a deadlock error.
Thread starvation deadlock (lesson 11.4).
REQUIRES_NEW inside a transactionavoid nesting, or size the poolThe inner transaction needs a second connection while the outer one holds the first.
A distributed deadlock with no lock at all.
Livelock (lesson 11.5).
11.8Common mistakes
Common mistakes
- Taking two locks in different orders in different code paths.
- Calling listeners, callbacks or remote services while holding a lock.
- Blocking on a future that needs the same bounded pool (
get()orjoin()inside a pool task). - Nested connection or semaphore acquisition in one request flow.
- Fixed retry delays, which turn contention into livelock.
- One pool for fast and slow work, so slow tasks starve the fast ones.
- Restarting pods without taking a thread dump first, which throws away the evidence.
11.9Self-check
- Name the four conditions for deadlock.
Reveal
Mutual exclusion, hold and wait, no preemption, circular wait.
- Which condition does lock ordering break, and which does
tryLockwith a timeout break?Reveal
Lock ordering breaks circular wait;
tryLockwith a timeout breaks no preemption (and hold-and-wait, since the thread releases what it holds). - What is an open call?
Reveal
Calling outside code without holding any lock, after copying what you need under the lock.
- How is livelock different from deadlock?
Reveal
Deadlocked threads are stuck waiting and use no CPU; livelocked threads keep running and reacting to each other but make no progress.
- How do you detect a deadlock in a running service?
Reveal
Take thread dumps (
jcmd <pid> Thread.print) and look for "Found one Java-level deadlock", or callThreadMXBean.findDeadlockedThreads(). Both cover platform threads; for virtual threads usejcmd <pid> Thread.dump_to_fileand JFR.
Key takeaways
- A deadlock is a cycle of threads each waiting for something another holds; the JVM never breaks it.
- All four conditions are needed: break one, usually circular wait, with a global lock order.
- Use
tryLockwith a timeout and random back-off when an order is impossible. - Never call unknown code while holding a lock: make it an open call.
- Bounded pools and connection pools deadlock too: never block on work that needs the same pool.
- Find deadlocks with thread dumps or
ThreadMXBean(platform threads), and prevent them in design.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 12: ThreadLocal and thread reuse
ThreadLocal gives each thread its own copy of a variable: no sharing, so no locking. Frameworks use it everywhere for request context, logging context and transactions. This module covers what it solves, why it leaks data and memory in thread pools, why context disappears when work hops threads, and what changes with virtual threads.
- Two threads call
get()on the sameThreadLocal. Do they see the same value?Reveal
No. Each thread sees only the value it set itself (or its own initial value).
- A request filter sets a
ThreadLocaluser and never callsremove(). The server uses a thread pool. What can the next request on that thread see?Reveal
The previous request's user. Pool threads are reused, so the value survives into the next task: a data leak between users.
- Your log lines include a
traceIdfrom the MDC. Work moved toCompletableFuture.supplyAsynclogs without it. Why?Reveal
The MDC is stored in a
ThreadLocal, and the async task runs on a different thread that never had the value. Context must be copied to the new thread explicitly. - Does
InheritableThreadLocalfix context propagation into a thread pool?Reveal
No. Values are copied only when a thread is created. Pool threads are created once and reused, so they keep whatever they inherited at creation.
- What replaces
ThreadLocalfor passing immutable context with virtual threads in Java 25?Reveal
ScopedValue: a value bound for the duration of a call, immutable, and automatically gone when the call returns.
12.1What ThreadLocal solves
A SimpleDateFormat (not thread-safe) is stored in a static field and used by every request thread, producing garbled dates under load. Give two fixes.
Reveal the answer
Replace it with DateTimeFormatter, which is immutable and thread-safe (the best fix). Or keep one SimpleDateFormat per thread with ThreadLocal.withInitial(() -> new SimpleDateFormat(...)), so no two threads share an instance.
A ThreadLocal<T> is a variable with one value per thread. Each thread reads and writes only its own copy, so there is nothing to synchronize.
Lockers in a gym. Every member has a locker with the same number on their own key ring, and each member only ever opens their own. The number (the ThreadLocal) is shared; the contents are not.
The value lives as long as the thread, not as long as your task. In thread pools, threads live for hours, so forgotten values leak into later tasks.
Thread object has a map, and the ThreadLocal is just the key. That is why a value lives as long as its thread does, unless it is removed.Where you meet ThreadLocal in real systems:
- Request context: the current user, tenant or request ID, set by a filter and read deep in the call stack.
- Logging context: SLF4J's
MDC(traceId,userIdin every log line) is aThreadLocal. - Framework state: Spring keeps the current transaction (
TransactionSynchronizationManager), the security context (SecurityContextHolder) and the HTTP request (RequestContextHolder) inThreadLocals. - Per-thread reusable objects that are not thread-safe and costly to create (older code with
SimpleDateFormat,MessageDigest, buffers).
Example: the shared formatter bug, and the fix that needs no ThreadLocal at all.
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class Formatters {
static final SimpleDateFormat SHARED = new SimpleDateFormat("yyyy-MM-dd"); // NOT thread-safe
static final DateTimeFormatter SAFE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); // immutable, thread-safe
public static void main(String[] args) throws Exception {
List<String> dates = new ArrayList<>();
for (int d = 1; d <= 28; d++) dates.add(String.format("2026-02-%02d", d));
AtomicInteger badOld = new AtomicInteger(), badNew = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int i = 0; i < 20_000; i++) {
String s = dates.get(i % dates.size());
pool.execute(() -> {
try {
String back = new SimpleDateFormat("yyyy-MM-dd").format(SHARED.parse(s));
if (!back.equals(s)) badOld.incrementAndGet();
} catch (Exception e) {
badOld.incrementAndGet(); // garbled state often throws
}
if (!LocalDate.parse(s, SAFE).format(SAFE).equals(s)) badNew.incrementAndGet();
});
}
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
System.out.println("shared SimpleDateFormat wrong results: " + badOld.get() + " (often > 0 on multicore machines)");
System.out.println("DateTimeFormatter wrong results: " + badNew.get());
}
}12.2The API
What does ThreadLocal.withInitial(() -> new ArrayList<>()) return for get() in a thread that never called set()?
Reveal the answer
A new empty ArrayList, created the first time that thread calls get() and kept for that thread afterwards. Without withInitial, get() returns null.
final class RequestContext {
private static final ThreadLocal<String> USER = new ThreadLocal<>();
private static final ThreadLocal<MessageDigest> SHA256 = ThreadLocal.withInitial(() -> {
try { return MessageDigest.getInstance("SHA-256"); } // not thread-safe: one per thread
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
});
static void setUser(String user) { USER.set(user); }
static String user() { return USER.get(); } // null if never set on this thread
static void clear() { USER.remove(); } // remove, not set(null)
static byte[] hash(byte[] data) { return SHA256.get().digest(data); }
private RequestContext() { }
}| Method | Does |
|---|---|
get() |
This thread's value (runs the initial supplier on first use) |
set(v) |
Replace this thread's value |
remove() |
Delete this thread's entry; the next get() re-initializes |
withInitial(supplier) |
Create a ThreadLocal with a default per thread |
Declare ThreadLocals as private static final: one key shared by all threads, each thread with its own value.
12.3ThreadLocal in thread pools: data leaks between tasks
Predict the output. One worker thread runs two tasks in turn.
java
pool.submit(() -> { USER.set("alice"); handle(); });
pool.submit(() -> System.out.println("request 2 sees " + USER.get()));
Reveal the answer
request 2 sees alice. Both tasks ran on the same pool thread, and the first never removed its value. In a web server that means request 2 may act as the previous request's user.
import java.util.concurrent.*;
public class ThreadLocalLeak {
static final ThreadLocal<String> USER = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newSingleThreadExecutor(); // one reused thread, like a busy server
pool.submit(() -> { USER.set("alice"); /* handle request 1, forget to clean up */ }).get();
pool.submit(() -> System.out.println("request 2 sees user: " + USER.get())).get();
pool.submit(() -> {
USER.set("bob");
try {
/* handle request 3 */
} finally {
USER.remove(); // the fix: always clean up
}
}).get();
pool.submit(() -> System.out.println("request 4 sees user: " + USER.get())).get();
pool.shutdown();
}
}request 2 sees user: alice
request 4 sees user: nullThreadLocal, the next task on that thread inherits.Set in try, remove() in finally, at the same level that set it (the filter, the interceptor, the message listener). Never rely on "the next request will overwrite it": a request that fails early, or a code path that does not set it, reads stale data.
In security-sensitive code this is a real breach pattern: the tenant ID, user ID or auth token of one customer used for another. Treat a ThreadLocal holding identity like a credential: scoped, cleared, and covered by a test that runs two requests on one thread.
12.4Memory leaks
A pool of 200 threads lives for days. Each request stores a 2 MB report buffer in a ThreadLocal and never removes it. Roughly how much memory is held, and when is it freed?
Reveal the answer
About 200 x 2 MB = 400 MB, one buffer per pool thread, held as long as each thread lives, effectively forever in a long-running service. It is freed only when the value is removed or replaced, or the thread dies.
Inside each thread's map, the key (the ThreadLocal) is held weakly but the value is held strongly. With the usual static final ThreadLocal, the key never goes away, so every value stays reachable for the life of the thread unless you remove() it.
- Large values (buffers, caches, result lists) in long-lived pool threads quietly hold memory.
- Classloader leaks in application servers: a value whose class came from a web application keeps that application's whole classloader alive after undeploy, because the server's threads outlive the application.
- Calling
set(null)leaves the entry in the map;remove()deletes it.
12.5Context does not follow work to other threads
A Spring @Transactional method calls CompletableFuture.runAsync(() -> repo.save(entity)). Is the save part of the transaction?
Reveal the answer
No. The transaction is bound to the calling thread through a ThreadLocal. The async task runs on another thread with no transaction (or its own), so it commits or rolls back independently and may not even see uncommitted data from the caller.
Every thread hop drops ThreadLocal state: @Async, CompletableFuture.*Async, executor.submit, Kafka listener threads, reactive schedulers. Typical symptoms: log lines without traceId, SecurityContextHolder.getContext() returning no user, work silently outside the transaction.
The fix is to capture on the submitting thread and restore on the worker, then clean up:
final class ContextAwareExecutor implements Executor {
static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
private final Executor delegate;
ContextAwareExecutor(Executor delegate) { this.delegate = delegate; }
@Override public void execute(Runnable task) {
String captured = TRACE_ID.get(); // runs on the submitting thread
delegate.execute(() -> {
String previous = TRACE_ID.get();
TRACE_ID.set(captured); // runs on the worker thread
try {
task.run();
} finally {
if (previous == null) TRACE_ID.remove(); else TRACE_ID.set(previous);
}
});
}
}In Spring, register a TaskDecorator on your ThreadPoolTaskExecutor to do exactly this for the MDC (MDC.getCopyOfContextMap() / MDC.setContextMap(...)), or use Micrometer's context propagation, which Spring Boot 3 integrates with tracing. Keep transactions on one thread: do the database work synchronously, or start a fresh transaction inside the async task on purpose.
InheritableThreadLocal copies values to a child thread when the child is created. Pool threads are created once, so they keep a stale copy from whichever thread happened to create them. Do not use it to propagate per-request context into pools.
TaskDecorator is where you put this code, and Micrometer's context-propagation library automates it.import java.util.concurrent.*;
public class TraceDemo {
static final ThreadLocal<String> TRACE = new ThreadLocal<>();
static Runnable withTrace(Runnable task) {
String captured = TRACE.get(); // runs on the submitting thread
return () -> { // runs on the worker thread
String previous = TRACE.get(); // null on a clean pool thread
TRACE.set(captured);
try { task.run(); }
finally { // never leak into the next task, and stay safe
if (previous == null) TRACE.remove(); // when the task runs on the caller's own thread
else TRACE.set(previous); // (CallerRunsPolicy, a direct executor)
}
};
}
static void log(String msg) { System.out.println("[trace=" + TRACE.get() + "] " + msg); }
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
TRACE.set("req-42"); // a web filter does this in real code
try {
log("handling request");
pool.submit(() -> log("plain task")).get(); // lost: a different thread
pool.submit(withTrace(() -> log("wrapped task"))).get(); // carried over
} finally {
TRACE.remove(); // the filter clears it at the end of the request
}
pool.shutdown();
}
}12.6Virtual threads and ScopedValue
A service switches to virtual threads and now runs 100,000 concurrent requests. Each request thread lazily creates a 64 KB buffer in a ThreadLocal. What changes compared with a pool of 200 platform threads?
Reveal the answer
With a pool, 200 buffers were created and reused. With one virtual thread per request, every request creates its own buffer: up to 100,000 x 64 KB, about 6.4 GB. Per-thread caching stops paying off when threads are no longer pooled.
Virtual threads (Module 13) support ThreadLocal, but they are created per task and never pooled. So:
- Per-thread caches of expensive objects stop working: every task builds its own.
- Per-request context still works and, since nothing is pooled, does not leak to the next task. You still need cleanup if a virtual thread runs several steps.
ScopedValue (final in Java 25, JEP 506; preview in 21 to 24) is the modern way to pass immutable context down a call:
static final ScopedValue<String> TENANT = ScopedValue.newInstance();
void handle(Request req) {
ScopedValue.where(TENANT, req.tenantId()) // bound only for the duration of run()
.run(() -> service.process(req)); // anything called inside can read TENANT.get()
} // unbound again here: nothing to remove, nothing to leakA scoped value cannot be changed inside the scope (rebind it for a nested call instead), is automatically gone when the scope ends, and is cheap to share with child tasks under structured concurrency. If you are on Java 21, keep ThreadLocal with strict cleanup.
12.7Use cases: ThreadLocal in services
Where in a Spring Boot request should a ThreadLocal holding the tenant ID be set and cleared, and why exactly there?
Reveal the answer
In one servlet filter (or interceptor) that wraps the whole request: set it at the start and remove() it in finally. Being the outermost layer means every later piece of code sees it, and the finally guarantees the pooled thread is clean before the next request, even on errors.
Read anywhere down the call stack without passing parameters.
An MDC is a ThreadLocal map; clear it per request.
Capture on submit, set on the worker, restore afterwards (the TraceDemo above).
Keep that work on the request thread.
Prefer an immutable replacement (DateTimeFormatter).
Bound for a call, automatically gone afterwards.
12.8Common mistakes
Common mistakes
- No
remove()infinallyin pooled threads: data leaks between requests or users. set(null)instead ofremove().- Large objects in
ThreadLocals of long-lived pools, holding memory for the life of the thread. - Expecting context (MDC, security, transaction) to follow
@AsyncorCompletableFuture. InheritableThreadLocalwith thread pools.- Per-thread caches with virtual threads, which create one copy per task.
- Using
ThreadLocalto pass arguments that could simply be method parameters. Explicit is easier to test.
12.9Self-check
- Where is a
ThreadLocal's value actually stored?Reveal
In a map inside the
Threadobject, keyed by theThreadLocalinstance. - Why must
remove()go in afinallyblock?Reveal
So the value is cleared even when the task fails; otherwise the next task on the same pool thread sees it.
- Why does
@Transactionalnot cover work started withrunAsyncinside it?Reveal
Spring binds the transaction to the current thread through a
ThreadLocal; the async work runs on another thread. - How do you carry the MDC into an executor's tasks?
Reveal
Capture the context on the submitting thread, set it at the start of the task on the worker, and restore or clear it in
finally(aTaskDecoratorin Spring). - What does
ScopedValueimprove overThreadLocal?Reveal
The value is immutable, bound only for a well-defined call scope, automatically removed at the end, and cheap with many virtual threads.
Key takeaways
- A
ThreadLocalholds one value per thread, stored in the thread itself. - Pool threads outlive tasks: set in
try,remove()infinally, every time. - Forgotten values leak data between requests and hold memory for the life of the thread.
- Context does not cross thread hops: capture it on submit, restore it on the worker, clean up.
InheritableThreadLocaldoes not work with pools.- With virtual threads, avoid per-thread caches; on Java 25 consider
ScopedValuefor context.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 13: Virtual threads and ForkJoinPool
Java 21's virtual threads make blocking cheap, so a service can run one thread per request even at very high concurrency. ForkJoinPool solves the opposite problem: splitting CPU-heavy work across cores. This module covers both, plus parallel streams, pinning, resource limits, and where structured concurrency stands.
- Will virtual threads make a CPU-bound image resizing job faster?
Reveal
No. The work needs cores, not threads. Virtual threads help when tasks spend most of their time waiting (network, database, sleeps).
- Should you put virtual threads in a pool?
Reveal
No. They are cheap to create; create one per task (
Executors.newVirtualThreadPerTaskExecutor()). Pooling them only adds a limit you then have to tune. - 10,000 virtual threads each call a database through a HikariCP pool with 10 connections. How many queries run at once?
Reveal
Ten. The other threads wait for a connection. Virtual threads remove the thread limit, not the limits of the resources behind it.
- What does work stealing mean in
ForkJoinPool?Reveal
Each worker keeps its own queue of tasks. An idle worker takes (steals) tasks from the other end of a busy worker's queue, so all cores stay busy without one shared, contended queue.
- Is
list.parallelStream().forEach(results::add)with anArrayListsafe?Reveal
No. Many common-pool threads call
addon a non-thread-safe list at once. Usecollect(Collectors.toList())ortoList(), which combine per-thread results safely.
13.1Why platform threads are expensive
A service must handle 10,000 concurrent requests, each waiting 200 ms on a partner API. With thread-per-request on platform threads, what limits you?
Reveal the answer
Platform threads are OS threads: each reserves its own stack (about 1 MB of address space by default) and the OS schedules them all. A few thousand is practical; 10,000 strains memory and scheduling. The usual answer used to be async callbacks or reactive code, which are harder to write and debug.
A classic new Thread(...) is a platform thread: one Java thread mapped to one operating-system thread. That makes them:
- Heavy: a large fixed stack each, and creation goes through the OS.
- Few: practical limits in the low thousands per JVM.
- Wasted while waiting: a thread blocked on a 200 ms HTTP call holds all of that while doing nothing.
Little's law (Module 6) makes the problem concrete: 5,000 requests/s x 0.2 s each = 1,000 requests in flight, so 1,000 threads busy just waiting.
13.2Virtual threads
If a virtual thread is not an OS thread, what actually runs its code on a CPU?
Reveal the answer
A small pool of platform threads called carrier threads (a ForkJoinPool with about one thread per core). The JVM mounts a virtual thread on a carrier to run it. When the virtual thread blocks on I/O, the JVM unmounts it, parks its stack on the heap, and lets the carrier run another virtual thread.
A virtual thread is a java.lang.Thread managed by the JVM instead of the OS. It is cheap enough to create millions. Blocking calls (sleep, socket I/O, BlockingQueue.take, ReentrantLock.lock) unmount it from its carrier instead of tying up an OS thread.
Restaurant tables (virtual threads) and waiters (carriers). A table waiting for its food does not need a waiter standing beside it; the waiter serves other tables and comes back when the kitchen is ready.
The thread limit disappears, so other limits show up: connection pools, partner rate limits, memory per request. And CPU-bound work gains nothing.
import java.time.Duration;
import java.util.concurrent.*;
public class TenThousand {
public static void main(String[] args) {
long start = System.nanoTime();
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { // one new virtual thread per task
for (int i = 0; i < 10_000; i++) {
exec.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // blocking is fine: the carrier is released
return null;
});
}
} // close() waits for all tasks
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("10000 tasks finished in " + ms + " ms"); // about 1 second, not 10,000
}
}Ways to create one:
java
Thread.ofVirtual().name("vt-", 0).start(task); // builder
Thread.startVirtualThread(task); // shortcut
Executors.newVirtualThreadPerTaskExecutor(); // the usual choice for services
Thread.ofVirtual().factory(); // a ThreadFactory for libraries that take one
Virtual threads are always daemon threads and have normal priority. The code inside is ordinary blocking Java: the same synchronized, locks, ThreadLocal and exceptions as in the rest of this handbook.
13.3When virtual threads help, and when they do not
For each, would virtual threads help? (a) a REST service whose requests mostly wait on PostgreSQL and two HTTP APIs; (b) a batch job hashing 10 million records; (c) a Kafka consumer processing one partition strictly in order.
Reveal the answer
(a) Yes, high concurrency of waiting work is exactly the use case. (b) No, it is CPU-bound; use about one platform thread per core or a ForkJoinPool. (c) Not much: ordered processing of one partition is sequential by design, so one thread is busy either way.
| Workload | Virtual threads? | Why |
|---|---|---|
| Many concurrent requests that wait on I/O | Yes | Waiting becomes cheap; simple blocking code scales |
| Fan-out to several services per request | Yes, with structured code or CompletableFuture |
Each call can block its own virtual thread |
| CPU-bound computation | No | Cores are the limit, not threads |
| Very long-lived background loops | Neutral | One thread either way |
| Code that holds a lock while doing slow I/O | Fix the code first | It serializes callers regardless of thread type |
Virtual threads raise throughput for waiting work; they do not make a single request faster. A request that takes 200 ms still takes 200 ms.
13.4Pinning and resource limits
On Java 21, a virtual thread enters a synchronized block and then makes a blocking HTTP call inside it. What happens to its carrier thread?
Reveal the answer
On Java 21 to 23 the virtual thread is pinned: it cannot unmount while inside synchronized, so the carrier is blocked for the whole call. With enough of these, every carrier is stuck and the application stalls. Java 24 removed this case (JEP 491); on older versions, use ReentrantLock around blocking code.
Pinning means a virtual thread cannot unmount while blocked, so it holds its carrier.
| Situation | Java 21 to 23 | Java 24+ |
|---|---|---|
Blocking inside synchronized (or Object.wait()) |
Pins the carrier | Does not pin (JEP 491) |
Blocking inside ReentrantLock |
Does not pin | Does not pin |
| Blocking in native code or foreign functions that call back into Java | Pins | Still pins |
| Blocking in a class initializer, or waiting for another thread's class initialization | Pins | Still pins |
Find pinning with the JFR event jdk.VirtualThreadPinned (Java 21 also had -Djdk.tracePinnedThreads, removed in 24).
Resource limits are now your job. With platform threads, the pool size quietly limited how many requests hit the database at once. With virtual threads nothing does, so put explicit limits in front of scarce resources:
class PartnerGateway {
private final Semaphore concurrent = new Semaphore(20); // partner allows 20 parallel calls
Response call(Request req) throws InterruptedException {
if (!concurrent.tryAcquire(500, TimeUnit.MILLISECONDS)) throw new IllegalStateException("partner busy");
try {
return http.send(req); // blocking is fine on a virtual thread
} finally {
concurrent.release();
}
}
interface Http { Response send(Request r); }
record Request(String body) { }
record Response(int status) { }
private final Http http = r -> new Response(200);
}- Connection pools stay the real cap: 10,000 virtual threads and 10 database connections means 9,990 waiting in
getConnection(). Size the pool and set its timeout deliberately (Module 14). ThreadLocalcaches become one copy per task (Module 12).- Thread dumps:
jstackandjcmd <pid> Thread.printlist platform threads; for virtual threads usejcmd <pid> Thread.dump_to_file -format=json dump.json. - Spring Boot 3.2+ on Java 21:
spring.threads.virtual.enabled=trueruns request handling (Tomcat or Jetty),@Asyncand scheduled tasks on virtual threads.
Example: 1,000 virtual threads, at most 50 database queries at a time. The semaphore, not a thread pool, sets the limit:
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class LimitedFanOut {
public static void main(String[] args) {
Semaphore dbSlots = new Semaphore(50); // the database can take 50 concurrent queries
AtomicInteger inUse = new AtomicInteger(), peak = new AtomicInteger();
long start = System.nanoTime();
try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
vt.submit(() -> {
dbSlots.acquire(); // waiting parks the virtual thread cheaply
try {
peak.accumulateAndGet(inUse.incrementAndGet(), Math::max);
Thread.sleep(20); // the query
} finally {
inUse.decrementAndGet();
dbSlots.release();
}
return null;
});
}
}
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("1000 queries, peak concurrent = " + peak.get() + ", about " + ms + " ms"); // 20 waves of 20 ms
}
}13.5ForkJoinPool and work stealing
You must sum a 100-million-element array on 8 cores. Splitting it into 8 equal chunks can leave cores idle near the end. Why, and what does ForkJoinPool do differently?
Reveal the answer
Equal chunks do not always take equal time (cache misses, other load), so some threads finish early and sit idle. Fork/join splits the work recursively into many small tasks; an idle worker steals tasks from busy workers, so every core works until the end.
import java.util.concurrent.*;
public class ParallelSum extends RecursiveTask<Long> {
private static final int THRESHOLD = 100_000; // below this, just loop
private final long[] data; private final int from, to;
ParallelSum(long[] data, int from, int to) { this.data = data; this.from = from; this.to = to; }
@Override protected Long compute() {
if (to - from <= THRESHOLD) {
long sum = 0;
for (int i = from; i < to; i++) sum += data[i];
return sum;
}
int mid = (from + to) >>> 1;
ParallelSum left = new ParallelSum(data, from, mid);
ParallelSum right = new ParallelSum(data, mid, to);
left.fork(); // push left; another worker may steal it
long r = right.compute(); // do the right half ourselves
return left.join() + r; // join may run other tasks while waiting
}
public static void main(String[] args) {
long[] data = new long[10_000_000];
java.util.Arrays.fill(data, 2);
long total = ForkJoinPool.commonPool().invoke(new ParallelSum(data, 0, data.length));
System.out.println("sum = " + total);
}
}- Use fork/join for CPU-bound, divisible work. Pick a threshold so leaf tasks do real work (thousands of operations), or task overhead dominates.
ForkJoinPool.commonPool()is shared by the whole JVM: parallel streams (plus the calling thread) andCompletableFuture's default async methods use it. Its default size is cores - 1; on Java 21 to 24, if that is below 2,CompletableFuturestarts a new thread per async task instead. Never block on I/O in it, and pass your own executor for async work.- A worker that calls
join()helps by running other tasks while it waits, which is why fork/join recursion does not starve itself the way nestedget()calls on a fixed pool do (Module 11).
13.6Parallel streams
When does list.parallelStream() make code faster, and when slower? Give two of each.
Reveal the answer
Faster: large data sets with CPU-heavy, independent work per element (parsing, hashing, maths), and sources that split well (arrays, ArrayList, ranges). Slower: small collections (splitting costs more than it saves), blocking I/O per element (starves the common pool), shared mutable state, or a LinkedList source that splits poorly.
Shared mutable list: race
List<String> out = new ArrayList<>();
ids.parallelStream()
.map(this::normalize)
.forEach(out::add); // many threads, one ArrayListLet the stream combine results
List<String> out = ids.parallelStream()
.map(this::normalize)
.toList(); // per-thread pieces, merged safely, order keptRules: the operations must be stateless and free of side effects, reductions must be associative, and per-element work must be CPU-bound. Measure before and after; on a busy server, parallel streams also compete with every other user of the common pool.
import java.util.stream.LongStream;
public class ParallelStreams {
public static void main(String[] args) {
long n = 50_000_000;
for (int round = 1; round <= 3; round++) { // round 1 includes JIT warm-up: trust the later rounds
long t0 = System.nanoTime();
long seq = LongStream.rangeClosed(1, n).map(x -> x % 7).sum();
long t1 = System.nanoTime();
long par = LongStream.rangeClosed(1, n).parallel().map(x -> x % 7).sum(); // split across the common pool
long t2 = System.nanoTime();
System.out.println("round " + round + ": same result: " + (seq == par)
+ ", sequential " + (t1 - t0) / 1_000_000 + " ms, parallel " + (t2 - t1) / 1_000_000 + " ms");
}
}
}Read the later rounds: the first one also pays for JIT compilation, which flatters whichever version runs second. The speed-up can never exceed the number of cores, and it shrinks when other work shares the common pool. On a 2-core container expect under 2x; on 16 cores it can be large. Always measure in the environment you deploy to.
13.7Structured concurrency, briefly
Structured concurrency (StructuredTaskScope) treats a group of subtasks started by one method as a unit: they start together, the method waits for them together, and if one fails or the caller is cancelled, the others are cancelled too. No subtask outlives the method that started it, which removes a whole class of thread leaks.
It is still a preview API: it changed shape several times between Java 21 and 26, and Java 27 ships a seventh preview (JEP 533). Learn the idea now, and the API once it is final. Until then, CompletableFuture with allOf, timeouts and explicit cancellation, or an ExecutorService in try-with-resources, covers the same needs.
13.8Use cases: virtual threads, fork/join, parallel streams
A reconciliation job compares 5 million ledger rows already loaded in memory, using pure CPU work per row. Virtual threads, a fixed pool, or a parallel stream?
Reveal the answer
A parallel stream (or a fork/join task, or a fixed pool of about one thread per core). The work is CPU-bound with no waiting, so virtual threads add nothing; what matters is spreading the computation across cores.
spring.threads.virtual.enabled=true on Java 21; the connection pool stays the limit.
Blocking code stays simple; the semaphore protects partners.
Virtual threads do not add CPU.
Stateless operations, large data, measured speed-up.
Pick a threshold so leaves do real work.
Module 14; thread type does not change ordering needs.
13.9Common mistakes
Common mistakes
- Pooling virtual threads, or capping them with a small fixed pool.
- Using virtual threads for CPU-bound work and expecting a speed-up.
- Removing every limit, so 10,000 virtual threads hammer a database or partner API. Use semaphores and right-sized connection pools.
- On Java 21 to 23, blocking inside
synchronizedon virtual threads (pinning). Upgrade, or useReentrantLockthere. - Blocking I/O on the common pool through parallel streams or
*Asyncwithout an executor. - Parallel streams with side effects on shared collections.
- Fork/join with a tiny threshold, so task overhead exceeds the work.
13.10Self-check
- What is a carrier thread?
Reveal
A platform thread (from a
ForkJoinPoolscheduler, about one per core) that runs mounted virtual threads. - What happens when a virtual thread blocks on a socket read?
Reveal
It unmounts: its stack is saved on the heap and the carrier runs other virtual threads until the read can continue.
- What changed for
synchronizedin Java 24?Reveal
JEP 491: blocking inside
synchronizedno longer pins the virtual thread to its carrier. - Why do virtual threads make semaphores and connection pool sizing more important?
Reveal
The thread count no longer limits concurrency, so nothing else stops thousands of tasks from hitting a scarce resource at once.
- Why does a fork/join worker steal from the opposite end of another worker's deque?
Reveal
The owner works from one end and the thief from the other, so they rarely contend, and the oldest tasks are usually the largest remaining pieces of work.
Key takeaways
- Platform threads are OS threads: heavy, limited in number, wasted while waiting.
- Virtual threads are cheap JVM threads that unmount from carriers while blocked: great for waiting work.
- Do not pool virtual threads; limit scarce resources with semaphores and pool sizes instead.
- Pinning: before Java 24, blocking inside
synchronizedholds the carrier; native frames and class initializers still do. ForkJoinPoolsplits CPU work and balances it with work stealing; its common pool is shared JVM-wide.- Parallel streams help only for large, stateless, CPU-bound work, and never for blocking I/O.
Mastery checklist
Tick an item only when it is true with your notes closed.