Java Concurrency & Multithreading — Complete Notes
Threads, locks, visibility, the memory model, executors, CompletableFuture and virtual threads — built up from why shared memory is dangerous, through to the tools that make it safe. This is where backend interviews get serious.
00. Why any of this exists
Concurrency is structuring a program so several things can be in progress at once. Parallelism is actually executing them at the same instant on different cores. You can have concurrency on a single core; parallelism needs real hardware.
Concurrency is one cook juggling three pans — chopping while the water boils, switching between tasks so nothing sits idle. Parallelism is three cooks, one pan each. Concurrency is about dealing with many things at once; parallelism is about doing many things at once.
You reach for threads for exactly two reasons:
| Goal | The problem | What helps |
|---|---|---|
| Throughput (I/O-bound) | A thread waiting on the network is a wasted CPU | Many threads, so someone always has work |
| Speed (CPU-bound) | One core is doing all the work; the other 7 idle | Split the work across cores |
Process vs thread
| Process | Thread | |
|---|---|---|
| Memory | Its own, isolated | Shared with sibling threads |
| Cost to create | Heavy | Lighter (~1MB stack each, still not free) |
| Communication | IPC, sockets, pipes — explicit | Just read the same variable — implicit |
| A crash… | Kills only itself | Can take the whole process down |
Threads share memory by default. That's what makes them fast, and it's what makes them dangerous — two threads touching the same variable with no coordination is the root of every race condition, visibility bug and deadlock you will ever write.
Go's motto is "share memory by communicating" — goroutines pass data over channels. Java's
default is the opposite: "communicate by sharing memory", and it's your job to guard it. The
mapping is roughly: goroutine → thread (or virtual thread, which is much
closer) · channel → BlockingQueue ·
sync.Mutex → synchronized/ReentrantLock
· sync/atomic → java.util.concurrent.atomic ·
WaitGroup → CountDownLatch ·
go f() → executor.submit(f). Java's race detector equivalent is… discipline. There is no -race flag.
01. Threads — creating and controlling
A thread is an independent path of execution with its own call stack, running inside your process and sharing its heap.
// 1. Extend Thread — DON'T. Burns your single inheritance slot, couples task to mechanism.
class MyThread extends Thread {
public void run() { System.out.println("running"); }
}
new MyThread().start();
// 2. Implement Runnable — the task is a separate thing from the thread running it
class MyTask implements Runnable {
public void run() { System.out.println("running"); }
}
new Thread(new MyTask()).start();
// 3. A lambda + an executor — what you should actually write (see section 08)
executor.submit(() -> System.out.println("running"));
start() vs run() — the classic trick question
start() asks the JVM for a new thread, which then calls
run(). Calling run() directly is just an
ordinary method call on the current thread — no concurrency at all, and no
error to tell you. Also: calling start() twice throws
IllegalThreadStateException. A thread is not reusable.
Runnable task = () -> System.out.println(Thread.currentThread().getName());
new Thread(task).start(); // prints "Thread-0" — a real new thread
new Thread(task).run(); // prints "main" — no thread was created!
The six thread states
NEW created, start() not yet called
| start()
v
RUNNABLE <-------------+ runnable or running (Java doesn't distinguish)
| |
| wants a lock | lock acquired
v |
BLOCKED --------------+ waiting to enter a synchronized block
|
| wait() / join() / park()
v
WAITING ---------------+ waiting indefinitely for another thread
| notify()/notifyAll()/interrupt()
|
| wait(t) / sleep(t) / join(t)
v
TIMED_WAITING ----------+ same, but with a deadline
|
v
TERMINATED run() finished (or threw)
| State | Meaning |
|---|---|
NEW |
Object exists, start() not called |
RUNNABLE |
Eligible to run — may or may not be on a CPU right now |
BLOCKED |
Waiting for a monitor lock (i.e. to enter synchronized)
|
WAITING |
wait(), join(), LockSupport.park() — no timeout
|
TIMED_WAITING |
sleep(n), wait(n), join(n), tryLock(n)
|
TERMINATED |
Finished. Cannot restart. |
BLOCKED vs WAITING
BLOCKED = "I want a lock someone else holds" — nobody has to do anything, I'll get
in when they leave. WAITING = "I've been told to wait and I need someone to
actively wake me" (notify, signal,
unpark). A WAITING thread with nobody to wake it waits forever. This
distinction is the first thing you look for in a thread dump.
Essential thread methods
Thread.sleep(1000); // pause THIS thread ~1s. Holds any locks it owns! (see gotchas)
t.join(); // block until t finishes
t.join(500); // ...or 500ms, whichever comes first
t.interrupt(); // politely ASK t to stop — see below
Thread.currentThread(); // who am I
t.setDaemon(true); // JVM won't wait for this thread to exit (MUST be before start())
t.setName("worker-1"); // do this — it makes thread dumps readable
Thread.yield(); // "I'd share the CPU" — a hint, usually useless
Java has no way to kill a thread. t.interrupt() just sets a flag.
If the thread is blocked in sleep/wait/join, it throws
InterruptedException and clears the flag. Otherwise the thread
must check Thread.interrupted() itself. Cooperative cancellation is the only
cancellation there is. (Thread.stop() exists, is deeply broken, and was removed.)
// Option A — let it propagate (best, if your signature allows it)
void work() throws InterruptedException {
Thread.sleep(1000);
}
// Option B — you can't rethrow (e.g. inside Runnable.run) -> RESTORE THE FLAG
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // put the flag back!
return; // and actually stop
}
}
// WRONG — swallows the request. Your app now won't shut down.
try { Thread.sleep(1000); } catch (InterruptedException e) { } // never do this
public void run() {
while (!Thread.currentThread().isInterrupted()) {
doChunkOfWork();
}
cleanup();
}
02. The problem — shared mutable state
Every concurrency bug traces back to one of three things: atomicity (an operation isn't as indivisible as it looks), visibility (a thread never sees another's write), and ordering (things didn't happen in the order you wrote them).
Problem 1: atomicity — i++ is a lie
count++;
// is really:
// 1. READ count from memory (getfield)
// 2. ADD 1 to the value (iadd)
// 3. WRITE the result back (putfield)
//
// Another thread can interleave at ANY point between them.
// count = 0
// Thread A | Thread B
// read count -> 0 |
// | read count -> 0 <- B reads before A wrote
// add 1 -> 1 |
// | add 1 -> 1
// write 1 |
// | write 1 <- overwrites A's work
//
// count == 1. Two increments happened. One vanished.
class Counter {
private int count = 0;
public void increment() { count++; }
public int get() { return count; }
}
Counter c = new Counter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) c.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) c.increment(); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(c.get()); // Expected 200000. Actual: 137482. Or 189301. Never the same twice.
A race condition is when the result depends on the timing of threads. The section of code that touches shared state is the critical section. Fixing a race means making the critical section atomic — indivisible from any other thread's point of view.
if (map.get(k) == null) map.put(k, v); is a race even with a thread-safe map. Both
operations are individually atomic, but another thread can slip in
between them. Lazy initialisation (if (instance == null) instance = new …) is the same bug. You need one atomic operation: map.putIfAbsent(k, v) or
map.computeIfAbsent(k, …).
Problem 2: visibility — the write that never arrives
This one is genuinely counter-intuitive. Even with no interleaving at all, a thread may never see another thread's write. Not "later" — never.
class Task {
private boolean running = true; // no volatile!
public void stop() { running = false; }
public void run() {
while (running) { } // may spin FOREVER, even after stop() is called
System.out.println("stopped");
}
}
// Main thread calls stop(). The worker thread may never notice — and often doesn't
// once the JIT warms up. Adding `volatile` to `running` fixes it completely.
Each CPU core has its own cache. A write may sit in Core A's cache and never
reach Core B. On top of that, the JIT compiler sees a loop that never modifies
running and legally hoists the read out:
if (running) while (true) { }. Both are allowed, because
without synchronisation the JVM makes no promise at all that one thread's write
is visible to another.
You and a colleague each have a private notepad copy of the shared whiteboard.
You cross out "running" on your pad. Unless someone walks to the whiteboard and syncs, your
colleague keeps reading "running" off their own pad forever. volatile and locks are
the rule that says "always read and write the actual whiteboard."
Problem 3: reordering
// You wrote: // Another thread might observe:
a = 1; // flag = true; <- reordered ahead!
flag = true; // a = 1;
// So a reader that sees flag == true can still read a == 0.
// Single-threaded semantics are preserved; cross-thread ones are not.
The JVM only guarantees as-if-serial semantics — the result must look correct to the thread doing it. Any reordering invisible to that single thread is fair game, and the hardware does it aggressively. Other threads get no guarantees whatsoever unless you establish a happens-before relationship (section 05).
03. synchronized — the built-in lock
synchronized guarantees that only one thread at a time runs the
block, and that everything the previous holder did is visible to the
next. It fixes atomicity and visibility together.
class Counter {
private int count = 0;
public synchronized void increment() { count++; } // one thread at a time
public synchronized int get() { return count; } // MUST also be synchronized — see below
}
// Now always prints exactly 200000.
Synchronising only the writer is a classic half-fix. Locking gives visibility only
between threads that use the same lock. An unsynchronised
get() has no happens-before edge with increment(), so it may read a
stale value forever.
Every access to shared mutable state — reads included — must be synchronised.
Every object has a lock
Every Java object carries a hidden monitor (or intrinsic lock).
synchronized acquires it on entry and releases it on exit —
including when an exception is thrown, so you can never leak an intrinsic lock.
class Example {
public synchronized void a() { } // locks `this`
public static synchronized void b() { } // locks Example.class — a DIFFERENT lock from `this`!
public void c() {
synchronized (this) { } // identical to a()
}
private final Object lock = new Object();
public void d() {
synchronized (lock) { } // locks a private object — usually the best choice
}
}
A synchronized instance method locks this; a
static synchronized method locks the Class object. They are two
completely separate locks — so an instance method and a static method
can run at the same time and happily corrupt the same static field. This trips
people up constantly.
// Risky: `this` is public, so ANY outside code can lock your object and stall you
class Bad {
public synchronized void work() { }
}
Bad b = new Bad();
synchronized (b) { Thread.sleep(60_000); } // outsider just froze every call to work()
// Safe: nobody outside can reach the lock
class Good {
private final Object lock = new Object(); // final! reassigning a lock breaks everything
public void work() { synchronized (lock) { } }
}
Synchronized is reentrant
class Reentrant {
public synchronized void outer() {
inner(); // already hold the lock — walk straight in. No deadlock.
}
public synchronized void inner() { }
}
// The JVM keeps a hold COUNT: +1 on entry, -1 on exit. Released at zero.
// Without reentrancy, any object calling its own synchronized method would deadlock with itself.
A lock serialises threads — everything inside it is single-threaded, so it's a direct cap on throughput. Never do I/O, call unknown code, or sleep while holding a lock. Calling a callback or an overridable method inside a lock is an "alien method" and a classic deadlock source: you have no idea what locks it will grab.
// BAD — the network call holds the lock for 200ms
public synchronized void update(String id) {
Data d = httpClient.fetch(id); // slow! Everyone else is blocked.
cache.put(id, d);
}
// GOOD — do the slow part outside, lock only the shared bit
public void update(String id) {
Data d = httpClient.fetch(id); // no lock held
synchronized (lock) {
cache.put(id, d); // microseconds
}
}
04. volatile — visibility only
volatile makes a field's writes immediately visible to every thread, and forbids
reordering around it. It does not make anything atomic.
class Worker implements Runnable {
private volatile boolean running = true; // now the write is guaranteed to be seen
public void stop() { running = false; }
public void run() {
while (running) { doWork(); } // terminates promptly. Guaranteed.
}
}
Yes: reads/writes go to main memory, never a stale cache · reads/writes
are atomic even for long/double · no reordering across it
(it's a memory barrier). No: it does not make
count++ atomic — that's still read-modify-write, three operations, each
individually visible but collectively racy.
private volatile int count = 0;
public void increment() { count++; } // STILL BROKEN. Still loses updates.
// Because it's read, add, write — and volatile only makes each STEP visible,
// not the three of them indivisible.
// Fixes: synchronized, or AtomicInteger:
private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); } // correct
volatile |
synchronized |
|
|---|---|---|
| Visibility | Yes | Yes |
| Atomicity of compound ops | No | Yes |
| Blocks threads | No — never blocks | Yes |
| Applies to | A single field | A block or method |
| Cost | Cheap (a memory barrier) | More expensive (may park threads) |
Use it when exactly one of these holds: the write doesn't depend on the current
value (a flag, a reference swap), or only one thread ever writes. If the new value is computed
from the old one, you need atomics or a lock. The long/double point
matters too: on 32-bit JVMs, non-volatile 64-bit reads/writes may be torn into
two halves — volatile forbids that.
05. The Java Memory Model — happens-before
The Java Memory Model (JMM) is the contract that says exactly when one thread's writes must be visible to another. Its entire vocabulary is one relation: happens-before.
If action A happens-before B, then everything A did is guaranteed visible to B, and cannot be reordered after it. If there is no happens-before edge between two actions on shared data, and at least one is a write, you have a data race — and the JMM promises you nothing at all. Not "usually works". Nothing.
The rules that create an edge
| Rule | A happens-before B |
|---|---|
| Program order | Earlier statement → later statement, within one thread |
| Monitor lock | unlock on M → a later lock on the same M |
| Volatile | A write to v → every later read of v |
| Thread start | Everything before t.start() → everything inside t |
| Thread join | Everything in t → whatever follows t.join() |
| Final fields | Constructor finishing → any thread seeing a correctly published reference |
| Transitivity | A → B and B → C implies A → C |
class Publisher {
private int data; // plain field — NOT volatile
private volatile boolean ready; // volatile flag
void writer() {
data = 42; // (1)
ready = true; // (2) volatile WRITE
}
void reader() {
if (ready) { // (3) volatile READ
System.out.println(data); // (4) GUARANTEED to print 42 — never 0
}
}
}
// Why: (1) happens-before (2) by program order.
// (2) happens-before (3) by the volatile rule.
// (3) happens-before (4) by program order.
// Transitivity => (1) happens-before (4). `data` is visible even though it's not volatile.
// This is the "volatile piggyback": the barrier flushes EVERYTHING written before it.
int x = 10; // written before start()
Thread t = new Thread(() -> System.out.println(x)); // sees 10, guaranteed
t.start();
// --- and the other direction ---
Thread t2 = new Thread(() -> result = compute());
t2.start();
t2.join(); // everything t2 did happens-before here
System.out.println(result); // guaranteed to see it
Final fields and safe publication
final is a concurrency tool, not just a style choice
class Safe {
private final int x;
Safe(int x) { this.x = x; }
}
// The JMM guarantees: any thread that sees a Safe reference sees a fully-constructed
// object with x already set. No synchronisation needed. This is why immutable objects
// are automatically thread-safe.
class Unsafe {
private int x; // NOT final
Unsafe(int x) { this.x = x; }
}
// Another thread can see a non-null Unsafe reference but still read x == 0,
// because the constructor's write can be reordered with the reference publication.
this escape a constructor
Registering a listener, starting a thread, or storing this anywhere inside a
constructor publishes a half-built object. Other threads can observe fields
that aren't assigned yet — including final ones. Use a static factory that
constructs first and registers after.
1. Don't share — thread confinement, locals, ThreadLocal.
2. Don't mutate — immutable objects with final fields need no synchronisation
at all. 3. Synchronise — if you must share mutable state, guard every access
with the same lock. In that order of preference.
06. Atomics and CAS
The java.util.concurrent.atomic classes give you lock-free, atomic read-modify-write
on a single variable — built on one CPU instruction: compare-and-swap.
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // ++count -> returns the NEW value
count.getAndIncrement(); // count++ -> returns the OLD value
count.addAndGet(5);
count.get();
count.set(10);
count.compareAndSet(10, 20); // if it's 10, make it 20. Returns whether it worked.
count.updateAndGet(x -> x * 2); // arbitrary function, applied atomically
count.accumulateAndGet(5, Integer::sum);
How CAS works
// CAS(memoryLocation, expectedValue, newValue):
// atomically, in ONE uninterruptible CPU instruction:
// "if the value here is still `expected`, replace it with `new` and report success;
// otherwise change nothing and report failure."
// incrementAndGet() is essentially:
int current;
do {
current = get(); // read
} while (!compareAndSet(current, current + 1)); // try to swap; if someone beat us, LOOP and retry
return current + 1;
A lock is pessimistic: "assume conflict, so block everyone else first." CAS is optimistic: "assume no conflict, do the work, and check at the last instant — if I was beaten, just try again." Nobody blocks, no thread ever parks, and there's no context switch. Under low contention this is dramatically faster.
Editing a shared doc. Locking = check the doc out so nobody else can touch it. CAS = edit your copy, then try to save: "was it still version 7 when I saved? Yes → saved. No → someone else committed; discard my attempt and redo it on version 8."
Every failed CAS means a wasted loop iteration. With many threads hammering one variable,
threads spin burning CPU and a lock can actually win. That's what LongAdder is for:
it keeps multiple internal cells, so threads update different memory and only
sum() combines them. For a hot counter, prefer it over AtomicLong.
AtomicInteger / AtomicLong / AtomicBoolean
AtomicReference<T> // CAS on an object reference
AtomicIntegerArray / AtomicLongArray // per-element atomicity
LongAdder / LongAccumulator // high-contention counters — much faster
AtomicStampedReference<T> // reference + version stamp -> defeats the ABA problem
// AtomicReference: swap an immutable object atomically
AtomicReference<Config> cfg = new AtomicReference<>(initial);
cfg.updateAndGet(c -> c.withTimeout(30));
CAS only asks "is the value the same?", not "was it never changed?". If a value goes A → B → A,
your CAS succeeds even though the world moved underneath you — which matters for lock-free
stacks and linked structures. AtomicStampedReference attaches a version counter so
A-with-stamp-1 and A-with-stamp-3 are distinguishable.
07. Explicit locks
ReentrantLock does everything synchronized does, plus the things it
can't: timeouts, interruptible waits, fairness, and multiple wait-sets.
private final ReentrantLock lock = new ReentrantLock();
public void work() {
lock.lock();
try {
// critical section
} finally {
lock.unlock(); // MUST be in finally — an exception would leak the lock forever
}
}
synchronized releases automatically on any exit, including a thrown exception. With
ReentrantLock, forgetting finally means the lock is
never released and every other thread blocks forever. Never put
lock() inside the try — if it throws, you'd unlock a lock you never
acquired (IllegalMonitorStateException).
synchronized cannot do
// 1. Try, and give up immediately rather than blocking
if (lock.tryLock()) {
try { work(); } finally { lock.unlock(); }
} else {
doSomethingElse(); // no waiting around
}
// 2. Try with a timeout — the single best deadlock defence there is
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try { work(); } finally { lock.unlock(); }
} else {
log.warn("could not acquire lock in 500ms — backing off");
}
// 3. Interruptible acquisition (synchronized blocks are NOT interruptible)
lock.lockInterruptibly();
// 4. Fairness — longest waiter goes first (slower, but no starvation)
ReentrantLock fair = new ReentrantLock(true);
ReadWriteLock — many readers, one writer
private final ReadWriteLock rw = new ReentrantReadWriteLock();
private final Map<String,String> cache = new HashMap<>();
public String get(String k) {
rw.readLock().lock(); // MANY threads can hold this at once
try { return cache.get(k); }
finally { rw.readLock().unlock(); }
}
public void put(String k, String v) {
rw.writeLock().lock(); // EXCLUSIVE — blocks all readers and writers
try { cache.put(k, v); }
finally { rw.writeLock().unlock(); }
}
A ReadWriteLock only pays off when reads are frequent and long.
Its bookkeeping is heavier than a plain lock, so for short reads a
ReentrantLock often wins — and ConcurrentHashMap beats both for the
cache case above. Note you cannot upgrade a read lock to a write lock (that deadlocks); you must
release and re-acquire.
Condition — wait/notify, done properly
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // separate queues — this is the win
private final Condition notEmpty = lock.newCondition();
private final Queue<T> q = new ArrayDeque<>();
private final int cap = 10;
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (q.size() == cap) notFull.await(); // ALWAYS in a while, never an if
q.add(item);
notEmpty.signal(); // wake only a consumer
} finally { lock.unlock(); }
}
public T take() throws InterruptedException {
lock.lock();
try {
while (q.isEmpty()) notEmpty.await();
T item = q.poll();
notFull.signal(); // wake only a producer
return item;
} finally { lock.unlock(); }
}
while loop, never an if
Two reasons. Spurious wakeups: the JVM is permitted to wake a waiting thread
for no reason at all. Stolen conditions: by the time you reacquire the lock,
another thread may have already consumed what you were woken for. The condition must be
re-checked after waking — which is exactly what while does.
synchronized (lock) {
while (!condition) {
lock.wait(); // releases the monitor and waits. MUST hold it to call this.
}
// ... use the resource
lock.notifyAll(); // prefer notifyAll: notify() wakes ONE arbitrary thread,
} // which may be the wrong one -> lost signal -> hang.
synchronized |
ReentrantLock |
|
|---|---|---|
| Release | Automatic | Manual — finally or bust |
| Timeout / try | No | Yes |
| Interruptible | No | Yes |
| Fairness option | No | Yes |
| Multiple wait-sets | No — one per object | Yes — many Conditions |
| Verdict | Default choice — simpler, safer | When you need a feature above |
The coordination toolkit
// CountDownLatch — wait for N things to finish. ONE SHOT, cannot be reset. (Go: WaitGroup)
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
executor.submit(() -> { try { work(); } finally { latch.countDown(); } });
}
latch.await(); // blocks until the count hits 0
// CyclicBarrier — N threads wait for EACH OTHER, then all proceed. Reusable.
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("all arrived"));
barrier.await(); // each thread blocks here until the 3rd arrives
// Semaphore — limit concurrent access to N permits
Semaphore sem = new Semaphore(5); // at most 5 at a time
sem.acquire();
try { useResource(); } finally { sem.release(); } // release in finally!
// Phaser — like a reusable barrier with a dynamic party count (rarely needed)
08. ExecutorService & thread pools
Creating a thread per task is a mistake: each costs ~1MB of stack and a syscall, and unbounded thread creation will kill your JVM. An executor separates what to run from which thread runs it, and reuses a fixed pool.
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.execute(() -> System.out.println("fire and forget")); // Runnable, returns nothing
Future<String> f = pool.submit(() -> "I return a value"); // Callable, returns a Future
pool.shutdown(); // no new tasks; finish what's queued
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { // wait for them
pool.shutdownNow(); // interrupt the stragglers
}
Pool threads are non-daemon by default, so a forgotten
shutdown() keeps your JVM alive forever. Since Java 19,
ExecutorService implements AutoCloseable, so
try (var pool = Executors.newFixedThreadPool(4)) { … } closes and awaits
termination for you.
The factory methods — and why you probably shouldn't use them
| Factory | Behaviour | The catch |
|---|---|---|
newFixedThreadPool(n) |
n threads, unbounded queue | Queue grows forever → OOM under overload |
newCachedThreadPool() |
Unbounded threads, reused for 60s | Unbounded threads → OOM under a burst |
newSingleThreadExecutor() |
One thread, unbounded queue | Sequential; queue can still explode |
newScheduledThreadPool(n) |
Delayed / periodic tasks | One task throwing silently kills the schedule |
newWorkStealingPool() |
ForkJoinPool, one deque per thread | Doesn't preserve submission order |
newVirtualThreadPerTaskExecutor() |
Java 21: a new virtual thread per task | Not a pool — don't try to size it |
Every Executors factory is unbounded somewhere — in the queue or in the
thread count. Under load that turns backpressure into an OutOfMemoryError.
Construct a ThreadPoolExecutor with a bounded queue and an
explicit rejection policy so overload degrades instead of exploding.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // corePoolSize — kept alive even when idle
8, // maximumPoolSize — only grown when the QUEUE IS FULL
60, TimeUnit.SECONDS, // keepAliveTime — how long extra (non-core) threads linger
new ArrayBlockingQueue<>(100), // BOUNDED work queue — this is the important bit
new ThreadFactoryBuilder().setNameFormat("worker-%d").build(), // named threads = readable dumps
new ThreadPoolExecutor.CallerRunsPolicy() // what to do when full (see below)
);
A ThreadPoolExecutor grows past corePoolSize
only when the queue is full — not when tasks are waiting. So with an
unbounded queue, maximumPoolSize is never reached and is
completely ignored. That's precisely why newFixedThreadPool passes the same number
for both.
new ThreadPoolExecutor.AbortPolicy() // default: throw RejectedExecutionException
new ThreadPoolExecutor.CallerRunsPolicy() // the SUBMITTING thread runs it -> natural backpressure
new ThreadPoolExecutor.DiscardPolicy() // silently drop it (dangerous)
new ThreadPoolExecutor.DiscardOldestPolicy() // drop the oldest queued task, then retry
CallerRunsPolicy is usually the right answer
When the pool is saturated, the submitter is forced to execute the task itself. That slows the producer down — it can't submit again until it finishes — which is exactly the backpressure you want. The queue stops growing, nothing is dropped, and the system degrades gracefully instead of falling over.
How big should the pool be?
int cores = Runtime.getRuntime().availableProcessors();
// CPU-bound work: more threads than cores just adds context switching
int cpuBound = cores; // (or cores + 1 to cover the odd page fault)
// I/O-bound work: threads are mostly parked waiting, so you want many more
int ioBound = cores * (1 + waitTime / computeTime);
// e.g. 8 cores, 100ms waiting per 5ms computing -> 8 * (1 + 20) = 168 threads
// This formula is exactly why virtual threads exist — see section 12.
Callable and Future
Runnable r = () -> System.out.println("hi"); // void, cannot throw checked exceptions
Callable<String> c = () -> { return "result"; }; // returns a value, CAN throw checked
Future<String> f = pool.submit(c);
f.get(); // BLOCKS until done. Throws ExecutionException wrapping any failure.
f.get(1, TimeUnit.SECONDS); // ...or gives up. TimeoutException.
f.isDone();
f.cancel(true); // true = interrupt it if it's already running
Future.get() blocks — that's its whole problem
Submitting ten tasks then calling get() on each in turn is barely better than doing
them in sequence: you're blocked at the first one while the rest finish invisibly. You can't
chain, combine, or react to a Future without blocking a thread. That limitation is
exactly what CompletableFuture was invented to remove.
execute() sends a thrown exception to the thread's
UncaughtExceptionHandler. But submit()
captures it in the Future — so if you never call get(), the
exception is silently swallowed and your task just… doesn't work. Always get(), or
handle failures explicitly.
09. CompletableFuture
CompletableFuture is a Future you can chain. Instead of
blocking to ask "are you done?", you attach "when you're done, do this next" — and no thread
waits.
// async + returns a value
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> fetchUser());
// async + returns nothing
CompletableFuture<Void> g = CompletableFuture.runAsync(() -> log());
// already-finished
CompletableFuture<String> done = CompletableFuture.completedFuture("value");
// with your own pool — ALWAYS do this in production (the default is the common FJ pool)
CompletableFuture.supplyAsync(() -> fetchUser(), myExecutor);
CompletableFuture.supplyAsync(() -> fetchUserId())
.thenApply(id -> loadUser(id)) // map: T -> U (sync-ish, same thread)
.thenCompose(user -> loadOrdersAsync(user)) // flatMap: T -> CompletableFuture<U>
.thenAccept(orders -> render(orders)) // consume: T -> void
.thenRun(() -> log.info("done")); // ignore the value entirely
thenApply vs thenCompose — it's map vs flatMap
If your function returns a plain value, use thenApply. If it
returns another CompletableFuture, use thenCompose — otherwise you end up holding a
CompletableFuture<CompletableFuture<T>>, which is exactly as awkward as
it looks.
// WRONG — a future inside a future
CompletableFuture<CompletableFuture<User>> bad =
CompletableFuture.supplyAsync(() -> id).thenApply(id -> loadUserAsync(id));
// RIGHT — flattened
CompletableFuture<User> good =
CompletableFuture.supplyAsync(() -> id).thenCompose(id -> loadUserAsync(id));
CompletableFuture<String> user = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> prefs = CompletableFuture.supplyAsync(() -> fetchPrefs());
// both run CONCURRENTLY; combine when both finish
CompletableFuture<String> page = user.thenCombine(prefs, (u, p) -> render(u, p));
// wait for many
CompletableFuture.allOf(f1, f2, f3).join(); // Void — collect results from each yourself
CompletableFuture.anyOf(f1, f2, f3).join(); // the FIRST one to finish wins
// A useful idiom: allOf + collect
List<CompletableFuture<String>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> load(id), pool))
.toList();
List<String> results = CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList())
.join();
cf.exceptionally(ex -> "fallback") // only on failure -> supply a default
.handle((result, ex) -> ex != null ? "fallback" : result) // ALWAYS runs; sees both
.whenComplete((result, ex) -> log(result, ex)); // ALWAYS runs; changes nothing
// Timeouts (Java 9+)
cf.orTimeout(2, TimeUnit.SECONDS); // fail with TimeoutException
cf.completeOnTimeout("default", 2, TimeUnit.SECONDS); // ...or quietly substitute a value
If supplyAsync throws, every thenApply after it is
skipped and the exception travels to the first
exceptionally/handle. If you never attach one and never call
join(), the failure vanishes silently — no log, no crash. Always
terminate a chain with error handling.
join() vs get()
Same blocking behaviour, different exceptions. get() throws
checked InterruptedException/ExecutionException;
join() throws unchecked CompletionException — which
is why join() works inside lambdas and get() doesn't.
*Async suffix
thenApply may run on whichever thread completed the previous stage (or even the
calling thread). thenApplyAsync always hands the work to a pool. Use the
Async variants — with your own executor — whenever a stage does
anything slow, or you'll accidentally tie up a completing thread.
10. Concurrent collections
Never wrap a HashMap in a lock and call it a day. java.util.concurrent
ships collections designed for concurrency from the ground up.
| Instead of | Use | How it works |
|---|---|---|
HashMap |
ConcurrentHashMap |
Per-bucket locking + CAS — reads are lock-free |
ArrayList (read-heavy) |
CopyOnWriteArrayList |
Every write copies the whole array |
TreeMap |
ConcurrentSkipListMap |
Lock-free sorted skip list |
| A hand-rolled queue | ArrayBlockingQueue / LinkedBlockingQueue |
Blocking producer/consumer handoff |
| A non-blocking queue | ConcurrentLinkedQueue |
Lock-free (CAS) — never blocks |
Collections.synchronizedMap |
Basically nothing | One global lock — a bottleneck, and still not safe for compound ops |
ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap<>();
// BROKEN — two atomic calls are not one atomic operation
if (!map.containsKey(k)) map.put(k, 1); // another thread can slip in between
// CORRECT — single atomic operations
map.putIfAbsent(k, 1);
map.computeIfAbsent(k, key -> expensiveLoad(key)); // computed at most once per key
map.compute(k, (key, v) -> v == null ? 1 : v + 1);
map.merge(k, 1, Integer::sum); // the cleanest counter you'll write
map.getOrDefault(k, 0);
ConcurrentHashMap is fast
Reads never lock — nodes hold volatile fields, so a get is a plain
read. Writes lock only one bucket (Java 8+ synchronises on the first node of
that bin, after trying CAS for an empty bin). Two threads writing different keys almost never
contend. Compare that to Collections.synchronizedMap, where one giant lock
serialises every single operation.
HashMap allows them; ConcurrentHashMap throws NPE. The reason is
genuinely subtle: with a plain map you'd disambiguate with containsKey(), but
concurrently that answer can change between the two calls — so get() returning null
would be irreducibly ambiguous ("absent" vs "mapped to null"). Banning null removes the
ambiguity entirely.
Concurrent collections never throw ConcurrentModificationException. Their iterators
are weakly consistent: they reflect the state at some point since creation and
may or may not show later changes. Similarly, size() is an estimate that may be
stale the instant it returns — never use it in a
for (int i = 0; i < map.size(); i++).
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100); // BOUNDED = backpressure
// Producer — blocks when full, which throttles it naturally
queue.put(task);
// Consumer — blocks when empty, no busy-waiting
Task t = queue.take();
// The full method set — four behaviours for each operation:
// Throws Returns special Blocks Times out
// insert add(e) offer(e) put(e) offer(e, t, unit)
// remove remove() poll() take() poll(t, unit)
// examine element() peek() — —
// SynchronousQueue — capacity ZERO: a direct handoff, each put waits for a take.
// This is the closest thing Java has to an unbuffered Go channel.
CopyOnWriteArrayList copies the entire array on every write
That's O(n) per mutation. It's excellent for listener lists — write once at startup, read constantly, iterate without locking or CME. It is catastrophic for anything write-heavy.
11. Deadlock, livelock, starvation
A deadlock is two threads each holding the lock the other needs, both waiting forever. Nothing throws, nothing logs — the program simply stops.
final Object lockA = new Object();
final Object lockB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockA) { // gets A
Thread.sleep(50);
synchronized (lockB) { } // wants B — held by t2. Waits forever.
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) { // gets B
Thread.sleep(50);
synchronized (lockA) { } // wants A — held by t1. Waits forever.
}
});
// Both threads BLOCKED. No exception, no log, no CPU usage. The app just hangs.
1. Mutual exclusion — a resource can't be shared. 2. Hold and wait — you hold one while requesting another. 3. No preemption — locks can't be forcibly taken. 4. Circular wait — a cycle in the "waits for" graph. Break any one and deadlock is impossible. In practice you break #4.
// Rule: EVERY thread acquires locks in the same defined order. No cycle can form.
void transfer(Account from, Account to, int amount) {
Account first = from.id < to.id ? from : to; // order by a stable, unique key
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
// Now transfer(A,B) and transfer(B,A) both lock A then B. Circular wait is impossible.
boolean transfer(Account from, Account to, int amount) throws InterruptedException {
if (from.lock.tryLock(1, TimeUnit.SECONDS)) {
try {
if (to.lock.tryLock(1, TimeUnit.SECONDS)) {
try {
from.debit(amount);
to.credit(amount);
return true;
} finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); } // couldn't get the second -> RELEASE the first and retry
}
return false;
}
The neighbours
| Problem | What's happening | CPU |
|---|---|---|
| Deadlock | Everyone blocked in a cycle, forever | Idle |
| Livelock | Threads keep responding to each other and never progress | Busy — looks healthy! |
| Starvation | One thread never gets the lock; others hog it | Busy |
Deadlock: two people in a corridor, each waiting for the other to move first — frozen. Livelock: both politely step aside, at the same time, again and again — lots of motion, zero progress. Starvation: one person keeps getting shoved to the back of the queue while everyone else walks through.
jps # find the pid
jstack <pid> # dump every thread's stack
# jstack DETECTS deadlocks for you and prints:
#
# Found one Java-level deadlock:
# =============================
# "Thread-1":
# waiting to lock monitor 0x00007f... (object 0x000000076ab, a java.lang.Object),
# which is held by "Thread-0"
# "Thread-0":
# waiting to lock monitor 0x00007f... (object 0x000000076ac, a java.lang.Object),
# which is held by "Thread-1"
# This is why naming your threads matters — "pool-1-thread-7" tells you nothing.
12. Virtual threads (Java 21)
A virtual thread is a thread the JVM manages itself instead of the OS. They cost a few hundred bytes instead of a megabyte, so you can have millions — and the "thread per request" model becomes viable again.
A platform thread maps 1:1 to an OS thread: ~1MB of stack, a syscall to create, and a costly context switch. So you can afford maybe a few thousand — which is why we invented thread pools, then async callbacks, then reactive streams, all to avoid blocking a precious thread. Virtual threads make blocking cheap again, which deletes that entire tower of complexity.
// One-off
Thread.startVirtualThread(() -> System.out.println("hi"));
Thread t = Thread.ofVirtual().name("worker").start(runnable);
t.join();
// The real way — an executor that makes a NEW virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Thread.sleep(1000); // blocks the VIRTUAL thread — the carrier is freed!
return fetchSomething();
});
}
} // close() waits for all of them
// A million concurrent tasks. With platform threads this would OOM instantly.
How they work
// Virtual threads run on a small pool of PLATFORM threads called "carriers"
// (a ForkJoinPool sized to your core count).
//
// 1. Virtual thread MOUNTS onto a carrier and runs.
// 2. It hits a blocking call (I/O, sleep, lock).
// 3. The JVM UNMOUNTS it: its stack is copied to the heap, the carrier is released.
// 4. The carrier immediately runs another virtual thread.
// 5. When the I/O completes, the virtual thread is remounted (on any carrier) and continues.
//
// Blocking a virtual thread costs a heap copy, not an OS context switch.
Platform threads are hired staff — expensive, so you keep a small pool and never let one sit idle. Virtual threads are sticky notes: write one per task and stick it on the board. A handful of real workers (carriers) pick up whatever note is actionable. A note waiting on someone else just sits there costing nothing.
Pooling exists to amortise expensive creation. Virtual threads are cheap to create, so a pool
only reintroduces the limit you were escaping. Never write
newFixedThreadPool of virtual threads — use
newVirtualThreadPerTaskExecutor(). Similarly, don't cache anything in
ThreadLocal on them: with a million threads that's a million copies.
If a virtual thread blocks inside a synchronized block (or in a
native/JNI call), it cannot unmount — it's pinned to its carrier, and that
carrier is stuck too. Enough pinned threads and you starve the carrier pool. The fix in Java 21
is to use ReentrantLock instead of synchronized around blocking calls.
(JDK 24 largely removes this limitation, but on 21 it's real.) Diagnose with
-Djdk.tracePinnedThreads=full.
| Platform thread | Virtual thread | |
|---|---|---|
| Backed by | An OS thread | The JVM (runs on a carrier) |
| Cost | ~1MB stack | ~a few hundred bytes, grows on demand |
| Practical count | Thousands | Millions |
| Pool them? | Yes | No — never |
| Good for | CPU-bound work | I/O-bound work |
| Daemon? | Configurable | Always daemon; priority is ignored |
You still only have N cores. Virtual threads solve waiting, not computing. For CPU-bound work, a platform-thread pool sized to your cores is still exactly right.
13. Gotchas — where Java surprises you
1. Thread.sleep() does not release locks.
wait() releases the monitor; sleep() keeps everything it holds. So
sleeping inside synchronized blocks every other thread for the full duration.
wait() also requires you to hold the monitor — calling it without one
throws IllegalMonitorStateException.
2. Double-checked locking is broken without volatile.
instance = new Singleton() is three steps (allocate, construct, assign), and the
JIT may reorder the assignment before the constructor finishes. Another thread
then sees a non-null reference to a half-built object. Marking the field
volatile forbids that reordering and makes the idiom correct. Better still: use the
static holder idiom or an enum.
3. Collections.synchronizedList doesn't make compound actions safe.
Each call is atomic, but if (!list.contains(x)) list.add(x); is two calls with a
gap. Iteration isn't safe either — you must manually synchronized (list) around the
whole loop, or you'll get a ConcurrentModificationException.
4. SimpleDateFormat is not thread-safe.
It holds mutable state in a field, so a shared static instance silently produces wrong dates
under load — no exception, just garbage. Use java.time (DateTimeFormatter
is immutable and thread-safe). Same trap: Random (use
ThreadLocalRandom) and StringBuilder.
5. Exceptions in submit() vanish.
submit() stores the throwable in the Future instead of surfacing it.
If you never call get(), nothing is ever logged. execute() at least
routes it to the uncaught-exception handler. The same applies to a
CompletableFuture chain with no exceptionally/handle.
6. A ScheduledExecutorService task that throws stops rescheduling.
scheduleAtFixedRate cancels the whole schedule if the task ever throws — silently.
Your periodic job just stops running and nobody finds out for a week. Always wrap the body in
try/catch(Throwable).
7. ThreadLocal leaks memory in a thread pool.
Pool threads live forever, so anything you put in a ThreadLocal is never collected
— and the next task on that thread sees the previous task's value. Always
remove() in a finally.
8. notify() can lose a signal; prefer notifyAll().
notify() wakes one arbitrary waiter. If multiple threads wait on
different conditions on the same monitor, it may wake one whose condition is still false — which
goes back to sleep, and the signal is gone forever. Use notifyAll(), or use
separate Condition objects.
9. Non-volatile long/double can tear.
The JMM permits a 64-bit read/write to be split into two 32-bit halves, so another thread can
observe a value that was never written — half old, half new. Rare on 64-bit
JVMs, but specified and legal. volatile or AtomicLong forbids it.
10. Making every method synchronized is not thread safety.
It makes each call atomic, not each use case.
if (!list.isEmpty()) list.remove(0); still races. Thread safety is about
invariants, not methods — the whole compound action needs one lock.
14. Interview Q&A
Q: start() vs run()?
start() creates a new thread which then invokes run(). Calling
run() directly executes it on the current thread — a plain method call, no
concurrency. Calling start() twice throws IllegalThreadStateException.
Q: volatile vs synchronized?
volatile gives visibility and ordering for one field; it never blocks and gives no
atomicity for compound operations. synchronized gives visibility
and mutual exclusion over a block. Use volatile for a flag or a
reference swap; anything read-modify-write needs a lock or an atomic.
Q: Why isn't volatile int i; i++; thread-safe?
i++ is read-modify-write. volatile makes each individual read and
write visible, but another thread can interleave between them, so updates are still lost. Use
AtomicInteger.incrementAndGet() or a lock.
Q: What is happens-before?
The JMM's visibility guarantee: if A happens-before B, A's writes are visible to B and can't be
reordered past it. Created by program order, unlock→lock on the same monitor, volatile
write→read, start(), join(), and final-field publication — and it's
transitive. No edge + concurrent access with a write = data race = no guarantees at all.
Q: BLOCKED vs WAITING?
BLOCKED is waiting to acquire a monitor — it resumes automatically when the holder
releases. WAITING is waiting for an explicit signal
(notify/signal/unpark/thread termination) — someone must
actively wake it.
Q: synchronized vs ReentrantLock?
ReentrantLock adds tryLock, timeouts, interruptible acquisition,
fairness, and multiple Conditions — at the cost of manual unlock() in
a finally. synchronized is simpler, releases automatically, and is the
default. Both are reentrant.
Q: How does CAS work, and what's the ABA problem?
CAS atomically writes a new value only if the current value equals the expected one; failure
means retry in a loop. It's optimistic and lock-free, so no thread blocks. ABA: a value changes
A→B→A, so CAS succeeds despite intervening modification.
AtomicStampedReference adds a version to distinguish them.
Q: What causes deadlock and how do you prevent it?
All four Coffman conditions holding at once: mutual exclusion, hold-and-wait, no preemption, and
circular wait. Break one — usually circular wait, by imposing a global lock ordering. Other
tools: tryLock with timeout, and holding fewer locks (ideally one).
Q: Why is ConcurrentHashMap better than Hashtable?
Hashtable and synchronizedMap lock the entire map on every operation.
ConcurrentHashMap never locks reads and locks only a single bucket on write, so
unrelated keys don't contend. It also offers atomic compound ops (computeIfAbsent,
merge) which a lock-wrapped map can't express safely.
Q: How do you size a thread pool?
CPU-bound: roughly the number of cores. I/O-bound:
cores × (1 + waitTime/computeTime) — often hundreds. And in production build a
ThreadPoolExecutor with a bounded queue and
CallerRunsPolicy, because every Executors factory is unbounded
somewhere and will OOM under load.
Q: What are virtual threads and when do they help?
JVM-managed threads that unmount from their carrier when they block, so a blocking call costs a
heap copy instead of an OS context switch. Millions are practical, which makes
thread-per-request viable and removes the need for reactive plumbing. They help
I/O-bound work only; don't pool them; watch out for pinning inside
synchronized.
Q: How do you make a class thread-safe?
Best: don't share (confine it to one thread). Next best: don't mutate (immutable + final fields is automatically safe). Last resort: synchronise every access — reads included — with the same lock, keep the critical section small, and never call unknown code while holding it.
15. Cheat sheet
-
Three problems: atomicity (
i++is 3 ops) · visibility (caches + JIT) · ordering (reordering is legal). - States: NEW → RUNNABLE → (BLOCKED = wants a lock | WAITING = needs a signal | TIMED_WAITING) → TERMINATED.
-
start()= new thread ·run()= plain method call. Interrupt is a request; restore the flag if you swallow it. -
volatile: visibility + ordering, no atomicity, never blocks. Good for flags. -
synchronized: visibility + mutual exclusion. Reentrant. Instance locksthis; static locksClass— different locks. Lock a private final object. -
happens-before: program order · unlock→lock (same monitor) · volatile
write→read ·
start()·join()· final fields · transitive. -
Atomics: CAS = optimistic retry loop, lock-free.
LongAdderunder high contention. ABA →AtomicStampedReference. -
ReentrantLock:tryLock· timeout ·lockInterruptibly· fairness ·Condition. Alwaysunlock()infinally. -
Wait: always in a
whileloop (spurious wakeups) · prefernotifyAll()·wait()releases the lock,sleep()does not. -
Pools: grows past core only when the queue is full → unbounded
queue means max is ignored. Use a bounded queue +
CallerRunsPolicy. Size: cores (CPU) · cores×(1+wait/compute) (I/O). -
CompletableFuture:thenApply(map) ·thenCompose(flatMap) ·thenCombine(join two) ·allOf/anyOf·exceptionally/handle. Pass your own executor. -
Collections:
ConcurrentHashMap(no nulls, atomicmerge/computeIfAbsent) ·CopyOnWriteArrayList(read-heavy only) ·BlockingQueue(bounded = backpressure). -
Deadlock: 4 Coffman conditions · fix with
global lock ordering or
tryLocktimeout · diagnose withjstack. -
Virtual threads (21): cheap, millions, unmount on block. I/O only · never pool
· beware pinning in
synchronized. - Safety, in order: don't share → don't mutate → synchronise everything with one lock.