Multithreading and the Java Memory Model: the must-knows
The most thorough Last-Mile sheet: thread states, races, synchronized and wait/notify, the Java Memory Model's happens-before rules, volatile and double-checked locking, CAS and LongAdder, locks and deadlock, ThreadPoolExecutor and pool sizing, CompletableFuture, coordination utilities, ThreadLocal leaks and Java 21 virtual threads.
On this page
- Picture it first: threads and their states
- Creating threads: Thread, Runnable, Callable
- Race conditions and atomicity
- synchronized and monitors
- wait, notify and notifyAll: the rules
- The Java Memory Model: visibility, reordering, happens-before
- volatile: visibility and ordering, not atomicity
- Safe publication: double-checked locking done right
- Atomics and CAS
- ReentrantLock vs synchronized
- Deadlock: conditions, prevention, detection
- Executors and ThreadPoolExecutor
- Future vs CompletableFuture
- Coordination: latches, barriers, semaphores, queues
- ThreadLocal and the pool leak
- Virtual threads (Java 21)
- Pitfalls that cost offers
- How to say it in the interview
🧵 What is this sheet?
It's the concurrency round on one page: threads and their states, synchronized and wait/notify, the Java Memory Model and volatile, safe publication, atomics, locks, deadlock, thread pools, CompletableFuture, the coordination utilities, ThreadLocal and Java 21 virtual threads. This is the area where interviewers separate "has used threads" from "can reason about threads", so this is the longest sheet in the module. If you only have one evening, spend it here.
For this sheet, we'll go in the order an interviewer usually escalates: what a thread is and how it gets stuck, why count++ breaks, what synchronized really guarantees, the happens-before rules that explain volatile and double-checked locking, then the java.util.concurrent toolkit you'd use in production. Every item has the why, the must-say numbers and a snippet you can rewrite from memory. The deeper versions live in the free reference pages linked throughout.
Below the line (out of scope)
- Every topic at full length with more diagrams: Java Concurrency and Multithreading, end to end
- The monitor, the lost-wakeup race and the bounded buffer three ways: wait(), notify() and synchronized
- Lesson-by-lesson coverage as it ships: Module 05: Multithreading
ConcurrentHashMapinternals: Collections and HashMap: the must-knows- GC, safepoints and heap sizing: JVM memory and garbage collection: the must-knows
The 30-second answer
Threads in one process share the heap but each has its own stack, so shared mutable state needs two things: atomicity (no interleaving inside a compound action like count++) and visibility (one thread's write is seen by another). The Java Memory Model defines visibility through happens-before edges: program order, unlock before a later lock of the same monitor, volatile write before a later read, start() before the thread's actions, and the thread's actions before join() returns. synchronized gives both atomicity and visibility; volatile gives visibility and ordering but not atomicity; atomics give lock-free single-variable atomicity through CAS. In production I use ExecutorService with a bounded queue sized for the workload, CompletableFuture with my own executor, ConcurrentHashMap and BlockingQueue instead of hand-written locking, and on Java 21 virtual threads for blocking I/O-heavy code.
What you’ll learn
- Explain atomicity vs visibility, why
count++loses updates, and whatsynchronizedlocks on in each form - State the happens-before rules (program order, monitor, volatile, start/join, final fields) and use them to justify double-checked locking
- Choose between
volatile, atomics and CAS,LongAdder,synchronizedandReentrantLock, and prevent deadlock with lock ordering ortryLock - Walk through ThreadPoolExecutor's core, queue, max and reject steps, avoid the unbounded-queue trap and size pools for CPU and I/O work
- Compose async work with CompletableFuture on your own executor and pick latch, barrier, semaphore or
BlockingQueue - Avoid ThreadLocal leaks on pools and use virtual threads in Java 21 without pooling them or pinning carriers
Interview radar
Asked very often: synchronized vs volatile, why count++ isn't thread-safe, wait vs sleep, Runnable vs Callable, how a thread pool works, what a deadlock is and how to avoid it.
Senior rounds probe: happens-before and double-checked locking, CAS and ABA, LongAdder, ThreadPoolExecutor parameters and the unbounded-queue trap, pool sizing, CompletableFuture on the common pool, ThreadLocal leaks, virtual-thread pinning.
Production angle: A pool that never scales, a request that hangs because every worker waits on another task in the same pool, user data leaking between requests through a ThreadLocal, and a deadlock you diagnose from a thread dump.
01Picture it first: threads and their states
A process is a kitchen and threads are cooks. The cooks share the pantry and the stove (the heap, static fields, open files), but each has their own cutting board and recipe card (stack, program counter, registers). Two cooks reaching for the same pan at once is a race condition; a rule like "only the one holding the pan handle may cook" is a lock.
| Process | Thread | |
|---|---|---|
| Memory | own address space | shares the process heap; own stack |
| Creation cost | milliseconds, a fork | tens of microseconds and a kernel thread for a platform thread |
| Default stack | n/a | about 1 MB reserved per platform thread on 64-bit Linux (-Xss) |
| Communication | sockets, pipes, files | shared objects, so it needs synchronization |
| Failure | a crash stays inside one process | an uncaught exception kills only that thread; an OutOfMemoryError can affect all of them |
Every Java thread is in exactly one of six states from Thread.State. Here's how it moves between them.
The six thread states: NEW goes to RUNNABLE on start, RUNNABLE moves to BLOCKED on monitor contention, to WAITING or TIMED_WAITING on wait, join, park or sleep, and to TERMINATED when run ends
- BLOCKED is only for monitors: a thread is
BLOCKEDwhile it waits to enter asynchronizedblock (or to re-enter afterwait()). A thread waiting inReentrantLock.lock()is parked, so a thread dump shows it asWAITING. A thread blocked on socket I/O shows asRUNNABLE. - wait vs sleep:
wait()must be called while holding the object's monitor, releases that monitor and waits for a notify;sleep()can be called anywhere, keeps every lock it holds and wakes after the time. Both throwInterruptedException. wait(), notify() and synchronized - TERMINATED is final: calling
start()a second time throwsIllegalThreadStateException. To run work again, submit it again to an executor.
02Creating threads: Thread, Runnable, Callable
There are three ways to describe work and one modern way to run it.
// 1. Runnable: no result, can't throw checked exceptions. Prefer this over extending Thread.
Thread t = new Thread(() -> System.out.println("hello"), "worker-1");
t.start();
// 2. Callable<V>: returns a value and may throw. Runs through an ExecutorService.
try (ExecutorService pool = Executors.newFixedThreadPool(4)) { // AutoCloseable since Java 19
Future<Integer> f = pool.submit(() -> 6 * 7);
System.out.println(f.get()); // 42, blocks until done
}
// 3. Java 21 virtual thread: same Runnable, much cheaper thread.
Thread vt = Thread.ofVirtual().name("vt-1").start(() -> System.out.println("virtual"));
vt.join();
Extending Thread works but couples your task to the threading mechanism and burns your only superclass. A Runnable or Callable can run on a platform thread, a pool or a virtual thread without changing a line. That's the answer to "Runnable vs Thread".
Try it yourself first
What does each call print, and on which thread?
Thread t = new Thread(() ->
System.out.println("in " + Thread.currentThread().getName()), "worker");
t.run(); // in main
t.start(); // in worker
run() is an ordinary method call: it executes on the caller's thread, here main, and no new thread exists. Only start() asks the JVM to create a thread that then calls run(). Many candidates know this rule and still miss it when it's hidden inside a larger snippet, which is why interviewers keep using it.
- Stopping a thread is cooperative: call
t.interrupt(), and have the task checkThread.currentThread().isInterrupted()or catchInterruptedException.Thread.stop()is deprecated, and since Java 20 it throwsUnsupportedOperationException. - Never swallow an interrupt: catching
InterruptedExceptionclears the flag. Either rethrow it or restore it withThread.currentThread().interrupt()so the code above you knows. - Daemon threads don't keep the JVM alive: when only daemon threads remain, the JVM exits without running their
finallyblocks. Set it withsetDaemon(true)beforestart().
03Race conditions and atomicity
A race condition is a bug whose result depends on the timing of threads. The classic form is a read-modify-write that looks like one operation but isn't.
Try it yourself first
Two threads each increment a shared counter 1,000,000 times. What does it print?
static int count = 0;
Runnable inc = () -> { for (int i = 0; i < 1_000_000; i++) count++; };
Thread a = new Thread(inc), b = new Thread(inc);
a.start(); b.start();
a.join(); b.join();
System.out.println(count); // e.g. 1774549, different on each run, rarely 2000000
count++ compiles to three steps: read the field, add one, write it back. Thread A reads 41, thread B reads 41, both write 42, and one increment is lost. The join() calls guarantee you see the final value; they do nothing about the lost updates that already happened.
- Two race shapes to name: read-modify-write (
count++,balance -= amount) and check-then-act (if (!map.containsKey(k)) map.put(k, v), lazy initialization). Both need the check and the act to be one atomic step. - Three fixes, in order of preference: don't share (confine the data to one thread, or make it immutable), use a thread-safe building block (
AtomicInteger,ConcurrentHashMap.merge,LongAdder), or lock (synchronized,ReentrantLock). - Thread-safe classes don't compose: two calls to a thread-safe
VectororConcurrentHashMapare still two calls. The atomicity boundary is one method.
04synchronized and monitors
Every Java object has a monitor: a lock plus a wait set. synchronized acquires the monitor on entry and releases it on exit, including when an exception is thrown. It gives you mutual exclusion and visibility: everything a thread did before releasing the lock is visible to the next thread that acquires the same lock.
| You write | It locks on |
|---|---|
synchronized void m() |
this |
static synchronized void m() |
the Class object, e.g. Counter.class |
synchronized (lock) { ... } |
the object referenced by lock |
public final class Counter {
private final Object lock = new Object(); // private lock: no outsider can grab it
private int count;
public void increment() {
synchronized (lock) { count++; }
}
public int get() {
synchronized (lock) { return count; } // reads need the lock too, for visibility
}
}
- Reentrant: a thread that holds a monitor can enter another
synchronizedblock on the same object; the JVM counts the holds and releases the lock when the count returns to zero. Without reentrancy, a synchronized method calling another synchronized method onthiswould deadlock with itself. - Static and instance locks are different locks: a
static synchronizedmethod and an instancesynchronizedmethod on the same class can run at the same time, because one locksCounter.classand the other locks the instance. - Lock on a private final object:
synchronized (this)lets any caller holding your object interfere with your locking. Never lock on aStringliteral or a boxedInteger(shared, interned or cached instances); since Java 16 the compiler warns when you synchronize on a value-based class. - No timeout, no interrupt: a thread waiting to enter
synchronizedcan't give up and can't be interrupted. That's the main reasonReentrantLockexists.
05wait, notify and notifyAll: the rules
wait(), notify() and notifyAll() are final methods on Object and work on that object's monitor. The free article covers the why in depth; for the interview, these are the rules to say.
- Hold the monitor: call
x.wait()andx.notify()only insidesynchronized (x), or you getIllegalMonitorStateExceptionat runtime. It compiles fine. wait(), notify() and synchronized - Always wait in a
whileloop: the JLS allows spurious wakeups, and another thread may consume the condition between the notify and your thread reacquiring the lock. Re-check the condition after every wakeup. - wait releases only that monitor: if you hold two locks and wait on one, you still hold the other (a "nested monitor lockout" waiting to happen).
- Prefer notifyAll:
notify()wakes one arbitrary thread; if it's the wrong kind (a producer when a consumer was needed), everyone sleeps forever.notifyAll()is safe when waiters wait for different conditions. - notify doesn't release the lock: the woken thread moves to
BLOCKEDand competes for the monitor after the notifier leaves itssynchronizedblock.
private final Object lock = new Object();
private final Deque<String> items = new ArrayDeque<>();
String take() throws InterruptedException {
synchronized (lock) {
while (items.isEmpty()) { // while, never if
lock.wait(); // releases lock, waits, reacquires before returning
}
return items.removeFirst();
}
}
void put(String item) {
synchronized (lock) {
items.addLast(item);
lock.notifyAll(); // waiters wake after we exit the block
}
}
In production code, this whole block is one line: BlockingQueue.take(). Write wait/notify in an interview to prove you understand monitors, then say you'd use the queue.
06The Java Memory Model: visibility, reordering, happens-before
This is the part interviewers call "very important", and it's where most answers go vague. The JMM (JLS chapter 17) exists because two things happen under your code: CPUs keep values in per-core caches and store buffers, and the JIT compiler and the CPU reorder instructions when a single thread can't tell the difference. Both are legal and both make multithreaded code surprising.
Try it yourself first
Will this program stop after one second? Run it in your head with the JIT in mind.
static boolean stop = false; // not volatile
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long n = 0;
while (!stop) { n++; } // the JIT may read stop once and loop forever
System.out.println("stopped after " + n);
});
worker.start();
Thread.sleep(1000);
stop = true; // no happens-before edge to the worker's read
}
On HotSpot this commonly never stops. The JIT sees a loop that never writes stop and has no synchronization, so it's allowed to hoist the read out of the loop: if (!stop) while (true) n++;. Nothing in the JMM forces the worker to ever see main's write. Declare stop as volatile and the program stops within microseconds of the write.
Reordering is the second half. With x = y = 0, thread 1 runs x = 1; r1 = y; and thread 2 runs y = 1; r2 = x;. You'd expect at least one of r1, r2 to be 1, yet r1 == 0 && r2 == 0 is a legal and observed outcome, because each thread's store can sit in a store buffer while its load runs. Without synchronization, "what order did it happen in?" has no single answer.
The happens-before rules
The JMM doesn't promise "the latest value". It promises this: if action A happens-before action B, then A's effects are visible to B and A appears ordered before B. If two conflicting accesses to the same variable (at least one a write) aren't ordered by happens-before, you have a data race, and the JMM gives weak guarantees about what you'll read.
Thread A writes data and then a volatile ready flag; thread B reads the flag as true and then prints data; program order plus the volatile write-to-read edge make the print guaranteed to show 42, and a box lists the six happens-before rules
- Program order: each action in a thread happens-before every later action in that same thread.
- Monitor lock: an unlock of a monitor happens-before every subsequent lock of the same monitor. Two threads locking different objects get no edge.
- Volatile: a write to a volatile field happens-before every subsequent read of that field.
- Thread start and join:
t.start()happens-before every action int; every action inthappens-before another thread returns fromt.join()(or seest.isAlive()return false). - Transitivity: if A hb B and B hb C, then A hb C. This is the rule that makes the diagram work:
data = 42hbready = truehb the read ofreadyhbprint(data). - Final fields (JLS 17.5): once a constructor finishes, any thread that obtains the reference (without
thisescaping during construction) sees thefinalfields, and anything reachable through them, as initialized. Novolatileor lock needed. That's why immutable objects are thread-safe. Designing immutable objects
The java.util.concurrent classes document their own edges on top of these: putting an object into a BlockingQueue or ConcurrentHashMap happens-before taking it out, Future completion happens-before get() returns, and an action before countDown() happens-before await() returns. That's how you hand data between threads safely without writing a single volatile.
07volatile: visibility and ordering, not atomicity
A volatile field gives you three things. Every read sees the most recent write to it in the synchronization order; the write creates the happens-before edge above, so everything written before it is visible to a thread that reads it; and the JIT and CPU may not reorder ordinary accesses across it in ways that would break that edge. On x86, a volatile read costs about the same as a plain read; a volatile write adds a store-load fence, typically tens of cycles.
volatile |
synchronized |
AtomicInteger |
|
|---|---|---|---|
| Visibility | yes | yes | yes |
| Ordering (hb edge) | write to later read | unlock to later lock | same as volatile |
Atomic count++ |
no | yes | yes (CAS) |
| Blocks threads | never | yes, under contention | never (spins on CAS failure) |
| Typical use | flags, published references | invariants over several fields | counters, sequence numbers |
- The count++ bug survives volatile:
volatile int count; count++is still read, add, write. Two threads can read the same value. Volatile fixes "can't see the update", not "lost the update". UseAtomicIntegerorLongAdder. - Good volatile uses: a shutdown flag written by one thread and read by others, a reference to an immutable snapshot that you replace wholesale (copy-on-write config), and the instance field in double-checked locking.
- long and double: without
volatile, the JLS (17.7) allows a 64-bit write to be split into two 32-bit halves, so a reader could see a torn value.volatile longandvolatile doublewrites and reads are atomic. On 64-bit HotSpot plain writes are atomic in practice, but the guarantee comes fromvolatile.
08Safe publication: double-checked locking done right
Here is the code that shows whether you really understand the JMM.
public final class Config {
private static volatile Config instance; // volatile is not optional
private Config() { /* load properties, open resources */ }
public static Config getInstance() {
Config local = instance; // one volatile read on the fast path
if (local == null) {
synchronized (Config.class) {
local = instance;
if (local == null) { // second check, under the lock
instance = local = new Config();
}
}
}
return local;
}
}
instance = new Config() is three steps: allocate memory, run the constructor, store the reference. Without volatile, the store can become visible to another thread before the constructor's writes, so a thread on the unlocked fast path sees a non-null instance with default field values. The volatile write publishes the fully constructed object: everything the constructor did happens-before any read that sees the reference. The local variable is an optimisation: it saves a second volatile read on the common path.
Before Java 5 (JSR-133), even volatile didn't fix this, which is why older books say "double-checked locking is broken". Since Java 5, the version above is correct.
The idiom I'd write by default is simpler:
public final class Config {
private Config() { }
private static final class Holder { // not loaded until first use
static final Config INSTANCE = new Config();
}
public static Config getInstance() {
return Holder.INSTANCE; // class init is lazy and thread-safe
}
}
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- Atomics and CAS
- ReentrantLock vs synchronized
- Deadlock: conditions, prevention, detection
- Executors and ThreadPoolExecutor
- Future vs CompletableFuture
- Coordination: latches, barriers, semaphores, queues
- ThreadLocal and the pool leak
- Virtual threads (Java 21)
- Pitfalls that cost offers
- How to say it in the interview
From ₹199 · one-time payment · No auto-renewal
Interview questions for this topicComing soon
Further reading (8)
- JLS 21, Chapter 17: Threads and Locks (memory model, happens-before, final fields)
- java.util.concurrent package summary: memory consistency properties (Java SE 21 API)
- ThreadPoolExecutor (Java SE 21 API)
- CompletableFuture (Java SE 21 API)
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- JSR 133 (Java Memory Model) FAQ
- Java Concurrency in Practice (Goetz et al.): pool sizing and safe publication