Module 1: Threads and tasks
A thread is an independent path of execution inside your program. This module covers what threads are, how they differ from processes, how to create and start them, the six states a thread moves through, and how to wait for one to finish.
- One CPU core runs two threads. Are they running concurrently? In parallel?
Reveal
Concurrently, yes: the core switches between them, so both make progress over time. In parallel, no: parallel means executing at the same instant, which needs two or more cores.
Thread t = new Thread(task); t.run();Which thread executestask?Reveal
The calling thread (for example
main).run()is an ordinary method call. Onlystart()creates a new thread.- You call
t.start()twice on the same thread object. What happens?Reveal
The second call throws
IllegalThreadStateException. A thread runs once. To run the task again, create a new thread or submit it to a pool. - A thread is waiting to enter a
synchronizedblock that another thread holds. What doesgetState()return?Reveal
BLOCKED.WAITINGis forwait(),join()and parking;TIMED_WAITINGis the same with a timeout, such assleep(100). main()returns while another (non-daemon) thread is still running. Does the JVM exit?Reveal
No. The JVM exits when every non-daemon thread has finished (or someone calls
System.exit). Daemon threads do not keep it alive.
1.1Concurrency vs parallelism
A server with 4 cores handles 1,000 requests at once. Each request spends most of its time waiting for the database. Is that concurrency, parallelism, or both?
Reveal the answer
Both. At most 4 requests execute at the same instant (one per core): that is parallelism. All 1,000 are in progress at the same time, and while one waits for the database a core runs another: that is concurrency.
Concurrency means many tasks are in progress during the same period; their steps interleave. Parallelism means many tasks execute at the same instant on different cores.
One cook making three dishes, switching to the next dish while one simmers, is concurrency. Three cooks each making one dish is parallelism. A kitchen with three cooks juggling ten dishes has both.
Most backend work waits on networks and disks. Concurrency keeps the CPU busy during those waits. Parallelism speeds up CPU-heavy work by using every core.
Why programs use threads:
- Responsiveness: a slow task (a file upload, a report) runs in the background while the main flow continues.
- Throughput for waiting work: while one request waits 50 ms for the database, the CPU serves others.
- Speed for CPU work: split a large computation across all cores.
The cost is that threads in one program share memory, and shared memory is where every concurrency bug in this handbook comes from.
1.2Processes and threads
Two threads inside one JVM each hold a reference to the same ArrayList. Two separate JVM processes each create an ArrayList. Which pair can see each other's changes without extra work?
Reveal the answer
The two threads. Threads of one process share the same heap, so both can reach the same object. Two processes have separate memory and must communicate through something outside (a socket, a file, a database, Kafka).
A process is a running program with its own memory. A thread is a path of execution inside a process. All threads of a process share its heap, but each thread has its own stack (method calls and local variables) and its own position in the code.
A process is a restaurant kitchen; threads are the cooks in it. The cooks share the fridge and the pans (the heap). Each cook has a personal notepad saying which step of which recipe they are on (the stack).
Sharing makes threads cheap to communicate: just read the same object. It also makes them dangerous: two cooks can grab the same pan at once.
Class object (here Order.class); metaspace holds only class metadata.| Process | Thread | |
|---|---|---|
| Memory | Its own address space | Shares the heap with the other threads of its process |
| Cost to create | High (new address space) | Lower, but a Java platform thread is still a real OS thread with its own stack (default reserve 1 MB on x64 Linux, about 2 MB on ARM64 Linux; reserved address space, committed as used) |
| Communication | Sockets, pipes, files, databases | Reading and writing shared objects |
| Failure | A crash stays inside the process | An uncaught exception ends only that thread, but OutOfMemoryError or a corrupted shared object affects them all |
Every new Thread(...) in Java creates a platform thread: a thin wrapper around one OS thread. Java 21 added virtual threads, which are much cheaper (Module 13). The rules for races, locks, the memory model and interruption apply to both; daemon status, priorities, stack sizing, the state shown during I/O and pinning differ, and Module 13 covers those.
The single question behind most of this handbook: can this object be reached by more than one thread, and can any of them change it? Locals are safe by construction. Shared objects need a plan.
1.3Creating and starting a thread
What does this print, and in which order?
java
Thread t = new Thread(() -> System.out.println("hello from " + Thread.currentThread().getName()), "worker");
t.run();
t.start();
Reveal the answer
hello from main, then hello from worker. run() runs the lambda on the calling thread (main). start() makes the JVM create a new thread, and that new thread calls run().
A thread needs a task (what to run) and a thread (who runs it). Give the task as a Runnable, usually a lambda:
public class StartVsRun {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> System.out.println("running on " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker"); // NEW: nothing runs yet
task.run(); // runs on main, like any method call
t.start(); // a new thread starts and calls task.run()
t.join(); // wait for it to finish (lesson 1.5)
// Java 21 builder style, same result
Thread t2 = Thread.ofPlatform().name("worker-2").start(task);
t2.join();
}
}running on main
running on worker
running on worker-2run() is a plain method call. start() creates a new thread with its own stack, returns at once, and the two threads continue independently.Rules that catch people:
- Call
start(), notrun(). Callingrun()compiles and works, but nothing is concurrent. start()once per thread object. A second call throwsIllegalThreadStateException.- No order is guaranteed. After
start(), the scheduler decides who runs first. Never assume the new thread has done anything yet. - Name your threads (
new Thread(task, "payment-worker-1")). The name appears in logs and thread dumps, andThread-7tells you nothing at 3 a.m.
Two ways to supply the task: pass a Runnable (preferred, the task stays separate from the mechanism and can later move to a thread pool), or subclass Thread and override run() (couples the task to one thread and uses up your one superclass). In real services you rarely create threads by hand at all; you submit tasks to an executor (Module 6).
If run() throws, the thread dies and the exception is printed to stderr by the default handler. The thread that started it is not told. Set t.setUncaughtExceptionHandler(...) (or Thread.setDefaultUncaughtExceptionHandler) so failures reach your logs, or use an executor and check the Future (Module 6).
Example: never lose a thread's exception. Install a handler before start():
public class CrashHandler {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
throw new IllegalStateException("bad input in row 17");
}, "import-worker");
worker.setUncaughtExceptionHandler((t, e) ->
System.out.println(t.getName() + " failed: " + e)); // in a service: log it and raise an alert
worker.start();
worker.join();
System.out.println("main still running"); // other threads are unaffected
}
}1.4Thread states and lifecycle
Name the state getState() returns in each case:
(a) created, start() not called yet; (b) executing, or ready and waiting for a core; (c) waiting to enter a synchronized block another thread holds;
(d) inside Thread.sleep(1000); (e) inside other.join() with no timeout; (f) run() has returned.
Reveal the answer
(a) NEW (b) RUNNABLE (c) BLOCKED (d) TIMED_WAITING (e) WAITING (f) TERMINATED.
Thread.State. Step through it and say each transition out loud. One subtle path: a thread woken from wait() must re-acquire the lock, so it passes through BLOCKED before it is RUNNABLE again.| State | How a thread gets there | How it leaves |
|---|---|---|
NEW |
Created, start() not called |
start() |
RUNNABLE |
Running on a core, or ready and waiting for one. Java does not separate the two | Blocks, waits, sleeps, or finishes |
BLOCKED |
Trying to enter a synchronized block or method whose lock another thread holds |
The lock becomes free and this thread gets it |
WAITING |
Object.wait(), Thread.join(), LockSupport.park() (used inside ReentrantLock, BlockingQueue and friends), all without a timeout |
Notified, joined thread ends, unparked, or interrupted |
TIMED_WAITING |
Thread.sleep(ms), wait(ms), join(ms), parkNanos, poll(timeout) |
Time runs out, or the same events as WAITING |
TERMINATED |
run() returned or threw |
Never; a thread cannot be restarted |
A platform thread blocked on network or file I/O shows as RUNNABLE, not WAITING, because it is inside native code the JVM cannot see into. (A virtual thread blocked on a socket parks instead and shows WAITING.) In a thread dump, read the stack (for example sun.nio.ch.Net.poll or SocketInputStream.read) to know it is waiting for the network. Module 15 covers reading dumps.
public class StateDemo {
public static void main(String[] args) throws InterruptedException {
Thread sleeper = new Thread(() -> {
try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "sleeper");
System.out.println(sleeper.getState()); // NEW
sleeper.start();
Thread.sleep(100);
System.out.println(sleeper.getState()); // TIMED_WAITING (inside sleep)
Object lock = new Object();
Thread blocked = new Thread(() -> { synchronized (lock) { } }, "blocked");
synchronized (lock) { // main holds the lock
blocked.start();
Thread.sleep(100);
System.out.println(blocked.getState()); // BLOCKED (waiting for main's lock)
}
sleeper.join();
blocked.join();
System.out.println(sleeper.getState()); // TERMINATED
}
}The sleep(100) calls give the other thread time to reach its state. That is fine for a demo; never coordinate real code with sleeps.
1.5Waiting for threads: join and sleep
main starts three threads that each sum one third of an array, then immediately prints the total. What can go wrong, and what is the fix?
Reveal the answer
main may read the partial results before the workers finish, so the total is wrong (often 0). Call join() on each worker before reading. join() both waits for the thread to end and guarantees main sees everything the worker wrote (a happens-before edge, Module 4).
public class ParallelSum {
public static void main(String[] args) throws InterruptedException {
int[] data = new int[3_000_000];
java.util.Arrays.fill(data, 1);
int parts = 3;
long[] partial = new long[parts]; // each worker writes only its own slot
Thread[] workers = new Thread[parts];
int chunk = data.length / parts;
for (int p = 0; p < parts; p++) {
final int id = p;
workers[p] = new Thread(() -> {
long sum = 0; // local variable: private to this thread
for (int i = id * chunk; i < (id + 1) * chunk; i++) sum += data[i];
partial[id] = sum;
}, "summer-" + p);
workers[p].start();
}
for (Thread w : workers) w.join(); // wait for all three
long total = 0;
for (long s : partial) total += s;
System.out.println("total = " + total); // always 3000000
}
}join() turns "started" into "finished". main sits in WAITING until each worker has terminated, then reads their results safely.t.join()waits untiltterminates.t.join(500)waits at most 500 ms; afterwards checkt.isAlive().Thread.sleep(ms)pauses the current thread. It is a static method, soother.sleep(100)still sleeps the caller, notother.sleepdoes not release locks the thread holds. A thread sleeping insidesynchronizedkeeps everyone else out.- Both throw
InterruptedExceptionif the thread is interrupted while waiting (Module 5).
Sleeping to "give the other thread enough time" is a guess that fails under load. To wait for something, wait for that thing: join, a CountDownLatch (Module 10) or a Future (Module 7).
1.6Runnable vs Callable
You need a background task that returns a number and may throw IOException. Can a Runnable do it?
Reveal the answer
Not cleanly. run() returns void and cannot throw checked exceptions. Use a Callable<Integer>: call() returns a value and may throw any exception. Submit it to an ExecutorService and you get a Future<Integer> to collect the result or the exception.
Runnable |
Callable<V> |
|
|---|---|---|
| Method | void run() |
V call() throws Exception |
| Returns a value | No | Yes |
| Checked exceptions | Must be caught inside | Can be thrown; they come back wrapped in ExecutionException from Future.get() |
| Runs with | new Thread(r), executor.execute(r), executor.submit(r) |
executor.submit(c), invokeAll, or wrap it in a FutureTask for a plain thread |
ExecutorService pool = Executors.newFixedThreadPool(2);
Callable<Integer> countLines = () -> Files.readAllLines(Path.of("orders.csv")).size(); // may throw IOException
Future<Integer> result = pool.submit(countLines); // runs on a pool thread
// ... main does other work ...
int lines = result.get(); // waits if needed; an IOException arrives wrapped in ExecutionException
pool.shutdown();Both are functional interfaces, so lambdas work for either. submit() picks Callable when the lambda returns a value. Checked exceptions play no part in the choice: a lambda that returns nothing and throws a checked exception matches only Runnable and does not compile. Add return null; to make it a Callable.
Example: a Callable on a plain thread. FutureTask wraps a Callable so a Thread can run it and you can collect the result:
import java.util.concurrent.*;
public class CallableOnThread {
public static void main(String[] args) throws Exception {
Callable<Integer> countOrders = () -> { Thread.sleep(100); return 42; };
FutureTask<Integer> task = new FutureTask<>(countOrders); // a Runnable that also holds the result
new Thread(task, "counter").start();
System.out.println("doing other work...");
System.out.println("orders = " + task.get()); // waits for the result
}
}1.7Scheduling, context switches and daemon threads
An 8-core machine runs a CPU-heavy job split across 64 threads. Will it finish faster than with 8 threads?
Reveal the answer
Usually not, and often a little slower. Only 8 threads can run at once; the other 56 wait their turn, and the OS spends time switching between them (saving registers, refilling CPU caches). For pure CPU work, about one thread per core is the sweet spot. Waiting work is different: Module 6 sizes pools for it.
The OS scheduler decides which runnable thread gets a core and for how long (a time slice of a few milliseconds). When it swaps one thread for another it performs a context switch: save one thread's registers, load the other's, and run with caches that no longer hold the right data. One switch costs microseconds, and with thousands of busy threads those microseconds add up.
- Priorities and
Thread.yield()are hints. The OS may ignore them. Never use them to make code correct. - More threads than cores only helps when threads wait (I/O). While a thread waits, it costs memory but no CPU.
- Each platform thread reserves stack memory. Tens of thousands of them can exhaust memory and fail with
OutOfMemoryError: unable to create native thread.
Daemon threads. t.setDaemon(true) (before start()) marks a background thread. The JVM exits as soon as only daemon threads remain, and it abandons them mid-work: their finally blocks do not run. Use daemon threads only for work that may be dropped (a cache refresher), never for work that must finish (writing a file, committing a Kafka offset). A new thread copies the daemon status of the thread that creates it, so threads created from main are non-daemon. The fixed and cached pools from Executors also create non-daemon threads, which is why a program that forgets shutdown() never exits. ForkJoinPool threads (including the common pool behind CompletableFuture.runAsync) and virtual threads are always daemon, so work left on them is silently dropped when main returns.
In a Spring Boot service you almost never call new Thread. The web server owns the request threads, Kafka listeners own consumer threads, and your own background work goes to a configured, named, bounded executor (Modules 6 and 14). Knowing the thread model still matters: every bug in the later modules is about which thread touches which object.
finally blocks.public class DaemonDemo {
public static void main(String[] args) throws InterruptedException {
Thread refresher = new Thread(() -> {
while (true) { // runs forever
try { Thread.sleep(50); } catch (InterruptedException e) { return; }
}
}, "cache-refresher");
refresher.setDaemon(true); // must be set before start()
refresher.start();
Thread.sleep(120);
System.out.println("main done; the JVM exits although cache-refresher never ends");
}
}1.8Use cases: when you create threads yourself
A Spring Boot service must flush buffered audit events when the JVM stops. Where should that code run?
Reveal the answer
In a shutdown hook (Runtime.getRuntime().addShutdownHook(thread)), or in Spring's own shutdown callbacks (@PreDestroy, SmartLifecycle), which are built on one. Keep it quick: the hook runs while the JVM is going down.
One named thread owns the loop for the life of the app. Stop it with interrupt(); a platform thread blocked in a classic socket read ignores that, so also close the socket or set a read timeout (lesson 5.4).
Flush buffers or log a final line. Keep it short; it does not run on kill -9.
Give each thread its own slice, join, then combine (lesson 1.5).
Daemon if losing the work at exit is harmless.
Never new Thread per request; use a bounded pool or virtual threads.
Start many threads at the same instant (Module 15).
public class ShutdownHookDemo {
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() ->
System.out.println("flushing metrics before exit"), "shutdown-hook"));
System.out.println("app running");
} // main ends -> the JVM begins shutdown -> the hook runs
}1.9Common mistakes
Common mistakes
- Calling
run()instead ofstart(). It compiles, it works, and nothing runs concurrently. - Assuming start order is run order. The scheduler decides; code that depends on "thread 1 goes first" is broken.
- Sleeping to wait for another thread. Use
join, a latch or a future, which wait for the real event. - A new thread per task in a server. Thousands of threads exhaust memory. Use a bounded pool.
- Losing exceptions. An exception thrown in
run()kills the thread and nobody notices. Install an uncaught exception handler or use executors with futures. - Unnamed threads. A dump full of
Thread-12makes incidents slower to debug. - Daemon threads for work that must finish. The JVM drops them at exit without running
finally.
1.10Self-check
- What is the difference between
t.start()andt.run()?Reveal
start()creates a new thread that then callsrun(), and returns immediately.run()is a normal method call on the current thread. - From
RUNNABLE, which states can a thread move to?Reveal
BLOCKED(waiting for a monitor),WAITINGandTIMED_WAITING(wait, join, sleep, park), andTERMINATED(run finished). - A platform thread is blocked reading a socket that has no data. What state does a thread dump show?
Reveal
RUNNABLE. Native I/O is reported as runnable; the stack frames (socket read,Net.poll) show that it is waiting on the network. (A virtual thread would showWAITING.) - Why is about one thread per core right for CPU-bound work?
Reveal
Only one thread per core can execute at a time. Extra threads only add context switches and cache misses.
- Does
Thread.sleep()release locks the thread holds?Reveal
No.
sleepkeeps every lock. Onlywait()(on that monitor) andCondition.await()(on that lock) release a lock while waiting.
Key takeaways
- A thread is a path of execution. Threads share the heap; each has its own stack.
start()creates a thread;run()is just a method call.- Six states:
BLOCKEDwaits for a monitor;WAITINGandTIMED_WAITINGwait for an event or a time. - Wait for a thread with
join()(or a latch or future), never withsleep(). Runnablefor no result;Callablefor a result or a checked exception.- Name threads, handle uncaught exceptions, use daemon threads only for work that may be dropped. In services, use executors.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 2: Race conditions and thread safety
Threads share the heap. When two of them read and write the same data without coordination, the result depends on timing: a race condition. This module shows how races happen, names the three things that break (atomicity, visibility, ordering), and gives the three ways to make code thread-safe.
- Two threads each run
count++100,000 times on a sharedint. What is the final value?Reveal
Usually less than 200,000, and different on each run.
count++is three steps (read, add, write), and threads overwrite each other's updates. - Is a method that only uses local variables and its parameters thread-safe?
Reveal
Yes, as long as the objects passed in are not shared and modified by other threads. Locals live on the calling thread's own stack.
if (!map.containsKey(k)) map.put(k, v);on aConcurrentHashMap. Safe?Reveal
No. Each call is thread-safe, but the pair is a check-then-act: two threads can both see the key missing and both put. Use
putIfAbsentorcomputeIfAbsent, which do it in one atomic step.- Thread A sets a plain
boolean running = false. Thread B loopswhile (running) { }and never stops. Why?Reveal
Visibility. Without
volatileor a lock, B is not guaranteed to ever see A's write. The JIT compiler may read the field once and loop on the cached value forever. - Why is an immutable object always thread-safe?
Reveal
Its state cannot change after construction, so there is nothing for threads to race on. With
finalfields, Java also guarantees other threads see it fully built.
2.1Shared mutable state
Which of these can suffer a race condition?
(a) a StringBuilder created and used inside one method call; (b) a static int counter incremented by every request;
(c) a field of a Spring singleton bean, updated on every request; (d) an immutable record Money(long paise, String currency) shared by all threads.
Reveal the answer
(b) and (c). (a) never leaves one thread's stack. (d) cannot change. A race needs data that is shared, mutable, and accessed without coordination, all at once.
A race needs three ingredients at the same time: data that is shared between threads, data that is mutable, and access that is not coordinated. Remove any one and the race is gone.
Two people editing one paper document with pens. Give each a photocopy (do not share), laminate it (do not mutate), or pass a single pen around (coordinate).
Each fix has a price. Copies cost memory, immutability changes how you write updates, and coordination makes threads wait for each other.
2.2Race conditions and critical sections
count++ is one line of Java. How many separate steps does the CPU perform, and where can another thread cut in?
Reveal the answer
Three: read count into a register, add 1, write the result back. Another thread can run between any two of them. If it reads the same old value, one of the two increments is lost.
A race condition is a bug where the result depends on how the threads' steps happen to interleave. A critical section is a piece of code that touches shared state and must not be run by two threads at the same time.
public class RaceDemo {
static int count = 0; // shared and mutable
public static void main(String[] args) throws InterruptedException {
Runnable work = () -> {
for (int i = 0; i < 100_000; i++) count++; // read, add, write
};
Thread a = new Thread(work, "A"), b = new Thread(work, "B");
a.start(); b.start();
a.join(); b.join();
System.out.println("count = " + count + " (expected 200000)");
}
}count = 137512 (expected 200000)
count = 200000 (expected 200000)
count = 118904 (expected 200000)Note how the demo sometimes prints 200000. A race that did not show up in one run is still a race. Tests pass, then production loses money at peak traffic, when threads overlap more.
2.3Read-modify-write and check-then-act
Two threads call this for the same account at the same moment. The balance is 100 and each amount is 80. What is the worst outcome?
java
if (balance >= amount) {
balance -= amount;
approve();
}
Reveal the answer
Both threads check 100 >= 80, both pass, both subtract: the balance ends at -60 and two payments are approved. The check and the action must happen as one atomic step.
Almost every race is one of two shapes:
| Shape | Looks like | What goes wrong |
|---|---|---|
| Read-modify-write | count++, balance -= x, total = total + item |
Two threads read the same old value; one update is lost |
| Check-then-act | if (x == null) x = new X();, if (!map.containsKey(k)) map.put(k, v);, if (balance >= amt) debit(); |
The fact you checked is no longer true when you act on it |
The fix is always the same idea: make the whole compound action atomic. Hold one lock around both the check and the act (Module 3), or use one operation that does both, such as AtomicInteger.incrementAndGet(), map.putIfAbsent(k, v) or compareAndSet (Modules 8 and 9).
In payment systems this race is a double spend. Inside one JVM a lock fixes it. With several pods behind a load balancer, JVM locks cannot help, because each pod has its own memory. Use the database, with a conditional update such as UPDATE account SET balance = balance - 80 WHERE id = ? AND balance >= 80 (check the updated row count), a row lock or a unique constraint, or make one owner handle all writes for an account (for example, Kafka partitioned by account ID). Module 14 covers this.
Example: a check-then-act race you can reproduce every time. A slow constructor widens the window between the check and the act:
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class LazyInitRace {
static final AtomicInteger created = new AtomicInteger();
static class PartnerClient {
PartnerClient() { created.incrementAndGet(); sleep(50); } // slow, like opening connections
}
static PartnerClient instance; // plain shared field
static PartnerClient unsafeGet() {
if (instance == null) instance = new PartnerClient(); // check-then-act, no lock
return instance;
}
static synchronized PartnerClient safeGet() {
if (instance == null) instance = new PartnerClient(); // same code, under one lock
return instance;
}
static int run(Callable<PartnerClient> getter) throws Exception {
instance = null;
created.set(0);
ExecutorService pool = Executors.newFixedThreadPool(4);
CountDownLatch start = new CountDownLatch(1);
List<Future<PartnerClient>> calls = new ArrayList<>();
for (int i = 0; i < 4; i++) calls.add(pool.submit(() -> { start.await(); return getter.call(); }));
start.countDown(); // all four call at the same moment
for (Future<PartnerClient> f : calls) f.get();
pool.shutdown();
return created.get();
}
static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
public static void main(String[] args) throws Exception {
System.out.println("unsafe: clients created = " + run(LazyInitRace::unsafeGet)); // usually 4
System.out.println("safe: clients created = " + run(LazyInitRace::safeGet)); // always 1
}
}2.4Atomicity, visibility and ordering
Plain fields int data and boolean ready, both start at 0 and false.
java
// Thread A // Thread B
data = 42; while (!ready) { }
ready = true; System.out.println(data);
List everything that can go wrong.
Reveal the answer
B may loop forever because it never sees ready = true (visibility). B may see ready == true and still print 0, because the two writes can become visible in the other order (ordering). Without volatile or a lock, Java allows both.
Thread safety breaks in exactly three ways. Learn the names; the rest of the handbook maps tools onto them.
| Property | The question | Broken example | Fixed by |
|---|---|---|---|
| Atomicity | Does a multi-step action happen as one indivisible step? | count++ loses updates |
synchronized, locks, atomic classes |
| Visibility | Will another thread see my write, and when? | A stop flag the loop never notices | volatile, synchronized, locks, atomics, join |
| Ordering | Will others see my writes in the order I made them? | B sees ready == true but data == 0 |
volatile, synchronized, final fields |
Why can a write stay invisible? Hardware and compilers optimize aggressively, and those optimizations are legal for code that does not synchronize:
- Each core has a store buffer. A write can wait there briefly while other cores still read the old value, and writes can become visible to other cores in a different order. (The CPU caches themselves are kept consistent by hardware.)
- The JIT compiler can keep a field in a register, or hoist a read out of a loop:
while (!ready) {}becomesif (!ready) while (true) {}. This is what makes a write stay invisible forever. - The compiler and CPU can reorder independent statements, as long as the single thread that runs them cannot tell the difference.
volatile forbids both effects; it does not "flush to main memory". Module 4 gives the exact rules.class Worker implements Runnable {
private boolean stopped = false; // BUG: add volatile
public void stop() { stopped = true; } // called from another thread
@Override public void run() {
while (!stopped) { // may be compiled to an endless loop
doWork();
}
}
private void doWork() { }
}On a typical HotSpot JVM this loop really does run forever once it is JIT-compiled and doWork() is inlined. Marking the field volatile fixes visibility. Module 4 explains why, and Module 5 shows the better tool for stopping threads: interruption.
2.5What thread-safe means
Vector and Collections.synchronizedList make every single method synchronized. Is if (!list.contains(x)) list.add(x); thread-safe on them?
Reveal the answer
No. Each call is atomic on its own, but another thread can add x between contains and add. Thread-safe parts do not make a thread-safe whole. You must hold the list's lock around both calls, or use a structure with an atomic "add if absent" (ConcurrentHashMap.newKeySet().add(x) returns false when present).
A class is thread-safe if it behaves correctly when used from many threads at once, under any scheduling, without the callers adding their own synchronization. "Correctly" means it never breaks its invariants: the rules that must always hold, such as lower <= upper, "size equals the number of elements", or "total debits equal total credits".
Classes fall into four groups. Know which group the classes you use belong to:
| Group | Meaning | Examples |
|---|---|---|
| Immutable | State never changes; always safe | String, Integer, BigDecimal, LocalDate, List.of(...), records of immutable fields |
| Thread-safe | Safe for concurrent use; compound actions provided as single methods | ConcurrentHashMap, AtomicLong, LinkedBlockingQueue, DateTimeFormatter |
| Conditionally thread-safe | Single calls are safe; some sequences need the caller to lock | Collections.synchronizedList (iteration must lock the list), Vector, Hashtable |
| Not thread-safe | Needs external locking or confinement to one thread | ArrayList, HashMap, HashSet, StringBuilder, SimpleDateFormat |
Common swaps:
| Not thread-safe | Use instead |
|---|---|
HashMap, HashSet shared by threads |
ConcurrentHashMap, ConcurrentHashMap.newKeySet() |
ArrayList read by many, written rarely |
CopyOnWriteArrayList, or an immutable List.copyOf swapped by reference |
SimpleDateFormat field |
DateTimeFormatter (immutable) |
long counter field |
AtomicLong, or LongAdder for hot counters |
A shared Random |
ThreadLocalRandom.current() (Random is thread-safe but slows down under contention) |
Write down the thread-safety of every class you design that might be shared: "Thread-safe", "Not thread-safe", or "Immutable", plus which lock guards which field (a // guarded by lock comment). Most real bugs start with someone assuming the wrong group.
2.6Strategy 1: do not share
A request handler builds a report in a local StringBuilder and returns the string. Does it need a lock?
Reveal the answer
No. Every request has its own call, so every call has its own StringBuilder on its own stack. Nothing is shared. This is called stack confinement.
The cheapest synchronization is none. Keep data reachable from one thread only:
- Stateless objects. A class with no fields (or only
finalfields pointing at thread-safe collaborators) is thread-safe automatically. Most Spring@Serviceclasses should look like this. - Stack confinement. Locals and parameters belong to the call. Build results in locals and return them.
- Thread confinement. Only one thread ever touches the object, by design: the single consumer thread that owns its state, a UI event thread, one Kafka consumer per thread (Module 14).
- Hand-off. Thread A builds an object, puts it on a
BlockingQueue, and never touches it again; thread B takes it and owns it now. The queue makes the hand-off safe. ThreadLocalgives each thread its own copy (Module 12), with traps in thread pools.
Shared field: races
@Service
class ReportService {
private final StringBuilder sb = new StringBuilder(); // one per bean, shared!
String build(Order o) {
sb.setLength(0);
sb.append(o.id()).append(',').append(o.total());
return sb.toString(); // threads overwrite each other
}
}Local variable: confined
@Service
class ReportService {
String build(Order o) {
StringBuilder sb = new StringBuilder(); // one per call
sb.append(o.id()).append(',').append(o.total());
return sb.toString();
}
}Example: one owner thread. Give a non-thread-safe HashMap to a single thread and let every other thread send it work. No locks, and still correct, because only the owner ever touches the map:
import java.util.*;
import java.util.concurrent.*;
public class OwnerThread {
private final Map<String, Integer> counts = new HashMap<>(); // touched ONLY by the owner thread
private final ExecutorService owner = Executors.newSingleThreadExecutor();
void record(String merchant) { // any thread may call this
owner.execute(() -> counts.merge(merchant, 1, Integer::sum));
}
Future<Integer> countFor(String merchant) {
return owner.submit(() -> counts.getOrDefault(merchant, 0)); // reads also run on the owner
}
public static void main(String[] args) throws Exception {
OwnerThread stats = new OwnerThread();
ExecutorService callers = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4_000; i++) callers.execute(() -> stats.record("amazon"));
callers.shutdown();
callers.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("amazon = " + stats.countFor("amazon").get()); // always 4000
stats.owner.shutdown();
}
}This is the same idea as a Kafka consumer owning its partition, or an actor owning its state: confine the data, pass messages.
2.7Strategy 2: do not mutate
If an object can never change, how does a program "update" it?
Reveal the answer
It creates a new object with the change and swaps the reference: price = price.plus(tax). If other threads read that reference, the swap itself must be visible to them, so the reference goes in a volatile field or an AtomicReference.
An immutable object cannot change after its constructor finishes, so any number of threads can read it with no locks. Rules for writing one:
- Make every field
private final. - Do not provide setters or any method that changes state.
- Prevent subclasses that could add mutable state: make the class
final(records are final already). - Make defensive copies of mutable inputs and never hand out your internal mutable objects.
final List<String> tagsis only a final reference; the list itself can still change. - Do not let
thisescape from the constructor (lesson 2.8).
Records give you points 1 to 3 for free. Point 4 is still your job:
public record Order(String id, long amountPaise, List<String> tags) {
public Order { // compact constructor
Objects.requireNonNull(id);
tags = List.copyOf(tags); // immutable copy: callers cannot change it later
}
public Order withAmount(long newAmount) { // "modify" by creating a new object
return new Order(id, newAmount, tags);
}
}A powerful pattern built on this: an immutable snapshot behind a volatile reference. Readers never lock; a writer builds a complete new snapshot and swaps it in with one write.
class FeeConfigHolder {
private volatile FeeConfig current = FeeConfig.defaults(); // immutable object
FeeConfig get() { return current; } // readers: no lock, always a complete config
void reload(FeeConfig fresh) { current = fresh; } // writer: build fully, then publish in one step
}
record FeeConfig(long flatFeePaise, int percentBps) {
static FeeConfig defaults() { return new FeeConfig(0, 0); }
}Make values immutable by default: money amounts, IDs, configuration, events, DTOs. Each immutable class is one less thing to lock, test and reason about.
Watch out: an unmodifiable view is not an immutable copy. Collections.unmodifiableList blocks writes through the view, but changes to the original still show through:
import java.util.*;
public class ViewVsCopy {
public static void main(String[] args) {
List<String> source = new ArrayList<>(List.of("visa", "rupay"));
List<String> view = Collections.unmodifiableList(source); // read-only VIEW of source
List<String> copy = List.copyOf(source); // real immutable COPY
source.add("mastercard"); // someone changes the original
System.out.println("view = " + view);
System.out.println("copy = " + copy);
}
}Effectively immutable objects are a useful middle ground: technically mutable (a Date, a plain list), but never changed after being safely published. They are safe to share as long as everyone keeps that promise, so document it.
2.8Publishing objects safely
What can go wrong here?
java
class Poller {
private final Queue<String> inbox;
Poller() {
new Thread(this::loop).start(); // starts before the constructor finishes
inbox = new ArrayDeque<>();
}
void loop() { inbox.poll(); /* ... */ }
}
Reveal the answer
The new thread can run loop() before inbox is assigned and hit a NullPointerException. this escaped from the constructor. Even with the lines swapped, starting threads in a constructor is fragile. Create the object first, then start it from a separate start() method or a factory.
Publishing an object means making it reachable by other threads: storing it in a shared field, a collection, or passing it to another thread. If you publish without synchronization, another thread may see the reference before it sees the fields the constructor wrote, and observe a half-built object.
Safe ways to publish (Module 4 shows why each one works):
- Initialize it in a
staticinitializer:static final Rates RATES = load(); - Store the reference in a
volatilefield or anAtomicReference. - Store it in a
finalfield of an object that was properly constructed. - Store it in a field that writer and readers access under the same lock.
- Put it into a thread-safe collection (
ConcurrentHashMap,BlockingQueue) that the other thread reads from. - Create it before
thread.start()orexecutor.submit(...); the started task sees everything done before.
Escaping this during construction breaks all of these: starting a thread in the constructor, registering this as a listener, or calling an overridable method from the constructor.
2.9Use cases: where races hide in real services
Name three places in a typical Spring Boot service where shared mutable state hides, even if nobody wrote new Thread.
Reveal the answer
Fields of singleton beans, static caches and counters, and lazily initialized clients. All request threads run through them at the same time.
Every request thread shares the bean; per-request data belongs in locals and parameters.
static Map m = new HashMap<>()ConcurrentHashMap + computeIfAbsentA plain HashMap under concurrent writes can lose entries or corrupt its structure.
Two threads create two clients (the demo above).
count++ loses updates under load.
SimpleDateFormat keeps parsing state in fields, so threads garble each other's dates.
The check is stale by the time you act; across pods only the database sees both.
if (!seen.contains(id))ConcurrentHashMap.newKeySet().add(id)add returns false for a duplicate, atomically.
Readers always see a complete config (lesson 2.7).
2.10Common mistakes
Common mistakes
- Assuming one line is one step.
count++,x += yandlist.add(...)on a plain list are all multi-step. - Combining thread-safe calls into an unsafe sequence:
containsKeythenput,size()thenget,getthenput. - Fixing writes but not reads. Reading a shared field without the lock or
volatilestill has visibility problems. - Trusting a green test. Races are timing-dependent; they hide in low-concurrency tests and show up under load.
- Mutable fields in singleton beans. Every request thread shares the same bean instance.
finalreference to a mutable object treated as immutable.final Liststill allowsadd.- Starting threads or registering listeners in a constructor, which lets
thisescape half-built.
2.11Self-check
- Name the three ingredients of a race and one way to remove each.
Reveal
Shared (confine to one thread), mutable (make immutable), uncoordinated (synchronize with locks, atomics or concurrent collections).
- What is the difference between read-modify-write and check-then-act? Give an example of each.
Reveal
Read-modify-write updates from a value that may be stale (
count++). Check-then-act acts on a condition that may no longer hold (if (!map.containsKey(k)) map.put(k, v)). - Define visibility in one sentence.
Reveal
Whether and when a write made by one thread can be seen by another thread; without synchronization there is no guarantee it ever will.
- Why is
if (!vector.contains(x)) vector.add(x)broken even thoughVectoris synchronized?Reveal
Each method is atomic but the pair is not; another thread can add
xbetween the two calls. - List three safe ways to publish an object.
Reveal
Any three of: static initializer,
volatileorAtomicReferencefield,finalfield of a properly constructed object, field guarded by a lock, thread-safe collection, creating it beforestart()orsubmit().
Key takeaways
- A race needs shared data, mutable data, and no coordination. Remove one and it is gone.
- Most races are read-modify-write or check-then-act: make the whole step atomic.
- Thread safety breaks through atomicity, visibility and ordering.
- Thread-safe parts do not make a thread-safe whole:
containsKeythenputis still a race. - Prefer, in order: do not share, do not mutate, then synchronize.
- Publish objects safely and never let
thisescape a constructor.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 3: synchronized and intrinsic locks
synchronized is Java's built-in lock. Only one thread at a time can run code guarded by a given object, and that thread's writes become visible to the next thread that takes the same lock. This module covers how to use it, which object to lock on, reentrancy, and keeping critical sections small.
- Two threads call a
synchronizedinstance method on two different objects. Do they block each other?Reveal
No. Each object has its own lock. Threads exclude each other only when they lock the same object.
- A
static synchronizedmethod and an instancesynchronizedmethod of the same class. Do they exclude each other?Reveal
No. The static method locks the
Classobject (Foo.class); the instance method locksthis. Different locks. - A
synchronizedmethod calls anothersynchronizedmethod on the same object. Does the thread deadlock with itself?Reveal
No. Intrinsic locks are reentrant: the thread that owns a lock can acquire it again.
- An exception is thrown inside a
synchronizedblock. Is the lock released?Reveal
Yes. The lock is released whenever the block exits, normally or by an exception.
- The setter is
synchronized, the getter is not. Is the getter safe?Reveal
No. The reader takes no lock, so it has no visibility guarantee and may see stale values. Readers and writers must use the same lock (or, for a single independent field,
volatile).
3.1Locks and mutual exclusion
A shop has one fitting room and a single key kept at the counter. Map each part to Java: the fitting room, the key, the people waiting at the counter, taking and returning the key.
Reveal the answer
The fitting room is the critical section. The key is the lock (the object's monitor). People waiting at the counter are threads in BLOCKED state. Taking the key is acquiring the lock; returning it is releasing it, after which one waiting person gets it.
Every Java object has a built-in lock called its monitor (or intrinsic lock). synchronized (obj) { ... } acquires obj's monitor on entry and releases it on exit. While one thread holds it, every other thread trying to enter code synchronized on the same object waits in BLOCKED.
The fitting room key. Whoever holds it is inside; everyone else queues at the counter. The key is tied to one room: holding the key to room 1 says nothing about room 2.
Threads queue. A long critical section slows everyone, and taking two locks in different orders can deadlock (Module 11).
BLOCKED). The wait set holds threads that called wait() on this object; they come back in Module 5.3.2synchronized methods and blocks
Rewrite public synchronized void add(int x) { total += x; } as a synchronized block. Then do the same for a static synchronized method.
Reveal the answer
public void add(int x) { synchronized (this) { total += x; } }. For a static method of class Counter: synchronized (Counter.class) { ... }.
| You write | Lock used |
|---|---|
synchronized void m() (instance method) |
this |
static synchronized void m() |
the class object, for example Counter.class |
synchronized (obj) { ... } |
obj, whatever object you name |
Blocks are more flexible: they lock only the lines that need it and let you choose a private lock object (next lesson).
public class SafeCounter {
private int count = 0; // guarded by "this"
public synchronized void increment() { count++; } // read-add-write as one unit
public synchronized int get() { return count; } // readers lock too (visibility)
public static void main(String[] args) throws InterruptedException {
SafeCounter c = new SafeCounter();
Runnable work = () -> { for (int i = 0; i < 100_000; i++) c.increment(); };
Thread a = new Thread(work), b = new Thread(work);
a.start(); b.start();
a.join(); b.join();
System.out.println("count = " + c.get()); // always 200000
}
}3.3Choosing what to lock on
Find the bug:
java
class Inventory {
private final Map<String, Integer> stock = new HashMap<>();
public synchronized void add(String sku, int n) { stock.merge(sku, n, Integer::sum); }
public int get(String sku) { synchronized (stock) { return stock.getOrDefault(sku, 0); } }
}
Reveal the answer
add locks this, get locks stock. Two different locks do not exclude each other, so get can run in the middle of add on a HashMap that is being modified, and it has no visibility guarantee. Every access to stock must use the same lock.
The rule that prevents most locking bugs:
For each piece of shared mutable state, choose one lock, and hold it for every read and write of that state. If an invariant links several fields, the same lock guards all of them.
public class Wallet {
private final Object lock = new Object(); // private: no outside code can lock it
private long balancePaise; // guarded by lock
private final List<String> history = new ArrayList<>(); // guarded by lock (same invariant)
public boolean debit(long amount, String ref) {
synchronized (lock) { // check and act under one lock
if (balancePaise < amount) return false;
balancePaise -= amount;
history.add("debit " + amount + " " + ref);
return true;
}
}
public long balance() {
synchronized (lock) { return balancePaise; }
}
}Why a private lock object instead of this? Any code holding a reference to your object can also write synchronized (wallet), and then it can block your methods or join a deadlock you never planned for. A private final lock is yours alone.
Never lock on:
| Lock target | Why it is wrong |
|---|---|
A string literal, synchronized ("lock") |
Literals are shared across the whole JVM; unrelated code may lock the same string |
A boxed number, Integer, Long |
Small values are cached and shared, and count++ on an Integer field replaces the object, so the lock keeps changing. javac -Xlint:synchronization warns about value-based classes |
| A non-final field | If it is reassigned, threads lock different objects |
getClass() in a non-final class |
Subclass instances lock a different Class object |
3.4Reentrancy
synchronized void deposit() calls log(), which is also synchronized on the same object. Does the thread block on the lock it already holds?
Reveal the answer
No. Intrinsic locks are reentrant: the JVM records which thread owns the lock and a hold count. Entering again increments the count, each exit decrements it, and the lock is released when the count returns to zero.
class Account {
public synchronized void deposit(long amount) {
// hold count = 1
audit("deposit " + amount); // re-enters the same lock: hold count = 2, then back to 1
}
protected synchronized void audit(String msg) { /* ... */ }
}
class CheckedAccount extends Account {
@Override
public synchronized void deposit(long amount) { // locks the same object as super
if (amount <= 0) throw new IllegalArgumentException();
super.deposit(amount); // without reentrancy, this call would deadlock on itself
}
}Reentrancy is per thread, not per call: a different thread still blocks. ReentrantLock (Module 10) has the same behaviour, which is where its name comes from.
3.5What synchronized guarantees
A writer updates fields x and y inside synchronized (lock). A reader reads them without any lock. What is the reader guaranteed to see?
Reveal the answer
Nothing. The visibility guarantee only covers threads that later acquire the same lock. The reader may see old values, or a new x with an old y.
synchronized gives two guarantees:
- Mutual exclusion. At most one thread at a time runs code synchronized on a given object.
- Visibility and ordering. Everything a thread wrote before releasing a lock is visible to the next thread that acquires the same lock. In Module 4 terms: an unlock happens-before every later lock of the same monitor.
What synchronized does not give you:
- Fairness. A released lock goes to any waiting thread, or to a newcomer that arrives at the right moment. No first-come, first-served promise.
- Timeouts. A thread waiting for a monitor waits forever; you cannot say "give up after 2 seconds".
- Interruptible waiting. Interrupting a thread that is
BLOCKEDon a monitor does not wake it. - Non-block structure. The lock is released at the end of the block that took it; you cannot lock in one method and unlock in another.
ReentrantLock adds all four (Module 10). Use synchronized by default; switch when you need one of them. One more reason on Java 21 to 23 with virtual threads: blocking inside synchronized (or in wait()) pins the carrier thread, so prefer ReentrantLock where a critical section can block (Module 13). Java 24 (JEP 491) removes that reason.
3.6Keeping critical sections small
A synchronized method fetches a price over HTTP (200 ms) and then stores it in a map. Traffic is 50 requests per second. What happens?
Reveal the answer
Only one request at a time can be inside the method, so throughput is capped near 5 per second (1 / 0.2 s). The other 45 per second pile up as BLOCKED threads and latency grows without limit. Make the HTTP call outside the lock and lock only the map update, or use a ConcurrentHashMap.
I/O inside the lock
public synchronized Price price(String sku) {
Price p = cache.get(sku);
if (p == null) {
p = pricingClient.fetch(sku); // 200 ms while holding the lock
cache.put(sku, p);
}
return p;
}Lock only the shared state
public Price price(String sku) {
synchronized (lock) {
Price p = cache.get(sku);
if (p != null) return p;
}
Price fresh = pricingClient.fetch(sku); // no lock held
synchronized (lock) {
cache.putIfAbsent(sku, fresh); // another thread may have won
return cache.get(sku);
}
}The fixed version lets two threads fetch the same SKU at once; it accepts a duplicate fetch in exchange for never blocking on I/O. Module 9 shows ConcurrentHashMap.computeIfAbsent, and Lab 4 builds a cache that also avoids duplicate fetches.
Rules for critical sections:
- Lock only the code that touches shared state. Compute, format and log outside.
- Never do I/O, network calls or
sleepwhile holding a lock. - Do not call unknown code while holding a lock: listeners, callbacks, overridable methods. You cannot know what locks they take or how long they run, which is how deadlocks start (Module 11).
- Copy, then work. Take a snapshot of the data under the lock, release it, process the snapshot.
- Split unrelated state across separate locks when one lock becomes a bottleneck (lock splitting);
ConcurrentHashMaptakes this to the extreme with a lock per bin.
An uncontended synchronized costs very little on a modern JVM. Contention is what costs: threads parked, woken, and context-switched. Do not avoid locks out of fear; keep them short and measure.
3.7Build it yourself: a thread-safe inventory
A card-printing service keeps blank card stock in memory. 500 requests try to reserve one blank each, but only 100 are in stock. Which operations must be atomic, and what lock guards them?
Reveal the answer
reserve must check the stock and decrement it as one step, and the audit log must change with it. One private lock guards both the stock map and the log; every read (available) takes the same lock.
A recipe you can apply to any class:
- List the shared state (
stock,log) and the invariant ("stock never goes negative; every change has a log entry"). - Pick one lock for everything in the invariant and write
// guarded by locknext to each field. - Make each compound action one critical section: check, update, log.
- Lock reads too.
- Test it under load with an invariant check.
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class Inventory {
private final Object lock = new Object();
private final Map<String, Integer> stock = new HashMap<>(); // guarded by lock
private final List<String> log = new ArrayList<>(); // guarded by lock (same invariant)
public void add(String sku, int qty) {
synchronized (lock) {
stock.merge(sku, qty, Integer::sum);
log.add("+" + qty + " " + sku);
}
}
/** Reserves qty only if enough is in stock: check and act under one lock. */
public boolean reserve(String sku, int qty) {
synchronized (lock) {
int have = stock.getOrDefault(sku, 0);
if (have < qty) return false;
stock.put(sku, have - qty);
log.add("-" + qty + " " + sku);
return true;
}
}
public int available(String sku) {
synchronized (lock) { return stock.getOrDefault(sku, 0); }
}
public static void main(String[] args) throws InterruptedException {
Inventory inv = new Inventory();
inv.add("card-blank", 100);
AtomicInteger reserved = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int i = 0; i < 500; i++) pool.execute(() -> { if (inv.reserve("card-blank", 1)) reserved.incrementAndGet(); });
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("reserved " + reserved.get() + ", left " + inv.available("card-blank")); // always 100 and 0
}
}3.8Use cases: where synchronized is the right tool
You need a small in-memory cache that evicts the least recently used entry. LinkedHashMap can do LRU. Why must even get() hold the lock?
Reveal the answer
With access order turned on, get() moves the entry to the end of the internal list, so it modifies the map. Two unlocked readers could corrupt it. Every method, reads included, takes the lock.
The inventory above: check, update and log together.
Even get() reorders entries, so every call locks.
Simple and correct; see Module 4 for the faster holder idiom.
The number and its log entry must always match.
One lock would make every request wait in line.
synchronized waits forever.
import java.util.*;
public class LruCache<K, V> {
private final Map<K, V> map; // guarded by this
public LruCache(int maxEntries) {
this.map = new LinkedHashMap<>(16, 0.75f, true) { // true = access order: get() moves the entry to the end
@Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > maxEntries; }
};
}
public synchronized V get(K key) { return map.get(key); } // get() changes the order, so it needs the lock too
public synchronized void put(K key, V value) { map.put(key, value); }
public synchronized int size() { return map.size(); }
public static void main(String[] args) {
LruCache<String, String> bins = new LruCache<>(2); // card BIN -> issuing bank
bins.put("411111", "HDFC");
bins.put("522222", "SBI");
bins.get("411111"); // touch: now the most recently used
bins.put("633333", "ICICI"); // evicts 522222, the least recently used
System.out.println(bins.get("522222") + " " + bins.get("411111") + " " + bins.get("633333"));
}
}For production caches with size limits, expiry and high concurrency, use Caffeine. A synchronized LinkedHashMap is fine for small, moderately used caches.
3.9Common mistakes
Common mistakes
- Locking on different objects for the same data, often
thisin one method and a field in another. - Locking writes but not reads. Unlocked reads can see stale or half-updated state.
- Assuming two synchronized instances share a lock. Each object has its own monitor.
- Locking on strings, boxed numbers or reassignable fields.
- Slow work under the lock: HTTP calls, database queries, file writes,
Thread.sleep. - Calling listeners or callbacks while holding a lock, which invites deadlocks.
- Exposing the lock through
synchronized (this)in a library class that callers can also lock.
3.10Self-check
- Which lock does a
static synchronizedmethod use?Reveal
The
Classobject of its class, for exampleCounter.class. - Name the two guarantees of
synchronized.Reveal
Mutual exclusion among threads using the same lock, and visibility: writes before an unlock are visible to the next thread that locks the same object.
- What state is a thread in while waiting to enter a synchronized block?
Reveal
BLOCKED, in the monitor's entry set. - Why prefer a
private final Object lockoversynchronized (this)?Reveal
Outside code cannot lock a private object, so it cannot block your methods or create unplanned deadlocks.
- Name two things
synchronizedcannot do thatReentrantLockcan.Reveal
Any two of: try with a timeout, be interrupted while waiting, fairness, lock and unlock in different methods, multiple conditions.
Key takeaways
- Every object has a monitor;
synchronizedlets one thread at a time hold it. - Instance methods lock
this, static methods lock theClass, blocks lock what you name. - Each piece of shared state has one guarding lock, used for every read and every write; variables in the same invariant share that lock.
- Locks are reentrant: the same thread can enter again; the hold count tracks it.
synchronizedgives mutual exclusion and visibility, but no timeouts, fairness or interruptible waits.- Keep critical sections short: no I/O, no sleeps, no calls to unknown code inside.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 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. This module explains happens-before, what volatile does and does not do, why reordering happens, and how to publish objects safely, including the double-checked locking trap.
- Does
volatilemakecount++atomic?Reveal
No. It makes each read and each write visible, but read-add-write is still three steps, so two threads can still read the same value and lose an update.
- Thread A writes
x = 1, then writesvolatile ready = true. Thread B readsreadyand seestrue. Is B guaranteed to seex == 1?Reveal
Yes. A volatile write happens-before every later read that sees it, and everything A did before the write comes with it.
- Code with no synchronization passes every test on your laptop. Is it correct?
Reveal
Not necessarily. x86 CPUs reorder very little, the JIT may not have optimized the code yet, and tests rarely overlap threads. The bug can appear on ARM servers or under load.
- Name two happens-before edges other than locks and
volatile.Reveal
Thread.start()(the new thread sees everything done beforestart) andThread.join()(the joiner sees everything the thread did). Also executorsubmit,Future.get, and queueput/take. - Why does a double-checked locking singleton need
volatile?Reveal
Without it, another thread can see the non-null reference before it sees the writes the constructor made, and use a half-initialized object.
4.1Why Java needs a memory model
Can the compiler or CPU execute a = 1; b = 2; as b = 2; a = 1;?
Reveal the answer
Yes, if no code in that thread can tell the difference. The single thread always sees its own program order. Other threads may observe the swap unless synchronization forbids it.
CPUs and compilers reorder and cache to go fast. Inside one thread, the result always looks as if the code ran in order. Between threads there are no guarantees unless you create them. The JMM defines exactly how you create them.
Treat each thread as if it worked from its own notebook (registers, store buffers, compiler reordering) and was only promised to sync with a shared whiteboard at certain points: taking or releasing a lock, reading or writing a volatile, starting or joining a thread. Between sync points, others may see stale notes, in any order. It is a model of the guarantees, not a picture of the hardware.
Sync points block some optimizations. The cost is small compared with the cost of a wrong answer, so get correctness first and measure later.
The JMM (Java Language Specification, chapter 17) does not talk about caches or CPUs. It defines one relation, happens-before, and promises: if you order your accesses with happens-before, your program behaves as if it ran in some simple interleaving of the threads' steps. If you do not, almost anything is allowed.
The most useful sentence in the JMM: a program with no data races behaves as if it were sequentially consistent, that is, as if the threads' steps simply took turns in some order. Remove every data race (every shared variable guarded by a lock, volatile, or another happens-before edge) and all the reordering tricks become invisible to you. This removes reordering surprises, not race conditions: a volatile counter has no data race and still loses updates on count++.
4.2Happens-before
Thread A runs config = load(); worker.start();. The worker reads config. Is it guaranteed to see the loaded value? What if A sets config after calling start()?
Reveal the answer
Before start(): yes, by the thread start rule. After start(): no guarantee, because nothing orders A's later write with the worker's read.
If action X happens-before Y, then X and everything before it in its thread are visible to Y and ordered before Y. Two accesses to the same variable, at least one a write, that are not ordered by happens-before form a data race, and the JMM promises very little about what such a read returns.
| Rule | Edge |
|---|---|
| Program order | Each action in a thread happens-before every later action in that thread |
| Monitor lock | Unlocking a monitor happens-before every later lock of the same monitor |
| Volatile | A write to a volatile field happens-before every later read of that field |
| Thread start | t.start() happens-before every action of t |
| Thread end | Every action of t happens-before another thread returns from t.join() |
| Interruption | t.interrupt() happens-before t notices it (InterruptedException, isInterrupted()) |
| Transitivity | If A happens-before B and B happens-before C, then A happens-before C |
java.util.concurrent adds edges you use every day (from its package documentation):
| Action in thread 1 | happens-before, in thread 2 |
|---|---|
Putting an object into a concurrent collection or BlockingQueue |
Reading or removing it |
Submitting a task to an Executor |
The task running |
| The task's work | Future.get() returning its result |
unlock(), release(), countDown() |
A later successful lock(), acquire(), await() on the same synchronizer |
4.3volatile: visibility and ordering
Fix the stop flag from Module 2, where while (!stopped) never noticed stopped = true.
Reveal the answer
Declare it private volatile boolean stopped;. Every read of a volatile field sees the latest write, so the loop notices the change.
A volatile field gives two guarantees:
- Visibility. A read always sees the most recent write to that field by any thread. The JIT may not cache it in a register.
- Ordering. Writes made before a volatile write cannot be moved after it, and reads made after a volatile read cannot be moved before it. So a volatile write publishes everything the thread did before it, to any thread that reads the new value.
public class VolatileStop {
private static volatile boolean stopped = false; // remove volatile and the loop may never end
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long spins = 0;
while (!stopped) spins++;
System.out.println("worker stopped after " + spins + " spins");
});
worker.start();
Thread.sleep(100);
stopped = true; // visible to the worker's next read
worker.join();
}
}Good uses of volatile:
- Status flags written by one thread and read by others:
stopped,shutdownRequested,healthy. - Publishing an immutable object by swapping one reference: the config holder in Module 2.
- One writer, many readers of a single value, such as a timestamp updated by one scheduler thread.
volatile long and volatile double also make reads and writes of 64-bit values atomic. Plain long and double writes may be split into two 32-bit halves by the JVM specification (in practice 64-bit JVMs do not split them, but the rule is the rule).
Example: a health flag. One scheduler thread writes, every request thread reads, and the new value never depends on the old one, so volatile is exactly enough:
class DownstreamHealth {
private volatile boolean healthy = true; // written by the checker, read by every request thread
void startChecker(ScheduledExecutorService scheduler, Supplier<Boolean> ping) {
scheduler.scheduleWithFixedDelay(() -> {
try { healthy = ping.get(); }
catch (RuntimeException e) { healthy = false; }
}, 0, 5, TimeUnit.SECONDS);
}
boolean isHealthy() { return healthy; } // no lock: one writer, value independent of the old one
}4.4Why volatile does not make count++ atomic
volatile int count; and two threads each run count++ 100,000 times. What is the result?
Reveal the answer
Still usually less than 200,000. Both threads see the latest value when they read, but both can read the same latest value before either writes back.
volatile fixes visibility, not atomicity. Nothing stops B from reading between A's read and A's write.volatile is enough only when all of these hold:
- Writes do not depend on the current value (
flag = true,config = newConfig), or only one thread ever writes. - The field is not part of an invariant with other fields (
low <= highneeds both updated together). - You need no other reason to lock.
Otherwise use an atomic class (Module 8) or a lock.
volatile |
synchronized / lock |
AtomicInteger and friends |
|
|---|---|---|---|
| Visibility | Yes | Yes | Yes |
| Atomic read-modify-write | No | Yes | Yes, for one variable |
| Several variables together | No | Yes | Only through AtomicReference to an immutable holder |
| Can block threads | No | Yes | No (retries instead) |
4.5Reordering, and why tested code can still be broken
x and y start at 0, both plain fields.
java
// Thread 1 // Thread 2
x = 1; y = 1;
int r1 = y; int r2 = x;
Can both r1 and r2 end up 0?
Reveal the answer
Yes. Each write can still sit in its core's store buffer when the other thread's read runs. This happens even on x86, and the JMM allows it for unsynchronized code. Make x and y volatile and the outcome r1 == 0 && r2 == 0 becomes impossible.
Why does broken code so often pass tests?
- x86 is forgiving. Intel and AMD CPUs keep most operations in order (they only let a load pass an earlier store). ARM CPUs, such as Apple Silicon and AWS Graviton, reorder more, so bugs appear when a service moves to ARM.
- The JIT changes the code under you. Code runs in the interpreter first; after a few thousand calls or loop iterations (often within milliseconds) the JIT compiles it and applies optimizations like hoisting a field read out of a loop. A test that finishes before that point never sees the optimized code.
- Tests overlap threads rarely. Production has many cores, real traffic and GC pauses that shuffle timing.
- Rare is common at scale. A race that fails once in a million calls fails every few minutes at 5,000 requests per second.
The JMM is the only thing you can rely on. "It works on my machine" proves nothing about concurrent code. If two threads touch the same variable and one writes, there must be a happens-before edge between them.
r1 == 0 && r2 == 0 happens even on x86. Each core's write waits in its store buffer while its next read goes ahead. With volatile fields the JVM adds the fences that forbid this outcome.4.6Safe publication and final fields
class Point { int x, y; Point(int x, int y) { this.x = x; this.y = y; } }. Thread A stores new Point(1, 2) into a plain static field; thread B reads the field and sees a non-null Point. Can B see x == 0?
Reveal the answer
Yes. With plain fields and no synchronization, B may see the reference before it sees the constructor's writes. If x and y are final, B is guaranteed to see 1 and 2, provided this did not escape during construction.
The final field guarantee (JLS 17.5): once a constructor finishes, any thread that gets a reference to the object, even through a data race, sees the values the constructor assigned to its final fields, and the state of objects reachable through them as of the end of the constructor (for example, the contents of an array assigned to a final field). Later changes to those reachable objects are not covered. This is why immutable classes with final fields are safe to share.
The safe publication idioms from Module 2, now with the rule that makes each one work:
| Idiom | Why it is safe |
|---|---|
static final Rates RATES = load(); |
Class initialization runs under a JVM lock; every thread using the class sees it done |
volatile field or AtomicReference |
Volatile rule |
final field of a properly built object |
Final field rule |
| Field read and written under the same lock | Monitor rule |
Put into a ConcurrentHashMap, BlockingQueue |
java.util.concurrent guarantee |
Create it before start() or submit() |
Thread start and executor rules |
4.7Double-checked locking
What can go wrong with this lazy singleton?
java
private static Registry instance;
static Registry get() {
if (instance == null) {
synchronized (Registry.class) {
if (instance == null) instance = new Registry();
}
}
return instance;
}
Reveal the answer
The first if reads instance without the lock. The write instance = new Registry() can become visible before the constructor's writes, so another thread can see a non-null reference to a half-built object and use it. Declaring instance as volatile fixes it.
// 1. Correct double-checked locking: volatile is essential
class Registry {
private static volatile Registry instance;
static Registry get() {
Registry r = instance; // one volatile read on the fast path
if (r == null) {
synchronized (Registry.class) {
r = instance;
if (r == null) instance = r = new Registry();
}
}
return r;
}
}
// 2. Simpler and lazy: the holder class idiom (the JVM initializes Holder once, on first use)
class Rates {
private Rates() { }
private static class Holder { static final Rates INSTANCE = new Rates(); }
static Rates get() { return Holder.INSTANCE; }
}
// 3. Simplest: an enum singleton, or plain eager initialization
enum Clock { INSTANCE }In Spring you rarely write singletons by hand: beans are singletons by default and the container publishes them safely. The pattern still matters for caches, lazily created clients and any field initialized on first use.
4.8Use cases: choosing the right visibility tool
A pricing service reloads its fee table from the database every minute. Request threads read the table thousands of times per second. What is the simplest safe design?
Reveal the answer
Build a new immutable fee table on the reloading thread and assign it to a volatile field. Readers take no lock and always see either the old complete table or the new complete one, never a half-filled one.
One writer, many readers, and the value does not depend on the old one.
Readers never lock and never see a half-built object.
The DownstreamHealth example above.
Lesson 4.7; the holder idiom is simpler.
volatile makes each access visible, not the pair atomic.
These tools carry the happens-before edge for you.
4.9Common mistakes
Common mistakes
- Using
volatilefor counters or check-then-act. It gives visibility, not atomicity. - Making only the writer synchronized. The reader needs the same lock, or the field must be volatile.
- Publishing through a plain field and hoping readers see a fully built object.
- Double-checked locking without
volatile. - Assuming x86 behaviour is Java behaviour. The JMM is weaker than any one CPU.
- Relying on
Thread.sleepfor visibility. Sleeping creates no happens-before edge.
4.10Self-check
- Define happens-before in one sentence.
Reveal
If X happens-before Y, then X's effects, and everything before X in its thread, are visible to Y and ordered before it.
- Which happens-before edge makes
Future.get()safe to read results from?Reveal
The
java.util.concurrentguarantee: actions taken by the task happen-beforeget()returns. - When is
volatilealone enough?Reveal
When writes do not depend on the current value (or there is one writer), the field is not in an invariant with others, and nothing else needs locking.
- Why does
r1 == 0 && r2 == 0happen in the store-load example?Reveal
Each write can still be in a store buffer when the other core reads, so both reads see the old 0. Volatile fields forbid that outcome.
- What does the final field guarantee promise?
Reveal
After construction, every thread sees the constructor's values of
finalfields (and of objects reachable through them, as of construction), even without synchronization, ifthisdid not escape.
Key takeaways
- The JMM promises nothing about unsynchronized shared variables: writes may be late, never seen, or seen out of order.
- Happens-before is the guarantee: unlock to lock, volatile write to read, start, join, and the
java.util.concurrenthand-offs. - Data-race-free programs behave as if their single reads and writes took turns, so remove every data race. Compound actions like
count++still need a lock or an atomic. volatilegives visibility and ordering for one field, never atomicity ofx++or check-then-act.finalfields are seen correctly after construction ifthisdid not escape.- Code that passes tests can still be wrong: x86, JIT timing and low overlap hide races.
Mastery checklist
Tick an item only when it is true with your notes closed.
Module 5: wait, notify and interruption
Threads often need to wait for something: an item in a queue, a free slot, a request to stop. This module covers wait and notify (the low-level way to wait for a condition), interruption (Java's way to ask a thread to stop), and how to handle InterruptedException correctly.
- You call
obj.wait()without holdingobj's lock. What happens?Reveal
IllegalMonitorStateException.wait,notifyandnotifyAllmust be called insidesynchronized (obj). - Why must
wait()sit inside awhileloop rather than anif?Reveal
The condition may be false when the thread wakes up: another thread took the item first,
notifyAllwoke everyone, or a spurious wakeup happened. The loop re-checks. - Does
wait()release the lock? Doessleep()?Reveal
wait()releases that object's lock while waiting and re-acquires it before returning.sleep()keeps every lock it holds. - What does
t.interrupt()actually do?Reveal
It sets
t's interrupted flag. Iftis blocked insleep,wait,joinor another interruptible call, that call throwsInterruptedException. It never forcibly stops the thread. - You catch
InterruptedExceptioninrun()and cannot rethrow it. What should you do?Reveal
Restore the flag with
Thread.currentThread().interrupt()and finish cleanly, so code higher up still sees that a stop was requested.
5.1Waiting for a condition
A consumer must wait until a queue has an item. Why is while (queue.isEmpty()) { } a bad way to wait?
Reveal the answer
It burns a whole core doing nothing (busy-waiting), and without synchronization it may never even see the producer's write. The thread should sleep until someone tells it the state changed.
obj.wait() puts the current thread to sleep in obj's wait set and releases obj's lock. obj.notify() wakes one waiting thread; obj.notifyAll() wakes all of them. A woken thread must re-acquire the lock before wait() returns.
You hand back the room key and sit in the waiting room. When the bell rings you queue for the key again, and once you have it you check whether your table is really ready before you walk in.
Easy to get subtly wrong. Real code should use BlockingQueue, CountDownLatch or Condition, which are built on the same idea. Learn this to understand those tools, and because interviews ask.
The four rules:
- Call
wait,notifyandnotifyAllonly while holding the lock of that same object. wait()releases the lock, waits, then re-acquires the lock before it returns.- Always wait in a loop that re-checks the condition:
while (!condition) obj.wait(); - Change the state and call
notifyAll()while holding the lock.
BLOCKED until the producer leaves.5.2Why the loop: spurious wakeups, stolen conditions, lost signals
Two consumers wait on an empty list. A producer adds one item and calls notifyAll(). The consumers use if (list.isEmpty()) wait(); instead of while. What happens?
Reveal the answer
Both wake. The first re-acquires the lock and removes the item. The second re-acquires the lock, does not re-check, and calls remove(0) on an empty list: IndexOutOfBoundsException. With while, it would see the list is empty and wait again.
When wait() returns, the condition may be false for three reasons:
- Stolen condition. Another thread got the lock first and used up the item.
- Wrong wake-up.
notifyAllwoke threads that wait for a different condition (producers and consumers share one wait set). - Spurious wakeup. The Java specification allows
wait()to return with no notify at all.
The while loop handles all three. The fourth trap is the lost notification: the producer notifies before the consumer starts waiting. If the consumer waits for "a notify" instead of checking the real state, it sleeps forever. That is why you always wait on a condition about shared state, checked under the same lock the notifier uses. Checking and starting to wait must be atomic with respect to the notifier, which is exactly why wait requires the lock.
if: breaks on any early wake
synchronized (lock) {
if (items.isEmpty()) lock.wait();
return items.remove(0); // may be empty
}while: always re-checks
synchronized (lock) {
while (items.isEmpty()) lock.wait();
return items.remove(0); // guaranteed non-empty
}5.3notify vs notifyAll, and a bounded buffer
A bounded buffer has producers waiting for "not full" and consumers waiting for "not empty", all in the same wait set. A consumer takes an item and calls notify(). What can go wrong?
Reveal the answer
notify() wakes one arbitrary waiter, possibly another consumer, which finds the buffer still empty and waits again, while the producer that could have run keeps sleeping. The system can stall. notifyAll() wakes everyone and each re-checks its own condition. Condition objects (Module 10) give producers and consumers separate wait sets.
import java.util.ArrayDeque;
import java.util.Deque;
public class BoundedBuffer<T> {
private final Deque<T> items = new ArrayDeque<>();
private final int capacity;
public BoundedBuffer(int capacity) { this.capacity = capacity; }
public synchronized void put(T item) throws InterruptedException {
while (items.size() == capacity) wait(); // wait for "not full"
items.addLast(item);
notifyAll(); // wake consumers (and anyone else)
}
public synchronized T take() throws InterruptedException {
while (items.isEmpty()) wait(); // wait for "not empty"
T item = items.removeFirst();
notifyAll(); // wake producers
return item;
}
public static void main(String[] args) throws InterruptedException {
BoundedBuffer<Integer> buf = new BoundedBuffer<>(5);
Thread producer = new Thread(() -> {
try { for (int i = 1; i <= 100; i++) buf.put(i); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread consumer = new Thread(() -> {
try {
long sum = 0;
for (int i = 0; i < 100; i++) sum += buf.take();
System.out.println("consumed 100 items, sum = " + sum);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
producer.start(); consumer.start();
producer.join(); consumer.join();
}
}Use notifyAll() by default. notify() is a valid optimization only when every waiter waits for the same condition and each state change lets at most one of them proceed (for example, identical worker threads waiting for "a job is available", where each put adds exactly one job).
In production code, use ArrayBlockingQueue instead of writing this class (Module 9). Lab 2 builds it three ways so you can compare.
5.4Interruption: asking a thread to stop
How do you stop a thread that is inside Thread.sleep(60_000)?
Reveal the answer
Call t.interrupt(). The sleep throws InterruptedException at once, and the thread's own code decides how to stop: clean up and return.
Interruption is a request, not a command. t.interrupt() sets a flag on t. Blocking methods check it and throw InterruptedException; running code checks it with Thread.currentThread().isInterrupted().
A tap on a colleague's shoulder. They finish the sentence, save their work, and stop. You do not pull their chair away.
It works only if the code cooperates. A loop that never blocks and never checks the flag ignores interruption completely.
| Call | What it does | Clears the flag? |
|---|---|---|
t.interrupt() |
Sets t's flag; wakes t if it is in an interruptible blocking call |
n/a |
t.isInterrupted() |
Reads t's flag |
No |
Thread.interrupted() (static) |
Reads the current thread's flag and clears it | Yes |
A blocking method throwing InterruptedException |
Signals the interrupt by exception | Yes, the flag is cleared when it is thrown |
Interruptible blocking calls include Thread.sleep, Object.wait, Thread.join, BlockingQueue.put and take, Future.get, CountDownLatch.await, Semaphore.acquire and Lock.lockInterruptibly. Not interruptible: waiting to enter a synchronized block, Lock.lock(), and classic java.net.Socket stream reads on platform threads (close the socket or set a read timeout to unblock those).
5.5Handling InterruptedException correctly
What is wrong with catch (InterruptedException e) { } inside a worker loop?
Reveal the answer
It swallows the stop request. The flag was cleared when the exception was thrown, so nothing else knows a stop was asked for. The loop keeps running and a shutdown that waits for this thread hangs.
Two correct choices:
- Propagate it. Declare
throws InterruptedExceptionand let the caller decide. Best for library and helper methods. - Restore the flag, then stop. Where you cannot throw (inside
Runnable.run), callThread.currentThread().interrupt()and exit cleanly.
Never swallow it, and never log it and carry on looping.
class OrderWorker implements Runnable {
private final BlockingQueue<String> queue;
OrderWorker(BlockingQueue<String> queue) { this.queue = queue; }
@Override public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
String orderId = queue.take(); // blocks; throws if interrupted
process(orderId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore, then fall through and exit
} finally {
System.out.println("worker stopped, resources released");
}
}
private void process(String orderId) { /* ... */ }
}If you wrap the exception, throw new RuntimeException(e), call Thread.currentThread().interrupt() first. Wrapping alone loses the flag.
5.6Cooperative cancellation, and why Thread.stop is gone
Thread.stop() has been deprecated since Java 1.2 and throws UnsupportedOperationException since Java 20. What made it so dangerous?
Reveal the answer
It threw an error into the target thread at whatever line it happened to be on. The thread then released all its locks in the middle of an update, leaving shared objects half-changed (half a funds transfer done) for every other thread to see.
Ways to stop work cleanly:
- Interruption (preferred). Works with blocking calls, and the standard tools use it:
ExecutorService.shutdownNow()andFuture.cancel(true)interrupt the running threads. - A
volatileflag for simple compute loops. It does not wake a thread that is blocked, so combine it with interruption or with timeouts. - Close the resource for blocking calls that ignore interrupts: closing a socket makes a blocked read throw.
- Timeouts on every blocking call (
poll(1, SECONDS), HTTP and JDBC timeouts), so no thread can wait forever.
public class CancelDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
int batches = 0;
try {
while (!Thread.currentThread().isInterrupted()) {
batches++;
Thread.sleep(50); // simulated work that blocks
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore
}
System.out.println("worker exiting cleanly after " + batches + " batches");
}, "batch-worker");
worker.start();
Thread.sleep(300);
worker.interrupt(); // ask, do not force
worker.join();
}
}Graceful shutdown of a service depends on this. On a Kubernetes SIGTERM, the JVM runs its shutdown hooks; Spring shuts down executors, which interrupts their workers. A task that swallows interrupts either dies mid-work when the JVM halts at the end of shutdown, or, if shutdown waits for executors to terminate, holds the pod until that wait or the grace period runs out and Kubernetes sends SIGKILL (Module 14).
Example: cancel a running task. Future.cancel(true) interrupts the pool thread running it; the task's own code decides how to stop:
import java.util.concurrent.*;
public class CancelFuture {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newSingleThreadExecutor();
CountDownLatch started = new CountDownLatch(1);
Future<?> report = pool.submit(() -> {
started.countDown();
try {
Thread.sleep(10_000); // a long report
} catch (InterruptedException e) {
System.out.println("report task saw the interrupt and stopped");
Thread.currentThread().interrupt();
}
});
started.await(); // make sure it is running
report.cancel(true); // true: interrupt the running thread
pool.shutdown();
pool.awaitTermination(1, TimeUnit.SECONDS);
System.out.println("cancelled = " + report.isCancelled());
}
}5.7Build it yourself: a latch with wait and notifyAll
Before reading the code: which state does a countdown latch need, which condition do waiters wait for, and when should a thread call notifyAll()?
Reveal the answer
One int count, guarded by the latch's lock. Waiters wait while count > 0. The thread whose countDown() brings the count to zero calls notifyAll(), because every waiter can proceed once the gate opens.
Writing a tiny version of a library class is the fastest way to understand it. This is the guarded-wait pattern from lesson 5.1, plus a timed wait that recomputes how long is left after every wake-up:
import java.util.concurrent.TimeUnit;
public class MiniLatch {
private int count; // guarded by this
public MiniLatch(int count) { this.count = count; }
public synchronized void countDown() {
if (count > 0 && --count == 0) notifyAll(); // the gate opens: wake every waiter
}
public synchronized void await() throws InterruptedException {
while (count > 0) wait(); // loop: spurious wakeups
}
/** Waits at most timeoutMs; returns true if the gate opened in time. */
public synchronized boolean await(long timeoutMs) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); // nanoTime: never jumps
while (count > 0) {
long leftMs = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime());
if (leftMs <= 0) return false; // timed out
wait(leftMs); // may wake early: the loop recomputes what is left
}
return true;
}
public static void main(String[] args) throws InterruptedException {
MiniLatch ready = new MiniLatch(3);
for (int i = 1; i <= 3; i++) {
int id = i;
new Thread(() -> { System.out.println("service " + id + " ready"); ready.countDown(); }).start();
}
System.out.println("opened in time: " + ready.await(2_000));
}
}Compare with the real CountDownLatch: same behaviour, but it is built on AbstractQueuedSynchronizer and LockSupport.park instead of a monitor (Module 10 explains that machinery). Use the real one; write this one once to understand it.
5.8Use cases: waiting and stopping in real code
A worker thread pulls payout jobs from a queue. During deployment it must stop within a few seconds without losing a half-processed job. What two things must its code do?
Reveal the answer
Block in an interruptible call (queue.take() or poll(timeout)) and treat interruption as "finish the current job, then exit": catch InterruptedException between jobs, restore the flag, and leave the loop. The executor or shutdown hook interrupts it.
A guarded wait on a condition, always with a timeout.
The standard worker loop of lesson 5.5.
Both interrupt the running task. CompletableFuture.cancel(true) does not: it only completes the future, and the work keeps running (Module 7).
Not a while (true) { sleep(); } loop in a raw thread (Module 6).
Hand-written wait/notify belongs in interviews and libraries, not services.
A request must never wait forever. An idle worker may block in take(), because an interrupt wakes it.
5.9Common mistakes
Common mistakes
wait()withifinstead ofwhile.- Calling
waitornotifywithout holding that object's lock:IllegalMonitorStateException. - Using
notify()when threads wait for different conditions, which can wake the wrong thread and stall. - Waiting for "a notify" instead of a state change, which loses signals sent before the wait began.
- Swallowing
InterruptedException. Restore the flag or rethrow. - Expecting interruption to stop a platform thread blocked on
synchronizedor on a classicjava.net.Socketread. It does not. (On a virtual thread, an interrupt does close a blocked socket.) - Busy-waiting with an empty loop instead of blocking.
5.10Self-check
- What does a thread do between being notified and returning from
wait()?Reveal
It moves from the wait set to competing for the lock (
BLOCKED), and returns only after re-acquiring it. - Give the three reasons a woken thread might find its condition false.
Reveal
Another thread used the condition first,
notifyAllwoke it for a different condition, or a spurious wakeup. - What is the difference between
isInterrupted()andThread.interrupted()?Reveal
isInterrupted()reads a thread's flag without changing it; staticThread.interrupted()reads the current thread's flag and clears it. - Name two ways that
java.util.concurrentinterrupts your tasks.Reveal
ExecutorService.shutdownNow()andFuture.cancel(true). - Why must checking the condition and calling
wait()happen under the same lock the notifier uses?Reveal
Otherwise the notifier could change the state and notify between the check and the wait, and the waiter would sleep through the only signal (a lost notification).
Key takeaways
wait()releases the lock and sleeps;notifyAll()wakes waiters, which must re-acquire the lock.- Always wait in a
whileloop on a condition about shared state, under the same lock as the notifier. - Use
notifyAll()unless every waiter is interchangeable and each change frees only one of them. - Interruption is a polite request: blocking calls throw
InterruptedException, running code checks the flag. - Never swallow
InterruptedException: rethrow it or restore the flag, then stop. - In real code use
BlockingQueue, latches andFuture.cancel; they are built on these same rules.
Mastery checklist
Tick an item only when it is true with your notes closed.