Skip to main content

Java Locks & Synchronization

To coordinate thread execution and protect shared mutable state, Java provides synchronization primitives and highly flexible locking structures.


Synchronization Primitivesโ€‹

๐Ÿ‘ถ Beginner Example: 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 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);
}
}
// Fix: use synchronized, AtomicInteger, or ReentrantLock

Why? Both threads read counter = 5, both compute 5 + 1 = 6, both write 6. One increment is lost. This is the classic "lost update" race condition.

synchronizedโ€‹

Java's built-in monitor lock. Can be applied to methods or blocks.

// Synchronized method โ€” locks on `this`
public synchronized void increment() { count++; }

// Synchronized block โ€” locks on specific object
public void increment() {
synchronized (this) { count++; }
}

// Static synchronized โ€” locks on the Class object
public static synchronized void staticMethod() { }
Interview Focus: Lock Escalation (JDK 1.6+)

Q: How did JDK 1.6 optimize synchronized? To reduce the heavy OS-level context switching overhead, Java introduced Lock Escalation:

  1. Biased Locking: Assumes only one thread will access the block. Marks the object header with the thread ID.
  2. Lightweight Locking: If another thread requests the lock, it upgrades to a lightweight lock. The new thread uses CAS (Compare-And-Swap) to spin and wait for the lock.
  3. Heavyweight Locking: If the spin-lock fails too many times (high contention), it escalates to a heavyweight lock, which delegates to the OS mutex, blocking threads entirely.

volatileโ€‹

Ensures visibility and prevents JVM instruction reordering.

๐Ÿ‘ถ Beginner Concept: The "Whiteboard vs Pocket Notebook"โ€‹

When a Chef (Thread) works, she doesn't want to constantly walk to the giant fridge (Main System RAM) just to check the temperature of an oven. So, she writes the temperature down in her personal Pocket Notebook (CPU L1 Cache).

  • If Chef A updates the oven temp in her notebook, Chef B has no idea it changed because Chef B is looking at his own notebook! (A Visibility Problem).
  • Adding the volatile keyword tells the Chefs: "Do not write this in your notebook. You must walk over and write this change on the giant shared Whiteboard (Main System RAM) for everyone to see instantly."
private volatile boolean running = true;
// Writer thread
running = false; // automatically flushes CPU cache to main RAM

๐Ÿง  Senior Deep Dive: The MESI Protocol & False Sharingโ€‹

At the hardware level, volatile triggers a Memory Barrier (StoreLoad). When a core writes to a volatile variable, it broadcasts an invalidation signal across the motherboard's bus.

  • The Cost: The CPU's L1/L2 caches use the MESI (Modified, Exclusive, Shared, Invalid) cache coherence protocol. The broadcast forces all other CPU cores to mark their cached cache-lines as "Invalid," forcing them to fetch from slow main RAM on the next read.
  • False Sharing: CPU caches load data in 64-byte chunks (Cache Lines). If two independent volatile variables sit next to each other in memory, changing Variable A invalidates the entire 64-byte line, destroying the cache for Variable B even though B never changed! Seniors fix this using @Contended (padding objects with blank bytes to force them into separate CPU cache lines).
Interview Trap: Volatile Atomicity

Q: Does volatile guarantee thread safety for i++? No. volatile does NOT provide atomicity. count++ is a read-modify-write operation (3 steps). Multiple threads can still read the same initial value simultaneously. You need AtomicInteger or synchronized for atomicity.

๐Ÿง  Double-Checked Locking Singleton (The Classic volatile Use Case)โ€‹

The most famous real-world application of volatile. Without volatile, this pattern is broken due to instruction reordering:

public class Singleton {
// volatile prevents reordering of object construction steps
private static volatile Singleton INSTANCE;

private Singleton() { /* expensive initialization */ }

public static Singleton getInstance() {
if (INSTANCE == null) { // 1st check: avoid locking on every call
synchronized (Singleton.class) {
if (INSTANCE == null) { // 2nd check: prevent double-creation
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

Why is volatile required? INSTANCE = new Singleton() compiles to 3 steps: (1) allocate memory, (2) invoke constructor, (3) assign reference to INSTANCE. The JIT can reorder step 3 before step 2. Without volatile, Thread B can read a non-null but uninitialized INSTANCE, bypassing the null check and using an object whose constructor hasn't finished.

Quick Comparison: synchronized vs ReentrantLockโ€‹

FeaturesynchronizedReentrantLock
Lock/unlock scopeLexical (block/method)Programmatic (lock()/unlock())
FairnessNot configurablenew ReentrantLock(true)
Try-lock with timeoutโŒ Not possibletryLock(5, TimeUnit.SECONDS)
Interruptible waitingโŒlockInterruptibly()
Multiple conditionsSingle wait-set per objectMultiple Condition objects
Performance (low contention)Slightly faster (JVM optimized)Slightly slower
Risk of forgetting unlockImpossible (auto-released)Must use finally block
RecommendationDefault choice for simple casesUse when you need advanced features

Locks & AQSโ€‹

Choosing a Locking Primitiveโ€‹

When coordinating access to shared mutable state in Java, select the simplest synchronization mechanism that satisfies your throughput, latency, and correctness requirements:

synchronizedโ€‹

Use when you need simple mutual exclusion with clear, lexical boundaries.

  • Optimized: Fast under low/zero contention due to JVM lock escalation (biased/lightweight locking).
  • Limitations: Cannot configure fairness, try-lock timeout, or interruptible lock acquisition.
public synchronized void increment() {
count++;
}

ReentrantLockโ€‹

Use when you require advanced capabilities like timeouts, interruptible acquisition, fairness configuration, or multiple condition variables.

private final ReentrantLock lock = new ReentrantLock();

public void update() {
lock.lock();
try {
sharedState++;
} finally {
lock.unlock(); // ALWAYS unlock in a finally block
}
}

tryLock() โ€” Avoiding Deadlocks with Timeoutsโ€‹

private final ReentrantLock lock = new ReentrantLock();

public boolean tryUpdate() {
try {
// Try to acquire for 2 seconds; if another thread holds it, give up gracefully
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try {
sharedState++;
return true;
} finally {
lock.unlock();
}
} else {
log.warn("Could not acquire lock within timeout โ€” skipping update");
return false; // Fail fast instead of deadlocking
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}

Condition Variables โ€” Precise Thread Signalingโ€‹

Condition is the ReentrantLock equivalent of wait()/notify(), but with the power of having multiple wait-sets on the same lock:

// Producer-Consumer with Condition (more flexible than wait/notify)
public class BoundedBuffer<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // producers wait here
private final Condition notEmpty = lock.newCondition(); // consumers wait here

public BoundedBuffer(int capacity) { this.capacity = capacity; }

public void put(T item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await(); // Release lock & wait; re-acquire on wake
}
queue.add(item);
notEmpty.signal(); // Wake ONE waiting consumer
} finally {
lock.unlock();
}
}

public T take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // Release lock & wait; re-acquire on wake
}
T item = queue.remove();
notFull.signal(); // Wake ONE waiting producer
return item;
} finally {
lock.unlock();
}
}
}

Why Condition is better than wait()/notify(): With synchronized, there is only ONE wait-set. notifyAll() wakes ALL waiting threads (both producers AND consumers), even though only one type can make progress. With two Condition objects, signal() wakes exactly the right thread.

ReadWriteLock (ReentrantReadWriteLock)โ€‹

Use when read operations are significantly more frequent than write operations, allowing multiple threads to read concurrently while ensuring writes remain exclusive.

Real-World Use Case: In-Memory Configuration Cacheโ€‹
public class ConfigCache {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Map<String, String> cache = new HashMap<>();

// Multiple threads can read concurrently โ€” no blocking
public String getConfig(String key) {
rwLock.readLock().lock();
try {
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}

// Write lock blocks ALL readers AND writers โ€” exclusive access
public void reloadConfig(Map<String, String> newConfig) {
rwLock.writeLock().lock();
try {
cache.clear();
cache.putAll(newConfig);
log.info("Config reloaded: {} entries", newConfig.size());
} finally {
rwLock.writeLock().unlock();
}
}
}
// Perfect when: config is read 1000x/sec but updated once every few minutes

StampedLockโ€‹

Use in highly read-heavy workloads where standard read locks could starve writers. StampedLock supports optimistic read operations, which do not block writers. The reader acquires a version stamp, reads the values, and validates the stamp. If a write invalidated the stamp, the reader falls back to a blocking read lock.

private final StampedLock lock = new StampedLock();

public double readWithOptimisticLock() {
long stamp = lock.tryOptimisticRead();
double currentVal = balance;
if (!lock.validate(stamp)) { // Stale read detected?
stamp = lock.readLock(); // Fallback to standard blocking read lock
try {
currentVal = balance;
} finally {
lock.unlockRead(stamp);
}
}
return currentVal;
}

๐Ÿง  Complete Lock Comparison Matrixโ€‹

FeaturesynchronizedReentrantLockReadWriteLockStampedLock
Lock typeExclusiveExclusiveShared read / Exclusive writeOptimistic read / Shared read / Exclusive write
FairnessNot configurableConfigurableConfigurableNot configurable
Try-lock / TimeoutโŒโœ…โœ…โœ…
InterruptibleโŒโœ…โœ…โœ…
Condition variables1 (implicit)MultipleMultiple (write lock only)โŒ
Reentrantโœ…โœ…โœ…โŒ (not reentrant!)
Optimistic readโŒโŒโŒโœ…
Best forSimple mutual exclusionAdvanced locking needsRead-heavy workloadsUltra-read-heavy, write-rare
Interview Trap: StampedLock Is NOT Reentrant

Unlike every other lock in Java, StampedLock is not reentrant. If a thread holding a StampedLock write lock tries to acquire it again, it will deadlock itself. Always ensure your code path doesn't recursively enter a stamped-locked section.

AQS (AbstractQueuedSynchronizer)โ€‹

AQS is the foundation framework for ReentrantLock, Semaphore, CountDownLatch, and CyclicBarrier.

[!TIP] ๐Ÿง  Senior Deep Dive Because AQS internals are one of the most rigorously tested topics in Senior Java interviews (involving the CLH queue, LockSupport.park(), and Unsafe memory management), we have dedicated an entire guide to it.

๐Ÿ‘‰ Read the AbstractQueuedSynchronizer Deep Dive here