12. Concurrency & Parallelism
Every previous chapter assumed one thread executing your code in the order you wrote it. Drop that assumption and almost everything becomes harder: a counter loses increments, a field never shows its new value, two correct methods combine into a broken one. This chapter covers why concurrency is necessary anyway, the rules the Java Memory Model actually guarantees, the tools for enforcing correctness, and the high-level utilities that let you avoid most of the danger.
<a id="121-concurrency-foundations"></a>
12.1 Concurrency Foundations
This subchapter covers the distinction between doing many things at once and merely managing many things at once, and the mechanics of getting a second thread running.
Concurrency versus Parallelism
Two completely different pressures push programs toward multiple threads, and conflating them causes bad design decisions.
The first pressure is waiting. A program that fetches a web page spends almost all its time doing nothing, blocked on the network. A single-threaded server handling one request at a time would leave the processor idle while serving nobody. The problem here is not a shortage of computing power; it is that work is blocked.
The second pressure is capacity. A machine has sixteen cores, and a single-threaded program uses one of them. Summing a billion numbers takes sixteen times longer than it needs to. Here nothing is blocked; there is simply more computing power available than one thread can use.
These two pressures have different names.
Concurrency is a property of the structure of a program: it is composed of tasks that can make progress independently, in overlapping time periods. Parallelism is a property of the execution: multiple operations physically happen at the same instant, which requires multiple processors.
The clearest way to hold the distinction is that concurrency is about dealing with many things at once, while parallelism is about doing many things at once. Concurrency is a way of structuring a solution; parallelism is a way of executing one.
A kitchen makes it concrete. One cook juggling three dishes, stirring one while another simmers, is concurrent but not parallel. Three cooks each making one dish are parallel. Three cooks each juggling three dishes are both.
CONCURRENT, NOT PARALLEL (one core, interleaved)
core 1: [A--][B--][A--][C--][B--][A--][C--] tasks take turns
time ------------------------------->
PARALLEL (three cores, simultaneous)
core 1: [A------------------------]
core 2: [B------------------------]
core 3: [C------------------------]
time ------------------------------->
SEQUENTIAL (neither)
core 1: [A--------][B--------][C--------]
Crucially, concurrency does not require multiple cores, and a correct concurrent program is correct on one core or sixty-four. Parallelism is an optimization that concurrency makes possible.
The distinction maps directly onto what kind of work you have.
| Aspect | I/O-bound work | CPU-bound work |
|---|---|---|
| Time spent | Waiting on network, disk, database | Executing instructions |
| Bottleneck | Latency of external systems | Number of cores |
| Useful thread count | Far more than cores, sometimes thousands | Roughly the number of cores |
| Goal | Throughput and responsiveness | Reducing wall-clock time |
| Best tool | Virtual threads, async I/O | Fixed pool sized to cores, parallel streams |
That last row matters. Running four hundred threads for CPU-bound work is actively harmful, because context switching adds overhead without adding capacity. Running four hundred threads for I/O-bound work is often exactly right, because they are all blocked anyway.
Parallelism also has a hard ceiling, given by Amdahl's law. If a fraction
p of a program can be parallelized and the rest is inherently serial, the best
possible speedup on n processors is:
1
speedup = ---------------
(1 - p) + p/n
With p = 0.95 (95% parallelizable):
n = 2 -> 1.9x
n = 8 -> 6.0x
n = 32 -> 15.4x
n = inf -> 20.0x <- the hard ceiling from the 5% serial part
The lesson is that the serial fraction, which includes every lock you hold and every point where threads must coordinate, sets the limit. Doubling the cores does not double the throughput, and beyond a point it adds nothing.
Java offers two kinds of thread, and since Java 21 the choice is a real design decision.
| Kind | Mapped to | Cost each | Good for |
|---|---|---|---|
| Platform thread | One operating-system thread | About 1 MB of stack, expensive to create | CPU-bound work, and bounded pools |
| Virtual thread | Many multiplexed onto few OS threads | A few hundred bytes, cheap to create | I/O-bound work, millions of concurrent tasks |
A platform thread that blocks holds an OS thread hostage. A virtual thread that blocks is unmounted from its carrier, which continues with other work. That is why virtual threads make the simple blocking style viable at a scale that previously demanded callbacks or reactive frameworks.
// Platform thread: expensive, backed by the OS scheduler
Thread platform = Thread.ofPlatform().name("worker-1").start(() -> work());
// Virtual thread: cheap enough to create one per task, even a million of them
Thread virtual = Thread.ofVirtual().name("task-1").start(() -> work());
Key Takeaways
- Two different pressures motivate threads: work that blocks, and processing capacity that sits unused.
- Concurrency is a structural property, meaning tasks that can progress independently; parallelism is simultaneous execution.
- Concurrency is about dealing with many things at once, parallelism about doing many at once.
- Concurrency needs no extra cores, and a correct concurrent program is correct on one core.
- I/O-bound work benefits from many more threads than cores; CPU-bound work does not.
- Amdahl's law caps speedup by the serial fraction, which includes all coordination and locking.
- Platform threads map one-to-one onto OS threads and cost roughly a megabyte each.
- Virtual threads are cheap and unmount when they block, making blocking code scale.
🧪 Practice
- Classify each as concurrent, parallel, both, or neither: a single-core server handling ten requests, a parallel stream summing an array, a cooperative event loop, a plain
forloop.- Compute the maximum speedup from Amdahl's law for
p = 0.9at 4, 16, and infinite processors. State what limits the third answer.- Write a program that sums a large array in one thread and then in four, and report the speedup. Explain any gap between your result and 4x.
- Interview: A service handles requests that each wait 200 ms on a database. How many threads would you configure, and how does your answer change if each request instead burns 200 ms of processor time? (Hint: ask what the thread is doing during those 200 ms.)
Creating Threads: Thread versus Runnable
A thread needs two separate things: a task, meaning the code to run, and an execution mechanism, meaning something to run it. Java's API offers two ways to combine them, and they differ in whether they keep those concerns separate.
The first way is to extend Thread and override run(). This bundles task
and mechanism into one class.
public class DownloadThread extends Thread { // IS-A thread
private final String url;
public DownloadThread(String url) { this.url = url; }
@Override
public void run() { System.out.println("downloading " + url); }
}
new DownloadThread("https://example.com").start();
The second way is to implement Runnable and hand it to a thread. Here the
task is just a task, and something else decides how to run it.
public class DownloadTask implements Runnable { // HAS-A job to do
private final String url;
public DownloadTask(String url) { this.url = url; }
@Override
public void run() { System.out.println("downloading " + url); }
}
new Thread(new DownloadTask("https://example.com")).start();
// Runnable is a functional interface, so a lambda is usually enough
new Thread(() -> System.out.println("inline task")).start();
Prefer Runnable, for reasons that are mostly the composition-over-
inheritance argument from the design chapter.
| Consideration | extends Thread | implements Runnable |
|---|---|---|
| Inheritance slot | Used up; you cannot extend anything else | Free |
| Separation of concerns | Task and execution mechanism fused | Task is independent of how it runs |
| Reusability | Tied to one thread, single use | Submit to a pool, reuse, wrap, test |
| Works with executors | Awkward | Directly, which is how real code runs |
| Lambda-friendly | No | Yes |
| Testability | Needs a thread to test | Call run() directly in a unit test |
The most common beginner error is calling run() instead of start().
Runnable task = () -> System.out.println(Thread.currentThread().getName());
new Thread(task).run(); // WRONG: runs on the CALLER's thread, prints "main"
new Thread(task).start(); // RIGHT: creates a new thread, prints "Thread-1"
The second line prints Thread-1 rather than Thread-0 because the first
Thread object was still constructed, and constructing one consumes a name even
if it is never started.
start() asks the JVM to create a thread and invoke run() on it. Calling
run() directly is an ordinary method call with no concurrency at all. A thread
can also be started only once; a second start() throws
IllegalThreadStateException.
Every thread moves through a fixed set of states, which Thread.getState()
reports.
start()
+-------+ ------------------> +----------+
| NEW | | RUNNABLE | <-- may or may not be
+-------+ +----------+ on a core right now
/ | \
waiting for a / | \ wait(), join(),
monitor lock / | \ park()
v | v
+---------+ | +---------+
| BLOCKED | | | WAITING |
+---------+ | +---------+
| | TIMED_ | sleep(n), wait(n),
| | WAITING | join(n)
| +---------+
v
+------------+
| TERMINATED | run() returned or threw
+------------+
| State | Meaning |
|---|---|
NEW | Created but start() not yet called |
RUNNABLE | Eligible to run; may be executing or waiting for a core |
BLOCKED | Waiting to acquire a monitor lock |
WAITING | Waiting indefinitely for another thread, via wait() or join() |
TIMED_WAITING | Same, but with a deadline, via sleep(n) or wait(n) |
TERMINATED | run() has completed or thrown |
Note that RUNNABLE does not mean "running". Java does not distinguish a thread
holding a core from one queued for one, and a thread blocked on ordinary file
I/O also reports RUNNABLE.
The essential control methods:
public class Controls {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
try {
Thread.sleep(1000); // TIMED_WAITING, keeps no lock
} catch (InterruptedException e) {
// Interrupted while sleeping: the flag was CLEARED by the throw,
// so restore it and let the thread finish
Thread.currentThread().interrupt();
return;
}
}, "worker"); // naming aids every stack trace
worker.setDaemon(true); // JVM will not wait for this thread to finish
worker.start();
worker.join(2000); // wait up to 2 s for worker to terminate
worker.interrupt(); // a polite REQUEST to stop, not a kill
System.out.println(worker.getState());
}
}
Interruption is cooperative. interrupt() sets a flag; it does not stop
anything. A method that blocks, such as sleep, join, or wait, notices the
flag and throws InterruptedException, and that throw clears the flag. Code
that cannot propagate the exception must therefore restore the flag with
Thread.currentThread().interrupt(), or the interruption is silently lost. Long
computations should poll Thread.currentThread().isInterrupted() themselves.
Never swallow an InterruptedException:
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// WRONG: the cancellation request has now vanished
}
The old forcible controls are gone for good reason. Thread.stop() could leave
an object half-modified while holding its lock, and it now throws
UnsupportedOperationException. suspend() and resume() are deprecated for
removal because suspending a thread holding a lock deadlocks anything that needs
it. Cooperative interruption is the only supported mechanism.
Two more details worth knowing. An exception escaping run() kills only that
thread, silently unless you install a handler:
Thread t = new Thread(() -> { throw new RuntimeException("boom"); });
t.setUncaughtExceptionHandler((thread, ex) ->
System.out.println(thread.getName() + " died: " + ex.getMessage()));
t.start();
And setPriority is a hint the OS may ignore entirely, so never build
correctness on it.
In practice you will rarely write new Thread(...) in application code. Tasks
go to an executor, which the last subchapter covers, and that is another reason
Runnable is the right abstraction: it is what executors accept.
Key Takeaways
- A thread needs a task and an execution mechanism;
Runnablekeeps them separate,extends Threadfuses them.- Prefer
Runnable, because it leaves the inheritance slot free and works with executors and lambdas.start()creates a new thread;run()is a plain call on the current thread.- A thread can be started only once, and a second
start()throws.- The six states are
NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING, andTERMINATED.RUNNABLEmeans eligible to run, not necessarily executing.interrupt()only sets a flag, so cancellation is cooperative.- Catching
InterruptedExceptionclears the flag, so restore it or propagate the exception.Thread.stop()now throws, andsuspend/resumeare deprecated for removal; use interruption.- An uncaught exception kills just that thread, silently without a handler.
🧪 Practice
- Write the same task twice, once extending
Threadand once implementingRunnable, then rewrite the second as a lambda.Thread.currentThread().getName()from a task invoked viarun()and again viastart(), and explain the two outputs.- Start a thread that loops until interrupted, printing a counter. Interrupt it from
mainand make it exit cleanly without swallowing the exception.- Write a program that observes a thread in at least four different states by calling
getState()from another thread.- Interview: A colleague catches
InterruptedExceptionand logs it. What is wrong, and what are the two acceptable responses? (Hint: what happened to the interrupt flag when the exception was thrown?)
<a id="122-shared-state-and-the-java-memory-model"></a>
12.2 Shared State and the Java Memory Model
This subchapter covers what actually goes wrong when threads share data, and the precise guarantees the language gives you in exchange for using its synchronization tools.
Shared Mutable State Hazards
Two threads running independently cause no trouble at all. Every concurrency bug you will ever write comes from a single source: shared mutable state. Both words are load-bearing, and removing either one eliminates the problem.
- If state is shared but not mutable, every thread reads the same unchanging value. Safe.
- If state is mutable but not shared, only one thread can see it. Safe.
- Shared and mutable: every hazard below applies.
That is the most useful fact in this chapter, because it tells you that the primary tools are not locks. They are immutability and confinement. Locks are what you reach for when you cannot have either.
The hazards come in exactly three kinds, and mixing them up leads to fixes that do not fix anything.
| Hazard | Question it answers | Broken example | Fixed by |
|---|---|---|---|
| Atomicity | Can another thread see a half-finished operation? | count++ losing increments | synchronized, atomics |
| Visibility | Will another thread ever see my write? | A loop never noticing a stopped flag | volatile, synchronized |
| Ordering | Can operations appear to happen out of order? | Seeing an object before its fields are set | volatile, synchronized, final |
Atomicity is about operations that look indivisible in source but are not.
public class Counter {
private int count;
public void increment() {
count++; // THREE operations: read count, add 1, write count
}
}
Two threads can both read 7, both compute 8, and both write 8. One
increment is lost. No amount of volatile fixes this, because the problem is
not staleness; the problem is that the sequence is interruptible.
Visibility is about writes that another thread never observes. Each core has its own caches and registers, and the compiler is free to keep a value in a register rather than re-reading memory.
public class Stopper {
private boolean stopped; // not volatile
public void stop() { stopped = true; }
public void run() {
while (!stopped) { } // may hoist the read OUT of the loop and
} // spin forever, even after stop() returns
}
This is not theoretical. Compiled with optimization, that loop commonly becomes
an infinite loop, because the JIT is permitted to treat stopped as unchanging
within a thread that never writes it.
Ordering is about reordering. The compiler, the JIT, and the processor may all reorder operations as long as a single-threaded observer cannot tell. Another thread is not a single-threaded observer.
public class Reorder {
int a = 0;
boolean ready = false;
void writer() {
a = 42; // these two writes can be reordered,
ready = true; // because neither depends on the other
}
void reader() {
if (ready) {
System.out.println(a); // may legally print 0
}
}
}
Here is the shape of all three, with each thread's view drawn separately:
THREAD 1 MAIN MEMORY THREAD 2
+-------------+ +-------------+
| cache/regs | +-----------+ | cache/regs |
| count = 8 | ------> | count=7 | <--------- | count = 8 |
+-------------+ write +-----------+ write +-------------+
^ ^
| read 7 | read 7
+-------------------------------------------------+
Both read 7, both wrote 8. Expected 9, got 8: one update LOST.
The way out, in order of preference:
- Do not share. Confine state to one thread, or pass copies.
- Do not mutate. Make the shared object immutable.
- Synchronize. If you must share mutable state, every access, including reads, must be coordinated.
Point three has a detail people miss constantly: synchronizing only the writes is useless. If a reader does not synchronize, it has no guarantee of seeing anything. Synchronization is a protocol both sides must follow.
Key Takeaways
- Every concurrency bug comes from state that is both shared and mutable.
- Removing either sharing or mutability removes the problem entirely.
- The three hazards are atomicity, visibility, and ordering, and they need different fixes.
count++is a read, an add, and a write, so it is not atomic.- Visibility failures are real: a non-volatile flag read in a loop can be hoisted into an infinite loop.
- Compilers and processors may reorder operations that no single thread can distinguish.
- Prefer confinement, then immutability, and use locks only when neither is possible.
- Both readers and writers must synchronize; guarding only writes guarantees nothing.
🧪 Practice
- For a shared
int totalupdated by several threads, state which of the three hazards apply and which single keyword, if any, fixes each.- Write the
Stopperclass above and run it with a second thread callingstop()after a second. Report whether it terminates, then addvolatileand report again.- Take a class with a shared mutable
Listfield and rewrite it twice: once using confinement, once using immutability.- Interview: A colleague fixes a lost-update bug by marking the counter field
volatile. Why does the bug survive? (Hint: which of the three hazards doesvolatileaddress, and which one is the actual bug?)
Race Conditions and Interleaving
A race condition exists when the correctness of a program depends on the relative timing of operations in different threads. The name is apt: two threads are racing, and the result depends on who wins, which nobody controls.
The crucial insight is that threads do not execute in whole statements. The JVM may switch threads between any two bytecode instructions, so the real execution is an arbitrary interleaving of the participating threads' instructions.
Consider count++ in bytecode:
getfield count <- read the current value onto the stack
iconst_1
iadd <- compute value + 1
putfield count <- write it back
A switch can occur at any of those boundaries. Interleave two threads at the worst moment:
time Thread A Thread B count in memory
---- ------------------ ------------------ ---------------
1 read count -> 7 7
2 read count -> 7 7
3 compute 7 + 1 = 8 7
4 compute 7 + 1 = 8 7
5 write 8 8
6 write 8 8 <- LOST
Two increments, one result. And this is not a rare interleaving; with two threads doing a hundred thousand increments each, you lose thousands.
public class LostUpdates {
private static int count; // shared and mutable
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10_000_000; i++) count++; // the race
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join(); // wait for both to finish
System.out.println("expected 20000000, got " + count);
// Observed on a warm JVM: 10063454, 10133588, 13173489, ...
// Almost exactly half is common, because each thread runs mostly alone
// on its own cached copy and the last writer simply wins.
}
}
The loop count is deliberately large, and that detail is itself instructive. With only a hundred thousand iterations this program usually prints the correct answer, because once the JIT compiles the loop it finishes in under a millisecond, which can be less time than starting the second thread takes. The threads never overlap, so there is no race to lose. The bug is not in the code that runs slowly; it is in the code whose threads actually overlap, which is precisely why small tests miss it and production does not.
Race conditions come in two recognizable shapes, and naming them helps you spot them in review.
Read-modify-write: the new value depends on the old one, and the pair is not
atomic. count++, balance -= amount, and list.set(i, list.get(i) + 1) are
all this shape.
Check-then-act: you test a condition and then act on it, but the condition may have changed in between. This one is sneakier because each half looks innocent.
public class LazyInit {
private Resource instance;
public Resource get() {
if (instance == null) { // CHECK
instance = new Resource(); // ACT: two threads can both get here
}
return instance; // and return DIFFERENT objects
}
}
// Check-then-act hiding behind a thread-safe collection
if (!map.containsKey(key)) { // CHECK: map is thread-safe ...
map.put(key, compute(key)); // ACT: ... but this PAIR is not
}
// Fixed with a single atomic operation
map.computeIfAbsent(key, this::compute);
That last pair generalizes into the most important warning about concurrency:
composing two thread-safe operations does not produce a thread-safe
operation. ConcurrentHashMap guarantees each individual call is atomic; it
guarantees nothing about a sequence of them. This is why the concurrent
collections provide compound methods such as computeIfAbsent and
putIfAbsent.
Any group of operations that must appear indivisible to other threads is a compound action, and it must be made atomic as a unit, either with a lock or with a single atomic primitive.
// Same logic, made atomic by holding one lock across the whole compound action
public synchronized void increment() { count++; }
One warning about testing. Race conditions are nondeterministic: they depend
on timing, core count, and JIT decisions. A test that passes proves nothing, and
bugs commonly appear only in production under load. Reason about correctness
from the memory model rather than from observed behaviour, and use tools such as
jcstress when you need real evidence.
Key Takeaways
- A race condition is correctness that depends on thread timing.
- Threads interleave at bytecode boundaries, not statement boundaries.
count++is four bytecode operations and can lose updates at any of three boundaries.- The two common shapes are read-modify-write and check-then-act.
- Lazy initialization without synchronization is a check-then-act race that can return two different objects.
- Composing thread-safe operations does not yield a thread-safe operation.
- Concurrent collections expose compound methods such as
computeIfAbsentfor exactly this reason.- Any group of operations that must appear indivisible is a compound action needing one lock or one atomic call.
- Races are nondeterministic, so a passing test is not evidence of correctness.
🧪 Practice
- Run
LostUpdatesabove five times and record the results. Then lower the loop count to one hundred thousand and explain why the bug often disappears.- Identify the race in this snippet and fix it with one method call:
if (!set.contains(x)) set.add(x);wheresetis a synchronized set.- Write a
Ledgerclass withdepositandwithdrawthat loses money under two threads, then make the compound action atomic.- Interview: Your
check-then-actbug disappears when you add a log statement. What does that tell you, and would you ship it? (Hint: what does a slow call do to the width of the race window?)
The Java Memory Model and Happens-Before
Everything so far has been bad news: writes may be invisible, operations may be reordered, and nothing executes in the order you wrote. The obvious question is what, precisely, is guaranteed. The answer is the Java Memory Model (JMM), specified in the language itself since Java 5.
First, why any of this is allowed. Reordering is not the JVM being unhelpful; it is where most of your performance comes from.
- The compiler reorders to keep values in registers and to schedule instructions well.
- The processor executes out of order and speculates past branches.
- Caches mean a write may sit in a core-local store buffer before reaching memory.
Forbidding all of that would cost an order of magnitude in speed. So the JMM strikes a bargain: within a single thread, everything appears to happen in program order. Across threads, nothing is guaranteed unless you establish a happens-before relationship.
Happens-before is the central concept. If action A happens-before action B, then the effects of A are visible to B, and A appears to occur first from B's perspective. It is not about wall-clock time; it is a guarantee about visibility and ordering.
If two accesses to the same non-volatile variable are not ordered by happens-before, and at least one is a write, you have a data race and the program has no defined behaviour for that variable.
The rules that create happens-before edges are a short list worth memorizing.
| Rule | A happens-before B when |
|---|---|
| Program order | A precedes B in the same thread |
| Monitor lock | A releases a lock, B later acquires that same lock |
| Volatile | A writes a volatile field, B later reads that same field |
| Thread start | A calls t.start(), B is any action in thread t |
| Thread join | A is any action in thread t, B follows t.join() returning |
| Thread termination | A is any action in t, B detects termination via isAlive() |
| Interruption | A calls t.interrupt(), B detects it in t |
| Final field | A constructor finishes, B reads a final field of that object |
| Transitivity | A happens-before C, and C happens-before B |
Transitivity is what makes the list useful, because it lets you chain edges through a lock or a volatile field:
THREAD 1 THREAD 2
------------------------------ ------------------------------
data = 42; (1)
lock.lock(); (2)
flag = true; (3)
lock.unlock(); (4) ---+
| release/acquire edge
+-> lock.lock(); (5)
read flag -> true (6)
lock.unlock(); (7)
read data -> 42 (8)
(1) happens-before (4) by program order
(4) happens-before (5) by the monitor rule
(5) happens-before (8) by program order
therefore (1) happens-before (8) by TRANSITIVITY: data is guaranteed to be 42
Notice what that example really shows. Thread 2 reads data, which is an
ordinary non-volatile field, and is guaranteed to see 42. The lock published
not just flag but everything written before the release. A lock release
flushes all prior writes; a lock acquire makes them visible. That is why
synchronization is about far more than mutual exclusion.
The same argument works with volatile, which is why a single volatile write
can safely publish a whole object graph:
public class Publisher {
private Config config; // ordinary field
private volatile boolean published; // the publishing barrier
void publish() {
config = new Config("prod", 8080); // 1. ordinary writes
published = true; // 2. volatile write: flushes 1
}
Config consume() {
if (published) { // volatile read: sees everything before step 2
return config; // guaranteed fully constructed, non-null
}
return null;
}
}
Reverse the two lines in publish() and the guarantee vanishes, because the
happens-before edge must be created after the data is written.
Safe publication is the name for getting this right, and there are exactly five safe ways to publish an object so other threads see it fully constructed:
- Initialize it from a static initializer
- Store it in a
volatilefield or anAtomicReference - Store it in a
finalfield of a properly constructed object - Store it in a field guarded by a lock, read under that same lock
- Put it in a thread-safe collection, which does one of the above internally
final fields get a special guarantee. If an object's fields are final
and the constructor does not let this escape, then any thread that sees a
reference to the object is guaranteed to see its final fields fully
initialized, with no synchronization at all. This is the formal reason immutable
objects are automatically thread-safe.
public final class Range {
private final int lo; // final: frozen when the constructor ends
private final int hi;
public Range(int lo, int hi) {
if (lo > hi) throw new IllegalArgumentException();
this.lo = lo;
this.hi = hi;
// Do NOT do this: publishing `this` before construction completes
// breaks the final-field guarantee entirely.
// Registry.register(this);
}
}
The practical upshot is that you almost never reason about happens-before directly. You use a lock, a volatile field, an atomic, or an immutable object, and the model guarantees the rest. What the model gives you is the ability to say why a piece of code is correct, rather than hoping.
Key Takeaways
- The Java Memory Model defines what one thread is guaranteed to see of another's writes.
- Reordering by compiler, processor, and caches is allowed because it is where performance comes from.
- Within a thread everything appears to run in program order; across threads only happens-before guarantees anything.
- Unordered concurrent access with at least one write is a data race with undefined behaviour.
- The edges come from locks, volatile fields,
start,join, interruption, final fields, and program order.- Transitivity chains those edges, which is what makes them useful.
- A lock release publishes every write made before it, not just the guarded field.
- One volatile write can safely publish an entire object graph, but only if it comes last.
- The five safe publication idioms are static initializer, volatile or atomic, final field, lock-guarded field, and thread-safe collection.
finalfields are guaranteed visible after construction, providedthisnever escapes the constructor.
🧪 Practice
- For the
Publisherclass above, explain which happens-before rules makeconfigvisible, then explain what breaks if the two writes are swapped.- Write out the happens-before chain that guarantees a worker thread sees a value written by
mainbeforet.start().- Find a class in your own code that publishes an object without synchronization and rewrite it using each of two different safe publication idioms.
- Interview: Thread A writes a non-volatile field then releases a lock; thread B acquires that lock and reads the field. Is the read guaranteed to see the write, and why? (Hint: which rule covers release-then-acquire, and what does transitivity add?)
Visibility and the volatile Keyword
The visibility hazard deserves its own treatment, because its failure mode is the most surprising in all of Java: a loop that never ends, reading a field that was definitely changed.
public class StopFlag {
private static boolean stopped; // NOT volatile
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long spins = 0;
while (!stopped) { spins++; } // may never observe the write
System.out.println("stopped after " + spins + " spins");
});
worker.start();
Thread.sleep(1000);
stopped = true; // the write happens
System.out.println("main set stopped = true");
worker.join(3000);
System.out.println("worker alive: " + worker.isAlive());
}
}
The JIT, seeing a loop that never writes stopped, is entitled to transform it:
SOURCE LEGAL OPTIMIZATION
while (!stopped) { boolean s = stopped; // hoisted out
spins++; while (!s) { // now never re-read
} spins++;
}
That is not a bug in the JVM. Without a happens-before edge, the write from
main simply has no defined relationship to the read in the worker, so the
compiler may assume no other thread changes it.
volatile fixes exactly this. Declaring a field volatile makes three
promises:
- Visibility. Every read sees the most recent write by any thread. Reads and writes go to memory, not to a register or a stale cache line.
- Ordering. A volatile write acts as a release barrier and a volatile read as an acquire barrier, so surrounding operations cannot be reordered across them.
- Atomicity of the access itself, including for
longanddouble, whose 64-bit non-volatile reads and writes are otherwise permitted to tear into two 32-bit halves.
private static volatile boolean stopped; // one word fixes the loop above
What volatile does not do is make compound operations atomic. This is the
single most common misunderstanding.
public class Broken {
private volatile int count;
public void increment() {
count++; // STILL BROKEN: volatile read, +1, volatile write.
// Each access is atomic; the sequence is not.
}
}
volatile guarantees you see a fresh value, not that nobody changes it
between your read and your write.
The rule for when volatile is sufficient: use it when a write does not
depend on the current value, or when only one thread ever writes.
| Use case | volatile enough? | Why |
|---|---|---|
A boolean stop or shutdown flag | Yes | The write ignores the old value |
| Publishing an immutable object reference | Yes | A single reference assignment |
| A counter incremented by many threads | No | Read-modify-write |
| A value only one thread writes, many read | Yes | No competing writers |
| Two fields that must change together | No | Needs one lock for the pair |
The instance field in double-checked locking | Yes, and required | Prevents publishing a partly built object |
Double-checked locking is the classic case where volatile is not an
optimization but a correctness requirement:
public class Singleton {
// Without volatile, another thread can see a NON-NULL reference to an
// object whose constructor has not finished.
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 1. cheap unsynchronized check
synchronized (Singleton.class) {
if (instance == null) { // 2. re-check while holding lock
instance = new Singleton();
}
}
}
return instance;
}
}
In practice, prefer a holder class or an enum, which the JVM makes thread-safe through class initialization with no volatile reasoning at all:
public class Config {
private Config() { }
// The JVM guarantees Holder's static initializer runs once, safely,
// and not until getInstance() is first called.
private static class Holder {
static final Config INSTANCE = new Config();
}
public static Config getInstance() { return Holder.INSTANCE; }
}
Finally, how the three main tools compare:
| Feature | volatile | synchronized | AtomicInteger |
|---|---|---|---|
| Visibility | Yes | Yes | Yes |
| Ordering guarantees | Yes | Yes | Yes |
| Mutual exclusion | No | Yes | No |
| Compound atomicity | No | Yes | Yes, per operation |
| Can block a thread | No | Yes | No |
| Relative cost | Very low | Higher | Low |
Key Takeaways
- A non-volatile flag read in a loop can be hoisted into a register, producing an infinite loop.
volatileguarantees visibility, prevents reordering across the access, and makes 64-bit reads and writes atomic.volatiledoes not makecount++atomic, because the sequence is still three steps.- Use
volatilewhen the write ignores the previous value, or when a single thread writes.- Use a lock or an atomic when the new value depends on the old one, or when several fields must change together.
volatileis mandatory, not optional, in double-checked locking.- Prefer a static holder class or an enum for lazy singletons.
volatileis much cheaper than a lock but provides no mutual exclusion.
🧪 Practice
- Run
StopFlagabove. If it terminates, add work inside the loop or run with-serveruntil you observe it hanging, then fix it withvolatile.- For each field, decide whether
volatilesuffices: ashutdownRequestedflag, ahitCountincremented per request, a cached immutableSettingsreference, and a pair ofx/ycoordinates.- Implement a lazy singleton three ways: double-checked locking, a holder class, and an enum. State one advantage of each.
- Interview: Why is
volatilerequired on theinstancefield in double-checked locking, given that the write happens inside asynchronizedblock? (Hint: what does the thread taking the first, unsynchronized branch see?)
Atomic Variables and Compare-and-Swap
Locks work, but they are heavyweight for small jobs. A thread that fails to acquire a lock is suspended and later rescheduled, which costs thousands of cycles, and while it is blocked it makes no progress at all. For a single counter that is a poor trade.
The alternative reverses the strategy. A lock is pessimistic: assume a conflict, exclude everyone, then act. Atomics are optimistic: act as though there is no conflict, then verify, and retry if you were wrong.
The hardware primitive that makes this work is compare-and-swap (CAS), a single instruction with three operands:
CAS(memory_location, expected_value, new_value):
atomically, in one uninterruptible instruction:
if (*memory_location == expected_value) {
*memory_location = new_value;
return true; // I won the race
} else {
return false; // someone else changed it; retry
}
Because the compare and the swap happen as one indivisible machine operation, no interleaving can slip between them. Conceptually every atomic method is this loop:
// What AtomicInteger.incrementAndGet() does underneath
public int incrementAndGet() {
while (true) {
int current = get(); // optimistic read
int next = current + 1;
if (compareAndSet(current, next)) { // did anyone else change it?
return next; // no: done, never blocked
}
// yes: someone won the race, so loop and try again with a fresh value
}
}
Notice that no thread is ever suspended. A thread that loses the race retries immediately, which is why this is called lock-free: the failure of one thread cannot prevent others from progressing.
The java.util.concurrent.atomic package packages this up.
import java.util.concurrent.atomic.*;
public class Counters {
private final AtomicInteger count = new AtomicInteger();
private final AtomicLong total = new AtomicLong();
private final AtomicReference<String> state = new AtomicReference<>("IDLE");
private final AtomicBoolean initialized = new AtomicBoolean(false);
void work() {
count.incrementAndGet(); // atomic ++, returns the new value
count.getAndIncrement(); // atomic ++, returns the old value
total.addAndGet(50); // atomic +=
// Compare-and-set for a conditional transition
state.compareAndSet("IDLE", "RUNNING"); // only if still IDLE
// Arbitrary atomic update expressed as a pure function
count.updateAndGet(n -> Math.min(n * 2, 1000));
count.accumulateAndGet(5, Integer::sum);
// A run-once guard, correct even under contention
if (initialized.compareAndSet(false, true)) {
System.out.println("this branch runs exactly once");
}
}
}
The updateAndGet family takes a function and applies it in a retry loop. The
function must be pure and side-effect free, because it can be invoked
several times when there is contention.
Fixing the earlier counter is now a one-line change:
public class SafeCounter {
private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); } // atomic, no lock
public int get() { return count.get(); }
}
CAS has one classic weakness, the ABA problem. A value changes from A to B and back to A. A CAS checking for A succeeds, even though the world changed underneath it. For a counter this is harmless; for a data structure where identity matters, it is not.
// AtomicStampedReference pairs the value with a version counter,
// so A -> B -> A is detectable because the stamp advanced.
AtomicStampedReference<Node> head = new AtomicStampedReference<>(node, 0);
int[] stampHolder = new int[1];
Node current = head.get(stampHolder); // read value AND stamp
int stamp = stampHolder[0];
head.compareAndSet(current, newNode, stamp, stamp + 1); // both must match
The other weakness is contention. Under heavy write pressure many threads keep failing and retrying, burning processor time. For that case Java 8 added accumulators that trade exact-at-all-times reads for scalability by keeping per-thread cells and summing them on demand.
import java.util.concurrent.atomic.LongAdder;
// Much faster than AtomicLong under high write contention.
// sum() is not a point-in-time snapshot, which is fine for statistics.
private final LongAdder requests = new LongAdder();
void onRequest() { requests.increment(); }
long total() { return requests.sum(); }
Choosing between the three approaches:
| Situation | Best tool |
|---|---|
| A single counter or flag | AtomicInteger / AtomicBoolean |
| A hot counter with heavy contention | LongAdder |
| One reference swapped atomically | AtomicReference |
| Reference where A-B-A must be detected | AtomicStampedReference |
| Two or more fields that must change together | A lock, or one immutable holder object |
| Anything that must block or wait | A lock with a Condition |
That fifth row is the boundary. Atomics handle one variable. The moment an
invariant spans two fields, atomics cannot express it, and you need either a
lock or a single immutable object holding both values swapped through one
AtomicReference.
Key Takeaways
- Locks are pessimistic and can suspend a thread; atomics are optimistic and never block.
- Compare-and-swap is one hardware instruction that compares and conditionally writes indivisibly.
- Every atomic operation is a retry loop around CAS, so a losing thread retries rather than sleeping.
- Lock-free means one stalled thread cannot block the others.
- Functions passed to
updateAndGetmust be pure, because they can run several times.compareAndSetgives a correct run-once guard.- The ABA problem needs
AtomicStampedReferencewhen identity matters.LongAdderbeatsAtomicLongunder heavy contention by trading snapshot accuracy for throughput.- Atomics protect one variable; invariants spanning several fields need a lock or one immutable holder.
🧪 Practice
- Replace the broken counter from the race-conditions topic with an
AtomicIntegerand confirm it reaches exactly 200000 every run.- Implement
incrementAndGetyourself using onlygetandcompareAndSet, and add a counter for how many retries occur under four threads.- Benchmark
AtomicLongagainstLongAdderwith eight threads doing ten million increments each, and report both times.- Use
AtomicReferenceto atomically replace an immutable settings object, then explain why two separateAtomicIntegerfields could not maintain an invariant between them.- Interview: Explain the ABA problem and give a scenario where it causes real corruption. (Hint: think about popping a node from a lock-free stack that gets reused.)
<a id="123-synchronization-and-locking"></a>
12.3 Synchronization and Locking
This subchapter covers the tools for enforcing mutual exclusion when confinement and immutability are not available, and the liveness failures that using them badly can introduce.
Synchronization and Intrinsic Locks
Atomics handle one variable. Once an invariant spans several fields, or a compound action must appear indivisible, you need to exclude other threads for a stretch of code. That is what a lock does.
Every Java object carries a built-in lock, called its intrinsic lock or
monitor. You never create it; it is part of the object header described in
the memory chapter. The synchronized keyword acquires it on entry and releases
it on exit, including when an exception is thrown.
A monitor behaves like a single-occupancy room with one key hanging on the door. Whoever takes the key can enter; everyone else waits in the corridor. Leaving the room, by any exit, puts the key back.
synchronized delivers two guarantees, and people routinely remember only
the first:
- Mutual exclusion. At most one thread holds a given lock at a time.
- Visibility. Releasing a lock publishes every write made before it, and acquiring the same lock makes those writes visible. This is the monitor rule from the memory model.
That second guarantee is why an unsynchronized reader is broken even when all writers synchronize perfectly.
There are two syntactic forms.
public class Account {
private double balance;
// FORM 1: synchronized method. Locks `this` for the whole body.
public synchronized void deposit(double amount) {
balance += amount;
}
// FORM 2: synchronized block. Locks a chosen object, for a chosen region.
public void transferFrom(Account other, double amount) {
String audit = buildAuditRecord(amount); // expensive, needs no lock
synchronized (this) { // hold the lock only where needed
balance += amount;
}
log(audit); // also outside the lock
}
private String buildAuditRecord(double a) { return "deposit " + a; }
private void log(String s) { }
}
Which object gets locked is the detail that causes the most confusion:
| Declaration | Lock acquired |
|---|---|
synchronized void m() | this |
static synchronized void m() | MyClass.class, the Class object |
synchronized (obj) { } | obj |
synchronized (MyClass.class) { } | Same lock as a static synchronized method |
Note the consequence: an instance method and a static method of the same class use different locks and therefore do not exclude each other at all.
Intrinsic locks are reentrant, meaning the thread already holding a lock can acquire it again. Without that, this ordinary code would deadlock against itself:
public class Reentrant {
public synchronized void outer() {
inner(); // already holds `this`; reentrancy lets this proceed
}
public synchronized void inner() { }
}
The JVM keeps an acquisition count, incremented on each entry and decremented on each exit, releasing the lock only at zero.
Choosing the right lock object matters, and the common mistakes all share a cause: locking something other threads can also lock for unrelated reasons.
public class LockingMistakes {
// BAD: a String literal is interned, so unrelated code may lock the same object
private final String badLock = "lock";
// BAD: boxed values are cached, so Integer.valueOf(1) is shared JVM-wide
private final Integer worseLock = 1;
// BAD: reassignable, so two threads can end up locking different objects
private Object mutableLock = new Object();
// BAD: public, so any external code can lock it and block you
public final Object exposedLock = new Object();
// GOOD: private and final. Nobody else can reach it or change it.
private final Object lock = new Object();
}
Using a private final lock object rather than this also stops callers from
locking your object and interfering with your policy, which is the same
encapsulation argument as for fields.
Granularity is the main tuning decision. A lock held too long serializes the program, which is exactly the serial fraction Amdahl's law punishes. A lock held too briefly may not cover the whole invariant.
public class Granularity {
private final Map<String, String> cache = new HashMap<>();
// TOO COARSE: the expensive computation runs while holding the lock,
// so only one thread can compute at a time.
public synchronized String badGet(String key) {
String v = cache.get(key);
if (v == null) {
v = expensiveComputation(key); // seconds, holding the lock
cache.put(key, v);
}
return v;
}
// BETTER: hold the lock only for the map accesses. Two threads may now
// compute the same key redundantly, which is usually an acceptable trade.
public String betterGet(String key) {
synchronized (cache) {
String v = cache.get(key);
if (v != null) return v;
}
String computed = expensiveComputation(key); // no lock held
synchronized (cache) {
cache.putIfAbsent(key, computed); // idempotent
return cache.get(key);
}
}
private String expensiveComputation(String k) { return k.toUpperCase(); }
}
Two rules follow from experience. Never hold a lock while performing I/O, and never call unknown code while holding a lock, because you cannot know what locks that code will take, which invites deadlock.
Coordination with wait and notify. Sometimes a thread must wait not for
a lock but for a condition, such as a queue becoming non-empty.
Object.wait() releases the lock and suspends the thread; notify() and
notifyAll() wake waiters.
public class BoundedBuffer<T> {
private final Queue<T> items = new LinkedList<>();
private final int capacity;
public BoundedBuffer(int capacity) { this.capacity = capacity; }
public synchronized void put(T item) throws InterruptedException {
// ALWAYS wait in a while loop, never an if. See below.
while (items.size() == capacity) {
wait(); // releases the lock, then suspends
}
items.add(item);
notifyAll(); // wake any thread waiting to take
}
public synchronized T take() throws InterruptedException {
while (items.isEmpty()) {
wait();
}
T item = items.remove();
notifyAll(); // wake any thread waiting to put
return item;
}
}
Four rules govern wait, and every one of them is commonly broken:
- You must hold the lock to call
waitornotify, or you getIllegalMonitorStateException. - Always wait in a
whileloop, never anif. A thread can wake spuriously, and by the time it reacquires the lock another thread may have consumed the condition it woke for. - Prefer
notifyAllovernotify.notifywakes one arbitrary waiter, which may be waiting for a different condition, and the notification is then lost. waitreleases only the lock it was called on. Any other locks the thread holds stay held, which is a deadlock risk.
MONITOR STRUCTURE
+-------------------- object's monitor --------------------+
| |
| ENTRY SET OWNER WAIT SET |
| (BLOCKED) (running) (WAITING) |
| T2, T3 ---> acquire ---> T1 ---wait()---> T4, T5 |
| ^ | | |
| | release | |
| +-----------------------+ notifyAll() ---+ |
| moves them to entry set |
+----------------------------------------------------------+
In modern code you rarely write wait/notify at all. A BlockingQueue
already implements this correctly, and Condition objects on an explicit lock
are clearer. Know the mechanism, because interviewers ask and legacy code uses
it, but reach for the library first.
Key Takeaways
- Every object has an intrinsic lock, also called a monitor, used by
synchronized.synchronizedprovides both mutual exclusion and visibility, and the visibility half is why readers must synchronize too.- An instance
synchronizedmethod locksthis; a static one locks theClassobject, so they do not exclude each other.- Intrinsic locks are reentrant, counted per acquisition by the owning thread.
- Lock on a
private finalobject, never on a string literal, a boxed number, a reassignable field, or a public object.- Hold locks for the shortest region that still covers the invariant.
- Never do I/O or call unknown code while holding a lock.
waitreleases the lock and suspends;notifyandnotifyAllwake waiters, and all three require holding the lock.- Always
waitinside awhileloop, because wakeups can be spurious and the condition can change again.- Prefer
notifyAll, and prefer aBlockingQueueorConditionover writingwait/notifyyourself.
🧪 Practice
- Add
synchronizedto the broken counter and verify it now reaches exactly 200000. Then move the keyword to a static method and explain why it stops working.- Rewrite a
synchronizedmethod to use aprivate finallock object, and state what attack or accident that prevents.- Implement
BoundedBufferabove and drive it with two producers and two consumers. Then changewhiletoifand describe the failure you would expect.- Take a method holding a lock across a network call and restructure it so the call happens outside the lock.
- Interview: Why must
wait()be called inside a loop that rechecks the condition? (Hint: between being notified and reacquiring the lock, what can another thread do?)
Explicit Locks: ReentrantLock and ReadWriteLock
synchronized is concise and hard to misuse in one specific way: you cannot
forget to release the lock. But that same rigidity is limiting. A synchronized
block gives you no way to:
- Give up after waiting a while
- Attempt the lock and do something else if it is taken
- Be interrupted while waiting
- Acquire in one method and release in another
- Wait on several distinct conditions with one lock
- Request fair, first-come-first-served ordering
java.util.concurrent.locks.ReentrantLock provides all of it, at the cost of
requiring you to release the lock yourself.
import java.util.concurrent.locks.*;
import java.util.concurrent.TimeUnit;
public class Explicit {
private final ReentrantLock lock = new ReentrantLock();
private int counter;
public void increment() {
lock.lock();
try {
counter++;
} finally {
lock.unlock(); // MANDATORY, and must be in finally
}
}
}
The try/finally is not a style preference. An exception between lock()
and unlock() would otherwise leave the lock held forever, deadlocking every
other thread. Note also that lock() goes before the try: if acquisition
itself throws, there is nothing to release.
The capabilities that justify the extra ceremony:
public class LockFeatures {
private final ReentrantLock lock = new ReentrantLock();
// 1. tryLock: never blocks. Do other work, or report busy.
public boolean attempt() {
if (!lock.tryLock()) return false; // already held: give up now
try {
return true;
} finally {
lock.unlock();
}
}
// 2. Timed acquisition: bounded waiting, which makes deadlock recoverable
public boolean attemptFor(long ms) throws InterruptedException {
if (!lock.tryLock(ms, TimeUnit.MILLISECONDS)) return false;
try {
return true;
} finally {
lock.unlock();
}
}
// 3. Interruptible acquisition: a waiting thread can be cancelled
public void interruptibly() throws InterruptedException {
lock.lockInterruptibly(); // synchronized cannot do this
try {
// ...
} finally {
lock.unlock();
}
}
}
A fair lock hands ownership to the longest-waiting thread, which prevents starvation but costs significant throughput because it cannot use barging.
ReentrantLock fair = new ReentrantLock(true); // default is false, unfair
Default to unfair. Unfair locks are much faster, and the starvation they permit rarely matters in practice.
Condition is the explicit-lock equivalent of wait/notify, with one
decisive improvement: a single lock can have several condition queues, so you
can wake exactly the threads waiting for the relevant condition.
import java.util.concurrent.locks.*;
import java.util.*;
public class BoundedBuffer<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // two SEPARATE queues
private final Condition notEmpty = lock.newCondition();
private final Queue<T> items = new LinkedList<>();
private final int capacity;
public BoundedBuffer(int capacity) { this.capacity = capacity; }
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (items.size() == capacity) {
notFull.await(); // waits on the not-full queue only
}
items.add(item);
notEmpty.signal(); // wakes ONE taker, not everybody
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) {
notEmpty.await();
}
T item = items.remove();
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
Compare with the wait/notify version earlier: that one had to call
notifyAll and wake every waiter, because there was only one wait set and no
way to tell producers from consumers. Here signal is precise. The while loop
is still required, for the same reason.
ReadWriteLock exploits an asymmetry: concurrent reads are harmless, and
only writes need exclusivity. It offers two locks that share state, where
readers may hold theirs simultaneously but a writer excludes everyone.
import java.util.concurrent.locks.*;
import java.util.*;
public class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReadWriteLock rw = new ReentrantReadWriteLock();
private final Lock read = rw.readLock();
private final Lock write = rw.writeLock();
public V get(K key) {
read.lock(); // many threads can be here at once
try {
return map.get(key);
} finally {
read.unlock();
}
}
public void put(K key, V value) {
write.lock(); // excludes all readers and other writers
try {
map.put(key, value);
} finally {
write.unlock();
}
}
}
The catch: a read-write lock only pays off when reads greatly outnumber writes
and the guarded operations are long enough to offset its higher bookkeeping
cost. For short operations a plain ReentrantLock is often faster, and for a
map specifically, ConcurrentHashMap beats both. Note also that a read lock
cannot be upgraded to a write lock; attempting it deadlocks.
StampedLock (Java 8) adds an optimistic read mode that takes no lock at all,
validating afterwards that no write intervened. It is faster still but is not
reentrant and is easy to misuse, so treat it as a specialist tool.
The decision table:
| Need | Use |
|---|---|
| Simple mutual exclusion | synchronized |
Timeout, tryLock, or interruptibility | ReentrantLock |
| Several distinct wait conditions | ReentrantLock with Conditions |
| Guaranteed fairness | new ReentrantLock(true) |
| Read-heavy with long read operations | ReentrantReadWriteLock |
| Maximum read throughput, expert use | StampedLock |
| A concurrent map | ConcurrentHashMap, not a lock |
Default to synchronized. It is shorter, impossible to leak, and the JVM
optimizes it well. Reach for an explicit lock when you need a capability from
that list, not as a habit.
Key Takeaways
ReentrantLockadds timeouts,tryLock, interruptible acquisition, fairness, and multiple conditions.- You must call
unlock()in afinallyblock, withlock()placed before thetry.tryLocknever blocks, which makes deadlock avoidable and recoverable.- Fair locks prevent starvation but cost throughput, so unfair is the default for good reason.
Conditionallows several wait queues per lock, sosignalcan target the right waiters.awaitstill belongs in awhileloop that rechecks the condition.ReadWriteLockhelps only when reads dominate and are long; read locks cannot be upgraded to write locks.- Prefer
synchronizedby default andConcurrentHashMapover any hand-locked map.
🧪 Practice
- Convert a
synchronizedcounter to aReentrantLockversion and verify thefinallyblock by throwing inside the critical section.- Implement a
tryLock-based method that returnsfalseinstead of waiting, and demonstrate it with two threads.- Implement the bounded buffer with two
Conditions, then explain precisely what thewait/notifyAllversion wakes unnecessarily.- Benchmark
ReentrantReadWriteLockagainstsynchronizedfor a map with 95% reads, then with 50% reads, and report both.- Interview: When would you choose
ReentrantLockoversynchronized, and what new bug does that choice make possible? (Hint: what doessynchronizeddo for you automatically at every exit point?)
Deadlock, Livelock, and Starvation
Locks solve safety problems, meaning "nothing bad happens". They introduce liveness problems, meaning "something good eventually happens". A program can be perfectly thread-safe and still be useless because it stops making progress.
Deadlock is the classic case: two or more threads each hold a lock the other needs, and none will ever release it.
public class Deadlock {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void first() {
synchronized (lockA) { // takes A
synchronized (lockB) { } // then wants B
}
}
public void second() {
synchronized (lockB) { // takes B
synchronized (lockA) { } // then wants A
}
}
// Thread 1 in first(), thread 2 in second(), and both wait forever.
}
Thread 1 Thread 2
-------- --------
holds lockA ---- wants ----> holds lockB
^ |
+------------ wants -----------+
A CYCLE in the wait-for graph. That cycle IS the deadlock.
Deadlock requires all four Coffman conditions simultaneously, and breaking any one prevents it:
| Condition | Meaning | How to break it |
|---|---|---|
| Mutual exclusion | A lock is held by one thread at a time | Use immutable data or atomics |
| Hold and wait | A thread holds one lock while requesting another | Acquire all at once, or none |
| No preemption | A lock cannot be forcibly taken away | Use tryLock with a timeout |
| Circular wait | A cycle exists in the wait-for graph | Impose a global lock ordering |
Lock ordering is the standard fix, because it is simple and static: define a global order on all locks and have every thread acquire them in that order. A cycle becomes impossible, because a cycle requires some thread to acquire out of order.
The canonical example is a bank transfer:
public class Account {
private final int id; // a stable, unique ordering key
private double balance;
public Account(int id, double balance) { this.id = id; this.balance = balance; }
// BROKEN: transfer(a, b) and transfer(b, a) deadlock against each other
public static void badTransfer(Account from, Account to, double amt) {
synchronized (from) {
synchronized (to) {
from.balance -= amt;
to.balance += amt;
}
}
}
// FIXED: always lock the lower id first, whatever the argument order
public static void transfer(Account from, Account to, double amt) {
Account first = from.id < to.id ? from : to; // deterministic order
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
from.balance -= amt;
to.balance += amt;
}
}
}
}
When there is no natural ordering key, System.identityHashCode provides one,
with a third "tie-breaker" lock for the rare collision. When ordering is
impossible, use timed acquisition and back off:
public boolean tryTransfer(Lock a, Lock b, long ms) throws InterruptedException {
long deadline = System.nanoTime() + ms * 1_000_000;
while (System.nanoTime() < deadline) {
if (a.tryLock()) {
try {
if (b.tryLock()) { // got both: proceed
try { return true; }
finally { b.unlock(); }
}
} finally {
a.unlock(); // release A before retrying,
} // so we never hold-and-wait
}
Thread.sleep(10 + (long) (Math.random() * 40)); // randomized backoff
}
return false;
}
The randomized sleep matters: a fixed delay makes two threads retry in lockstep and collide again.
Livelock is deadlock's active cousin. Threads are not blocked, but they keep undoing each other's work and never finish. Two people stepping aside for each other in a corridor, repeatedly, is the standard analogy. The naive retry loop above would livelock without the randomized backoff, and message systems livelock when a failed message is rolled back and retried identically forever.
Starvation is when a thread is permanently denied a resource it needs, even though the system as a whole progresses. Causes include unfair locks that consistently favour barging threads, thread priorities that let high-priority threads monopolize a core, and threads that hold a lock for very long stretches. The fix is fair locks where necessary, short critical sections, and not relying on priorities.
| Failure | Threads are | Progress overall | Typical cause | Fix |
|---|---|---|---|---|
| Deadlock | Blocked | None | Circular lock waiting | Lock ordering, tryLock |
| Livelock | Running | None | Symmetric retry or rollback | Randomized backoff |
| Starvation | Waiting | Some | Unfairness, long critical sections | Fair locks, shorter holds |
Diagnosing a deadlock is refreshingly easy, because the JVM detects cycles in intrinsic locks for you:
# Find the process, then dump all thread stacks
jcmd -l
jcmd <pid> Thread.print
# Look for the explicit section the JVM prints:
# Found one Java-level deadlock:
# =============================
# "Thread-1": waiting to lock monitor 0x... which is held by "Thread-0"
# "Thread-0": waiting to lock monitor 0x... which is held by "Thread-1"
jconsole and jvisualvm show the same thing graphically, and
ThreadMXBean.findDeadlockedThreads() lets a program detect it at runtime.
Caution: automatic detection covers intrinsic locks and ReentrantLock, but not
deadlocks formed from semaphores or other coordination.
The prevention checklist, in order of value:
- Hold as few locks as possible, ideally one.
- Define and document a global lock order; enforce it in code review.
- Never call foreign or overridable code while holding a lock.
- Use timed
tryLockwhere ordering cannot be guaranteed. - Prefer the concurrent collections and immutable objects, which need no locks.
Key Takeaways
- Safety means nothing bad happens; liveness means something good eventually happens, and locks trade one for the other.
- Deadlock is a cycle in the wait-for graph and needs all four Coffman conditions at once.
- Breaking any single Coffman condition prevents deadlock.
- A global lock ordering is the standard fix, using a stable key such as an id.
- Timed
tryLockwith release-and-retry breaks hold-and-wait when ordering is impossible.- Retry loops need randomized backoff, or they livelock in lockstep.
- Livelock means threads run without progressing; starvation means one thread is denied while others progress.
- The JVM detects intrinsic-lock deadlocks, reported by
jcmd <pid> Thread.print.- Never call unknown code while holding a lock, and prefer designs with no locks at all.
🧪 Practice
- Write the two-lock deadlock above, reproduce the hang, then capture it with
jcmd <pid> Thread.printand read the deadlock section.- Fix the bank transfer with lock ordering and prove it by running a thousand transfers in both directions concurrently.
- Implement the
tryLockversion with randomized backoff and count how often it retries under contention.- Construct a starvation scenario with one thread holding a lock 99% of the time, then show a fair lock changing the outcome.
- Interview: You have a deadlock between two locks and cannot change the acquisition order because the locks come from a library. What options remain? (Hint: which Coffman condition can you still break from outside?)
<a id="124-designing-thread-safe-objects"></a>
12.4 Designing Thread-Safe Objects
This subchapter covers thread safety as a design property rather than a keyword, and the strategy that removes the problem altogether.
Thread-Safe Object Design
"Thread-safe" is used loosely, so start with a usable definition. A class is thread safe if it behaves correctly when accessed from multiple threads, regardless of their scheduling or interleaving, with no additional coordination required from the caller.
Two parts of that deserve emphasis. "Behaves correctly" means it preserves its invariants, so you cannot judge thread safety without knowing what the invariants are. And "no additional coordination from the caller" is what separates a thread-safe class from one that merely can be used safely.
Designing for it is a sequence of four questions, and answering them in order usually makes the implementation obvious.
- What is the state? Every field, plus the state of every object those fields reference.
- What are the invariants? Which combinations of values are legal, and which state transitions are legal.
- Which state is shared and mutable? Only that part needs protection.
- What is the synchronization policy? Which lock guards what, stated explicitly and written down.
That fourth point is the one teams skip. An undocumented synchronization policy does not survive maintenance, because the next person cannot see it in the code. Annotate it:
import javax.annotation.concurrent.GuardedBy; // from jsr305 or jcip-annotations
public class Inventory {
private final Object lock = new Object();
@GuardedBy("lock") private int available; // says exactly what protects it
@GuardedBy("lock") private int reserved;
}
The invariant determines the granularity. If two fields must always agree, they must be read and written under the same lock, as a unit.
public class NumberRange {
private int lower;
private int upper;
// INVARIANT: lower <= upper, always
// BROKEN even though each field is individually protected: the invariant
// spans both fields, and this method checks one then writes the other.
public void setLowerBroken(int i) {
synchronized (this) {
if (i > upper) throw new IllegalArgumentException();
}
// Another thread can lower `upper` right here, breaking the invariant.
synchronized (this) {
lower = i;
}
}
// CORRECT: the check and the write are one atomic action
public synchronized void setLower(int i) {
if (i > upper) throw new IllegalArgumentException();
lower = i;
}
public synchronized int[] get() { return new int[] { lower, upper }; }
}
This is worth restating as a rule: using AtomicInteger for each field would
not help. Atomics make single variables safe; only a lock, or one immutable
object holding both values, can protect a relationship between them.
There are four broad strategies, and the first two are far better than the last two.
1. Confinement: do not share. If only one thread can reach the state, no synchronization is needed.
public class Confinement {
// Stack confinement: the list never escapes this method, so it is safe
// even though ArrayList is not thread-safe.
public int countMatches(List<String> input) {
List<String> local = new ArrayList<>(input); // local only
local.removeIf(String::isBlank);
return local.size();
}
// Thread confinement: one instance per thread, never shared
private static final ThreadLocal<SimpleDateFormat> FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String format(Date d) {
return FORMAT.get().format(d); // SimpleDateFormat is NOT thread-safe
}
}
Remember the lifecycle warning from the memory chapter: a ThreadLocal on a
pool thread must be removed when the task finishes.
2. Immutability: do not mutate. Covered fully in the next topic, and it is the best answer whenever it fits.
3. The monitor pattern: guard everything with one lock. Simple, easy to verify, and the right default when you do need mutable shared state.
public class MonitorCounter {
private final Object lock = new Object(); // private: policy is ours
@GuardedBy("lock") private int count;
public void increment() {
synchronized (lock) { count++; }
}
public int get() {
synchronized (lock) { return count; } // readers synchronize too
}
}
4. Delegation: let a thread-safe component hold the state. Often the least code and the best performance, because the library has already solved it.
public class DelegatingRegistry {
// All state lives in a thread-safe object, so this class needs no locks
private final ConcurrentMap<String, Integer> counts = new ConcurrentHashMap<>();
public void record(String key) {
counts.merge(key, 1, Integer::sum); // one atomic operation
}
public int count(String key) {
return counts.getOrDefault(key, 0);
}
}
Delegation has one trap, and it is the same one from the race-conditions topic: delegation only works if each public method maps to a single atomic operation on the delegate. The moment a method performs two calls that must be atomic together, the safety is gone and you need your own lock.
public class BrokenDelegation {
private final ConcurrentMap<String, Integer> counts = new ConcurrentHashMap<>();
// NOT thread safe: two atomic calls do not make an atomic pair
public void recordBroken(String key) {
Integer current = counts.getOrDefault(key, 0); // another thread can
counts.put(key, current + 1); // interleave here
}
}
Three additional rules complete the picture.
Never let this escape from a constructor. A half-built object visible to
another thread has no guarantees at all, including the final field guarantee.
public class Listener {
private final int id;
public Listener(EventBus bus, int id) {
bus.register(this); // WRONG: another thread may see a half-built
this.id = id; // Listener whose id is still 0
}
// FIX: a static factory, constructing first and publishing after
public static Listener create(EventBus bus, int id) {
Listener l = new Listener(id);
bus.register(l); // fully constructed before publication
return l;
}
private Listener(int id) { this.id = id; }
}
Do not return references to mutable internal state, because that hands callers an unguarded path to your state.
private final List<String> items = new ArrayList<>();
// LEAKS the internal list: callers mutate it with no lock held
public List<String> getItemsBad() { return items; }
// Returns a snapshot taken under the lock
public synchronized List<String> getItems() { return List.copyOf(items); }
Document the class's thread-safety contract, because callers cannot guess it. State one of: immutable, thread-safe, conditionally thread-safe with the required client-side locking spelled out, or not thread-safe.
Key Takeaways
- Thread safe means correct under any interleaving with no extra coordination required from the caller.
- You cannot assess thread safety without knowing the invariants.
- Work through state, invariants, what is shared and mutable, then the synchronization policy.
- Document the policy, for example with
@GuardedBy, or it will be lost.- An invariant spanning two fields needs one lock; per-field atomics cannot protect a relationship.
- Confinement and immutability are better strategies than locking.
- The monitor pattern guards all state with one private final lock, and readers must take it too.
- Delegation to a thread-safe component works only when each method is a single atomic call on it.
- Never publish
thisfrom a constructor, and never return references to mutable internal state.
🧪 Practice
- Write down the state, invariants, and synchronization policy for a
BankAccountwith a balance and an overdraft limit.- Take the
NumberRangeclass and show, with an interleaving table, how the broken version violates its invariant.- Refactor a class with a
synchronizedArrayListfield into the delegation style withCopyOnWriteArrayList, then identify any method that is still not atomic.- Find a class that publishes
thisin its constructor and fix it with a static factory method.- Interview: A class has two
AtomicIntegerfields and a documented invariant that the first never exceeds the second. Is it thread safe? (Hint: what can happen between reading one and writing the other?)
Immutability as a Concurrency Strategy
Every problem in this chapter has one root cause: state that changes while it is shared. Locks, volatile fields, and atomics all manage that change. Immutability eliminates it.
An immutable object is automatically thread safe, with no locks, no volatile fields, and no reasoning about interleavings. The argument is short: the hazards are atomicity, visibility, and ordering; all three concern writes; an immutable object has no writes after construction; therefore none of the three can occur.
A class is immutable when all of the following hold:
- All fields are
final. - All fields are
private. - The class cannot be extended, so it is
final, or has only private constructors. Otherwise a subclass could add mutable state. - No method modifies state, and none can be overridden to do so.
- Mutable components are never shared. Copy them on the way in and on the way out.
thisnever escapes the constructor.
Rules 1 and 6 together invoke the final field guarantee from the memory model:
any thread that sees the reference sees fully initialized fields, with no
synchronization.
public final class Money { // final: no mutable subclass
private final long cents; // final and private
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = Objects.requireNonNull(currency);
}
// Operations RETURN A NEW OBJECT rather than mutating
public Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException();
return new Money(cents + other.cents, currency);
}
public long cents() { return cents; }
public String currency() { return currency; }
}
Mutable components are the trap. A final field means the reference
cannot change, not that the object it points at is immutable.
public final class Itinerary {
private final List<String> stops; // final reference, MUTABLE list
// BROKEN: the caller keeps a reference to the same list and can mutate it
public Itinerary(List<String> stops) {
this.stops = stops;
}
// Also broken: hands the internal list straight out
public List<String> getStopsBad() { return stops; }
}
public final class SafeItinerary {
private final List<String> stops;
public SafeItinerary(List<String> stops) {
this.stops = List.copyOf(stops); // defensive copy IN, and unmodifiable
}
public List<String> getStops() {
return stops; // already unmodifiable: safe to share
}
}
Note the two distinct jobs. List.copyOf copies, which defends against the
caller mutating the original, and returns an unmodifiable list, which defends
against callers mutating what you hand back. Collections.unmodifiableList does
only the second: it wraps without copying, so the underlying list can still
change beneath the wrapper.
Records give you most of this for free, which is why they pair so well with concurrency:
// Fields are automatically private and final, and accessors are generated.
public record Point(int x, int y) {
public Point {
if (x < 0 || y < 0) throw new IllegalArgumentException(); // validation
}
public Point translate(int dx, int dy) {
return new Point(x + dx, y + dy); // a new value, not a mutation
}
}
Records are not automatically immutable, though. A record with a List
component holds a mutable list, so apply rule 5 in the compact constructor:
public record Route(String name, List<Point> points) {
public Route {
points = List.copyOf(points); // reassigning the parameter copies it
}
}
Immutable objects still let you model change, by swapping the whole object atomically. This solves the multi-field invariant problem that atomics could not.
import java.util.concurrent.atomic.AtomicReference;
public class Statistics {
// One immutable snapshot holding BOTH values keeps them consistent
private record Snapshot(long count, long sum) { }
private final AtomicReference<Snapshot> current =
new AtomicReference<>(new Snapshot(0, 0));
public void record(long value) {
// One atomic swap replaces both fields together, so no reader can ever
// observe a count from one update with a sum from another.
current.updateAndGet(s -> new Snapshot(s.count() + 1, s.sum() + value));
}
public double average() {
Snapshot s = current.get(); // one consistent read
return s.count() == 0 ? 0 : (double) s.sum() / s.count();
}
}
That pattern is worth remembering: when several values must change together, put them in one immutable object and swap the reference. It is lock-free and trivially correct.
An object is effectively immutable if it is never modified after
publication, even though it is technically mutable. A Date created, published
safely, and never touched again is effectively immutable, and treating it as
such is legitimate as long as the "never modified" rule is genuinely enforced.
The costs, stated honestly:
| Concern | Reality |
|---|---|
| Allocation pressure | Real, but escape analysis and cheap young-generation collection absorb most of it |
| Large object copies | A genuine cost; use a builder for multi-step construction, or a persistent data structure |
| Hot loops | Measure. A mutable local, confined to one thread, is a fine optimization |
| Identity | equals and hashCode become safe to cache, which is a benefit |
The overall guidance: make classes immutable unless you have a measured reason not to. It removes an entire category of bug, and the performance objection is usually theoretical.
Key Takeaways
- An immutable object is thread safe with no locks, because all three hazards concern writes.
- Immutability needs private final fields, no extension, no mutating methods, no shared mutable components, and no escaping
this.- A
finalfield fixes the reference, not the mutability of the referenced object.- Copy mutable components on the way in and on the way out;
List.copyOfdoes both jobs,unmodifiableListonly one.- Records give private final fields and accessors, but a record with a mutable component is still mutable.
- Operations should return new instances instead of mutating.
- To change several values together, hold them in one immutable object and swap the reference atomically.
- Effectively immutable objects, never modified after safe publication, can be treated as immutable.
- Allocation cost is usually theoretical; prefer immutability unless you have measured otherwise.
🧪 Practice
- Make a mutable
Personclass with name and address immutable, and list every change you had to make.- Write a class with a
final Listfield that is still mutable, demonstrate the mutation from outside, then fix it with defensive copying.- Convert a class with
synchronizedgetters and setters for three related fields into an immutable snapshot held in anAtomicReference.- Write a record with a
Mapcomponent and make it genuinely immutable using a compact constructor.- Interview: Why is an immutable object thread safe without any synchronization, and what single constructor mistake destroys that guarantee? (Hint: which memory model rule covers
finalfields, and what precondition does it carry?)
<a id="125-high-level-concurrency-utilities"></a>
12.5 High-Level Concurrency Utilities
This subchapter covers the libraries you should actually reach for, which replace almost all hand-written thread management and locking.
Executors and Thread Pools
Creating a thread per task fails in three ways at scale. Thread creation costs
milliseconds and about a megabyte of stack. An unbounded number of threads means
an unbounded amount of memory, so a traffic spike becomes
OutOfMemoryError: unable to create native thread. And thousands of runnable
threads on sixteen cores spend their time context switching instead of working.
The fix is to decouple task submission from task execution. You hand tasks
to a component that owns a managed set of threads and decides when each runs.
That component is an Executor, and the interface is deliberately tiny:
public interface Executor {
void execute(Runnable command); // that is the entire contract
}
ExecutorService extends it with lifecycle management and result-bearing
submission, and ScheduledExecutorService adds delays and repetition.
+------------+ submit() +-----------------+ +------------------+
| producers | ----------> | task QUEUE |-->| WORKER THREADS |
| (any thread)| | (BlockingQueue) | | (a fixed pool) |
+------------+ +-----------------+ +------------------+
| |
queue full -> RejectedExecutionHandler +-> run task,
then take
the next
The factory methods on Executors cover the common cases:
| Factory method | Threads | Queue | Use for |
|---|---|---|---|
newFixedThreadPool(n) | Exactly n | Unbounded | CPU-bound work, steady load |
newSingleThreadExecutor() | One | Unbounded | Serialized tasks, ordering guaranteed |
newCachedThreadPool() | Unbounded, reused, 60 s idle timeout | Direct handoff | Many short-lived I/O tasks, trusted load |
newScheduledThreadPool(n) | n | Delay queue | Timers and periodic jobs |
newWorkStealingPool() | One per core | Per-thread deques | Recursive divide-and-conquer |
newVirtualThreadPerTaskExecutor() | One virtual thread per task | None | Blocking I/O at high concurrency (Java 21) |
Two of those carry real hazards. newFixedThreadPool uses an unbounded
queue, so a backlog grows until the heap is gone rather than pushing back on
producers. newCachedThreadPool has an unbounded thread count, so a burst
of ten thousand tasks tries to create ten thousand threads. For production,
configure ThreadPoolExecutor directly:
import java.util.concurrent.*;
public class Pools {
public static ExecutorService production() {
return new ThreadPoolExecutor(
8, // core threads, kept alive
16, // maximum threads
60L, TimeUnit.SECONDS, // idle timeout above core size
new ArrayBlockingQueue<>(1000), // BOUNDED: applies backpressure
new ThreadFactory() { // named threads aid debugging
private int n = 0;
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "worker-" + n++);
t.setUncaughtExceptionHandler((th, ex) -> ex.printStackTrace());
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // on overflow, slow the caller
);
}
}
The growth rule surprises people: a ThreadPoolExecutor creates threads up to
the core size, then fills the queue, and only creates threads beyond the
core size once the queue is full. With an unbounded queue the maximum is
therefore never reached.
The four rejection policies, used when the queue is full and threads are maxed:
| Policy | Behaviour |
|---|---|
AbortPolicy | Throws RejectedExecutionException (the default) |
CallerRunsPolicy | Runs the task on the submitting thread, throttling it |
DiscardPolicy | Silently drops the task |
DiscardOldestPolicy | Drops the oldest queued task and retries |
CallerRunsPolicy is usually the best choice for a server, because it converts
overload into natural backpressure instead of an error or silent data loss.
Sizing follows from the earlier CPU-bound versus I/O-bound distinction:
CPU-bound: threads = cores + 1 (the +1 covers an occasional page fault)
I/O-bound: threads = cores * targetUtilization * (1 + waitTime/computeTime)
Example: 8 cores, 90% target, 100 ms waiting per 10 ms computing
8 * 0.9 * (1 + 100/10) = 8 * 0.9 * 11 = about 79 threads
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuBound = Executors.newFixedThreadPool(cores + 1);
For I/O-bound work on Java 21, the arithmetic is obsolete: use virtual threads and stop sizing pools.
// One virtual thread per task. Blocking is cheap, so this scales to millions.
try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
vt.submit(() -> { fetchFromNetwork(); return null; });
}
} // close() waits for all submitted tasks to finish
Shutdown is mandatory. Pool threads are non-daemon by default, so a JVM will
not exit while a pool is alive. Since Java 19, ExecutorService is
AutoCloseable, which makes try-with-resources the shortest correct form. The
explicit idiom, still needed for pools with a longer lifetime:
public static void shutdownGracefully(ExecutorService pool) {
pool.shutdown(); // stop accepting; finish what is queued
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt running tasks
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
System.err.println("pool did not terminate");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt(); // restore the flag
}
}
| Method | Effect |
|---|---|
shutdown() | Rejects new tasks, runs queued ones, returns immediately |
shutdownNow() | Rejects new tasks, interrupts running ones, returns the queued ones |
awaitTermination | Blocks until terminated or the timeout expires; returns a boolean |
isShutdown | True once shutdown has been initiated |
isTerminated | True once all tasks have actually finished |
One last trap: a task submitted with execute that throws prints a stack
trace, but a task submitted with submit swallows the exception into its
Future. If nobody calls get(), the failure is invisible. This causes more
silent production bugs than any other executor behaviour.
Key Takeaways
- Thread-per-task fails on creation cost, unbounded memory, and context switching.
- Executors decouple task submission from execution and manage the threads for you.
newFixedThreadPoolhas an unbounded queue;newCachedThreadPoolhas an unbounded thread count.- Configure
ThreadPoolExecutordirectly in production, with a bounded queue and a named thread factory.- The pool fills its queue before growing past the core size, so an unbounded queue never reaches the maximum.
CallerRunsPolicyturns overload into backpressure rather than errors or dropped work.- Size CPU-bound pools at about cores plus one, and I/O-bound pools by the wait-to-compute ratio.
- For I/O-bound work on Java 21, use
newVirtualThreadPerTaskExecutorand stop sizing.- Always shut down:
shutdown, thenawaitTermination, thenshutdownNow, or use try-with-resources.submithides a thrown exception inside theFuture, so failures vanish if nobody inspects it.
🧪 Practice
- Submit a hundred tasks to a fixed pool of four and print
Thread.currentThread().getName()from each. Confirm only four names appear.- Build a
ThreadPoolExecutorwith a queue of size 2 and a maximum of 2 threads, submit ten tasks, and observe the rejection policy firing. Then swap inCallerRunsPolicyand compare.- Write a task that throws, submit it with
executeand then withsubmit, and show that only one of them reports the exception.- Time ten thousand tasks that each sleep 100 ms, on a fixed pool of 100 and on a virtual-thread executor.
- Interview: Why does a
newFixedThreadPoolnever create more than its core threads, and why is that dangerous under a traffic spike? (Hint: trace what happens to a submitted task when all core threads are busy.)
Runnable versus Callable and the Future API
Runnable has two limitations that only become obvious once tasks run
elsewhere: it returns nothing, and it cannot throw a checked exception.
public interface Runnable {
void run(); // no result, no checked exception
}
public interface Callable<V> {
V call() throws Exception; // a result, and any exception
}
If a task computes something, you need Callable. But the result does not exist
yet when you submit, so submission returns a promise of it: a Future. The
analogy is a coat-check ticket. You get the ticket immediately; presenting it
later either gets you the coat or makes you wait for it.
import java.util.concurrent.*;
public class Futures {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
Callable<Integer> task = () -> {
Thread.sleep(500);
return 42;
};
Future<Integer> future = pool.submit(task); // returns at once
System.out.println("submitted; done? " + future.isDone()); // false
Integer result = future.get(); // BLOCKS until the value is ready
System.out.println("result " + result);
// Always prefer the timed form in production code
try {
Integer r = future.get(1, TimeUnit.SECONDS);
System.out.println("timed get " + r);
} catch (TimeoutException e) {
future.cancel(true); // true: interrupt if already running
}
pool.shutdown();
}
}
The Future methods:
| Method | Behaviour |
|---|---|
get() | Blocks until done, then returns the value or throws |
get(timeout, unit) | Same, but throws TimeoutException on expiry |
isDone() | True if completed, cancelled, or failed |
cancel(boolean) | Attempts cancellation; the flag decides whether to interrupt |
isCancelled() | True if cancelled before completing |
Exception handling is the detail that trips people up. A task's exception
does not propagate at submission; it is captured and rethrown from get(),
wrapped in an ExecutionException.
Future<Integer> f = pool.submit(() -> { throw new IllegalStateException("boom"); });
try {
f.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // the ACTUAL exception the task threw
System.out.println("task failed: " + cause.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
This is the same wrapping pattern as InvocationTargetException in the
reflection chapter, and the same rule applies: always unwrap with getCause().
For several tasks at once, ExecutorService offers two bulk methods:
List<Callable<String>> tasks = List.of(
() -> "alpha", () -> "beta", () -> "gamma");
// invokeAll: blocks until ALL complete, results in submission order
List<Future<String>> all = pool.invokeAll(tasks);
for (Future<String> f : all) {
System.out.println(f.get()); // already done, so this will not block
}
// invokeAny: returns the FIRST successful result and cancels the rest
String first = pool.invokeAny(tasks);
invokeAll has a sharp edge: it returns only when every task has finished, so
one slow task delays all results. Use the overload with a timeout, or a
CompletionService, which yields results as they arrive:
CompletionService<String> cs = new ExecutorCompletionService<>(pool);
tasks.forEach(cs::submit);
for (int i = 0; i < tasks.size(); i++) {
Future<String> done = cs.take(); // blocks for the next COMPLETED task
System.out.println(done.get()); // process in completion order
}
Future has a real weakness: you cannot say what to do next without
blocking. Reading a result means calling get() and waiting.
CompletableFuture (Java 8) fixes that by letting you attach continuations, so
the pipeline is described up front and runs as results arrive.
import java.util.concurrent.*;
public class Composition {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(4);
CompletableFuture<String> pipeline =
CompletableFuture.supplyAsync(() -> fetchUserId(), pool)
// thenApply: transform the value with a plain function
.thenApply(id -> "user-" + id)
// thenCompose: chain a call that ITSELF returns a future (flatMap)
.thenCompose(name -> CompletableFuture.supplyAsync(
() -> loadProfile(name), pool))
// exceptionally: supply a fallback if anything above failed
.exceptionally(ex -> "anonymous");
System.out.println(pipeline.get());
// Combine two independent futures once both complete
CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 20, pool);
CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> 22, pool);
System.out.println(a.thenCombine(b, Integer::sum).get()); // 42
// Wait for a whole group
CompletableFuture.allOf(a, b).join();
pool.shutdown();
}
static String fetchUserId() { return "7"; }
static String loadProfile(String n) { return "profile of " + n; }
}
| Method | Purpose |
|---|---|
supplyAsync | Start an asynchronous computation that returns a value |
runAsync | Start one that returns nothing |
thenApply | Transform the result with a function |
thenCompose | Chain another future-returning call, avoiding nesting |
thenCombine | Merge two independent futures |
allOf / anyOf | Wait for all, or for the first |
exceptionally | Provide a fallback value on failure |
handle | Handle success and failure in one function |
Always pass an explicit executor to the *Async methods. The default is the
common ForkJoinPool, which is shared process-wide and sized for CPU-bound
work, so blocking in it can starve unrelated code including parallel streams.
| Aspect | Future | CompletableFuture |
|---|---|---|
| Get the result | Blocking get() only | Blocking or via callbacks |
| Chain operations | No | Yes, thenApply and friends |
| Combine several | Manually | thenCombine, allOf |
| Error handling | Catch at get() | exceptionally, handle |
| Complete manually | No | Yes, complete(value) |
On Java 21, structured concurrency is available as a preview feature and is
worth knowing about. StructuredTaskScope ties a group of subtasks to a lexical
scope, so they are all cancelled and joined together, which removes the
leaked-task and forgotten-cancellation failure modes. Being a preview, it needs
--enable-preview and may change.
Key Takeaways
Runnablereturns nothing and cannot throw checked exceptions;Callabledoes both.submitreturns immediately with aFuturestanding in for the eventual result.get()blocks, so prefer the timed overload in production.- A task's exception surfaces from
get()wrapped inExecutionException; unwrap withgetCause().cancel(true)interrupts a running task, and interruption is still cooperative.invokeAllwaits for every task, so one slow task delays all results.CompletionServicedelivers results in completion order instead.CompletableFuturelets you compose non-blocking pipelines withthenApply,thenCompose, andthenCombine.- Always pass an explicit executor to
*Async, or you share the commonForkJoinPool.- Structured concurrency in Java 21 is a preview that scopes and cancels subtasks as a unit.
🧪 Practice
- Convert a
Runnablethat prints a computed value into aCallablethat returns it, and retrieve it through aFuture.- Submit a task that throws, then write the
try/catchthat prints the original exception message rather than the wrapper's.- Submit three tasks sleeping 3 s, 1 s, and 2 s. Print results with
invokeAlland then with aCompletionService, and compare the orders.- Build a
CompletableFuturepipeline that fetches an id, loads a profile, and falls back to a default on failure, using an explicit executor throughout.- Interview: What is the difference between
thenApplyandthenCompose, and what goes wrong if you use the first where the second belongs? (Hint: what is the type of the result when the function itself returns a future?)
Concurrent Collections Overview
The obvious way to make a collection thread-safe is to wrap it so every method takes a lock, and Java has done that since 1.0:
List<String> list = Collections.synchronizedList(new ArrayList<>());
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
This is rarely good enough, for two reasons.
One lock for the whole collection serializes everything. Sixteen threads reading a map take turns, so the collection becomes the serial fraction in Amdahl's law.
Compound operations are still not atomic, and neither is iteration.
// Each call is atomic; the PAIR is not. Two threads can both pass the check.
if (!syncMap.containsKey(key)) {
syncMap.put(key, value);
}
// Iteration is a long compound operation. Without external locking this throws
// ConcurrentModificationException if another thread modifies the list.
for (String s : syncList) { }
// Correct but coarse: the whole loop is one critical section
synchronized (syncList) {
for (String s : syncList) { }
}
java.util.concurrent replaces the single lock with data structures designed
for concurrency from the start.
ConcurrentHashMap is the workhorse. Instead of one lock it locks
individual hash bins, and read operations usually take no lock at all, so
throughput scales with cores.
import java.util.concurrent.*;
public class Counts {
private final ConcurrentMap<String, Integer> counts = new ConcurrentHashMap<>();
public void record(String key) {
// Each of these is a SINGLE atomic operation, unlike check-then-act
counts.merge(key, 1, Integer::sum);
}
public void ensure(String key) {
counts.putIfAbsent(key, 0); // atomic
counts.computeIfAbsent(key, k -> expensive(k)); // atomic, computed once
counts.compute(key, (k, v) -> v == null ? 1 : v + 1);
}
private int expensive(String k) { return k.length(); }
}
Two behaviours to internalize. Its iterators are weakly consistent: they
never throw ConcurrentModificationException, they reflect the state at some
point during traversal, and they may or may not show concurrent updates. And
size() is an estimate, because computing an exact total would require
locking everything. Two restrictions: null keys and values are forbidden,
because get returning null would be ambiguous, and the function passed to
computeIfAbsent must not modify the same map, which can deadlock.
CopyOnWriteArrayList takes the opposite approach: every mutation copies
the entire backing array. Reads are completely lock-free and iterators are
immutable snapshots.
// Ideal for a listener list: thousands of iterations, a handful of mutations
private final List<Listener> listeners = new CopyOnWriteArrayList<>();
public void fire(Event e) {
for (Listener l : listeners) { // snapshot: safe, no lock, never throws
l.onEvent(e); // safe even if a listener removes itself
}
}
Use it only when reads vastly outnumber writes. A thousand-element list written frequently copies a thousand references per write.
BlockingQueue is the coordination primitive that replaces most
wait/notify code. It blocks a consumer when empty and a producer when full,
which makes the producer-consumer pattern almost trivial and gives you
backpressure for free.
import java.util.concurrent.*;
public class ProducerConsumer {
private static final String POISON = "STOP"; // a sentinel to end cleanly
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue = new ArrayBlockingQueue<>(10); // bounded
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 50; i++) {
queue.put("item-" + i); // BLOCKS while the queue is full
}
queue.put(POISON);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
while (true) {
String item = queue.take(); // BLOCKS while empty
if (POISON.equals(item)) break; // shutdown signal
process(item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start(); consumer.start();
producer.join(); consumer.join();
}
private static void process(String s) { }
}
The queue methods come in four flavours for the same operation, and picking the wrong one is a common bug:
| Operation | Throws | Returns special value | Blocks | Times out |
|---|---|---|---|---|
| Insert | add(e) | offer(e) | put(e) | offer(e, time, unit) |
| Remove | remove() | poll() | take() | poll(time, unit) |
| Examine | element() | peek() | n/a | n/a |
Use put and take for producer-consumer, because they provide the
backpressure. Using add on a full bounded queue throws instead of waiting.
The full landscape:
| Collection | Replaces | Characteristics |
|---|---|---|
ConcurrentHashMap | HashMap | Bin-level locking, lock-free reads, atomic compute methods |
ConcurrentSkipListMap | TreeMap | Sorted, concurrent, O(log n) |
CopyOnWriteArrayList | ArrayList | Snapshot iteration, expensive writes |
CopyOnWriteArraySet | HashSet | Same trade-offs |
ConcurrentLinkedQueue | LinkedList as a queue | Unbounded, non-blocking, lock-free |
ArrayBlockingQueue | Hand-written buffer | Bounded, blocking, one lock |
LinkedBlockingQueue | Hand-written buffer | Optionally bounded, separate head and tail locks |
SynchronousQueue | Direct handoff | Zero capacity; each put waits for a take |
PriorityBlockingQueue | PriorityQueue | Unbounded, ordered, blocking |
DelayQueue | Scheduling by hand | Elements become available at a set time |
Finally, the iteration semantics, which differ from the standard collections:
| Behaviour | Standard collections | Concurrent collections |
|---|---|---|
| Concurrent modification | ConcurrentModificationException (fail-fast) | Tolerated (weakly consistent) |
| What the iterator sees | The live collection | A snapshot, or an approximation |
size() | Exact | Possibly an estimate |
| Needs external locking | Yes, for iteration | No |
The guidance is simple: default to the concurrent collections and never use
Collections.synchronizedX for new code. They are faster, they need no
external locking for iteration, and their compound methods eliminate the
check-then-act races that wrappers invite.
Key Takeaways
Collections.synchronizedXuses one lock for the whole collection, which serializes all access.- Wrapper collections still leave compound operations and iteration unsafe without external locking.
ConcurrentHashMaplocks individual bins and reads mostly without locking.- Use its atomic compound methods such as
merge,putIfAbsent, andcomputeIfAbsentinstead of check-then-act.ConcurrentHashMapforbidsnullkeys and values, and itssize()is an estimate.- Concurrent iterators are weakly consistent and never throw
ConcurrentModificationException.CopyOnWriteArrayListgives lock-free snapshot reads at the cost of copying on every write, which suits listener lists.BlockingQueuereplaceswait/notifyfor producer-consumer and supplies backpressure.- Queue operations come in throwing, special-value, blocking, and timed forms; use
putandtakefor backpressure.- Prefer concurrent collections over synchronized wrappers in all new code.
🧪 Practice
- Replace a
Collections.synchronizedMapwith aConcurrentHashMapand rewrite one check-then-act block as a single atomic call.- Iterate a
CopyOnWriteArrayListwhile another thread adds to it, then try the same with anArrayListand compare what happens.- Implement producer-consumer with an
ArrayBlockingQueueof capacity 5, two producers, and three consumers, terminating cleanly with a sentinel.- Benchmark
ConcurrentHashMapagainstCollections.synchronizedMapwith eight threads at 90% reads, and report the ratio.- Interview: Why does
ConcurrentHashMaprejectnullvalues, and why is itssize()only approximate? (Hint: what would anullreturn fromget()mean, and what would an exact count require locking?)
<a id="13-input-output-and-serialization"></a>