On this page
Concurrency isn't a language feature bolted onto Java — it's a direct answer to a hardware problem. Every idea below, from volatile to CompletableFuture, exists because of the same underlying fact: CPU cores got fast, memory didn't keep up, and caches created a world where two threads can honestly disagree about the value of a variable. Keep that one sentence in mind and the rest of this guide reads as consequences, not trivia.
00JVM memory architecture
Before threads, you need the map they live on. Each Java program you run is one OS process and one JVM instance — completely isolated from every other JVM instance, even one running the exact same program. Two JVMs never share heap memory; the same virtual address in each points to different physical RAM.
Inside a single JVM process, memory splits into segments that are either shared across every thread or private to one thread:
Shared vs. private memory inside one JVM process
| Memory area | Shared or private? | Sync needed? |
|---|---|---|
| Code segment | Shared (all threads in process) | No — read-only |
| Data segment (statics) | Shared | Yes |
| Heap | Shared | Yes |
| Stack | Private per thread | No |
| Registers | Private per thread | No |
| Program Counter | Private per thread | No |
From bytecode to a CPU register
Source compiles to bytecode (.class), which the JVM interprets at first, then profiles for "hot" methods and compiles to native machine code via the JIT. Each thread has its own register set and Program Counter — the PC tracks exactly which instruction a thread is on, and registers hold the JIT's optimized intermediate values. Both are saved and restored on every context switch.
CPU cache hierarchy and access latency
volatile has to fight against them.counter = 5 — the write lands in Core 1's L1 cache first, not immediately in RAM. Thread B on Core 2 reads counter from its own cache and may still see the old value. This single fact — the CPU cache visibility problem — is the root cause behind volatile, synchronized, and the entire Java Memory Model.
Context switching, briefly
When the OS scheduler moves a core from Thread A to Thread B, it saves A's registers, program counter, and stack pointer to a control block in RAM, then restores B's. This is expensive for two reasons beyond the save/restore cost itself: the cache is now "cold" for the new thread, so its next few memory accesses pay full RAM latency, and — when switching processes rather than threads — the CPU's address-translation cache is flushed too. This is part of why Project Loom's virtual threads, scheduled by the JVM rather than the OS, are cheaper to switch.
01Why concurrency exists
Concurrency is not a software abstraction invented for convenience — it's a consequence of hardware history.
- Single-core era: the OS faked parallelism via time-slicing; only one instruction ever executed at a time.
- The memory wall: through the 1990s–2000s, clock speeds grew far faster than RAM access speed. CPU caches were built to bridge that gap.
- The multi-core turn (~2005): manufacturers hit a power/heat ceiling on clock speed and added cores instead. A modern laptop has 8–16; a server may have 128.
A single-threaded Java program on a 16-core machine uses roughly 6% of available CPU. Concurrency is the tool that unlocks the other 94% — at the cost of introducing race conditions, deadlocks, visibility bugs, and liveness failures that don't exist in single-threaded code. The rest of this guide is about taming exactly those costs.
02Thread life cycle — all six states
Every Java thread moves through exactly six states, defined by Thread.State.
The six Java thread states and their transitions
| State | OS thread exists? | Holds a lock? | How to exit |
|---|---|---|---|
| NEW | No | No | .start() |
| RUNNABLE | Yes | Maybe | Voluntary or blocking op |
| BLOCKED | Yes | No | Other thread releases the lock |
| WAITING | Yes | No | notify() / notifyAll() / join() returns |
| TIMED_WAITING | Yes | Maybe (see sleep) | Timeout or signal |
| TERMINATED | No | No | Final — cannot leave |
Java doesn't distinguish "ready to run" from "actually running on a core" — both are just RUNNABLE. The JVM delegates scheduling entirely to the OS, and tracking the exact sub-state would require expensive, platform-specific calls the abstraction deliberately avoids.
wait()/join()/park() and released whatever lock it held before suspending.
03Creating threads
new Thread(() -> ...) does nothing at the OS level. It allocates a plain Java object on the heap, copies thread-group, daemon status, and priority from the calling thread, stores the Runnable in a field, sets an internal threadStatus to 0 (NEW), and assigns an ID. Zero system calls.
Calling .start() is the real event: it's a thin wrapper around a native start0() that asks the OS (pthread_create on Linux, CreateThread on Windows) to create a kernel thread, allocate a native stack (often 512KB–1MB), and register it with the scheduler.
Thread t1 = new Thread(myRunnable); // auto-named "Thread-0"
Thread t2 = new Thread(myRunnable, "payment-processor-1"); // always do this in prod
Thread t3 = new Thread(group, myRunnable, "io-worker-1"); // + ThreadGroup
class MyThread extends Thread { public void run(){ /*...*/ } } // avoid — see §4
Naming threads is not cosmetic. A 2 a.m. thread dump full of Thread-47 tells you nothing; payment-processor-3 tells you exactly where to look.
Daemon vs. non-daemon
The JVM exits once every non-daemon thread has terminated — daemon threads are killed outright, mid-task, without ceremony. Use non-daemon for work that must finish (writing files, committing transactions); daemon for interruptible housekeeping (log flushing, heartbeats). Thread pool threads are non-daemon by default — this is exactly why forgetting executor.shutdown() keeps a JVM alive forever.
04Runnable vs. extending Thread
Both work. Only one scales.
implements Runnable — preferred
- Leaves your single inheritance slot free for a real base class.
- Models HAS-A behavior, not IS-A thread.
- The same task can run on a bare thread, a pool, or a scheduler.
- Lambda-compatible —
Runnableis a functional interface. - Callers never get a
Threadreference to fiddle with.
extends Thread — rarely justified
- Burns your one inheritance slot.
- Exposes
setName(),setPriority(), even deprecatedstop()to every caller. - Only reasonable if you're overriding Thread's own internals — genuinely rare.
05The synchronized keyword
Every Java object carries a monitor (intrinsic lock) as part of its header. At most one thread can own a given monitor at a time; everyone else trying to acquire it goes BLOCKED.
One monitor, one owner, everyone else blocked
this; static methods lock the Class object; a synchronized block can lock anything you choose.What synchronized actually guarantees
- Atomicity — the block runs as one indivisible unit from every other thread's point of view.
- Visibility — releasing a monitor flushes writes to main memory; acquiring one invalidates the local cache and re-reads from memory.
- Happens-before ordering — everything before a release is visible to whoever acquires next.
- Mutual exclusion — exactly one owner at a time.
- Reentrancy — the owning thread can re-acquire its own lock; the JVM tracks a hold count.
public class SafeCounter {
private final Object lock = new Object(); // nobody outside can grab this
private int count = 0;
public void increment() {
synchronized (lock) { count++; }
}
}
Deadlock — the classic setup
Prevent it with a consistent global lock order, tryLock(timeout) instead of blocking forever, the smallest possible lock scope, and by avoiding holding two locks at once wherever you can.
06The volatile keyword
volatile exists to defeat exactly the cache visibility problem from §00: writes go straight through to main memory, reads come straight from it, and a memory barrier stops the compiler/CPU reordering instructions across the access.
counter++ on a volatile int is still read-modify-write in three separate steps — two threads can both read 5, both compute 6, both write 6, and an increment silently vanishes. For atomic compound updates, use AtomicInteger or synchronized.
The double-checked-locking singleton
Without volatile, the JVM may reorder "initialize the object" and "assign the reference," letting another thread see a non-null, half-constructed instance. The memory barrier forbids exactly that reordering.
| Need | Use |
|---|---|
| Only visibility (a simple flag) | volatile |
| Atomic compound operation | synchronized or AtomicXxx |
| Mutual exclusion | synchronized or ReentrantLock |
| Lazy singleton init | volatile + double-checked locking |
| Multiple related variables | synchronized (all-or-nothing update) |
07wait() and notify()
Polling a condition in a loop with Thread.sleep(10) wastes CPU and adds latency. wait()/notify() suspend a thread completely — zero CPU — until precisely signaled.
Three iron rules
- Must be called from inside a
synchronizedblock on the same object — otherwiseIllegalMonitorStateException. wait()atomically releases the monitor and suspends — there is no gap where you'd hold the lock but miss a signal.- After being notified, the thread must re-acquire the monitor before it runs again — it goes
BLOCKEDfirst, never straight toRUNNABLE.
The exact sequence after notifyAll()
public synchronized void produce(T item) throws InterruptedException {
while (queue.size() == capacity) wait(); // WHILE, never if
queue.offer(item);
notifyAll();
}
public synchronized T consume() throws InterruptedException {
while (queue.isEmpty()) wait();
T item = queue.poll();
notifyAll();
return item;
}
notify() at all — and even a real notification can be stale by the time you actually re-acquire the lock, because another thread may have already consumed the resource while you were BLOCKED. Re-checking the condition on every wake is the only fix for both. Default to notifyAll() unless you can prove every waiter shares one condition and only one thread benefits per signal — notify() picking the wrong thread is a real, if quiet, deadlock.
08wait() vs. sleep()
| Property | Object.wait() | Thread.sleep() |
|---|---|---|
| Defined in | Object | Thread |
| Releases the monitor? | Yes | No |
| Needs synchronized? | Yes — throws otherwise | No restriction |
| Wakes early via | notify()/notifyAll() | interrupt() |
| Typical use | Inter-thread coordination | Fixed time delay |
sleep() inside a synchronized block holds the lock the entire time — every other thread trying to enter is blocked for the full duration. That's a common, easy-to-miss source of accidental serialization. Also worth knowing: Thread.sleep() is static — calling it on a thread reference (t.sleep(1000)) actually sleeps the current thread, not t.
09Thread.join()
t.join() blocks the caller until t reaches TERMINATED. Internally it's implemented with wait() on the Thread object itself — the JVM calls notifyAll() on it when the thread finishes.
In modern code, prefer CompletableFuture.allOf(...).join(), CountDownLatch.await(), or ExecutorService.invokeAll() — all more flexible than raw join().
10Runnable vs. Callable
Runnable
void run() — no return value, no checked exceptions. Fire-and-forget.
Callable<V>
V call() throws Exception — returns a value, can throw anything. Pair with Future<V>.
Exceptions thrown from call() are wrapped in ExecutionException and surface at future.get() — they don't crash the pool thread.
11Stopping a thread, correctly
ThreadDeath at an arbitrary point in the stack — including mid-way through updating a shared data structure — leaving it visibly inconsistent to every other thread, with no way to defend against it.
1. volatile flag
Simple and clean, but only checked between iterations — a thread blocked in I/O won't notice until it unblocks.
volatile boolean running = true;
while (running) { doWork(); }
// caller: running = false;
2. Thread.interrupt()
Sets an interrupt flag and throws InterruptedException out of sleep()/wait()/join() immediately — handles blocking calls the flag approach can't.
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore it!
}
Restoring the flag matters: catching InterruptedException clears it automatically, so if you swallow the exception silently, every caller further up the stack loses the fact that an interrupt ever happened.
For pools, prefer executor.shutdown() (graceful) and, if needed, shutdownNow(), which sends interrupt() to every running task — one more reason interrupt-responsive code matters even when you never touch a raw Thread.
12Thread pools — internals
Creating a thread costs a system call, a native stack allocation, and scheduler registration. At 1,000 requests/second, a thread-per-request model means 1,000 thread creations a second. Pools amortize that cost by reusing a fixed, warm set of threads.
| Factory | Behavior | Use for |
|---|---|---|
newFixedThreadPool(n) | Exactly n threads; tasks queue when busy | CPU-bound work |
newCachedThreadPool() | Grows on demand, no bound | risky can OOM under load |
newSingleThreadExecutor() | Exactly 1, sequential | Serializing access to a resource |
newScheduledThreadPool(n) | Delayed / periodic tasks | Heartbeats, scheduled jobs |
All four are convenience wrappers around ThreadPoolExecutor, which you should configure explicitly in production:
Where a submitted task actually goes
The four rejection policies
AbortPolicy(default) — throwsRejectedExecutionException.CallerRunsPolicy— the submitting thread runs the task itself, a natural, self-throttling back-pressure.DiscardPolicy— silently drops the task.DiscardOldestPolicy— drops the oldest queued task and retries submission.
Sizing rules of thumb
CPU-bound: corePoolSize = maxPoolSize = number of cores — more threads than cores just adds context-switch overhead with no throughput gain. IO-bound: cores × (1 + wait_time / compute_time) — a thread that's 90% waiting on IO needs roughly 10x more threads than cores to keep one core saturated. Either way: estimate, then load-test and tune from real measurements, never a guess.
13corePoolSize vs. maxPoolSize
Spring's ThreadPoolTaskExecutor follows the exact same algorithm as §12 above. With corePoolSize=4, maxPoolSize=8, queue capacity 100: tasks 1–4 start core threads; tasks 5–104 simply queue with no new threads; only task 105 (queue now full) creates the pool's fifth thread; task 109 with 8 threads busy and the queue still full finally triggers the rejection policy.
Expose getPoolSize(), getActiveCount(), and getQueue().size() as metrics — and alert well before the queue nears capacity, not after.
14java.util.concurrent — the full toolkit
Prefer these battle-tested building blocks over hand-rolled synchronized whenever they fit.
Locks
ReentrantLock
Explicit lock: tryLock(), timed acquisition, lockInterruptibly(), optional fairness, multiple Conditions. Always unlock() in finally.
ReadWriteLock
Many concurrent readers, one exclusive writer — ideal when reads vastly outnumber writes.
StampedLock
Adds an optimistic read mode with no lock at all, validated afterward — fastest option for read-heavy workloads (Java 8+).
Semaphore
N permits, N concurrent holders — natural fit for rate limiting and bounded resource pools.
Atomics — compare-and-swap, no locking
CAS is a single CPU instruction — no OS lock, no thread parking, no context switch — which is why atomics dramatically outperform synchronized for simple counters under contention.
Concurrent collections
| Type | Best for |
|---|---|
ConcurrentHashMap | General-purpose thread-safe map, bucket-level locking |
CopyOnWriteArrayList | Rare writes, frequent reads (e.g. listener lists) |
ConcurrentLinkedQueue | Lock-free, high-throughput producer/consumer |
ArrayBlockingQueue(n) | Bounded queue that blocks producers/consumers at capacity |
SynchronousQueue | Zero storage — every put is matched instantly by a take |
Synchronizers
15Mutex patterns in Java
Java has no class literally called Mutex — three things implement the same one-owner-at-a-time semantics, each with a real trade-off.
| Feature | synchronized | ReentrantLock | Semaphore(1) |
|---|---|---|---|
Must unlock in finally | No — JVM handles it | Yes | Yes |
| Reentrant | Yes | Yes | No |
tryLock() / timeout | No | Yes | Yes (tryAcquire) |
| Interruptible wait | No | Yes | Yes |
| Multiple conditions | No (one wait set) | Yes (newCondition()) | No |
| Release by a different thread | No | No | Yes |
ReentrantLock's biggest structural win over synchronized: multiple named Conditions. A bounded buffer needs to wake producers and consumers separately — synchronized only gives you one wait set and forces a blanket notifyAll(); ReentrantLock lets you notFull.signal() and notEmpty.signal() independently, waking only the threads that can actually proceed.
new ReentrantLock(true) serves waiting threads in FIFO order and prevents starvation — but fair locks run 2–10x slower in benchmarks because the JVM must maintain and check a queue instead of using the unfair fast path. Reach for fairness only once starvation is an observed problem, not by default.
16java.util.concurrent.Future
Future<V> is a handle to a background computation submitted via Callable.
Its real limitation is that get() is blocking with no callback, chaining, or combining story — exactly the gap CompletableFuture was built to close.
17ThreadLocal
Every Thread object owns a private ThreadLocalMap keyed by ThreadLocal instances. Two threads reading the same ThreadLocal get completely independent values — no sharing, no synchronization required.
remove(), request 2 — served by the same recycled thread — silently sees request 1's stale value, and the original object can never be garbage-collected while the thread lives. Always threadLocal.remove() in a finally block in anything that runs on pooled threads.
InheritableThreadLocal copies a value to a child thread at creation time — which means it does not propagate into thread-pool workers, since those threads are created once at pool startup, not per task. For request-context propagation into a pool, use a framework's TaskDecorator equivalent instead.
18CompletableFuture
CompletableFuture<V> lets you describe what happens when a result becomes available, without blocking anything to wait for it.
Composing two independent futures
thenApply(fn) is Stream.map — fn returns a plain value. thenCompose(fn) is Stream.flatMap — fn itself returns a CompletableFuture, and the result is flattened rather than nested. Using thenApply where the next step is async gives you the wrong type: CompletableFuture<CompletableFuture<User>>.
19The Java Memory Model
The JMM specifies exactly when a write by one thread becomes visible to another. Its central idea is happens-before: if action A happens-before action B, every effect of A is guaranteed visible to B.
- Program order — actions within one thread happen-before each other, in order.
- Monitor release/acquire — releasing a lock happens-before the next thread acquiring that same lock.
- volatile write/read — a volatile write happens-before any subsequent read of it.
- Thread start/join —
start()happens-before anything in the new thread; everything in a thread happens-before a successfuljoin()on it. - Transitivity — A before B, B before C, therefore A before C.
Happens-before chain across two threads
ready = true with a stale value.Safe publication means an object becomes visible to other threads only in a state the JMM guarantees is fully initialized — via final fields, a volatile write after construction, or static initialization (which the class-loading mechanism guarantees is safe on its own).
20Common concurrency bugs
Race condition
Behavior depends on thread interleaving. Classic check-then-act: if (!map.containsKey(k)) map.put(...) — fix with computeIfAbsent.
Data race
Two threads touch the same variable concurrently, at least one a write, with no synchronization — fix with AtomicInteger or a lock.
Deadlock
Circular lock waits — see §5.6. Fixed by consistent lock ordering or tryLock.
Livelock
Threads stay active but keep yielding to each other, never progressing — like two people repeatedly stepping the same way in a hallway.
Starvation
A thread never gets a fair shot at a resource because others monopolize it — fair locks (ReentrantLock(true)) address this directly.
Thread confinement
The actual fix behind most of the above: don't share. Local variables, ThreadLocal, and by-convention ownership all sidestep synchronization entirely.
21Interview questions
Core concepts
BLOCKED is involuntary — the thread wants to proceed but a monitor is held elsewhere. WAITING is voluntary — the thread called wait(), join(), or park() and released any lock it held. A BLOCKED thread holds nothing; it's competing for something.
No. It goes to TIMED_WAITING but keeps every monitor it holds — anyone else waiting on that lock is blocked for the whole sleep. wait() is the one that releases the lock, and it's the key difference between the two.
No — it's read, add, write as three separate steps. Two threads can read the same value, both compute the same increment, and both write it back, silently losing an update. Use AtomicInteger.incrementAndGet() or synchronized.
Spurious wakeups are explicitly legal per the JLS, and even a genuine notification can be stale by the time the thread re-acquires the lock — another thread may already have consumed the resource. Re-checking the condition on every wake handles both cases; if handles neither.
Synchronization
volatile gives visibility and ordering only — no atomicity, no mutual exclusion. synchronized gives visibility, atomicity, ordering, and exclusive access, at the cost of potential blocking. Use volatile for simple flags; synchronized/AtomicXxx for anything compound.
External code can call synchronized(myObject) on your instance and unintentionally contend with your own internal locking. A private final Object lock = new Object() keeps the lock encapsulated and safe from outside interference.
Thread pools
Unbounded max pool size plus a SynchronousQueue with zero buffering means every task with no idle thread available spawns a brand-new one. Under sustained load that's thousands of threads and an OOM. Always configure a bounded ThreadPoolExecutor explicitly.
Rejected tasks run on the submitting thread instead of being thrown away or erroring out. That thread is now busy executing the task instead of submitting more, which naturally throttles the whole pipeline under overload — back-pressure without dropping work.
CompletableFuture & ThreadLocal
thenApply is map — a synchronous value transform. thenCompose is flatMap — used when the next step itself returns a CompletableFuture, flattening the result instead of nesting it.
Pool threads persist for the pool's lifetime. A value set without a matching remove() stays in that thread's ThreadLocalMap indefinitely — visible (wrongly) to the next unrelated task on the same thread, and never eligible for GC while the thread lives. Always remove() in finally.
Advanced
A thread reads value A, another thread changes it to B and back to A, and the first thread's CAS succeeds because the value looks unchanged — even though it wasn't stable the whole time. Usually harmless; for lock-free structures where it matters (like some stacks), AtomicStampedReference adds a version counter to detect it.
A pool tuned for divide-and-conquer recursion, using work-stealing so idle threads pull tasks from busy threads' queues. CompletableFuture.supplyAsync() uses the common ForkJoinPool by default. Best for CPU-bound recursive work (parallel sort, parallel streams); for IO-bound work, a regular sized ThreadPoolExecutor is the better fit.
Quick reference
| Thread states | NEW → RUNNABLE → {BLOCKED, WAITING, TIMED_WAITING} → TERMINATED |
|---|---|
| sleep() vs wait() | sleep keeps the lock · wait releases it |
| volatile | Visibility + ordering only — never atomicity |
| synchronized | Visibility + atomicity + ordering + mutual exclusion |
| i++ atomic? | No — use AtomicInteger.incrementAndGet() |
| notify vs notifyAll | notifyAll is the safe default; notify only if exactly one waiter can ever benefit |
| wait() rule | Always inside a while loop, never if |
| Pool submission order | core threads → queue → extra threads → rejection policy |
| Stopping a thread | volatile flag or interrupt() — never Thread.stop() |
| ThreadLocal in pools | Always remove() in finally |
| thenApply vs thenCompose | thenApply = map · thenCompose = flatMap (next step is async) |
Resources
For going deeper than any single-page guide can, in roughly the order this page introduces the topics:
- Java Language Specification — Chapter 17, Threads and Locks (the source of truth for happens-before)
java.lang.ThreadandThread.State— official Javadocjava.util.concurrentpackage summary — official Javadoc (locks, atomics, executors, synchronizers)java.util.concurrent.CompletableFuture— official Javadoc
- Java Concurrency in Practice — Brian Goetz et al. — still the definitive text on the JMM, locking, and safe publication
- The Art of Multiprocessor Programming — Herlihy & Shavit — deeper theory on lock-free and wait-free algorithms
- Doug Lea's original
java.util.concurrentdesign notes and papers onForkJoinPooland work-stealing - OpenJDK Project Loom documentation — virtual threads and JVM-scheduled concurrency
- JEP 444 (Virtual Threads) — for how the thread model is evolving beyond the six-state model above
- Thread dumps from your own running services —
jstackor a profiler, read against the state table in §2 - Reproduce the race conditions in §20 yourself in a scratch project — seeing a lost update happen once is worth more than reading about it ten times
Go deeper