Java Concurrency Handbook
Java concurrency from your first thread to SDE-2 production work, in 15 modules and 10 hands-on labs. Every lesson opens with a question to try, every flow diagram steps through, every Java sample on these pages compiles and runs on Java 21, and a practice tracker brings each exercise back for spaced review.
Study in four parts
Each phase is its own page, so you only load what you are studying right now.
Labs and reference
Shared across every phase, so they stay here on the handbook's home page.
Loading your progress.
How this handbook teaches
The goal is not to memorize APIs. It is to look at any piece of concurrent code and answer three questions: which data is shared, what can interleave, and what guarantees the ordering. So the handbook asks you to think before it explains.
- Try before you read. Every lesson opens with a short question. Answer it first, on paper or in your head. Getting it wrong is what makes the explanation stick.
- Analogy, then mechanism, then cost. Each idea starts with what it solves and a plain analogy, then how it works, then what it costs. Extra depth sits in folded "Go deeper" boxes.
- Step through every interleaving. Diagrams with a step bar reveal one action at a time. Predict the next arrow before pressing Next.
- Run everything. Every complete program here was compiled and run on Java 21 while this handbook was built. Paste it into your IDE, run it, then break it on purpose.
- Production notes. Green "In production" boxes show how each idea appears in real services: Spring Boot, connection pools, Kafka, payments.
- Hands-on labs. Ten labs make you build the things interviews and incidents are made of: counters, bounded buffers, caches, aggregators, overloaded pools, deadlocks, bulkheads, a rate limiter, and a capstone job runner that uses almost everything.
- Build it yourself. Several modules rebuild a JDK tool in a few lines (a latch, a thread pool, a spin lock, a resource pool) so the real one is no longer magic.
- Use-case tables. Each module ends with a table of real service situations and the tool for each, then a short list of key takeaways.
- Live tools. A race simulator, a thread pool simulator and a pool sizing calculator let you test your intuition.
How to study one module
- Clear the revision queue above: redo the due labs and exercises from a blank file.
- Do the warm-up without reading anything. Note what you missed.
- For each lesson: answer "Try it first", read, step through the figure, run the code, then say out loud what problem the idea solves and when it fails. Tick "Studied".
- Do the self-check, then the mastery checklist, ticking only what is true with your notes closed.
- Do the matching lab or exercises (the tracker shows which modules each one uses).
How to do a lab
- Read only the task and set a timer. Write the code yourself in your IDE.
- Open the hints one at a time only when you are stuck.
- Compare with the reference solution, then do the "What to observe" experiments: they show why the solution is shaped the way it is.
- Answer the follow-ups out loud, as in an interview.
- Set an honest status at the top of the lab; the handbook schedules a review.
Statuses and the revision queue
| Status | Meaning | Comes back in |
|---|---|---|
| Not started | Not attempted | Not scheduled |
| Could not solve it | You needed the solution for the core of it | 2 days |
| Solved with hints | You got there with a few peeks | 4 days |
| Solved independently | Working, tested code on your own | 10 days |
| Needs revision | Solved before, but a redo went badly | 1 day |
| Can teach it | Solved twice, weeks apart, and you can answer every follow-up | 30 days |
When an item comes up, redo it without notes, then set the new status or press "Reviewed, same status" to restart its timer.
Where your progress is saved
Lesson ticks, mastery checks, readiness checks and lab statuses save automatically to your account, privately: nobody else who opens this page sees them, and they follow you to other devices. The indicator in the top bar shows where the latest change went. If the page cannot reach your account (for example in a private window), it saves in this browser instead, and the practice tracker has a backup and restore box.
Scope and Java versions
The baseline is Java 21, a long-term-support release that is still widely deployed (Java 25 is the newer LTS), with notes where Java 24 and 25 change something: synchronized no longer pins virtual threads (24), ScopedValue is final (25). Structured concurrency is still a preview API (seventh preview in Java 27), so it gets one lesson on the idea only.
Deliberately left for later (see the syllabus): lock-free algorithm design, VarHandle and memory fences, StampedLock, custom fork/join algorithms, and JVM scheduling internals. They matter for specialists, rarely for SDE-2 work or interviews.
Syllabus and roadmap
Fifteen modules in four phases, then ten labs. Each phase uses the ideas of the one before. Below the map, your roadmap's priorities are mapped onto the modules.
The map
| Module | What you learn | Needs |
|---|---|---|
| 1. Threads and tasks | A thread is an independent path of execution inside your program. | - |
| 2. Race conditions and thread safety | Threads share the heap. | M1 |
| 3. synchronized and intrinsic locks | synchronized is Java's built-in lock. |
M2 |
| 4. volatile and the Java Memory Model | The Java Memory Model (JMM) is the contract that says when one thread's write is guaranteed to be visible to another. | M3 |
| 5. wait, notify and interruption | Threads often need to wait for something: an item in a queue, a free slot, a request to stop. | M3, M4 |
| 6. Executors and thread pools | Real services do not create a thread per task. | M1, M5 |
| 7. Future and CompletableFuture | A Future is a handle to a result that is not ready yet. |
M6 |
| 8. Atomics and CAS | Atomic classes such as AtomicInteger and AtomicReference update a single variable safely without a lock, using a CPU instruction called compare-and-set (CAS). |
M2, M4 |
| 9. Concurrent collections | java.util.concurrent ships collections built for many threads: ConcurrentHashMap, copy-on-write lists, non-blocking queues and blocking queues for producer-consumer designs. |
M3, M8 |
| 10. Locks and synchronizers | When synchronized is not enough, java.util.concurrent.locks gives you ReentrantLock, Condition and read-write locks. |
M3, M5 |
| 11. Deadlock, livelock, starvation | Locks fix races but bring a new failure: threads that wait for each other forever. | M3, M10 |
| 12. ThreadLocal and thread reuse | ThreadLocal gives each thread its own copy of a variable: no sharing, so no locking. |
M2, M6 |
| 13. Virtual threads and ForkJoinPool | Java 21's virtual threads make blocking cheap, so a service can run one thread per request even at very high concurrency. | M6, M7 |
| 14. Concurrency in backend systems | Everything so far, applied to a real service: request threads, connection pools, async executors, HTTP fan-out, Kafka consumers, several pods, and shutdown. | M6 to M13 |
| 15. Debugging and testing | Concurrency bugs hide in tests and appear under load. | M1, M11, M14 |
Labs: L1 Counter four ways, L2 Bounded buffer three ways, L3 Two threads take turns, L4 Thread-safe cache, L5 Parallel aggregator, L6 Executor under overload, L7 Deadlock lab, L8 Bulkhead lab, L9 Rate limiter, L10 Capstone: job runner.
Why this order
- Problems before tools. Module 2 shows races and visibility failures before Module 3 offers a lock, so every tool arrives as the answer to a problem you have seen.
synchronizedbefore the memory model. The Java Memory Model (Module 4) is easier once you have used a lock: happens-before then explains why the lock worked. Your roadmap lists the JMM before synchronization; the handbook swaps them for that reason.- The toolkit before the hazards. Deadlocks,
ThreadLocalleaks and pinning (Phase 3) are about how the tools of Phase 2 fail. - Production last, because it combines everything: pools, futures, locks, collections and the database.
Your roadmap: must master
Priority: highest. Understand the concepts, write the code, and explain the trade-offs.
| # | Topic | Where |
|---|---|---|
| 1 | Concurrency fundamentals | M1 |
| 2 | Thread safety and shared state | M2 |
| 3 | Java Memory Model | M4 |
| 4 | Synchronization and volatile | M3, M4, M5 |
| 5 | Interruption and cancellation | M5 |
| 6 | Executors and thread pools | M6 |
| 7 | Future and CompletableFuture | M7 |
| 8 | Atomic classes and CAS | M8 |
| 9 | Concurrent collections | M9 |
| 10 | Deadlocks and starvation | M11 |
| 11 | ThreadLocal and thread reuse | M12 |
| 12 | Concurrency in backend systems | M14 |
| 13 | Concurrency debugging and testing | M15 |
Your roadmap: understand and practise
Priority: medium. Know what each tool solves, when to use it, and its limits.
| Topic | Where |
|---|---|
BlockingQueue, producer-consumer |
M9, Lab 2 |
CountDownLatch |
M10 |
CyclicBarrier |
M10 |
Semaphore |
M10, Lab 8 |
ReentrantLock and Condition |
M10 |
| Read-write locks | M10 |
| Scheduled execution | M6 |
| Virtual threads | M13 |
ForkJoinPool |
M13 |
| Concurrency testing, jcstress | M15 |
Your roadmap: learn later, when needed
Priority: low for now. One line each, so you know what they are and when you would need them.
| Topic | What it is | When you would need it |
|---|---|---|
| Lock-free algorithms and data structures | Structures built only from CAS, such as the Michael-Scott queue inside ConcurrentLinkedQueue |
Writing a library or a latency-critical engine; using the JDK's versions covers almost all service work |
VarHandle and memory fences |
Low-level access modes (plain, opaque, acquire/release, volatile) on fields and array elements, the API under the atomic classes | Writing your own atomics or lock-free code |
StampedLock |
A lock with optimistic reads that skip locking when no write happened | Extremely read-heavy hot paths, after profiling shows a read-write lock is the bottleneck |
| Custom fork/join algorithms | Your own RecursiveTask designs beyond simple splitting |
Large CPU-bound computations: parsing, simulations, analytics |
| Structured concurrency | StructuredTaskScope: subtasks that start, fail and are cancelled as a unit |
Once it leaves preview; Module 13 has the idea |
| Thread scheduling and JVM internals | Safepoints, lightweight (thin) locks, monitor inflation, the virtual thread scheduler | Performance engineering and JVM tuning |
| Reactive programming | Project Reactor and Spring WebFlux: non-blocking pipelines of events with backpressure built in | Working in a codebase already built on WebFlux, or streaming with very high fan-out; for ordinary blocking services, virtual threads are simpler |
| Distributed locks and leases | A lock shared by all pods, kept in Redis, ZooKeeper or the database (PostgreSQL advisory locks) | Making exactly one pod run a scheduled job or own a resource; prefer database constraints and conditional updates first (Module 14) |
A study pace that works
About two modules and one lab a week finishes the handbook in roughly eight weeks:
| Weeks | Modules | Labs |
|---|---|---|
| 1 to 2 | 1 to 5: threads, races, locks, the memory model, waiting and interruption | L1, L2, L3 |
| 3 to 4 | 6 to 10: executors, futures, atomics, collections, synchronizers | L4, L5, L6 |
| 5 to 6 | 11 to 13: deadlocks, ThreadLocal, virtual threads | L7 |
| 7 to 8 | 14 to 15: backend systems, debugging and testing | L8, L9, then the L10 capstone and the readiness check |
Spend the revision queue's due items first in every session. Depth beats speed: a module is done when its mastery checklist is true with notes closed.
Lab 1: Counter four ways
Build the same counter unsafely, with synchronized, with AtomicLong and with LongAdder, then prove which ones are correct and compare their speed under contention.
Implement interface Counter { void increment(); long get(); } four ways, and a harness that runs 8 threads x 1,000,000 increments with a start gate. Print each counter's final value, whether it is correct, and the time taken.
What a complete solution includes
Four implementations; a harness with CountDownLatch start and end gates; a correctness check against threads x increments; timing that starts when the gate opens. The unsafe counter loses updates; the other three are exact.
L1.1The task
- Write
Unsafe(a plainlong n; n++),Locked(synchronizedmethods),Atomic(AtomicLong) andAdder(LongAdder). - Write
run(counter, threads, perThread)that starts all threads at the same instant and waits for them to finish. - Run all four with 8 threads and 1,000,000 increments each. Expected value: 8,000,000.
- Then try 2 and 32 threads and note how the timings change.
L1.2Hints
Hint 1
Use CountDownLatch start = new CountDownLatch(1) that every worker awaits, and CountDownLatch done = new CountDownLatch(threads) that each worker counts down in finally.
Hint 2
Start the clock just before start.countDown(), stop it after done.await().
Hint 3
In this harness the latch already orders the final read (countDown happens-before a successful await). get() in Locked must still be synchronized for readers that run while increments are in progress (Module 3), and because a plain long read may tear in two halves (JLS 17.7).
L1.3Reference solution
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class CounterLab {
interface Counter { void increment(); long get(); }
static final class Unsafe implements Counter { private long n; public void increment() { n++; } public long get() { return n; } }
static final class Locked implements Counter { private long n; public synchronized void increment() { n++; } public synchronized long get() { return n; } }
static final class Atomic implements Counter { private final AtomicLong n = new AtomicLong(); public void increment() { n.incrementAndGet(); } public long get() { return n.get(); } }
static final class Adder implements Counter { private final LongAdder n = new LongAdder(); public void increment() { n.increment(); } public long get() { return n.sum(); } }
static long[] run(Counter c, int threads, int perThread) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1), done = new CountDownLatch(threads);
for (int t = 0; t < threads; t++) {
pool.execute(() -> {
try {
start.await();
for (int i = 0; i < perThread; i++) c.increment();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
long t0 = System.nanoTime();
start.countDown();
done.await();
long ms = (System.nanoTime() - t0) / 1_000_000;
pool.shutdown();
return new long[] { c.get(), ms }; // read after done.await(): happens-before via the latch
}
public static void main(String[] args) throws InterruptedException {
int threads = 8, perThread = 1_000_000;
long expected = (long) threads * perThread;
for (Counter c : new Counter[] { new Unsafe(), new Locked(), new Atomic(), new Adder() }) {
long[] r = run(c, threads, perThread);
System.out.printf("%-7s value=%,d correct=%b time=%d ms%n", c.getClass().getSimpleName(), r[0], r[0] == expected, r[1]);
}
}
}Unsafe value=2,915,734 correct=false time=21 ms
Locked value=8,000,000 correct=true time=412 ms
Atomic value=8,000,000 correct=true time=158 ms
Adder value=8,000,000 correct=true time=37 msL1.4What to observe
Unsafeis fastest and wrong. Speed means nothing without correctness.- Some runs of
Unsafecome out exactly right, or wrong by a round 1,000,000. The JIT may keep the plain field in a register for the whole loop and write it back once at the end, which the memory model allows for a non-volatile field. The result is still unpredictable. LongAdderpulls ahead as threads increase, because threads update different cells instead of fighting over one.synchronizedis slowest under heavy contention but simplest when the counter is part of a larger invariant.- These timings are rough: the JIT warms up during the run and the machine may be busy. For real performance decisions use JMH (the Java Microbenchmark Harness).
L1.5Follow-ups
- Why must
Locked.get()be synchronized if all writes already are?Reveal
A reader that runs while increments are still happening has no happens-before edge with the writers without it, so it may see a stale value (in this harness the latch happens to order the final read).
- When would you still choose
AtomicLongoverLongAdder?Reveal
When you need an exact value at a point in time or operations like
incrementAndGetthat return the new value, for example generating sequence numbers. - How would you make the unsafe counter fail more reliably in a test?
Reveal
More threads than cores, a start gate, more iterations, and repeating the test many times.
Lab 2: Bounded buffer three ways
Write a bounded producer-consumer buffer with wait/notifyAll, then with ReentrantLock and two Conditions, then with ArrayBlockingQueue, and test all three under load.
Implement interface Buffer<T> { void put(T t) throws InterruptedException; T take() throws InterruptedException; int size(); } three ways with capacity 16. Test with 4 producers and 4 consumers moving 100,000 integers: check that the sum is right and the size never exceeds the capacity.
What a complete solution includes
Waits in while loops; notifyAll (or signal on separate conditions); unlock in finally; a test that checks the sum of all items and the maximum observed size; every wait in the test has a timeout.
L2.1The task
MonitorBuffer:synchronizedmethods,wait()inwhileloops,notifyAll()after each change.ConditionBuffer: oneReentrantLock, conditionsnotFullandnotEmpty,signal()the right one.QueueBuffer: a thin wrapper aroundArrayBlockingQueue.- A test that runs all three and prints
sum okand the largest size seen.
L2.2Hints
Hint 1
The condition for put to wait is size == capacity; for take it is isEmpty(). Both are re-checked in a while.
Hint 2
In ConditionBuffer, call lock.lock() just before try, and unlock() in finally.
Hint 3
Submit producers and consumers as Callables and call future.get(30, SECONDS) on each, with shutdownNow() in a finally, so a lost wakeup fails the test instead of hanging it.
L2.3Reference solution
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.*;
public class BufferLab {
interface Buffer<T> { void put(T t) throws InterruptedException; T take() throws InterruptedException; int size(); }
static final class MonitorBuffer<T> implements Buffer<T> {
private final Deque<T> q = new ArrayDeque<>(); private final int cap;
MonitorBuffer(int cap) { this.cap = cap; }
public synchronized void put(T t) throws InterruptedException { while (q.size() == cap) wait(); q.addLast(t); notifyAll(); }
public synchronized T take() throws InterruptedException { while (q.isEmpty()) wait(); T t = q.removeFirst(); notifyAll(); return t; }
public synchronized int size() { return q.size(); }
}
static final class ConditionBuffer<T> implements Buffer<T> {
private final Deque<T> q = new ArrayDeque<>(); private final int cap;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(), notEmpty = lock.newCondition();
ConditionBuffer(int cap) { this.cap = cap; }
public void put(T t) throws InterruptedException {
lock.lock();
try { while (q.size() == cap) notFull.await(); q.addLast(t); notEmpty.signal(); }
finally { lock.unlock(); }
}
public T take() throws InterruptedException {
lock.lock();
try { while (q.isEmpty()) notEmpty.await(); T t = q.removeFirst(); notFull.signal(); return t; }
finally { lock.unlock(); }
}
public int size() { lock.lock(); try { return q.size(); } finally { lock.unlock(); } }
}
static final class QueueBuffer<T> implements Buffer<T> {
private final BlockingQueue<T> q;
QueueBuffer(int cap) { q = new ArrayBlockingQueue<>(cap); }
public void put(T t) throws InterruptedException { q.put(t); }
public T take() throws InterruptedException { return q.take(); }
public int size() { return q.size(); }
}
static void test(String name, Buffer<Integer> buf, int cap) throws Exception {
int producers = 4, consumers = 4, perProducer = 25_000;
AtomicLong sum = new AtomicLong();
AtomicInteger maxSize = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(producers + consumers);
List<Future<?>> fs = new ArrayList<>();
for (int p = 0; p < producers; p++)
fs.add(pool.submit(() -> { for (int i = 1; i <= perProducer; i++) { buf.put(i); maxSize.accumulateAndGet(buf.size(), Math::max); } return null; }));
for (int c = 0; c < consumers; c++)
fs.add(pool.submit(() -> { for (int i = 0; i < perProducer; i++) sum.addAndGet(buf.take()); return null; }));
try {
for (Future<?> f : fs) f.get(30, TimeUnit.SECONDS); // a lost wakeup shows up as a TimeoutException
} finally {
pool.shutdownNow(); // interrupt stuck waiters so the JVM can exit
}
long expected = (long) producers * perProducer * (perProducer + 1L) / 2;
System.out.printf("%-18s sum ok=%b, max size seen=%d (capacity %d)%n", name, sum.get() == expected, maxSize.get(), cap);
}
public static void main(String[] args) throws Exception {
test("wait/notifyAll", new MonitorBuffer<>(16), 16);
test("Condition", new ConditionBuffer<>(16), 16);
test("ArrayBlockingQueue", new QueueBuffer<>(16), 16);
}
}L2.4What to observe
- Change one
whiletoifinMonitorBufferand rerun a few times: sooner or later a consumer removes from an empty deque and dies withNoSuchElementException. You will usually see aTimeoutExceptionfrom a producer instead (the consumer's exception sits inside its ownFuture, and its missing takes leave producers blocked on a full buffer). - Replace
notifyAll()withnotify()inMonitorBuffer: with producers and consumers sharing one wait set, the test can stall until the 30-second timeout. - In
ConditionBuffer,signal()is safe because each condition has only one kind of waiter. - The three versions behave the same. In production code, the third is the one to write.
L2.5Follow-ups
- Why does
MonitorBufferneednotifyAll()butConditionBuffercan usesignal()?Reveal
In the monitor version producers and consumers share one wait set, so
notifymight wake the wrong kind. Separate conditions guarantee the woken thread is of the right kind. - How would you add a
putthat gives up after a timeout?Reveal
Use
notFull.awaitNanos(remaining)in the loop and returnfalsewhen the remaining time reaches zero, likeBlockingQueue.offer(e, timeout, unit). - How do you shut consumers down cleanly?
Reveal
Interrupt them (their
takethrowsInterruptedException), or send one poison pill per consumer.
Lab 3: Two threads take turns
The classic interview question: two threads print 1 to 10 alternately, odd and even. Solve it with semaphores, then with wait/notifyAll, and explain why the shared output is safe.
Thread odd prints 1, 3, 5, 7, 9 and thread even prints 2, 4, 6, 8, 10, so that the combined output is exactly 1 2 3 4 5 6 7 8 9 10, every time.
What a complete solution includes
Strict hand-over between the threads (each waits for its turn, then passes it on), no busy-waiting, no sleep, a clean exit for both threads, and a reason why the shared output needs no extra lock.
L3.1Hints
Hint 1
With semaphores: oddTurn starts with 1 permit, evenTurn with 0. Each thread acquires its own, prints, and releases the other's.
Hint 2
With a monitor: share int next; each thread waits while next belongs to the other thread, prints, increments, and calls notifyAll().
Hint 3
Remember to stop: both threads must leave their loops once next > max, and the last one must wake the other so it can see that.
L3.2Reference solution
import java.util.concurrent.Semaphore;
public class TakeTurns {
public static void main(String[] args) throws InterruptedException {
int max = 10;
Semaphore oddTurn = new Semaphore(1), evenTurn = new Semaphore(0);
StringBuilder out = new StringBuilder(); // safe: every access is ordered by release -> acquire
Thread odd = new Thread(() -> {
try {
for (int i = 1; i <= max; i += 2) { oddTurn.acquire(); out.append(i).append(' '); evenTurn.release(); }
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "odd");
Thread even = new Thread(() -> {
try {
for (int i = 2; i <= max; i += 2) { evenTurn.acquire(); out.append(i).append(' '); oddTurn.release(); }
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "even");
odd.start(); even.start();
odd.join(); even.join(); // join: main sees the final contents
System.out.println(out.toString().trim());
}
}public class TakeTurnsMonitor {
private final int max;
private int next = 1; // guarded by this
private final StringBuilder out = new StringBuilder(); // guarded by this
TakeTurnsMonitor(int max) { this.max = max; }
synchronized void loop(boolean oddThread) throws InterruptedException {
while (true) {
while (next <= max && (next % 2 == 1) != oddThread) wait(); // not my turn
if (next > max) { notifyAll(); return; } // done: wake the other so it can exit too
out.append(next).append(' ');
next++;
notifyAll(); // pass the turn
}
}
public static void main(String[] args) throws InterruptedException {
TakeTurnsMonitor m = new TakeTurnsMonitor(10);
Thread odd = new Thread(() -> { try { m.loop(true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } });
Thread even = new Thread(() -> { try { m.loop(false); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } });
odd.start(); even.start();
odd.join(); even.join();
synchronized (m) { System.out.println(m.out.toString().trim()); }
}
}L3.3Follow-ups
- Why is the shared
StringBuildersafe in the semaphore version without a lock?Reveal
Only one thread appends at a time (the turns never overlap), and each
release()happens-before the other thread's successfulacquire(), so every append is visible to the next one. - Extend it to three threads printing 1, 2, 3, 1, 2, 3, ...
Reveal
Use three semaphores in a ring (A releases B, B releases C, C releases A), or a shared
nextwithnext % 3deciding whose turn it is. - What happens if one thread throws in the middle?
Reveal
The other waits forever for a turn that never comes. Use
tryAcquirewith a timeout or interrupt the survivor.
Lab 4: A thread-safe memoizing cache
Build a cache that loads each key exactly once, even when ten threads ask for it at the same moment, without holding a lock during the slow load. Then decide what to do with failures.
Write Memoizer<K, V> with CompletableFuture<V> get(K key) around a slow Function<K, V> loader (200 ms, for example an FX-rate lookup). Ten threads call get("USD/INR") at the same instant: the loader must run once, all ten get the value, no lock is held while loading, and a failed load is not cached.
What a complete solution includes
A ConcurrentHashMap<K, CompletableFuture<V>>; one atomic step that decides which caller starts the load; the load running on an executor, not under a map lock; failed futures removed so the next caller retries; a test that counts loader calls.
L4.1Four attempts
Each version fixes the previous one's problem (after Java Concurrency in Practice, section 5.6):
| Version | Idea | Problem |
|---|---|---|
| 1 | synchronized V get(K) around a HashMap |
Correct, but every caller waits for every load, even of different keys |
| 2 | ConcurrentHashMap, get then put |
Two callers both miss and both load the same key (check-then-act) |
| 3 | computeIfAbsent(key, loader) |
Loads once, but the slow load runs while that bin is locked; other keys in the bin wait, and the loader must not touch the map |
| 4 | computeIfAbsent(key, k -> supplyAsync(load)): store a future |
Loads once, the map lock is held only to insert the future, and waiting happens on the future |
L4.2Reference solution
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
public class Memoizer<K, V> {
private final ConcurrentHashMap<K, CompletableFuture<V>> cache = new ConcurrentHashMap<>();
private final Function<K, V> loader;
private final Executor executor;
public Memoizer(Function<K, V> loader, Executor executor) { this.loader = loader; this.executor = executor; }
public CompletableFuture<V> get(K key) {
// Atomic and fast: the bin is locked only while the future object is created, not during the load.
CompletableFuture<V> f = cache.computeIfAbsent(key, k -> CompletableFuture.supplyAsync(() -> loader.apply(k), executor));
f.whenComplete((v, ex) -> { if (ex != null) cache.remove(key, f); }); // never cache a failure
return f.copy(); // callers get a copy: their orTimeout or cancel cannot complete the cached future
}
public static void main(String[] args) throws Exception {
AtomicInteger loads = new AtomicInteger();
ExecutorService io = Executors.newFixedThreadPool(4);
Memoizer<String, String> rates = new Memoizer<>(pair -> {
loads.incrementAndGet();
try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return pair + "=83.12";
}, io);
ExecutorService callers = Executors.newFixedThreadPool(10);
CountDownLatch start = new CountDownLatch(1);
List<Future<String>> results = new ArrayList<>();
for (int i = 0; i < 10; i++)
results.add(callers.submit(() -> { start.await(); return rates.get("USD/INR").get(1, TimeUnit.SECONDS); }));
start.countDown();
for (Future<String> f : results) f.get();
System.out.println("10 callers, loads = " + loads.get() + ", value = " + results.get(0).get());
callers.shutdown();
io.shutdown();
}
}L4.3What to observe
- Remove the start gate and the race rarely happens; keep it and version 2 loads several times. Try it.
remove(key, f)removes the entry only if it still maps to that failed future, so it never deletes a newer, successful one.f.copy()matters:orTimeout,completeOnTimeout,completeandcancelchange the future they are called on. Hand out the shared cached future and one caller's 100 ms timeout would complete it for everybody, and a fallback value would stay cached forever.- This cache never expires entries and grows without limit. Real caches need a maximum size and a time-to-live: use Caffeine (
AsyncLoadingCachedoes exactly this pattern) rather than writing your own eviction.
L4.4Follow-ups
- Why not call the slow loader directly inside
computeIfAbsent?Reveal
It would run while the map holds the bin lock, blocking other updates to keys in that bin for 200 ms, and the loader must not modify the same map.
- What happens if the executor is a direct executor (
Runnable::run)?Reveal
supplyAsyncwould run the load inline, insidecomputeIfAbsent, bringing back version 3's problem. The design depends on a real asynchronous executor. - How would you add a 10-minute expiry?
Reveal
Store a holder with the future and a load timestamp and reload when stale (with care for concurrent reloads), or use Caffeine's
expireAfterWrite.
Lab 5: Parallel aggregator with timeouts
Call four partner APIs in parallel for a quote, keep whatever answers within 300 ms, ignore failures, and return the best price, with a dedicated executor and no thread left blocked.
Simulate four partners: alpha (120 ms), beta (900 ms, too slow), gamma (80 ms, fails with an exception) and delta (200 ms). Return the cheapest quote among those that answer successfully within 300 ms. The whole call must take about 300 ms, never 900.
What a complete solution includes
All calls started before any wait; each call turned into an optional result with completeOnTimeout and exceptionally; allOf to join; a named, bounded executor; shutting it down (interrupting the abandoned slow call); measured total time near the 300 ms budget.
L5.1Hints
Hint 1
Wrap each call as CompletableFuture<Optional<Quote>>: success becomes Optional.of(q), a timeout or failure becomes Optional.empty().
Hint 2
After allOf(...), each join() returns immediately; collect with flatMap(Optional::stream).
Hint 3
Timeouts on futures do not stop the work. In a real client, also set the HTTP read timeout.
L5.2Reference solution
import java.util.*;
import java.util.concurrent.*;
public class Aggregator {
record Quote(String partner, long pricePaise) { }
static Quote callPartner(String name, long latencyMs, boolean fail) {
try { Thread.sleep(latencyMs); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
if (fail) throw new IllegalStateException(name + " returned 500");
return new Quote(name, 10_000 + latencyMs);
}
static CompletableFuture<Optional<Quote>> quote(Executor io, String name, long ms, boolean fail) {
return CompletableFuture.supplyAsync(() -> callPartner(name, ms, fail), io)
.thenApply(Optional::of)
.completeOnTimeout(Optional.empty(), 300, TimeUnit.MILLISECONDS) // too slow: ignore
.exceptionally(ex -> Optional.empty()); // failed: ignore
}
public static void main(String[] args) {
ExecutorService io = new ThreadPoolExecutor(8, 8, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100),
Thread.ofPlatform().name("partner-io-", 0).factory()); // bounded: a full queue rejects at supplyAsync
long start = System.nanoTime();
List<CompletableFuture<Optional<Quote>>> calls = List.of(
quote(io, "alpha", 120, false),
quote(io, "beta", 900, false),
quote(io, "gamma", 80, true),
quote(io, "delta", 200, false));
List<Quote> got = CompletableFuture.allOf(calls.toArray(new CompletableFuture[0]))
.thenApply(v -> calls.stream().map(CompletableFuture::join).flatMap(Optional::stream).toList())
.join();
Quote best = got.stream().min(Comparator.comparingLong(Quote::pricePaise)).orElseThrow();
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("quotes from " + got.stream().map(Quote::partner).toList() + ", best " + best.partner() + ", in " + ms + " ms");
io.shutdownNow(); // interrupts beta's abandoned call
}
}L5.3Follow-ups
- Why must all four
supplyAsynccalls start before anyjoin()?Reveal
Joining each one before starting the next makes them sequential: about 120 + 300 (beta, cut off by its timeout) + 80 + 200 = 700 ms instead of about 300 ms.
- What still runs after the result is returned, and how do you stop it?
Reveal
Beta's call keeps a pool thread until it finishes.
shutdownNow()interrupts it here; in a service, a client read timeout ends it. - How would you return the first quote under 10,100 paise immediately instead of waiting for all?
Reveal
Complete a separate
CompletableFuturefrom each call'sthenAcceptwhen the condition matches (firstcompletewins), withcompleteOnTimeoutas the fallback.
Lab 6: A bounded executor under overload
Configure a ThreadPoolExecutor, flood it with a burst of tasks, and measure what each rejection policy does. Then shut it down cleanly.
Build a pool with core 2, max 4, an ArrayBlockingQueue of 10 and named threads. Submit 30 tasks of 100 ms each as fast as possible. With AbortPolicy, predict and then measure how many complete and how many are rejected. Repeat with CallerRunsPolicy.
What a complete solution includes
Prediction first: 2 core + 10 queued + 2 extra = 14 accepted, 16 rejected with AbortPolicy; with CallerRunsPolicy all 30 complete and some run on the submitting thread, slowing the submit loop. Then shutdown() and awaitTermination with a timeout.
L6.1Reference solution
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class OverloadLab {
static void burst(String label, RejectedExecutionHandler policy) throws InterruptedException {
ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10),
Thread.ofPlatform().name("job-", 0).factory(), policy);
AtomicInteger done = new AtomicInteger(), rejected = new AtomicInteger(), onCaller = new AtomicInteger();
String submitter = Thread.currentThread().getName();
long start = System.nanoTime();
for (int i = 0; i < 30; i++) {
try {
pool.execute(() -> {
if (Thread.currentThread().getName().equals(submitter)) onCaller.incrementAndGet();
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
done.incrementAndGet();
});
} catch (RejectedExecutionException e) {
rejected.incrementAndGet(); // in a service: return 429/503 here
}
}
long submitMs = (System.nanoTime() - start) / 1_000_000;
pool.shutdown();
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) pool.shutdownNow();
System.out.printf("%-10s completed=%d rejected=%d ranOnCaller=%d largestPool=%d submitLoop=%d ms%n",
label, done.get(), rejected.get(), onCaller.get(), pool.getLargestPoolSize(), submitMs);
}
public static void main(String[] args) throws InterruptedException {
burst("Abort", new ThreadPoolExecutor.AbortPolicy());
burst("CallerRuns", new ThreadPoolExecutor.CallerRunsPolicy());
}
}Abort completed=14 rejected=16 ranOnCaller=0 largestPool=4 submitLoop=3 ms
CallerRuns completed=30 rejected=0 ranOnCaller=4 largestPool=4 submitLoop=402 msL6.2What to observe
AbortPolicy: the submit loop finishes in milliseconds and 16 tasks fail loudly. That is fail fast.CallerRunsPolicy: nothing is lost, but the submit loop takes hundreds of milliseconds because the main thread itself ran tasks. That is backpressure: the producer slowed down to the pool's pace.- Change the queue to
new LinkedBlockingQueue<>()(unbounded): no rejections,largestPoolstays 2, and all 30 tasks take 1.5 seconds on two threads. Max size never mattered. - Change to
new SynchronousQueue<>()with max 4: only 4 tasks are accepted at a time; the rest are rejected immediately.
L6.3Follow-ups
- Why can
CallerRunsPolicybe dangerous for a web request thread?Reveal
The request thread runs the task itself, so that request becomes as slow as the task, and if every request thread does it, the web server stops accepting new work. Sometimes that is the backpressure you want; sometimes a fast 503 is better.
- How would you expose this pool's health?
Reveal
Metrics for active count, queue size, largest pool size, completed tasks and a counter in a custom
RejectedExecutionHandler; Micrometer'sExecutorServiceMetricsdoes most of it. - Why
awaitTerminationwith a timeout and thenshutdownNow?Reveal
It bounds how long shutdown waits for in-flight work.
shutdownNowonly interrupts, so a task that ignores interrupts (a socket read with no timeout, a CPU loop) keeps running; await again and log if tasks are still alive.
Lab 7: Deadlock: create, detect, fix
Make random money transfers between accounts deadlock, detect the deadlock from inside the JVM and in a thread dump, then fix it with a global lock order and check that no money was created or lost.
Five accounts with 1,000 each; eight threads each make 100,000 random transfers of 1 between random pairs. Version 1 locks from then to. Version 2 must never deadlock. Detect version 1's deadlock automatically, and check that version 2 ends with a total of exactly 5,000.
What a complete solution includes
A naive version that deadlocks within seconds; a watchdog using ThreadMXBean.findDeadlockedThreads() (and a jstack dump you read yourself); a fixed version that orders locks by account id; a check of the total under the locks at the end.
L7.1Reference solution
import java.lang.management.ManagementFactory;
import java.util.concurrent.*;
public class DeadlockLab {
static final class Account { final int id; long balance; Account(int id, long b) { this.id = id; this.balance = b; } }
interface Transfer { void run(Account from, Account to, long amount); }
static void naive(Account from, Account to, long amt) {
synchronized (from) { synchronized (to) { from.balance -= amt; to.balance += amt; } }
}
static void ordered(Account from, Account to, long amt) {
Account first = from.id < to.id ? from : to, second = (first == from) ? to : from; // global order: by id
synchronized (first) { synchronized (second) { from.balance -= amt; to.balance += amt; } }
}
static void simulate(String label, Transfer transfer) throws InterruptedException {
Account[] accts = new Account[5];
for (int i = 0; i < accts.length; i++) accts[i] = new Account(i, 1_000);
CountDownLatch done = new CountDownLatch(8);
for (int t = 0; t < 8; t++) {
Thread th = new Thread(() -> {
ThreadLocalRandom r = ThreadLocalRandom.current();
for (int i = 0; i < 100_000; i++) {
int a = r.nextInt(accts.length), b = r.nextInt(accts.length);
if (a != b) transfer.run(accts[a], accts[b], 1);
}
done.countDown();
}, label + "-" + t);
th.setDaemon(true); // stuck demo threads must not keep the JVM alive
th.start();
}
if (done.await(5, TimeUnit.SECONDS)) {
long total = 0;
for (Account a : accts) synchronized (a) { total += a.balance; } // safe: all transfers finished (latch)
System.out.println(label + ": all transfers finished, total = " + total);
} else {
long[] ids = ManagementFactory.getThreadMXBean().findDeadlockedThreads();
System.out.println(label + ": stuck, deadlocked threads = " + (ids == null ? 0 : ids.length));
}
}
public static void main(String[] args) throws InterruptedException {
simulate("naive", DeadlockLab::naive);
simulate("ordered", DeadlockLab::ordered);
}
}naive: stuck, deadlocked threads = 2
ordered: all transfers finished, total = 5000L7.2What to observe
- While the naive version is stuck, run
jcmd <pid> Thread.printfrom another terminal (find the PID withjcmd). Find theFound one Java-level deadlocksection and match eachwaiting to lockwith the other thread'slocked(Module 15). - Often only 2 or 3 threads are in the cycle; the others are
BLOCKEDbehind them. The JVM never recovers on its own. - The ordered version also proves conservation: the total stays 5,000, because every transfer debits and credits under both locks.
- Try a
tryLock-with-timeout version (Module 11) and count how often transfers have to retry.
L7.3Follow-ups
- What if two accounts could have the same sort key?
Reveal
Use a unique key (the id), or when keys tie, take a third global tie-breaker lock first, as in JCiP 10.1.2.
- How would this look with accounts in PostgreSQL across 4 pods?
Reveal
No JVM locks at all: one transaction that updates both rows in id order (
SELECT ... FOR UPDATEordered by id), or a conditionalUPDATE; the database detects remaining deadlocks and aborts one transaction, which you retry. - Is the total read correct, and would the per-account locks be enough while transfers are running?
Reveal
Here it is correct because every transfer has finished and
countDown/awaitgives happens-before; the locks add nothing. While transfers were still running, locking accounts one at a time would not help: money could move between an account already counted and one not yet counted. You would need all locks at once (taken in id order), or to pause the writers.
Lab 8: A bulkhead for a slow dependency
Build a semaphore-based bulkhead with metrics, put a slow notifications API behind it, and show that payment requests on the same thread pool stay fast while notifications are degraded.
A service handles payments (each calls a fraud API, 20 ms) and receipts (each calls a notifications API that has become very slow, 1 s) on one shared 40-thread pool. Measure the average payment latency without protection, then add a Bulkhead (at most 5 concurrent notification calls, wait at most 50 ms for a permit) and measure again.
What a complete solution includes
A Bulkhead class with tryAcquire(timeout), release in finally, counters for rejections and maximum in-flight calls; a degrade path for rejected receipts; payment latency measured from submission (queue time included); a clear before/after comparison.
L8.1Reference solution
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.function.Supplier;
public class BulkheadLab {
static final class Bulkhead {
private final String name; private final Semaphore permits; private final long waitMs;
final LongAdder rejected = new LongAdder();
final AtomicInteger maxInFlight = new AtomicInteger();
private final AtomicInteger inFlight = new AtomicInteger();
Bulkhead(String name, int limit, long waitMs) { this.name = name; this.permits = new Semaphore(limit); this.waitMs = waitMs; }
<T> T call(Supplier<T> work) {
boolean ok;
try { ok = permits.tryAcquire(waitMs, TimeUnit.MILLISECONDS); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException("interrupted"); }
if (!ok) { rejected.increment(); throw new RejectedExecutionException(name + " bulkhead full"); }
try {
maxInFlight.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
return work.get();
} finally {
inFlight.decrementAndGet();
permits.release();
}
}
}
static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
static long run(Bulkhead notifications) throws Exception {
ExecutorService requests = Executors.newFixedThreadPool(40); // the shared request threads
LongAdder paymentMs = new LongAdder(); LongAdder payments = new LongAdder();
List<Future<?>> fs = new ArrayList<>();
for (int i = 0; i < 200; i++) {
long submitted = System.nanoTime();
boolean payment = i % 2 == 0;
fs.add(requests.submit(() -> {
if (payment) {
sleep(20); // fraud check
paymentMs.add((System.nanoTime() - submitted) / 1_000_000);
payments.increment();
} else {
try { notifications.call(() -> { sleep(1_000); return "sent"; }); }
catch (RejectedExecutionException e) { /* degrade: store the receipt and send it later */ }
}
}));
}
for (Future<?> f : fs) f.get(60, TimeUnit.SECONDS);
requests.shutdown();
return paymentMs.sum() / payments.sum();
}
public static void main(String[] args) throws Exception {
long without = run(new Bulkhead("no limit", 1_000, 50));
Bulkhead bh = new Bulkhead("notifications", 5, 50);
long with = run(bh);
System.out.println("average payment latency without bulkhead: " + without + " ms");
System.out.println("average payment latency with bulkhead: " + with + " ms"
+ " (notifications max in flight " + bh.maxInFlight.get() + ", rejected " + bh.rejected.sum() + ")");
System.out.println("bulkhead kept payments fast: " + (with * 2 < without));
}
}average payment latency without bulkhead: 1186 ms
average payment latency with bulkhead: 41 ms (notifications max in flight 5, rejected 95)
bulkhead kept payments fast: trueL8.2What to observe
- Without a limit, receipt tasks hold most of the 40 threads for a second each, so payments wait in the executor queue behind them: the slow, unimportant dependency sets the latency of the important path.
- With the bulkhead, at most 5 threads are ever inside the notifications call; other receipts fail within 50 ms and are degraded, and payments stay near their real cost.
- The rejected receipts must not be lost: in production they go to a retry or outbox table written in the same transaction as the payment, which a relay later sends on (for example to Kafka), and a circuit breaker would stop calling the dependency at all for a while.
L8.3Follow-ups
- Semaphore bulkhead or a separate thread pool per dependency?
Reveal
A semaphore limits concurrency on the caller's own threads (cheap, works with virtual threads). A separate pool also isolates the caller from waiting (it can time out on the future) but costs threads and adds a queue. Resilience4j offers both.
- How do you choose the limit of 5?
Reveal
From Little's law on the healthy dependency (rate x normal latency, plus headroom), from the partner's documented limit, and divided across pods.
- Why measure latency from submission rather than from when the task starts?
Reveal
Because the damage is queueing: payments waiting for a free thread. Measuring from task start would hide it.
Lab 9: A rate limiter (token bucket)
Build a thread-safe token bucket that allows short bursts but caps the average rate, test it exactly with a fake clock, and give every merchant its own bucket.
Write TokenBucket(capacity, tokensPerSecond, clock) with boolean tryAcquire(). It starts full, refills continuously, never holds more than capacity tokens, and stays correct when many threads call it at once. Prove it with a test in which time only moves when you move it. Then limit each merchant separately, so one noisy merchant cannot use another's quota.
What a complete solution includes
One lock guarding the two fields that change together (tokens and last refill time); a lazy refill computed from elapsed time on each call, with no background thread; integer arithmetic so no token is lost to rounding; an injected clock (LongSupplier) so the test is exact; many threads behind a start gate in the test; a ConcurrentHashMap of buckets created with computeIfAbsent.
L9.1How a token bucket works
A bucket holds up to capacity tokens and gains tokens at a fixed rate. Each request takes one token; with none left, it is rejected (HTTP 429). A full bucket allows a burst of capacity requests at once, and over a long time the rate can never exceed the refill rate. Partners publish limits like "100 requests per second, bursts of 10": that is exactly a bucket with capacity 10 and rate 100.
L9.2Hints
Hint 1
Do not refill from a scheduled thread. On every call, compute how many whole tokens were earned since the last refill: elapsed / nanosPerToken.
Hint 2
tokens and lastRefill must change together, so one synchronized method guarding both is the simple, correct choice. Two separate AtomicLongs would not be atomic together.
Hint 3
Pass the clock in as a LongSupplier. Production uses System::nanoTime; the test uses an AtomicLong that it moves by hand.
L9.3Reference solution
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.function.LongSupplier;
public class RateLimiterLab {
static final class TokenBucket {
private final long capacity, nanosPerToken;
private final LongSupplier clock;
private long tokens, lastRefill; // guarded by this
TokenBucket(long capacity, long tokensPerSecond, LongSupplier clock) {
this.capacity = capacity;
this.nanosPerToken = 1_000_000_000L / tokensPerSecond;
this.clock = clock;
this.tokens = capacity; // start full: allows one burst
this.lastRefill = clock.getAsLong();
}
synchronized boolean tryAcquire() { // refill and take are one atomic step
refill();
if (tokens == 0) return false;
tokens--;
return true;
}
private void refill() { // lazy: no background thread
long now = clock.getAsLong();
long earned = (now - lastRefill) / nanosPerToken;
tokens = Math.min(capacity, tokens + earned);
// a full bucket restarts the clock; otherwise keep the unused part of a token
lastRefill = (tokens == capacity) ? now : lastRefill + earned * nanosPerToken;
}
}
// Many threads call the limiter at the same moment; returns how many calls got a token
static int hammer(TokenBucket b, int threads, int callsEach) throws Exception {
AtomicInteger allowed = new AtomicInteger();
CountDownLatch gate = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(threads);
List<Future<?>> fs = new ArrayList<>();
for (int t = 0; t < threads; t++) {
fs.add(pool.submit(() -> {
gate.await();
for (int i = 0; i < callsEach; i++) if (b.tryAcquire()) allowed.incrementAndGet();
return null;
}));
}
gate.countDown();
for (Future<?> f : fs) f.get(10, TimeUnit.SECONDS);
pool.shutdown();
return allowed.get();
}
public static void main(String[] args) throws Exception {
AtomicLong fakeNanos = new AtomicLong(); // a clock the test moves by hand
TokenBucket b = new TokenBucket(10, 100, fakeNanos::get); // bursts of 10, 100 per second
System.out.println("burst: " + hammer(b, 8, 100) + " allowed"); // 800 calls at once
fakeNanos.addAndGet(50_000_000); // 50 ms later
System.out.println("after 50 ms: " + hammer(b, 8, 100) + " allowed");
fakeNanos.addAndGet(1_000_000_000); // 1 s idle
System.out.println("after 1 s idle: " + hammer(b, 8, 100) + " allowed (capped at capacity)");
// One bucket per merchant, created on first use
ConcurrentHashMap<String, TokenBucket> perMerchant = new ConcurrentHashMap<>();
int noisy = 0, quiet = 0;
for (int i = 0; i < 50; i++)
if (perMerchant.computeIfAbsent("noisy", k -> new TokenBucket(5, 10, System::nanoTime)).tryAcquire()) noisy++;
for (int i = 0; i < 3; i++)
if (perMerchant.computeIfAbsent("quiet", k -> new TokenBucket(5, 10, System::nanoTime)).tryAcquire()) quiet++;
System.out.println("noisy merchant: " + noisy + " of 50 allowed, quiet merchant: " + quiet + " of 3 allowed");
}
}burst: 10 allowed
after 50 ms: 5 allowed
after 1 s idle: 10 allowed (capped at capacity)
noisy merchant: 5 of 50 allowed, quiet merchant: 3 of 3 allowedL9.4What to observe
- Each phase makes 800 calls at the same moment, and exactly the right number get through: 10, then the 5 earned in 50 ms, then 10 again because a full bucket stops filling. The lock makes refill-and-take one atomic step.
- Remove
synchronizedand run it a few times: two threads can both seetokens == 1and both take it (check-then-act, Module 2), so more calls pass than the limit allows. - The fake clock makes a time-based test exact and instant. It is the same idea as injecting an executor in Module 15: pass in what you need to control.
- A limiter inside the JVM limits one pod. With 6 pods each allowing 100 per second, the partner can see 600. Divide the limit by the number of pods, or keep the bucket in Redis, where a small Lua script does refill-and-take atomically for all pods.
L9.5Follow-ups
- How would you add a blocking
acquire()that waits for a token?Reveal
Inside the lock, work out how long until the next token; release the lock; sleep that long; then try again. Never sleep while holding the lock. Guava's
RateLimiterand Resilience4j'sRateLimiterdo this for you. - Can it be lock-free?
Reveal
Yes: keep both fields in one immutable record inside an
AtomicReferenceand update it in acompareAndSetloop (Module 8). It is not automatically faster under contention, and thesynchronizedversion is simpler and fast enough for almost every service. - The per-merchant map grows with every new merchant ID. What do you do?
Reveal
Bound it: use a cache with expiry (for example Caffeine with
expireAfterAccess) instead of a plain map, so buckets of idle merchants are removed. - Why a token bucket and not a counter reset every second?
Reveal
A fixed window can let through up to twice the limit around a boundary: a full window at the end of one second and another at the start of the next. The token bucket smooths that and still allows a controlled burst.
Lab 10: Capstone: a production-style job runner
Put the handbook together: a job runner with a bounded queue that rejects, a timeout on every attempt, retries with backoff, a status for every job, metrics, and a graceful shutdown that loses nothing silently.
Build JobRunner with submit(id, work) that: (1) rejects a job at once when 4 workers and a queue of 20 are full; (2) cancels any attempt that runs longer than 200 ms; (3) retries a failed or timed-out job up to 3 attempts in total, waiting about 50 ms, then 100 ms, with jitter, without blocking a worker; (4) keeps the status of every job in a map; (5) counts accepted, rejected, succeeded, failed, abandoned, retries and timeouts; (6) on close(), stops taking work, lets queued work finish within a grace period, and marks anything left unfinished as ABANDONED.
What a complete solution includes
A bounded ThreadPoolExecutor with the default abort policy; one ScheduledThreadPoolExecutor for watchdogs and retry delays, so no worker ever sleeps for a retry; each attempt as a FutureTask whose watchdog calls cancel(true) and whose done() decides the next step exactly once; statuses in a ConcurrentHashMap; LongAdder counters; a volatile closing flag; shutdown, awaitTermination, shutdownNow, then a sweep that leaves every job in a final state.
L10.1The design
Two pictures before any code: the parts, and the life of one job. Every arrow in the second figure is one line of the solution.
L10.2Hints
Hint 1
Do not Thread.sleep in a worker before a retry: that blocks a thread for nothing. Schedule the retry on a ScheduledExecutorService and resubmit when the delay ends.
Hint 2
A future timeout (get(timeout) or orTimeout) does not stop the work, so the worker stays busy. Cancel the attempt with cancel(true), which interrupts it.
Hint 3
Extend FutureTask and override done(). It runs exactly once per attempt, whether the job returned, threw or was cancelled, so it is the one place to decide: success, retry or give up.
Hint 4
Shut down in order: refuse new work, drain the workers, stop the timer, then mark whatever is left.
L10.3Reference solution
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.stream.IntStream;
public class JobRunner implements AutoCloseable {
enum Status {
QUEUED, RUNNING, RETRY_WAIT, SUCCEEDED, FAILED, REJECTED, ABANDONED;
boolean isFinal() { return this == SUCCEEDED || this == FAILED || this == REJECTED || this == ABANDONED; }
}
record Job(String id, Callable<String> work) { }
final ConcurrentHashMap<String, Status> status = new ConcurrentHashMap<>();
final LongAdder accepted = new LongAdder(), rejected = new LongAdder(), succeeded = new LongAdder(),
failed = new LongAdder(), abandoned = new LongAdder(), retries = new LongAdder(), timeouts = new LongAdder();
private final ThreadPoolExecutor workers;
private final ScheduledThreadPoolExecutor timer; // watchdogs and retry delays
private final int maxAttempts;
private final long timeoutMs, backoffMs;
private volatile boolean closing;
JobRunner(int threads, int queueSize, int maxAttempts, long timeoutMs, long backoffMs) {
workers = new ThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(queueSize), Thread.ofPlatform().name("job-worker-", 0).factory());
timer = new ScheduledThreadPoolExecutor(1, Thread.ofPlatform().name("job-timer").daemon(true).factory());
timer.setRemoveOnCancelPolicy(true); // cancelled watchdogs leave the queue at once
this.maxAttempts = maxAttempts; this.timeoutMs = timeoutMs; this.backoffMs = backoffMs;
}
/** False if the runner is closing, the id was already accepted, or the queue is full (a rejected id may try again). */
public boolean submit(String id, Callable<String> work) {
if (closing) return false;
if (status.putIfAbsent(id, Status.QUEUED) != null
&& !status.replace(id, Status.REJECTED, Status.QUEUED)) return false; // each id runs once
boolean ok = start(new Job(id, work), 1);
if (ok) accepted.increment();
return ok;
}
private boolean start(Job job, int attempt) {
try {
workers.execute(new Attempt(job, attempt));
return true;
} catch (RejectedExecutionException e) { // queue full, or shut down
end(job, closing ? Status.ABANDONED : attempt == 1 ? Status.REJECTED : Status.FAILED); // a retry that finds the queue full gives up
return false;
}
}
/** One attempt of one job. done() runs exactly once: after a return, a throw or a cancel. */
private final class Attempt extends FutureTask<String> {
private final Job job; private final int n;
Attempt(Job job, int n) { super(job.work()); this.job = job; this.n = n; }
@Override public void run() { // on a worker thread
status.put(job.id(), Status.RUNNING);
ScheduledFuture<?> watchdog = timer.schedule(() -> cancel(true), timeoutMs, TimeUnit.MILLISECONDS);
try { super.run(); } finally { watchdog.cancel(false); }
}
@Override protected void done() { finished(job, n, this); } // worker thread or timer thread
}
private void finished(Job job, int n, Future<String> attempt) {
Future.State s = attempt.state(); // Java 19+: SUCCESS, FAILED or CANCELLED
if (s == Future.State.SUCCESS) { end(job, Status.SUCCEEDED); return; }
if (s == Future.State.CANCELLED) timeouts.increment(); // only the watchdog cancels attempts
if (n == maxAttempts) { end(job, Status.FAILED); return; }
if (closing) { end(job, Status.ABANDONED); return; } // no new retries during shutdown
status.put(job.id(), Status.RETRY_WAIT);
retries.increment();
long delay = backoffMs << (n - 1); // 50, 100, 200 ms ...
delay += ThreadLocalRandom.current().nextLong(delay / 2 + 1); // jitter: retries do not collide
timer.schedule(() -> { status.put(job.id(), Status.QUEUED); start(job, n + 1); }, delay, TimeUnit.MILLISECONDS);
}
private void end(Job job, Status s) {
Status old = status.get(job.id());
if (old.isFinal() || !status.replace(job.id(), old, s)) return; // the first final state wins
switch (s) {
case SUCCEEDED -> succeeded.increment();
case FAILED -> failed.increment();
case REJECTED -> rejected.increment();
default -> abandoned.increment();
}
}
@Override public void close() throws InterruptedException {
closing = true; // 1. refuse new jobs and new retries
workers.shutdown(); // 2. queued and running attempts may finish
if (!workers.awaitTermination(5, TimeUnit.SECONDS)) {
workers.shutdownNow(); // grace period over: interrupt them
workers.awaitTermination(1, TimeUnit.SECONDS);
}
timer.shutdownNow(); // 3. drop pending retries
timer.awaitTermination(1, TimeUnit.SECONDS);
status.forEach((id, s) -> { // 4. nothing stays half done
if (!s.isFinal() && status.replace(id, s, Status.ABANDONED)) abandoned.increment();
});
}
String metrics() {
return "accepted=" + accepted + ", rejected=" + rejected + ", succeeded=" + succeeded + ", failed=" + failed
+ ", abandoned=" + abandoned + ", retries=" + retries + ", timeouts=" + timeouts;
}
public static void main(String[] args) throws Exception {
JobRunner runner = new JobRunner(4, 20, 3, 200, 50); // 4 workers, queue 20, 3 attempts, 200 ms timeout, 50 ms backoff
AtomicInteger flakyCalls = new AtomicInteger();
runner.submit("flaky", () -> {
Thread.sleep(20);
if (flakyCalls.incrementAndGet() < 3) throw new IllegalStateException("partner returned 503");
return "ok";
});
runner.submit("broken", () -> { Thread.sleep(20); throw new IllegalArgumentException("bad input"); });
runner.submit("slow", () -> { Thread.sleep(5_000); return "too late"; }); // the watchdog cancels every attempt
int ok = 0;
for (int i = 1; i <= 40; i++) if (runner.submit("job-" + i, () -> { Thread.sleep(30); return "done"; })) ok++;
System.out.println("burst of 40: " + ok + " accepted, " + (40 - ok) + " rejected");
Thread.sleep(2_000); // let retries and timeouts play out
System.out.println("flaky: " + runner.status.get("flaky") + " after " + flakyCalls.get() + " attempts");
System.out.println("broken: " + runner.status.get("broken"));
System.out.println("slow: " + runner.status.get("slow"));
for (int i = 1; i <= 8; i++) runner.submit("late-" + i, () -> { Thread.sleep(100); return "done"; });
runner.close(); // graceful: the 8 late jobs still finish
long drained = IntStream.rangeClosed(1, 8).filter(i -> runner.status.get("late-" + i) == Status.SUCCEEDED).count();
System.out.println("drained during shutdown: " + drained + " of 8 SUCCEEDED");
System.out.println("submit after close accepted: " + runner.submit("too-late", () -> "x"));
System.out.println(runner.metrics());
System.out.println("every job has a final status: " + runner.status.values().stream().allMatch(Status::isFinal));
}
}burst of 40: 21 accepted, 19 rejected
flaky: SUCCEEDED after 3 attempts
broken: FAILED
slow: FAILED
drained during shutdown: 8 of 8 SUCCEEDED
submit after close accepted: false
accepted=32, rejected=19, succeeded=30, failed=2, abandoned=0, retries=6, timeouts=3
every job has a final status: trueL10.4What to observe
- Backpressure: the burst is cut at 4 running plus 20 queued, and the rest are rejected at once. The caller learns immediately and can slow down or answer 429 (Module 14).
- Timeouts that free the worker:
slowis cancelled by its watchdog on every attempt, so a stuck job that responds to interrupts costs at most 200 ms of a worker per attempt, not 5 seconds. - Retries without blocking:
flakysucceeds on attempt 3 andbrokengives up after 3. During each backoff, the job waits on the timer and the workers keep running other jobs. - Two threads finish attempts:
done()runs on a worker thread when the job returns or throws, and on the timer thread when the watchdog cancels it. That is why statuses live in aConcurrentHashMapand counters areLongAdders. - Graceful shutdown:
close()lets the 8 late jobs finish, then nothing is left in QUEUED, RUNNING or RETRY_WAIT. - Interrupts are cooperative:
cancel(true)stopsslowbecauseThread.sleepresponds to interrupts. A CPU loop that never checksThread.interrupted()would keep its worker, and its retry would start on another worker while it still runs, so the same job would run twice at once (Module 5). Another reason jobs must be idempotent. - Why
FutureTaskand notCompletableFuture:orTimeoutcompletes the future but does not interrupt the work, so the worker would stay busy.FutureTask.cancel(true)interrupts it, and the pool clears that interrupt before the thread runs its next task.
L10.5Follow-ups
- A job ran, the pod died before its status was saved, and it ran again after restart. How do you make that safe?
Reveal
Make each job idempotent: record its ID together with its effect in one transaction, or use a unique key or conditional update (Module 14). A runner like this gives at-least-once execution, not exactly-once.
- How would this survive a pod restart?
Reveal
Keep jobs in a database table instead of an in-memory map: workers claim rows with
SELECT ... FOR UPDATE SKIP LOCKED, and update the status in the same transaction as the job's effect. The in-memory design is the same, with the map standing in for the table. - Why is one timer thread enough for all watchdogs and retries?
Reveal
Its tasks are tiny: cancel an attempt or resubmit a job. One thread handles thousands of them. Real work never runs on it; if it did, every timeout and retry would be delayed behind that work.
- How would you run the jobs on virtual threads?
Reveal
Replace the worker pool with a virtual-thread-per-task executor, a
Semaphorefor the concurrency limit, and an admission counter for the queue limit, so rejection still happens. The watchdog, retry and shutdown design stays the same.
Cheat sheet
The handbook on one page: which tool for which problem, the rules that matter, the defaults worth knowing, and the code smells to catch in review.
Which tool for which problem
| Problem | Reach for | Module |
|---|---|---|
| Data used by one thread only | Locals, confinement; no synchronization | 2 |
| Values that never change | Immutable classes, records with copied fields | 2 |
| A flag or a reference swapped by one writer | volatile |
4 |
| A counter or single value updated from its current value | AtomicInteger / AtomicLong / AtomicReference |
8 |
| A hot metrics counter | LongAdder |
8 |
| Several fields that change together | synchronized (or ReentrantLock) around all of them |
3 |
| A lock with a timeout or interruptible waits | ReentrantLock.tryLock(t), lockInterruptibly() |
10 |
| Shared map | ConcurrentHashMap with computeIfAbsent, merge, compute |
9 |
| Read-mostly list (listeners) | CopyOnWriteArrayList |
9 |
| Hand work between threads | BlockingQueue (bounded) |
9 |
| Wait for N events once | CountDownLatch |
10 |
| N threads meet repeatedly | CyclicBarrier |
10 |
| At most N at once | Semaphore |
10 |
| Run tasks | ThreadPoolExecutor with bounded queue, named threads, rejection policy |
6 |
| Results, composition, timeouts | CompletableFuture with your own executor |
7 |
| Many blocking tasks (Java 21) | Virtual threads, with semaphores in front of scarce resources | 13 |
| CPU-heavy divisible work | ForkJoinPool, parallel streams |
13 |
| Per-thread context | ThreadLocal with remove() in finally; ScopedValue on 25 |
12 |
| Safety across pods | The database: conditional updates, unique keys, row locks | 14 |
| Results in completion order | ExecutorCompletionService |
7 |
| Do something exactly once (start, close) | AtomicBoolean.compareAndSet(false, true) |
8 |
| Work that is due later | ScheduledExecutorService, or a DelayQueue |
6, 9 |
| Calls per second to a partner | A token bucket, one per key; Redis when the limit is across pods | L9 |
| Background jobs with timeouts and retries | Bounded pool + one timer thread for watchdogs and backoff | L10 |
Rules that prevent most bugs
- Every shared mutable field has one guard (a lock,
volatile, or an atomic), used on every read and write. - Check-then-act and read-modify-write must be one atomic step.
wait()andawait()always in awhileloop;unlock()always infinally.- Never do I/O, sleep, or call unknown code while holding a lock.
- Take multiple locks in one global order.
- Never swallow
InterruptedException: rethrow or restore the flag. - Every queue is bounded; every remote call has a timeout; every pool is named and shut down.
- Never block a pool thread on work that needs the same pool.
ThreadLocal: set intry,remove()infinally.- A thread-safe collection does not make a thread-safe workflow.
Thread states
| State | Typical cause |
|---|---|
NEW |
Created, not started |
RUNNABLE |
Running, ready, or blocked in native I/O |
BLOCKED |
Waiting to enter synchronized |
WAITING |
wait(), join(), park() (locks, queues, futures) without timeout |
TIMED_WAITING |
sleep(ms), wait(ms), join(ms), timed park or poll |
TERMINATED |
run() finished |
Happens-before in one table
| Edge | From | To |
|---|---|---|
| Program order | Earlier action in a thread | Later action in the same thread |
| Monitor | unlock of a monitor |
Later lock of the same monitor |
| Volatile | Write to a volatile field | Later read of that field |
| Start / join | t.start(); everything in t |
Everything in t; return from t.join() |
| Executors and futures | submit(task); the task's work |
The task running; Future.get() returning |
| Queues and synchronizers | put, countDown, release, unlock |
take, await, acquire, lock |
Separate rule, not a happens-before edge: final fields (and objects reached through them, as of the end of the constructor) are seen correctly initialized by any thread, if this did not escape during construction. Non-final fields of the same object get no such guarantee.
ThreadPoolExecutor and CompletableFuture
Where a task goes: fewer than core threads -> new core thread; else queue it; if the queue is full and fewer than max -> new extra thread; else the rejection policy (Abort default, CallerRuns, Discard, DiscardOldest).
CompletableFuture |
Use for |
|---|---|
supplyAsync(s, ex) / runAsync(r, ex) |
Start work on your executor |
thenApply / thenCompose / thenAccept |
Map / chain another future / consume |
thenCombine, allOf, anyOf |
Join two / all / first |
exceptionally, handle, whenComplete |
Recover / map both outcomes / observe |
orTimeout, completeOnTimeout |
Timeouts (the work keeps running) |
join() / get() |
Block: CompletionException / ExecutionException |
BlockingQueue and ConcurrentHashMap methods
| Throw | Special value | Block | Timeout | |
|---|---|---|---|---|
| Insert | add |
offer -> false |
put |
offer(e, t, unit) |
| Remove | remove |
poll -> null |
take |
poll(t, unit) |
ConcurrentHashMap atomic methods: putIfAbsent, computeIfAbsent, computeIfPresent, compute, merge, replace(k, old, new), remove(k, v). Keep their functions short and never modify the same map inside them.
Formulas and defaults
- Pool size:
cores x target CPU use x (1 + wait / compute); CPU-bound: about cores (+1). - Little's law:
in flight = arrival rate x time per request. - DB throughput cap:
connections / time per transaction.
| Default | Value |
|---|---|
ForkJoinPool.commonPool() size |
cores - 1. Java 21 to 24: if that is below 2, CompletableFuture's async methods without an executor use a new thread per task instead; Java 25+: they always use the common pool |
| Tomcat request threads / max connections | 200 / 8,192 (waiting requests queue in Tomcat's unbounded executor queue) |
| HikariCP pool size / connection timeout | 10 / 30 s |
Spring Boot @Async executor |
8 core threads, unbounded queue |
Spring @Scheduled threads |
1 |
Kafka max.poll.records / max.poll.interval.ms |
500 / 5 minutes |
Kubernetes terminationGracePeriodSeconds |
30 s |
| Spring Boot graceful shutdown | On by default since 3.4; 30 s per phase |
Code review smells
- A mutable field in a
@Service,@Componentor other singleton. if (map.containsKey(k))/getthenputon a shared map.volatileon a counter, orcount++on anything shared.synchronizedmethods that call HTTP, JDBC or listeners.catch (InterruptedException e) { }.Executors.newFixedThreadPoolornewCachedThreadPoolin service code;new Threadper request.submit(...)with theFutureignored.supplyAsync(...)without an executor for blocking work;join()inside a callback.ThreadLocal.setwithoutremove()infinally.Thread.sleepused to wait for another thread, in code or in tests.synchronizedused to prevent duplicate processing across pods.
Interview drill
Forty-three questions SDE-2 interviews ask, with short model answers. Answer out loud first, in under a minute each, then reveal. Each answer links back to its module.
How to answer concurrency questions
- Name the shared state and who reads and writes it.
- Name the failure: atomicity, visibility, ordering, deadlock, starvation, overload.
- Pick the smallest tool that fixes it, and say what it costs.
- Mention production: pools, timeouts, metrics, what happens with several pods.
An SDE-2 answer goes beyond "use synchronized": it explains why, what it costs under load, and how you would test and observe it.
Fundamentals
- Concurrency vs parallelism?
Reveal
Concurrency: tasks in progress during the same period, interleaved. Parallelism: tasks executing at the same instant on different cores. (M1)
- Process vs thread?
Reveal
A process has its own memory; threads inside it share the heap but each has its own stack and program counter. (M1)
start()vsrun()?Reveal
start()creates a new thread that callsrun(); callingrun()directly runs it on the current thread. (M1)- Name the six thread states.
Reveal
NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. (M1)
- What is a race condition? Give two shapes.
Reveal
A bug whose outcome depends on timing. Read-modify-write (
count++) and check-then-act (if absent then put). (M2) - What does thread-safe mean?
Reveal
Correct behaviour under any interleaving from many threads, without extra synchronization by callers; invariants always hold. (M2)
- Three ways to make shared state safe?
Reveal
Do not share (confinement), do not mutate (immutability), or coordinate (locks, atomics, concurrent collections). (M2)
- Why are immutable objects thread-safe?
Reveal
Their state cannot change, and final fields are guaranteed visible after construction, so there is nothing to race on. (M2)
Locks and the memory model
- What two guarantees does
synchronizedgive?Reveal
Mutual exclusion on the same monitor, and visibility: writes before an unlock are visible to the next thread that locks the same monitor. (M3)
- What is reentrancy and why does it matter?
Reveal
A thread can re-acquire a lock it already holds (hold count). Without it, a synchronized method calling another, or a subclass calling
super, would deadlock on itself. (M3) - What does
volatileguarantee? What not?Reveal
Every read sees the latest write, and a read that sees a volatile write also sees everything the writer did before it (which is why volatile flags and double-checked locking work). Volatile
long/doubleaccesses are atomic. Compound actions likecount++are still not atomic. (M4) - Explain happens-before.
Reveal
If A happens-before B, A's effects and everything before A are visible to B. Edges: program order, monitor unlock/lock, volatile write/read, start, join, plus
java.util.concurrentguarantees. (M4) - Why does double-checked locking need
volatile?Reveal
Without it another thread may see the non-null reference before the constructor's writes and use a half-built object. (M4)
- Why must
wait()be in a loop?Reveal
Spurious wakeups, a different thread may have consumed the condition, and
notifyAllwakes threads waiting for other conditions. (M5) notifyvsnotifyAll?Reveal
notifywakes one arbitrary waiter, which can be the wrong kind and stall the system;notifyAllwakes all to re-check. Usenotifyonly when all waiters wait for the same condition and each state change lets at most one of them proceed. (M5)- How should you handle
InterruptedException?Reveal
Propagate it, or restore the flag with
Thread.currentThread().interrupt()and exit cleanly. Never swallow it. (M5)
Executors and async
- How does
ThreadPoolExecutordecide where a task goes?Reveal
Core thread if below core size, else the queue, else an extra thread up to max, else the rejection handler. (M6)
- Why avoid
Executors.newFixedThreadPoolin services?Reveal
Its queue is unbounded, so overload turns into growing memory and latency instead of a visible rejection. (M6)
executevssubmitfor exceptions?Reveal
execute: the exception reaches the uncaught handler.submit: it is stored in theFutureand lost unlessget()is called. (M6)shutdownvsshutdownNow?Reveal
shutdownfinishes queued and running tasks;shutdownNowinterrupts running tasks and returns the queued ones. Neither waits; useawaitTermination. (M6)- How do you size a thread pool?
Reveal
CPU-bound: about the number of cores. I/O-bound: cores x utilization x (1 + wait/compute), then load test; also respect downstream limits such as the connection pool. (M6)
thenApplyvsthenComposevsthenCombine?Reveal
Map the value; chain a function that returns a future (flatten); combine two independent futures. (M7)
- Does
orTimeoutorcancelstop the underlying work?Reveal
For
CompletableFuture, no:orTimeout,completeOnTimeoutandcancelonly complete the future. For aFuturefromExecutorService.submit,cancel(true)interrupts the worker, which stops the task only if it responds to interrupts. Set client timeouts either way. (M7) - Why pass an executor to
supplyAsync?Reveal
The default common pool is small, shared JVM-wide and meant for CPU work; blocking calls starve it. (M7)
Atomics and collections
- What is CAS?
Reveal
Compare-and-set: atomically replace a value only if it still equals the expected one; on failure, retry with the fresh value. Lock-free and never blocks. (M8)
AtomicLongvsLongAdder?Reveal
LongAdderspreads contention over cells and is faster for hot counters, butsum()is not an exact snapshot and there is noincrementAndGet. (M8)- Why do two atomic variables not make an atomic operation?
Reveal
An invariant across them needs both changed together; separate atomics allow interleavings between the two updates. (M8)
- How does
ConcurrentHashMapachieve concurrency?Reveal
Lock-free reads; CAS into empty bins; a lock per non-empty bin for writes; cooperative resizing. (M9)
- Why no nulls in
ConcurrentHashMap?Reveal
getreturning null would be ambiguous (missing vs null value) and cannot be resolved atomically. (M9) - When is
CopyOnWriteArrayListright?Reveal
Small, read-mostly lists such as listeners; every write copies the array. (M9)
Liveness and production
- Four conditions for deadlock, and how to prevent it?
Reveal
Mutual exclusion, hold and wait, no preemption, circular wait. Global lock order,
tryLockwith timeout, open calls, fewer locks. (M11) - What is a thread starvation deadlock?
Reveal
Pool tasks block waiting for other tasks queued on the same bounded pool, so nothing can run them. (M11)
- Deadlock vs livelock vs starvation?
Reveal
Stuck waiting / busy but no progress / some threads never get their turn. (M11)
- What is the danger of
ThreadLocalwith thread pools?Reveal
Values survive into the next task on the same thread: data leaks between requests and memory retention. Always
remove()infinally. (M12) - When do virtual threads help?
Reveal
High concurrency of blocking, I/O-bound work. Not for CPU-bound work. Limit scarce resources with semaphores and pool sizing. (M13)
- What is pinning?
Reveal
A virtual thread that cannot unmount while blocked, holding its carrier: inside
synchronizedbefore Java 24, or in native frames. (M13) - Why does
synchronizednot prevent double processing across pods?Reveal
Each pod is a separate JVM with its own locks. Use the database: conditional updates, unique idempotency keys, row locks. (M14)
- How would you debug a service that stopped responding?
Reveal
Take several thread dumps; look for deadlocks, many BLOCKED on one lock, threads parked in
getConnectionorFuture.get, or stuck socket reads; check pool metrics. (For virtual threads, useThread.dump_to_file.) (M15)
Design questions
- Design a thread-safe bounded blocking queue.
Reveal
One lock, two conditions (
notFull,notEmpty), waits inwhileloops, signal the other condition after each change, unlock infinally; offer timed variants. (Lab 2) - Design a thread-safe cache that loads each key once.
Reveal
ConcurrentHashMap<K, CompletableFuture<V>>withcomputeIfAbsentstoring a future started on an executor; remove failed futures; add size and TTL limits (Caffeine). (Lab 4) - Design a rate limiter for one JVM, then for a cluster.
- Design an in-process job runner with timeouts, retries and graceful shutdown.
Reveal
A bounded pool that rejects when full; one scheduled thread for per-attempt watchdogs (
cancel(true)) and backoff with jitter, so workers never sleep; a status map and counters; shutdown in order: refuse work, drain, stop the timer, mark leftovers. Jobs idempotent, state in the database if it must survive restarts. (Lab 10) - Call three services in parallel with a deadline.
Reveal
Start all with
supplyAsyncon a bounded executor, per-callcompleteOnTimeoutorexceptionallyfor optional parts,allOf, an overallorTimeout, client timeouts, and bulkheads. (Lab 5)
Glossary
Every term in the handbook in one or two plain sentences, with the module or lab that explains it.
Terms
- ABA problem
- A CAS succeeds because a value changed and changed back;
AtomicStampedReferencedetects it. M8 - AQS (AbstractQueuedSynchronizer)
- The JDK framework under
ReentrantLock,SemaphoreandCountDownLatch: one atomic state number plus a queue of parked threads. M10 - Atomicity
- A multi-step action happens as one indivisible step, so no other thread sees it half done. M2
- Backpressure
- Letting a slow consumer slow down a fast producer instead of buffering without limit. M14
- BLOCKED
- Thread state while waiting to enter a
synchronizedblock held by another thread. M1 - BlockingQueue
- A queue whose
putwaits when full andtakewaits when empty; the backbone of producer-consumer. M9 - Bulkhead
- A separate limit per dependency so one slow dependency cannot use every thread. M14
- Carrier thread
- A platform thread that runs mounted virtual threads. M13
- CAS (compare-and-set)
- Set a variable to a new value only if it still holds the expected value, atomically. M8
- Check-then-act
- Acting on a condition that may have changed since it was checked. M2
- Circuit breaker
- Stops calling a failing dependency for a while and fails fast instead. M14
- CompletableFuture
- A future you can chain, combine, complete manually and time out. M7
- CompletionService
- Wraps an executor and hands back futures in the order they finish, not the order they were submitted. M7
- Condition
- A named wait set tied to a
Lock, withawaitandsignal. M10 - Context propagation
- Copying per-thread context (trace ID, user) from the submitting thread to the thread that runs the task, and clearing it afterwards. M12
- Context switch
- The OS saving one thread's state and loading another's on a core. M1
- CountDownLatch
- A one-shot gate that opens when a counter reaches zero. M10
- Critical section
- Code that touches shared state and must not run in two threads at once. M2
- CyclicBarrier
- A reusable meeting point where N threads wait for each other. M10
- Daemon thread
- A background thread that does not keep the JVM alive. M1
- Data race
- Two accesses to one variable, at least one a write, not ordered by happens-before. M4
- Deadlock
- A cycle of threads each waiting for a resource another holds; none can proceed. M11
- Distributed deadlock
- Services that call each other synchronously until every thread waits on the other side; no lock involved. M11
- DRF-SC
- Data-race-free programs behave as if sequentially consistent: remove every data race and reordering can no longer surprise you. M4
- Effectively immutable
- An object that could be changed but never is after it is safely published; safe to share without locks. M2
- Exchanger
- A meeting point where two threads swap objects. M10
- Executor / ExecutorService
- Interfaces for running tasks; the service adds futures and lifecycle. M6
- Fairness
- Granting a lock to the longest-waiting thread; costs throughput. M10
- False sharing
- Two threads writing different variables on the same CPU cache line, slowing each other down. M8
- Final field guarantee
- After construction, every thread sees the constructor's values of
finalfields (and of objects reached through them), even without synchronization, ifthisdid not escape. It is not a happens-before edge and does not cover non-final fields. M4 - ForkJoinPool
- A work-stealing pool for divide-and-conquer tasks; also the common pool. M13
- Future
- A handle to a result that will be available later. M7
- Happens-before
- The JMM relation that guarantees one action's effects are visible to another. M4
- Idempotent consumer
- A message handler that gives the same result when the same message arrives twice, usually by recording processed IDs. M14
- Immutable object
- An object whose state cannot change after construction. M2
- Interruption
- A cooperative request to a thread to stop what it is doing. M5
- Intrinsic lock (monitor)
- The lock built into every object, used by
synchronized. M3 - Invariant
- A rule about state that must always hold, such as
lower <= upper. M2 - Java Memory Model (JMM)
- The rules for when writes by one thread are visible to another. M4
- Little's law
- Items in flight = arrival rate x time per item. M6
- Livelock
- Threads keep reacting to each other but make no progress. M11
- Lock ordering
- Always acquiring several locks in one global order to prevent deadlock. M11
- LongAdder
- A counter that spreads updates over cells for low contention. M8
- MDC
- Mapped Diagnostic Context: per-thread logging fields such as
traceId. M12 - Open call
- Calling outside code without holding any lock. M11
- Optimistic locking
- Update only if a version is unchanged, retry on conflict; CAS in a database. M14
- Phaser
- A reusable barrier where parties can register and deregister at any time. M10
- Pinning
- A virtual thread that cannot unmount while blocked, holding its carrier. M13
- Platform thread
- A Java thread backed one-to-one by an OS thread. M1
- Poison pill
- A special item that tells a queue consumer to stop. M9
- Race condition
- A bug whose outcome depends on the timing of threads. M2
- Read-modify-write
- Reading a value, computing from it and writing it back, such as
count++. M2 - Reentrancy
- A thread can re-acquire a lock it already holds. M3
- ReentrantLock
- An explicit lock with timeouts, interruptible waits, fairness and conditions. M10
- Rejection policy
- What a pool does with a task when its queue is full and it is at max size, or when the pool has been shut down. M6
- Safe publication
- Making an object visible to other threads with a happens-before edge. M2
- ScopedValue
- An immutable value bound for the duration of a call (final in Java 25). M12
- Semaphore
- A set of permits limiting how many threads do something at once. M10
- Shutdown hook
- A thread the JVM runs when it is asked to exit, used for last cleanup. M1
- Spin lock
- A lock that loops on CAS instead of parking; only sensible for very short waits. M8
- Spurious wakeup
wait()returning without a notify; the reason waits sit in loops. M5- Starvation
- A thread that rarely or never gets the resource it needs. M11
- Thread confinement
- Keeping an object reachable from only one thread. M2
- Thread dump
- A snapshot of every thread's state, stack and locks. M15
- ThreadLocal
- A variable with one value per thread. M12
- ThreadPoolExecutor
- The configurable pool behind most executors: core, max, queue, policy. M6
- Token bucket
- A rate limiter that adds tokens at a fixed rate up to a capacity; each call takes one. L9
- Uncaught exception handler
- Code that runs when a thread dies from an exception nobody caught. M1
- Virtual thread
- A cheap JVM-managed thread that unmounts from its carrier while blocked (Java 21). M13
- Visibility
- Whether and when one thread's write can be seen by another. M2
- volatile
- A field modifier giving visibility and ordering, not atomicity. M4
- Wait set
- Threads that called
wait()on a monitor and are waiting to be notified. M5 - Weakly consistent iterator
- Never throws
ConcurrentModificationException; may or may not show concurrent changes. M9 - Work stealing
- Idle workers take tasks from the other end of busy workers' deques. M13
Practice tracker
All 10 labs and 26 more exercises in one list. Set a status after each attempt; due items come back in the revision queue on the home page.
Your exercises
How to use exercises
Exercises have no write-up of their own: each one combines the modules listed under it. Write the code in your IDE with a timer, test it (Module 15's harness works for most), then reread the linked lessons to check your design, and set your status honestly.
| Status | When to choose it |
|---|---|
| Could not solve it | You needed the answer for the core of it |
| Solved with hints | You looked something up for the core of it |
| Solved independently | Working, tested code on your own |
| Needs revision | You did it before, but a redo went badly |
| Can teach it | Done twice, weeks apart, and you can answer every follow-up |
Backup
Back up or move your progress
Progress saves to your account automatically. Use this only to move progress by hand, for example from a private window.
SDE-2 readiness check
Your completion criteria, plus the checks that sit behind them. Tick an item only when you can do it with notes closed. When every box is ticked, concurrency is covered.