Java Locks & Synchronization
To coordinate thread execution and protect shared mutable state, Java provides synchronization primitives and highly flexible locking structures built on top of a common framework — AQS. Understanding these tools at a mechanical level is what separates engineers who "know the APIs" from engineers who can diagnose a deadlock in production at 2am.
This guide covers two conceptual groups:
- Mutual-exclusion locks (
synchronized,volatile,ReentrantLock,ReadWriteLock,StampedLock) — control which thread can run in a given critical section. - Coordination utilities (
CountDownLatch,CyclicBarrier,Semaphore,Exchanger,Phaser) — control when threads run relative to each other, without necessarily protecting shared state.
Both groups ultimately build on the same foundation: AQS.
Synchronization Primitives
Beginner: Race Condition in Action
Before learning synchronization, you must see what breaks without it:
public class RaceConditionDemo {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
counter++; // NOT atomic: read → increment → write (3 separate steps)
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
// Expected: 200,000
// Actual: ~130,000–190,000 (varies every run!)
System.out.println("Counter: " + counter);
}
}
Why? Both threads read
counter = 5, both compute5 + 1 = 6, both write6. One increment is lost. This is the classic "lost update" — the root cause of most concurrency bugs in production.
synchronized
Java's built-in monitor lock. Every Java object has an implicit monitor — synchronized gates entry to a block or method by acquiring that monitor.
// Synchronized method — implicitly locks on `this`
public synchronized void increment() { count++; }
// Synchronized block — more granular; choose any Object as the lock
public void increment() {
synchronized (this) { count++; }
}
// Static synchronized — locks on the Class object, not on `this`
public static synchronized void staticMethod() { }
🧠 Senior Deep Dive: Lock Escalation (JDK 1.6+)
Naïve synchronized delegates to an OS-level mutex on every use — an expensive context switch. JDK 1.6 introduced lock escalation to minimize this cost through three progressive states stored in the object header (mark word):
- Biased Locking: Assumes the block is accessed by only one thread. The JVM stamps the object header with the acquiring thread's ID. Subsequent lock/unlock by the same thread is essentially free — no CAS, no atomics, no OS call.
- Lightweight Locking: A second thread requests the lock. Biased locking is revoked (costly — requires a safepoint). The new thread uses CAS (Compare-And-Swap) to spin-wait for the lock, burning CPU cycles rather than blocking.
- Heavyweight Locking: If spinning fails beyond a threshold (default: ~10 iterations), the JVM escalates to a kernel-level mutex. The waiting thread is descheduled by the OS — no CPU wasted, but context-switch overhead is significant (~1–10μs).
🔒Lock Escalation: BIASED
Biased Locking (Single Thread Access)
Overhead: Near-Zero (No CAS, no atomic CPU cycles)
Contention Level: None (Only 1 thread acquires)
- Processing Mechanics:
- JVM stamps the thread ID directly into the object's Mark Word header.
- Subsequent locking/unlocking by the same thread requires no synchronization operations.
- Highly optimized for single-threaded local execution paths (e.g. legacy Vector/Hashtable uses).
💡 Toggle the tabs above to step through Biased, Lightweight, and Heavyweight lock escalation stages.
Biased locking was deprecated in JDK 15 and removed in JDK 21 because modern multi-threaded applications rarely exhibit single-thread access patterns, making the revocation overhead rarely worthwhile. If you're on JDK 21+, lock escalation starts directly at lightweight locking.
volatile
Ensures two things — visibility (a write in one thread is immediately visible to all other threads) and ordering (prevents CPU/JIT instruction reordering around the variable).
👶 Beginner: The Whiteboard vs. Pocket Notebook
Each CPU core has its own cache (L1/L2). When a thread writes a variable, it first writes to its core's cache, not directly to main RAM. Other threads, reading from their caches, may see a stale value — a visibility problem.
volatile tells the JVM: "every read must come from main memory, every write must flush to main memory immediately." Think of it as replacing each thread's private pocket notebook with a shared public whiteboard.
private volatile boolean running = true;
// Writer thread
running = false; // flushes to main RAM; all cores see it immediately
// Reader thread
while (running) { /* do work */ } // always reads from main RAM
🧠 Senior Deep Dive: Memory Barriers and the MESI Protocol
At the hardware level, volatile translates to memory barrier instructions (e.g., MFENCE on x86, DMB on ARM) around reads and writes.
The underlying cache coherence mechanism is the MESI protocol — each cache line is in one of four states:
| State | Meaning |
|---|---|
| Modified | Line is dirty; only this core has it; main RAM is stale |
| Exclusive | Line is clean; only this core has it |
| Shared | Line is clean; multiple cores have copies |
| Invalid | Line is stale; must be fetched before use |
When a volatile write occurs, the writing core broadcasts an invalidation message on the memory bus, forcing every other core's cached copy of that cache line to transition to Invalid. Their next read must then fetch from main RAM — which is the visibility guarantee volatile provides.
False Sharing: CPU caches operate on 64-byte chunks called cache lines. If two unrelated variables happen to reside on the same 64-byte line, a write to either one invalidates the entire line for all cores — even if the other variable never changed. This silently destroys cache efficiency for the innocent variable.
// PROBLEM: counter1 and counter2 likely share a cache line
class Counters {
volatile long counter1 = 0;
volatile long counter2 = 0; // Same cache line! Writing counter1 invalidates counter2's cache
}
// FIX: @Contended pads the field with extra bytes, forcing it onto its own cache line
// Requires JVM flag: -XX:-RestrictContended
class Counters {
@jdk.internal.vm.annotation.Contended
volatile long counter1 = 0;
@jdk.internal.vm.annotation.Contended
volatile long counter2 = 0; // Now on its own cache line
}
volatile ensures visibility and ordering, but NOT atomicity. count++ is three steps: read, increment, write. Multiple threads can still read the same stale value simultaneously. Use AtomicInteger or synchronized for atomic compound operations.
ReentrantLock vs synchronized
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Acquisition | Implicit (block entry) | Explicit (lock.lock()) |
| Release | Implicit (block exit) | Explicit (lock.unlock() in finally) |
| Timed Try-Lock | ❌ Impossible | ✅ tryLock(time, unit) |
| Interruptible | ❌ Impossible | ✅ lockInterruptibly() |
| Fairness Policy | ❌ Non-fair only | ✅ Fair or Non-fair (new ReentrantLock(true)) |
| Multiple Conditions | ❌ 1 condition per monitor | ✅ Multiple Condition objects via newCondition() |
AQS (AbstractQueuedSynchronizer)
AQS is the internal backbone for ReentrantLock, Semaphore, CountDownLatch, CyclicBarrier, and Phaser.
🛠️AQS: CAS State
AQS Volatile State & CAS Acquisition
Overview: Acquiring a lock translates to an atomic CAS (Compare-And-Swap) operation on a single volatile integer.
- Processing Mechanics:
- volatile int state: Holds the synch status. 0 = free/unlocked. N > 0 = locked (and re-entry depth).
- CAS(0 -> 1): Thread A attempts to write 1 to state atomically. If successful, it claims ownership of the lock.
- Ownership tracking: The thread reference is saved in exclusiveOwnerThread for reentrancy checks.
💡 Toggle between CAS State, CLH Queue structure, and Unparking tabs to explore AbstractQueuedSynchronizer internals.
How AQS Works
AQS maintains a single volatile integer state and a FIFO variant of a CLH Queue:
ReentrantLock:staterepresents hold count (, ).Semaphore:staterepresents available permit count.CountDownLatch:staterepresents remaining latch count.
Choosing the Right Lock — Decision Tree
🗺️Concurrency Decision Guide
Is the goal to protect shared mutable state (mutual exclusion)?
💡 Answer the questions inside the flowchart cards to navigate Java concurrency and lock choices.
Reentrancy, Lock Ownership & The ReadWriteLock Convoy Effect
Understanding the API surface of ReentrantLock, ReentrantReadWriteLock, and StampedLock is only half the battle. In production systems, locks fail due to subtle interactions between ownership tracking, anti-writer-starvation heuristics, and blocking I/O inside critical sections.
ReentrantReadWriteLock checks seemingly innocent new HTTP readers (R2..R200): because the head of the AQS queue is an exclusive Writer W, all new non-reentrant readers are forced to queue BEHIND W!
1. Reentrancy & Hold Count Mechanics
A lock is reentrant if a thread that already holds the lock can acquire it again without deadlocking itself. Both synchronized and ReentrantLock maintain an internal hold count (state in AQS) alongside an owner reference (exclusiveOwnerThread):
- First acquisition by Thread :
owner = T1,holdCount = 1. - Nested acquisition (
outer()callsinner()on the same lock):owner == currentThread, soholdCountincrements to2. - Each
unlock()decrementsholdCount. The lock is only released to other waiting threads whenholdCount == 0.
Not all locks are reentrant! Calling acquire twice on the same thread with StampedLock, Semaphore(1), or Go's sync.Mutex causes the thread to block waiting for itself forever:
var stamped = new StampedLock();
long stamp = stamped.writeLock();
// ❌ Calling stamped.writeLock() again on the same thread hangs forever!
long second = stamped.tryWriteLock(); // Returns 0 (failed)
stamped.unlockWrite(stamp);
2. Lock Ownership: What Knowing the Owner Enables (and Costs)
synchronized, ReentrantLock, and the write lock of ReentrantReadWriteLock record the exact owning Thread. This 8-byte reference unlocks four critical JVM capabilities:
- Reentrancy: Distinguishing whether a lock request is a recursive reentry by the current owner or contention from another thread.
- Thread Dump Blame (
jstack -l): When 200 threads block on aReentrantLock,jstackprints the exact owner (Locked ownable synchronizers: <0x...>) under the holding thread's stack trace. Conversely,ReadWriteLock.readLock()(shared by multiple threads) andStampedLock(unowned) print- Nonein thread dumps, making it vastly harder to spot which thread is holding a read lock during an outage! - Automatic Deadlock Cycle Detection: Because each exclusive lock knows its owner, the JVM can traverse the directed wait-for graph (
Thread A -> Lock 1 -> Thread B -> Lock 2 -> Thread A) and automatically printFound one Java-level deadlockinjstack. ReplacingReentrantLockwithSemaphore(1)blinds the JVM's deadlock detector. Condition.await()State Restoration: When a thread callscondition.await(), the lock atomically saves and releases its entireholdCount(even ifholdCount > 1) and restores the exact depth upon waking up.
The Cost of Ownership: A lock with ownership must be unlocked by the exact same thread that acquired it. Acquiring a ReentrantLock on an HTTP worker thread and calling unlock() inside an async CompletableFuture callback throws IllegalMonitorStateException and permanently leaks the lock. When you must hold a concurrency permit across thread boundaries, use Semaphore.
3. The lock() Idiom & Fairness Trade-Off
Always invoke lock() or lockInterruptibly() immediately before try, never inside try:
// ❌ WRONG: If lockInterruptibly() throws InterruptedException before acquiring the lock,
// finally still executes unlock(), throwing IllegalMonitorStateException and masking the root cause!
try {
lock.lockInterruptibly();
doWork();
} finally {
lock.unlock();
}
// ✅ CORRECT: Only enter try block AFTER lock is held
lock.lockInterruptibly();
try {
doWork();
} finally {
lock.unlock();
}
- Non-fair mode (default): Allows barging—if a running thread calls
lock()at the exact instant the lock is released, it cuts in front of parked queue threads, avoiding an OS context switch (~1–10 µs) while a queued thread wakes up. - Fair mode (
new ReentrantLock(true)): Enforces strict FIFO ordering but reduces total throughput significantly ("often much slower"per JDK Javadoc). Enable fair mode only when metrics prove tail-latency starvation.
4. Writer Starvation vs. The Production ReadWriteLock Convoy Outage
Textbook descriptions often claim that ReadWriteLock suffers from endless writer starvation if readers arrive continuously. While true for default pthread_rwlock on Linux, Java's ReentrantReadWriteLock and Go's sync.RWMutex actively prevent writer starvation:
- In Java's non-fair
ReentrantReadWriteLock,writerShouldBlock()is false, whilereaderShouldBlock()checksapparentFirstQueuedIsExclusive() / apparentlyFirstQueuedIsExclusive(). - Rule: Whenever a Writer is waiting at the head of the AQS queue, all new incoming Readers are blocked and queued behind (with a special exception allowing threads that already hold a read lock to re-acquire it via
ThreadLocalHoldCounterso they don't self-deadlock—whereas Go'ssync.RWMutexforbids recursiveRLock()entirely!).
Anatomy of a Real Production Outage: The 20-Second ReadLock Convoy
Suppose a service caches 5,000 tenant feature flags (5 MB map) read 10,000 times/sec by 200 Tomcat threads using rwl.readLock(), reloaded once a minute via rwl.writeLock(). A background sync thread acquires readLock() and streams the map directly over a TCP socket to a DR region:
t = 0 ms: Network congestion drops DR bandwidth to250 KB/s. The OS TCP send buffer fills up, and blocks insideSocketDispatcher.write0()(Java sockets have no write timeout!). holdsreadLock()for 20 seconds.t = 100 ms: The minute-reload job callswriteLock().lock(). Because holds the read lock, parks at the head of the AQS queue.t = 150 ms(The Convoy Trap): Regular HTTP requests () callreadLock().lock(). Even though read locks are shared,apparentFirstQueuedIsExclusive() / apparentlyFirstQueuedIsExclusive()sees Writer at the head of the queue and blocks every new reader behind !t = 170 ms: At 10,000 req/s, all 200 Tomcat threads park within 20 milliseconds. The entire service freezes for 20 seconds—including endpoints that don't even touchConfigCache!
Production Fixes for Read-Heavy Caches:
- Never perform I/O inside any lock (especially a read lock!). Copy references in microseconds and perform socket/disk I/O outside
finally. - Copy-on-Write with
volatileImmutable Snapshot (Best for read-heavy, infrequent-write caches likeConfigCache):public final class ConfigCache {private volatile Map<String, String> snapshot = Map.of(); // Immutable snapshotpublic String get(String key) { return snapshot.get(key); } // Lock-free! Never blockspublic Map<String, String> all() { return snapshot; } // Zero-copy returnpublic synchronized void reload(Map<String, String> fresh) {snapshot = Map.copyOf(fresh); // Atomic reference swap}} StampedLockOptimistic Read: UsetryOptimisticRead()+ local primitive copy +validate(stamp)so readers never dirty shared cache lines or block writers.
5. Deadlock Prevention: Global Lock Ordering & Single-Lock Callback Deadlocks
public static boolean transfer(Account from, Account to, long amount) throws InterruptedException {
// ✅ Enforce deterministic global lock ordering by unique ID
Account first = from.id < to.id ? from : to;
Account second = first == from ? to : from;
if (!first.lock.tryLock(200, TimeUnit.MILLISECONDS)) return false;
try {
if (!second.lock.tryLock(200, TimeUnit.MILLISECONDS)) return false;
try {
if (from.balance < amount) return false;
from.balance -= amount;
to.balance += amount;
return true;
} finally {
second.lock.unlock();
}
} finally {
first.lock.unlock();
}
}
A deadlock can occur even with only one lock: if OrderBook.update() holds lock and invokes listener.onChange(), and the listener submits a task to an executor thread that calls orderBook.snapshot() (which needs lock) while onChange() synchronously .get()s that future, both threads wait on each other forever—and jstack cannot detect this cycle because one edge is a Future.get() wait rather than an ownable lock! Rule: Never invoke external callbacks or listeners while holding a lock.
Senior/Staff Interview Playbook: How to Ace Java Locking Interviews
1. The Senior Framing Strategy (First 60 Seconds)
- State Hardware & AQS Invariants First: Explain that every lock balances CPU cache-line contention (CAS on AQS
volatile int state) against OS scheduler context switches (LockSupport.park()costing ~1.5–10 µs + L1/L2 cache cold misses). - Challenge
ReadWriteLockReflexes: Point out thatReentrantReadWriteLockis often slower thansynchronizedfor short critical sections (e.g., a singleHashMap.get()) because everyreadLock()still executes a shared atomic CAS on the AQSstateword and updates per-threadThreadLocalHoldCounter, causing L1 cache-line bouncing across cores. - Proactive Failure-Boundary Identification: Highlight convoy cascades (slow reader + queued writer blocking all readers), lock upgrade self-deadlocks (holding readLock and requesting writeLock), and JDK 21–23 Virtual Thread pinning inside
synchronizedblocks containing I/O (fixed in JEP 491 / JDK 24). - Day-2 Observability: Monitor
ReentrantReadWriteLock.getQueueLength()vsgetReadLockCount()—a spiking queue length withreadLockCount == 1is the smoking gun of a reader convoy.
2. Classic Interviewer Traps & Principal Rebuttals
Trap 1: "Since 99% of our requests only read from an in-memory HashMap, replacing synchronized with ReentrantReadWriteLock will make it much faster, right?"
- Junior/Mid Answer: "Yes, because
synchronizedblocks all threads one by one, whereasReadWriteLockallows all 99% of reader threads to read theHashMapin parallel without contention." - Principal Rebuttal:
"For a fast O(1) lookup like
HashMap.get()(~20–50 ns),ReentrantReadWriteLockfrequently degrades throughput compared tosynchronized. Even though read locks are logically shared, physically everyreadLock().lock()andunlock()must execute acompareAndSeton the sharedvolatile int statefield inside AQS and update aThreadLocalhold counter. Across 16+ CPU cores, all cores fight for exclusive ownership (Modifiedstate in MESI) of that single 64-byte cache line—creating a hardware serialization bottleneck.ReadWriteLockonly wins when the read critical section is long enough (e.g., iterating/copying thousands of entries) to amortize the atomic bookkeeping. For read-mostly caches, an immutableMap.copyOf()published via avolatilereference orConcurrentHashMapeliminates read-side locking altogether."
Trap 2: "If a thread holds readLock() and realizes it needs to update the cache, can it just call writeLock().lock() to upgrade?"
- Junior/Mid Answer: "Yes, since
ReentrantReadWriteLockis reentrant, the same thread can upgrade its read lock to a write lock." - Principal Rebuttal:
"
ReentrantReadWriteLocksupports lock downgrade (acquiringreadLockwhile holdingwriteLock, then releasingwriteLock), but strictly forbids lock upgrade. If a thread holding areadLockcallswriteLock().lock(), the write lock waits for all active read locks to reach 0—including the read lock held by the calling thread itself! The thread permanently self-deadlocks in theWAITINGstate. Worse, because read locks do not have a single owner in AQS,jstackwill NOT report this as a Java-level deadlock. You must releasereadLock()first, acquirewriteLock(), and re-verify the condition (since another writer may have mutated state in the gap)."
Trap 3: "If multiple readers can hold a ReadWriteLock simultaneously, how can a single slow reader freeze all other readers?"
- Junior/Mid Answer: "It can't. A slow reader only blocks writers; other readers will continue to acquire the shared read lock without waiting."
- Principal Rebuttal:
"That is only true until a single writer arrives. To prevent writer starvation, Java's
ReentrantReadWriteLockimplementsapparentFirstQueuedIsExclusive() / apparentlyFirstQueuedIsExclusive(): as soon as one writer blocks waiting for the slow reader and sits at the head of the AQS queue, every subsequent new reader is blocked behind that writer. This creates a lock convoy: 1 slow reader (e.g., doing socket I/O inside a read lock) + 1 waiting writer = 100% of incoming reader threads parked, exhausting a 200-thread Tomcat pool in milliseconds."
Trap 4: "Why not use StampedLock everywhere instead of ReentrantReadWriteLock?"
- Junior/Mid Answer: "We should, because
StampedLockis the newer Java 8 replacement and is always faster." - Principal Rebuttal:
"
StampedLockachieves zero-contention optimistic reads (tryOptimisticRead()) by stripping away two critical safety guarantees: it is non-reentrant and has no ownership tracking. If a method holding aStampedLockwrite lock calls a helper method that also acquires the write lock, the thread deadlocks itself. And becauseStampedLockdoes not record an owner thread,jstackshowsLocked ownable synchronizers: None, making production hangs notoriously painful to debug. UseStampedLockstrictly as a private internal primitive over flat primitive fields (never traversing object references beforevalidate(stamp))."
3. Mid-Level vs. Principal Candidate Cheat Sheet
| Concept / Scenario | Mid-Level Candidate Says | Principal Candidate Demonstrates |
|---|---|---|
| Reentrancy vs Ownership | "Reentrant means a thread can lock twice." | "Ownership (exclusiveOwnerThread + holdCount) powers 4 things: reentrancy, jstack owner blame, automatic JVM deadlock cycle detection, and Condition.await() state save/restore—at the cost of forbidding cross-thread unlock()." |
| Writer Starvation & Convoy | "ReadWriteLock starves writers when readers are heavy." | "Java's ReentrantReadWriteLock blocks new readers when an exclusive writer heads the AQS queue (apparentFirstQueuedIsExclusive), trading writer starvation for convoy risk if any reader performs slow I/O." |
| Read-Heavy Config Cache | "Use ReentrantReadWriteLock around the HashMap." | "Use Copy-on-Write with an immutable Map.copyOf() stored in a volatile reference: readers pay 0 lock overhead and never convoy; writers synchronize and swap the pointer." |
StampedLock Optimistic Read | "Call tryOptimisticRead() and use the object fields." | "Copy primitive fields into local stack variables first, call validate(stamp) (which issues a load fence), and fall back to a pessimistic readLock() if a writer interleaved. Never dereference objects before validating." |
Interview Questions
Q1. What is the difference between synchronized and ReentrantLock in Java?
synchronizedis a language keyword providing implicit, block-scoped monitor locking managed automatically by the JVM.ReentrantLockis an explicit API implementation of theLockinterface backed by AbstractQueuedSynchronizer (AQS).ReentrantLockoffers advanced capabilities unavailable insynchronized, such as interruptible lock acquisition (lockInterruptibly()), non-blocking or timed attempts (tryLock()), configurable fairness policies, and multiple wait-set conditions (newCondition()).
Q2. How does lock escalation work for synchronized monitors in the JVM?
Prior to JDK 15, the JVM escalated monitor locks through three stages stored in the object header's Mark Word: (1) Biased Locking (stamped with the owning thread ID, eliminating CAS overhead for single-thread locks); (2) Lightweight Locking (uses CAS spin-locking when a second thread contends for the monitor); (3) Heavyweight Locking (escalates to an OS-level kernel mutex when CAS spinning exceeds a threshold, parking waiting threads).
Q3. Why does volatile guarantee visibility and ordering, but NOT atomicity?
volatileforces memory reads and writes to bypass CPU L1/L2 caches and interact directly with main memory via memory barriers (preventing compiler and CPU reordering). However, compound operations likecount++involve three distinct steps: read, modify, write. If two threads executecount++concurrently, both can read the same volatile value before either writes back, resulting in a lost update. Atomicity requires CAS primitives (AtomicInteger) or mutual exclusion (ReentrantLock).
Q4. What is the difference between CountDownLatch and CyclicBarrier?
CountDownLatchis a one-shot coordination utility where one or more threads wait until a counter reaches zero viacountDown(). It cannot be reset.CyclicBarrieris a reusable synchronization point where a fixed number of threads must meet at a barrier viaawait()before any thread is allowed to proceed.CyclicBarrierautomatically resets after all threads arrive and supports an optional barrier action runnable.
