Course contents

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.

PremiumMust know for interviewsLesson36 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: threads and their states
  2. Creating threads: Thread, Runnable, Callable
  3. Race conditions and atomicity
  4. synchronized and monitors
  5. wait, notify and notifyAll: the rules
  6. The Java Memory Model: visibility, reordering, happens-before
  7. volatile: visibility and ordering, not atomicity
  8. Safe publication: double-checked locking done right
  9. Atomics and CAS
  10. ReentrantLock vs synchronized
  11. Deadlock: conditions, prevention, detection
  12. Executors and ThreadPoolExecutor
  13. Future vs CompletableFuture
  14. Coordination: latches, barriers, semaphores, queues
  15. ThreadLocal and the pool leak
  16. Virtual threads (Java 21)
  17. Pitfalls that cost offers
  18. 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)

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 what synchronized locks 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, synchronized and ReentrantLock, and prevent deadlock with lock ordering or tryLock
  • 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.

Fig. 1

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

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 BLOCKED while it waits to enter a synchronized block (or to re-enter after wait()). A thread waiting in ReentrantLock.lock() is parked, so a thread dump shows it as WAITING. A thread blocked on socket I/O shows as RUNNABLE.
  • 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 throw InterruptedException. wait(), notify() and synchronized
  • TERMINATED is final: calling start() a second time throws IllegalThreadStateException. 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 check Thread.currentThread().isInterrupted() or catch InterruptedException. Thread.stop() is deprecated, and since Java 20 it throws UnsupportedOperationException.
  • Never swallow an interrupt: catching InterruptedException clears the flag. Either rethrow it or restore it with Thread.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 finally blocks. Set it with setDaemon(true) before start().

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 Vector or ConcurrentHashMap are 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 synchronized block 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 on this would deadlock with itself.
  • Static and instance locks are different locks: a static synchronized method and an instance synchronized method on the same class can run at the same time, because one locks Counter.class and 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 a String literal or a boxed Integer (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 synchronized can't give up and can't be interrupted. That's the main reason ReentrantLock exists.

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() and x.notify() only inside synchronized (x), or you get IllegalMonitorStateException at runtime. It compiles fine. wait(), notify() and synchronized
  • Always wait in a while loop: 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 BLOCKED and competes for the monitor after the notifier leaves its synchronized block.
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.

Fig. 2

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

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 in t; every action in t happens-before another thread returns from t.join() (or sees t.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 = 42 hb ready = true hb the read of ready hb print(data).
  • Final fields (JLS 17.5): once a constructor finishes, any thread that obtains the reference (without this escaping during construction) sees the final fields, and anything reachable through them, as initialized. No volatile or 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". Use AtomicInteger or LongAdder.
  • 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 long and volatile double writes and reads are atomic. On 64-bit HotSpot plain writes are atomic in practice, but the guarantee comes from volatile.

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)