Java Locks and ReentrantLock — Concurrency Beyond synchronized
- Use synchronized for basic mutual exclusion due to its simplicity and automatic release, preventing lock leaks.
- Opt for ReentrantLock when advanced features are needed: tryLock (non-blocking/timed), lockInterruptibly, fairness control, or multiple Condition variables.
- The lifeblood of ReentrantLock safety: always
lock()before try andunlock()unconditionally in finally.
The synchronized keyword is like a simple lock on a door — you go in, it locks automatically, you do your work, leave, and it unlocks automatically. ReentrantLock is a more advanced smart lock: you can check if it's already locked before waiting, set a specific timeout for how long to wait, get woken up if another thread interrupts you while you're waiting, and you can even tell it to be fair to threads that have been waiting longer. This gives you much finer control over how threads access shared resources.
Java's synchronized keyword provides basic mutual exclusion — only one thread at a time inside a critical section. It's elegant and has served Java well since version 1.0. However, it has hard limits: you cannot try to acquire it without blocking forever, you cannot be interrupted while waiting, and you have no control over which waiting thread gets it next. The java.util.concurrent.locks package, introduced in Java 5, provides explicit locks that shatter these limitations. ReentrantLock is the workhorse of this package, and understanding it is essential for writing sophisticated, high-performance, and robust concurrent Java applications. We'll cover why it's not just an alternative, but a necessary upgrade in many scenarios — and when you should still stick with synchronized despite the temptation not to.
What is a Lock in Java? Intrinsic vs. Explicit
At its core, a lock is a synchronization mechanism that controls access to a shared resource, preventing race conditions. In Java, every object inherently possesses an intrinsic lock (often called a monitor lock).
When a thread enters a synchronized block or method, it implicitly acquires the intrinsic lock of the object associated with that block/method. Any other thread attempting to enter a synchronized block on the same object will block until the owning thread releases the lock upon exiting the block or method.
The java.util.concurrent.locks.Lock interface, introduced in Java 5's java.util.concurrent package, offers an explicit alternative to intrinsic locks. It provides the same core mutual exclusion guarantee but exposes a much richer API:
- Non-blocking lock attempts (
tryLock()): Check if a lock is available, acquire it if so, and return. Crucial for building deadlock-avoidance strategies. - Timed lock attempts (
tryLock(timeout, unit)): Wait for a lock only up to a specified duration. - Interruptible lock acquisition (
lockInterruptibly()): Wait for a lock, but allow the thread to be interrupted while waiting. - Multiple explicit conditions per lock: Go beyond the single wait set of intrinsic locks.
A fundamental property is *reentrancy*: a thread that already holds a lock can acquire it again without deadlocking. Both synchronized and ReentrantLock are reentrant. For synchronized, this means a thread can call another synchronized method on the same object. For ReentrantLock, it means calling again on a lock already held by the current thread. The lock implementation internally tracks a hold count and is only truly released to other threads when this count drops to zero.lock()
Every ReentrantLock also exposes query methods for introspection: isLocked() tells you if any thread holds it, getHoldCount() returns how many times the current thread has acquired it (useful for debugging reentrancy chains), getQueueLength() shows how many threads are waiting, and hasQueuedThreads() is a quick check for contention. These aren't just academic — I've used getQueueLength() in production to trigger alerts when a lock's contention exceeded a threshold, catching a scalability bottleneck before it became an outage.
package io.thecodeforge.concurrency; public class IntrinsicLockDemo { private int count = 0; public synchronized void increment() { System.out.println(Thread.currentThread().getName() + " entering critical section."); count++; System.out.println(Thread.currentThread().getName() + " incremented count to: " + count); } public synchronized int get() { return count; } public static void main(String[] args) throws InterruptedException { IntrinsicLockDemo counter = new IntrinsicLockDemo(); Thread t1 = new Thread(() -> { for(int i=0; i<5; i++) counter.increment(); }); Thread t2 = new Thread(() -> { for(int i=0; i<5; i++) counter.increment(); }); Thread t3 = new Thread(() -> { for(int i=0; i<5; i++) counter.increment(); }); t1.start(); t2.start(); t3.start(); t1.join(); t2.join(); t3.join(); System.out.println("Final count (intrinsic): " + counter.get()); } }
Thread-0 incremented count to: 1
Thread-1 entering critical section.
Thread-1 incremented count to: 2
Thread-2 entering critical section.
Thread-2 incremented count to: 3
Thread-0 entering critical section.
Thread-0 incremented count to: 4
Thread-1 entering critical section.
Thread-1 incremented count to: 5
Thread-2 entering critical section.
Thread-2 incremented count to: 6
Final count (intrinsic): 15
synchronized vs ReentrantLock: The Production Choice
synchronized is pure Java, JVM-managed, and incredibly simple. The JVM extensively optimizes it and, crucially, it cannot be misused to leak locks. The release is guaranteed on block exit, even if an exception flies out. This makes it the default, safest choice for basic mutual exclusion.
However, synchronized is a black box. When you need more control, you reach for ReentrantLock:
- Non-blocking or timed lock acquisition:
tryLock()andtryLock(long time, TimeUnit unit)are critical. Imagine a scenario where holding a lock for too long would starve other threads or cause timeouts in a web request.synchronizedmeans you wait forever. - Interruptible lock acquisition: If a thread is waiting for a
synchronizedlock, it's stuck. AlockInterruptibly()call allows that thread to be woken up if another thread callson it, enabling more responsive applications that can cancel long-running operations.interrupt() - Fairness policies:
new ReentrantLock(true)enforces fair ordering (FIFO). Whilesynchronizedoffers no fairness guarantees (a fast thread might always barge ahead, starving others),ReentrantLocklets you choose. Be warned: fairness usually comes with a significant performance penalty due to increased overhead managing the wait queue. - Multiple Condition Variables:
Object.wait()/notify()/notifyAll()operates on a single intrinsic lock's wait set.ReentrantLock'snewCondition()lets you create multiple, independentConditionobjects for a single lock. This is vital for complex producer-consumer scenarios where you might want one queue to wait if it's 'not full' and another if it's 'not empty' — separate wait sets mean cleaner logic and better performance.
The synchronized comeback — what most articles miss: Since Java 6, the JVM has gotten remarkably good at optimizing synchronized. Biased locking makes the common case (one thread, no contention) nearly free. Lock elision via escape analysis can completely remove synchronized blocks when the JIT proves the lock object doesn't escape the method. Lock coarsening merges adjacent synchronized blocks on the same object into one. Adaptive spinning lets threads spin-wait briefly before resorting to OS-level blocking, reducing context-switch overhead.
I benchmarked a high-throughput order processing pipeline at a fintech company a few years back. We expected ReentrantLock to crush synchronized. It didn't. On Java 17 with G1GC, synchronized was within 3% of ReentrantLock for our uncontended workload, and actually faster under moderate contention because the JVM's adaptive spinning was better tuned than our manual lock configuration. The lesson: don't assume ReentrantLock is faster. Choose it for features, not performance. If your only reason is 'it's faster,' benchmark first — you might be surprised.
For simple cases, stick to synchronized. The moment you hit its limitations, ReentrantLock becomes your powerful, explicit alternative. It's a trade-off: more control and features, but also greater responsibility for correct usage (especially ).unlock()
The Cardinal Rule: Always Unlock in a finally Block
This is non-negotiable. The primary danger of ReentrantLock is forgetting to release it. If a thread calls and then an exception occurs before lock.lock() is called, that lock is held forever.lock.unlock()
Every other thread attempting to acquire that lock will block indefinitely, leading to a permanent deadlock. Your application will grind to a halt.
The established, canonical pattern to prevent this is to acquire the lock before the try block and release it unconditionally within the finally block. This guarantees is called, even if unlock()riskyOperation() throws a RuntimeException or Error.
*Why must be before lock()try: Even itself can, in rare circumstances (e.g., extreme JVM memory pressure), throw an exception. If you put lock() inside* the lock.lock()try block and it fails, the finally block would attempt to a lock that was never acquired, throwing an unlock()IllegalMonitorStateException. This is less common but underscores the -then-lock()try-then-finally-with- structure.unlock()
Beyond the basics — other unlock traps I've seen in production:
- Calling
on a lock you don't hold: This throwsunlock()IllegalMonitorStateException. It happens when code paths diverge — a method acquires a lock conditionally, but a refactoring changes the path, and suddenlyfires without a matchingunlock(). Guard against this withlock()isLocked()checks during debugging, but never rely on it in production logic. - Double
: If a thread callsunlock()twice on the same lock (without a matching secondunlock()), the second call throwslock()IllegalMonitorStateException. This commonly happens when a developer adds a defensivein a catch block and the finally block. One of them will fire without a held lock.unlock() lockInterruptibly()and interrupts: When a thread is blocked onlockInterruptibly()and another thread calls, the waiting thread receives aninterrupt()InterruptedException. If you catch this and return without calling, that's fine — the lock was never acquired. But if you catch it after acquisition (inside the try block), you must still unlock. The key:unlock()lockInterruptibly()can throwInterruptedExceptionbefore acquiring the lock, so the lock may or may not be held when the exception is caught.
Production Anecdote: I once debugged a system that would intermittently hang. The root cause? A deeply nested ReentrantLock usage where a finally block was missing in one of the internal paths. A specific sequence of operations would trigger an exception, leaving a critical lock held forever. Only realizing the try-finally structure was paramount prevented a production outage. We added a lint rule after that — any call without a corresponding lock()finally block within 10 lines was flagged as a build error.
package io.thecodeforge.concurrency; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; public class CorrectLockPattern { private final ReentrantLock lock = new ReentrantLock(); private int value = 0; public void brokenMethod() { lock.lock(); try { System.out.println(Thread.currentThread().getName() + " acquired lock (BROKEN)."); riskyOperation(); } finally { System.out.println(Thread.currentThread().getName() + " releasing lock."); lock.unlock(); } } public void alsoWrongMethod() { try { lock.lock(); System.out.println(Thread.currentThread().getName() + " acquired lock (ALSO WRONG)."); riskyOperation(); } finally { System.out.println(Thread.currentThread().getName() + " releasing lock (ALSO WRONG)."); lock.unlock(); } } public void correctMethod() { lock.lock(); try { System.out.println(Thread.currentThread().getName() + " acquired lock (CORRECT)."); riskyOperation(); value = 1; } finally { System.out.println(Thread.currentThread().getName() + " releasing lock (CORRECT)."); lock.unlock(); } } public void lockInterruptiblyExample() throws InterruptedException { lock.lockInterruptibly(); try { System.out.println(Thread.currentThread().getName() + " acquired lock interruptibly."); riskyOperation(); } finally { System.out.println(Thread.currentThread().getName() + " releasing lock."); lock.unlock(); } } private void riskyOperation() { System.out.println(Thread.currentThread().getName() + " performing risky operation..."); try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } } public static void main(String[] args) throws InterruptedException { CorrectLockPattern demo = new CorrectLockPattern(); Thread t1 = new Thread(() -> { try { demo.correctMethod(); } catch (Exception e) { System.err.println(Thread.currentThread().getName() + " caught exception: " + e.getMessage()); } }, "WorkerThread"); t1.start(); t1.join(); System.out.println("Main thread finished."); } }
WorkerThread performing risky operation...
WorkerThread releasing lock (CORRECT).
Main thread finished.
lock() must come BEFORE the try block. If lock() itself fails (rare, but possible), the finally block should not attempt to unlock() a lock that was never acquired, which would throw IllegalMonitorStateException. The try-finally structure protects the unlock operation from exceptions occurring within the critical section.Condition Variables: Granular Notification
The , wait(), and notify()notifyAll() methods associated with Object locks are notoriously difficult to use correctly. They require a single lock, and only manage one wait set. This is fine for simple scenarios but inadequate for complex coordination, like a bounded buffer (producer-consumer).
A textbook example is a bounded buffer: producers must wait if the buffer is full, and consumers must wait if it's empty. With synchronized, you'd typically use notifyAll() and have both producers and consumers re-check their conditions in a while loop, which is inefficient. ReentrantLock lets you create multiple Condition objects, each associated with the lock, each with its own wait set.
The Pattern: 1. Acquire the ReentrantLock. 2. Inside a while loop (to guard against spurious wakeups), check if the current thread needs to wait. If so, call condition.await(). 3. Perform the guarded action (e.g., add to buffer, remove from buffer). 4. If the action potentially changes the state relevant to other threads, call otherCondition.signal() or otherCondition.signalAll() to wake them up. 5. Release the lock in a finally block.
vs signal()signalAll() — a decision that bit me once: Early in my career, I used everywhere for 'performance.' The reasoning was sound in theory — only wake the one thread that needs to act. But in a system with mixed producers and consumers, signal() can wake the wrong thread. A producer signals, but a consumer was the next in the wait queue, and the consumer's condition isn't met, so it goes back to sleep. Meanwhile, the producer that should have been woken is still waiting. This is a missed wakeup, and it's maddening to debug because it only manifests under specific timing conditions.signal()
The rule of thumb: use signalAll() by default. It's correct. Switch to only when you have proven, through profiling, that signal()signalAll() is causing measurable overhead, and you can guarantee that any woken thread will be able to proceed. In practice, the overhead of signalAll() is rarely the bottleneck.
package io.thecodeforge.concurrency; import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class ConditionDemo { private final Queue<String> queue = new LinkedList<>(); private final int capacity = 5; private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public void produce(String item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { System.out.println(Thread.currentThread().getName() + " buffer full, waiting..."); notFull.await(); } queue.add(item); System.out.println(Thread.currentThread().getName() + " produced: " + item + " (size: " + queue.size() + ")"); notEmpty.signal(); } finally { lock.unlock(); } } public String consume() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { System.out.println(Thread.currentThread().getName() + " buffer empty, waiting..."); notEmpty.await(); } String item = queue.poll(); System.out.println(Thread.currentThread().getName() + " consumed: " + item + " (size: " + queue.size() + ")"); notFull.signal(); return item; } finally { lock.unlock(); } } }
Producer-Consumer: The Complete Bounded Buffer
The Condition section above shows the API, but a real bounded buffer needs to be reusable and thread-safe end-to-end. Below is a production-grade bounded buffer implementation you can drop into any project. Note the use of while loops (never if) around await() to guard against spurious wakeups, and signalAll() to ensure correctness.
In production, I've used this exact pattern for inter-thread message passing in an event processing pipeline. The buffer was sized to match our consumer throughput — too small and producers block constantly, too large and you're holding memory for messages that haven't been processed yet. We settled on a capacity of 1024 after load testing showed our consumers could drain at roughly 800 items/sec and producers peaked at 900/sec.
package io.thecodeforge.concurrency; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class BoundedBuffer<T> { private final Object[] items; private int head = 0; private int tail = 0; private int count = 0; private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public BoundedBuffer(int capacity) { if (capacity <= 0) throw new IllegalArgumentException("Capacity must be positive"); items = new Object[capacity]; } public void put(T item) throws InterruptedException { lock.lock(); try { while (count == items.length) { notFull.await(); } items[tail] = item; if (++tail == items.length) tail = 0; count++; notEmpty.signalAll(); } finally { lock.unlock(); } } @SuppressWarnings("unchecked") public T take() throws InterruptedException { lock.lock(); try { while (count == 0) { notEmpty.await(); } T item = (T) items[head]; items[head] = null; if (++head == items.length) head = 0; count--; notFull.signalAll(); return item; } finally { lock.unlock(); } } public int size() { lock.lock(); try { return count; } finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { BoundedBuffer<String> buffer = new BoundedBuffer<>(3); Thread producer = new Thread(() -> { try { for (int i = 1; i <= 8; i++) { String item = "msg-" + i; buffer.put(item); System.out.println("Produced: " + item); Thread.sleep(50); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Producer"); Thread consumer = new Thread(() -> { try { for (int i = 0; i < 8; i++) { String item = buffer.take(); System.out.println("Consumed: " + item); Thread.sleep(150); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Consumer"); producer.start(); consumer.start(); producer.join(); consumer.join(); System.out.println("Buffer size after drain: " + buffer.size()); } }
Consumed: msg-1
Produced: msg-2
Produced: msg-3
Produced: msg-4
Consumed: msg-2
Produced: msg-5
Produced: msg-6
Consumed: msg-3
Produced: msg-7
Produced: msg-8
Consumed: msg-4
Consumed: msg-5
Consumed: msg-6
Consumed: msg-7
Consumed: msg-8
Buffer size after drain: 0
size() also acquires the lock. In a high-throughput system, polling size() from a monitoring thread adds contention. Consider using an AtomicInteger as a separate counter if you need lock-free size checks, but then you're managing two sources of truth — only do this if profiling shows the lock is the bottleneck.ReadWriteLock: Unlocking Concurrent Reads
A standard lock (synchronized or ReentrantLock.lock()) is exclusive: only one thread can hold it at a time, regardless of whether it intends to read or write. This is a bottleneck for data structures that are read far more often than they are modified — like caches, configuration maps, or lookup tables.
ReadWriteLock is an interface that provides two associated locks: a read lock and a write lock.
- Read Lock: Multiple threads can acquire the read lock simultaneously. Reads are typically safe to run concurrently.
- Write Lock: Only one thread can acquire the write lock. It's exclusive, meaning no other thread (reader or writer) can hold any lock while the write lock is active.
ReentrantReadWriteLock is the standard implementation. Readers acquire the read lock, writers acquire the write lock. This pattern can unlock massive throughput gains in read-heavy applications.
The lock downgrade trick: ReentrantReadWriteLock supports downgrading from a write lock to a read lock without releasing the write lock first. This is useful when you need to make a modification and then read the result atomically. You acquire the write lock, make your change, acquire the read lock, release the write lock, then read — all without another writer sneaking in between. The reverse — upgrading from read to write — is not supported and will deadlock if you try.
Real-world example: I built a feature-flag service that was read on every HTTP request (thousands/sec) but updated maybe once an hour via an admin API. Using ReentrantReadWriteLock, we let all request threads grab the read lock concurrently — zero contention for reads. The admin thread grabbed the write lock, updated the flags, and released. Throughput jumped 8x compared to a plain ReentrantLock that serialized all access. The read lock acquisition cost was negligible because there was no writer contention.
package io.thecodeforge.concurrency; import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class ReadWriteLockDemo { private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); private final Map<String, String> config = new HashMap<>(); public String get(String key) { readLock.lock(); try { return config.get(key); } finally { readLock.unlock(); } } public void put(String key, String value) { writeLock.lock(); try { config.put(key, value); } finally { writeLock.unlock(); } } public String computeIfAbsent(String key) { readLock.lock(); try { String value = config.get(key); if (value != null) return value; } finally { readLock.unlock(); } writeLock.lock(); try { String value = config.get(key); if (value == null) { value = expensiveCompute(key); config.put(key, value); } return value; } finally { writeLock.unlock(); } } private String expensiveCompute(String key) { return "computed-" + key; } public static void main(String[] args) throws InterruptedException { ReadWriteLockDemo demo = new ReadWriteLockDemo(); demo.put("timeout", "30s"); demo.put("retries", "3"); Thread reader1 = new Thread(() -> System.out.println("timeout=" + demo.get("timeout"))); Thread reader2 = new Thread(() -> System.out.println("retries=" + demo.get("retries"))); Thread writer = new Thread(() -> demo.put("timeout", "60s")); reader1.start(); reader2.start(); writer.start(); reader1.join(); reader2.join(); writer.join(); System.out.println("Final timeout=" + demo.get("timeout")); } }
retries=3
Final timeout=60s
Lock Stripping: Scaling Beyond a Single Lock
A single lock on an entire data structure is a serialization bottleneck. If your shared state can be partitioned into independent segments, you can use a separate lock per segment — this is called lock stripping (or lock striping). It's one of the most impactful concurrency patterns in production Java, and it's how ConcurrentHashMap achieves its performance.
The idea is simple: instead of one lock protecting a 1000-element map, use 16 locks, each protecting roughly 1/16th of the map. A thread accessing key K only needs the lock for K's segment, so 16 threads accessing keys in different segments can proceed concurrently. The trade-off: operations that need the entire data structure (like computing the total size) become more complex.
When I reach for this pattern: Any time I have a large, concurrent data structure where a single lock is clearly the bottleneck. A thread-safe cache, a connection pool indexed by endpoint, a rate limiter keyed by client ID. The implementation is straightforward — an array of locks, and you pick the lock based on a hash of the key.
The sizing question: How many segments? Too few and you don't reduce contention enough. Too many and you waste memory on lock objects and increase the chance of false sharing in the CPU cache. 16 is a solid default — it's what ConcurrentHashMap used before Java 8 (Java 8 moved to per-bucket CAS operations, but the principle is the same). For most applications, 16 or 32 segments is the sweet spot. Profile before going higher.
package io.thecodeforge.concurrency; import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.ReentrantLock; public class StripedLockMap<K, V> { private static final int STRIPE_COUNT = 16; private final ReentrantLock[] locks; private final Map<K, V>[] segments; @SuppressWarnings("unchecked") public StripedLockMap() { locks = new ReentrantLock[STRIPE_COUNT]; segments = new Map[STRIPE_COUNT]; for (int i = 0; i < STRIPE_COUNT; i++) { locks[i] = new ReentrantLock(); segments[i] = new HashMap<>(); } } private int segmentIndex(Object key) { return (key == null) ? 0 : (key.hashCode() & 0x7FFFFFFF) % STRIPE_COUNT; } public V get(K key) { int idx = segmentIndex(key); locks[idx].lock(); try { return segments[idx].get(key); } finally { locks[idx].unlock(); } } public V put(K key, V value) { int idx = segmentIndex(key); locks[idx].lock(); try { return segments[idx].put(key, value); } finally { locks[idx].unlock(); } } public int size() { int total = 0; for (int i = 0; i < STRIPE_COUNT; i++) { locks[i].lock(); try { total += segments[i].size(); } finally { locks[i].unlock(); } } return total; } public static void main(String[] args) throws InterruptedException { StripedLockMap<String, Integer> map = new StripedLockMap<>(); Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) map.put("key-" + i, i); }); Thread t2 = new Thread(() -> { for (int i = 1000; i < 2000; i++) map.put("key-" + i, i); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Map size: " + map.size()); System.out.println("key-500 = " + map.get("key-500")); System.out.println("key-1500 = " + map.get("key-1500")); } }
key-500 = 500
key-1500 = 1500
size() has to acquire all 16 locks sequentially to get an accurate count. This is the classic trade-off with lock stripping: per-key operations get faster, but whole-structure operations get slower. If your application frequently calls size(), consider maintaining a separate AtomicInteger counter — but then you have two sources of truth to keep in sync.Fairness, StampedLock, and Advanced Considerations
Choosing between fair and non-fair locks addresses thread starvation. Non-fair ReentrantLock (default): Prioritizes throughput. A thread requesting the lock might acquire it even if other threads have been waiting longer. This is faster but can lead to starvation, where a thread waits indefinitely. Fair ReentrantLock(true): Grants the lock to waiting threads in a roughly FIFO order. This prevents starvation but typically results in lower throughput due to the overhead of managing the wait queue and preventing barging.
For extreme read-heavy scenarios, StampedLock (Java 8+) offers an optimistic approach. It allows threads to read a value without acquiring a lock, merely stamping the state. After reading, the thread validates the stamp. If no write occurred in between, the read is considered valid. If a write did occur, the read is invalidated, and the thread can then fall back to acquiring a pessimistic read lock.
StampedLock gotchas — the things that will ruin your week:
- Not reentrant. If a thread already holds a stamp and tries to acquire another, it will deadlock. There's no hold count like
ReentrantLock. If your call chain might re-enter the same lock,StampedLockis the wrong tool. - No
Conditionsupport. You can't callorawait()on asignal()StampedLock. If you need wait/notify semantics, useReentrantLockwithCondition. - Starvation under writes. Optimistic reads can fail repeatedly if a writer keeps acquiring the write lock. The reader falls back to a pessimistic read lock each time, and if writers are frequent, the optimistic path becomes dead code.
StampedLockis designed for write-rare scenarios. If writes are more than ~5% of operations, benchmark carefully. - Always validate the stamp. Calling
tryOptimisticRead()returns a stamp, but the read is only valid ifvalidate(stamp)returns true after you've read the data. Forgetting the validation means you might use stale data silently. - Unlock with the correct method.
unlockRead(stamp)andunlockWrite(stamp)are separate methods. Calling the wrong one throwsIllegalMonitorStateException. After a pessimistic read lock, always useunlockRead().
The full escalation pattern: Start with tryOptimisticRead(). Read your data. Validate. If invalid, acquire a pessimistic read lock with readLock(). Read again. If you need to write, release the read lock and acquire writeLock(). This three-step escalation (optimistic → pessimistic read → write) maximizes throughput for read-dominant workloads.
Production Strategy: Start with non-fair ReentrantLock for optimal throughput. If you observe thread starvation or jstack output shows threads waiting excessively in , consider switching to fair locks or investigating if lock.lock()StampedLock is applicable for your read patterns.
package io.thecodeforge.concurrency; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.concurrent.locks.StampedLock; public class LockVariations { private final Lock nonFairLock = new ReentrantLock(); private final Lock fairLock = new ReentrantLock(true); private final ReentrantLock lockWithConditions = new ReentrantLock(); private final Condition bufferNotFull = lockWithConditions.newCondition(); private final Condition bufferNotEmpty = lockWithConditions.newCondition(); private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); private final StampedLock stampedLock = new StampedLock(); private double x, y; public double distanceFromOrigin() { long stamp = stampedLock.tryOptimisticRead(); double currentX = x, currentY = y; if (!stampedLock.validate(stamp)) { stamp = stampedLock.readLock(); try { currentX = x; currentY = y; } finally { stampedLock.unlockRead(stamp); } } return Math.sqrt(currentX * currentX + currentY * currentY); } public void move(double deltaX, double deltaY) { long stamp = stampedLock.writeLock(); try { x += deltaX; y += deltaY; } finally { stampedLock.unlockWrite(stamp); } } public static void main(String[] args) { System.out.println("Lock variations demonstration. Actual behavior requires multithreaded access."); } }
Lock Ordering and Deadlock Prevention
Deadlocks happen when two or more threads each hold a lock and wait for the other's lock, creating a circular dependency. The classic example: Thread A holds Lock 1 and waits for Lock 2. Thread B holds Lock 2 and waits for Lock 1. Neither can proceed.
The golden rule: always acquire locks in a consistent global order. If every thread acquires Lock 1 before Lock 2, deadlock is impossible. Define an ordering (by object identity hash code, by a logical ID, by alphabetical name) and enforce it everywhere. This sounds simple, but in a large codebase with multiple developers, it's easy to violate. I've seen production deadlocks caused by two utility methods that each acquired two locks in a different order — each method was correct in isolation, but composed together they created a deadlock under load.
tryLock() as a deadlock escape hatch: When you can't guarantee lock ordering (e.g., locks are determined at runtime), use tryLock() with a timeout. Attempt to acquire the second lock. If it fails, release the first lock, wait a random backoff, and retry. This breaks the circular wait condition. The cost is complexity and potential livelock (threads repeatedly backing off without making progress), but in practice, exponential backoff with jitter works well.
The transfer-between-accounts example: This is the canonical deadlock scenario. You need to lock both the source and destination accounts to transfer money. If Thread A transfers from Account 1 to Account 2 (locking 1 then 2) while Thread B transfers from Account 2 to Account 1 (locking 2 then 1), you get a deadlock. The fix: always lock the account with the smaller ID first.
Detecting deadlocks: Use ThreadM to programmatically detect deadlocks. In production, we ran this check every 30 seconds in a health-check thread and alerted if deadlocks were detected. It saved us at least twice during incidents where the application appeared hung but the JVM was still responsive enough to run the check.XBean.findDeadlockedThreads()
package io.thecodeforge.concurrency; import java.lang.management.ManagementFactory; import java.lang.management.ThreadInfo; import java.lang.management.ThreadMXBean; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; public class DeadlockPrevention { static class Account { private final int id; private final ReentrantLock lock = new ReentrantLock(); private int balance; Account(int id, int balance) { this.id = id; this.balance = balance; } public int getId() { return id; } public ReentrantLock getLock() { return lock; } } public static void transfer(Account from, Account to, int amount) throws InterruptedException { Account first = from.getId() < to.getId() ? from : to; Account second = from.getId() < to.getId() ? to : from; first.getLock().lock(); try { second.getLock().lock(); try { if (from.balance >= amount) { from.balance -= amount; to.balance += amount; System.out.println("Transferred " + amount + " from Account-" + from.id + " to Account-" + to.id); } else { System.out.println("Insufficient funds in Account-" + from.id); } } finally { second.getLock().unlock(); } } finally { first.getLock().unlock(); } } public static void transferWithTryLock(Account from, Account to, int amount) throws InterruptedException { while (true) { from.getLock().lock(); try { if (to.getLock().tryLock(100, TimeUnit.MILLISECONDS)) { try { if (from.balance >= amount) { from.balance -= amount; to.balance += amount; System.out.println("Transferred " + amount + " from Account-" + from.id + " to Account-" + to.id); return; } } finally { to.getLock().unlock(); } } } finally { from.getLock().unlock(); } Thread.sleep(10 + (int)(Math.random() * 20)); } } public static void detectDeadlocks() { ThreadMXBean mxBean = ManagementFactory.getThreadMXBean(); long[] deadlockedThreadIds = mxBean.findDeadlockedThreads(); if (deadlockedThreadIds != null) { ThreadInfo[] threadInfos = mxBean.getThreadInfo(deadlockedThreadIds, true, true); System.out.println("DEADLOCK DETECTED!"); for (ThreadInfo info : threadInfos) { System.out.println("Thread " + info.getThreadName() + " is deadlocked."); System.out.println(" Blocked on: " + info.getLockName()); System.out.println(" Owned by: " + info.getLockOwnerName()); } } else { System.out.println("No deadlocks detected."); } } public static void main(String[] args) throws InterruptedException { Account acc1 = new Account(1, 1000); Account acc2 = new Account(2, 1000); Thread t1 = new Thread(() -> { try { transfer(acc1, acc2, 100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread t2 = new Thread(() -> { try { transfer(acc2, acc1, 200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); t1.start(); t2.start(); t1.join(); t2.join(); detectDeadlocks(); } }
Transferred 200 from Account-2 to Account-1
No deadlocks detected.
Monitoring and Debugging Locks in Production
Writing correct lock code is only half the battle. When things go wrong in production, you need to see what's happening. Here's the toolkit I rely on.
ReentrantLock introspection methods: These are your first line of defense. isLocked() tells you if any thread holds the lock right now. hasQueuedThreads() shows if anyone is waiting. getQueueLength() gives you the count of waiters. getHoldCount() tells you how many times the current thread has acquired the lock (useful for debugging unexpected reentrancy). isFair() confirms the fairness policy. None of these are synchronized with lock state — they're snapshots, not transactional reads — but they're invaluable for diagnostics.
Thread dumps with jstack: When your application hangs, jstack <pid> is your best friend. Look for threads in BLOCKED state (waiting for intrinsic locks) or WAITING state (waiting on a Condition). jstack reports deadlocks automatically if it detects them. For ReentrantLock, threads waiting on show up as lock.lock()WAITING on LockSupport.park() — not as informative as synchronized blocks, which clearly show the monitor they're waiting on.
Programmatic deadlock detection: ThreadM detects both XBean.findDeadlockedThreads()synchronized-based and ReentrantLock-based deadlocks. Run this in a background thread on a schedule (every 30-60 seconds) and alert if deadlocks are found. This catches deadlocks that manifest under specific load patterns that your test suite might miss.
JFR (Java Flight Recorder): JFR captures lock contention events (jdk.JavaMonitorEnter, jdk.ThreadPark). You can analyze these in JDK Mission Control to see which locks are most contended, how long threads spend waiting, and whether your fair locks are actually enforcing FIFO ordering. I've used JFR to prove that a lock we thought was uncontended was actually the bottleneck — it was held for only 50 microseconds, but 200 threads were trying to acquire it simultaneously, creating a queue that averaged 2ms of wait time per request.
The tryLock() return value problem: tryLock() returns false if the lock isn't available. What do you do with that? Three common strategies: (1) Retry with exponential backoff. (2) Return an error to the caller (e.g., HTTP 503 Service Unavailable). (3) Queue the work for later processing. The worst thing you can do is silently drop the operation. Always log when tryLock() fails — it's a signal that your system is under more contention than expected.
package io.thecodeforge.concurrency; import java.lang.management.ManagementFactory; import java.lang.management.ThreadInfo; import java.lang.management.ThreadMXBean; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; public class LockMonitor { private final ReentrantLock observedLock = new ReentrantLock(); private final ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(); public void startMonitoring() { monitor.scheduleAtFixedRate(() -> { System.out.println("--- Lock Status ---"); System.out.println("Locked: " + observedLock.isLocked()); System.out.println("Fair: " + observedLock.isFair()); System.out.println("Queued threads: " + observedLock.getQueueLength()); System.out.println("Has queued threads: " + observedLock.hasQueuedThreads()); System.out.println("Hold count (current thread): " + observedLock.getHoldCount()); checkDeadlocks(); }, 0, 5, TimeUnit.SECONDS); } private void checkDeadlocks() { ThreadMXBean mxBean = ManagementFactory.getThreadMXBean(); long[] deadlockedIds = mxBean.findDeadlockedThreads(); if (deadlockedIds != null) { ThreadInfo[] infos = mxBean.getThreadInfo(deadlockedIds, 20); System.err.println("ALERT: " + deadlockedIds.length + " deadlocked threads detected!"); for (ThreadInfo info : infos) { System.err.println(" " + info.getThreadName() + " blocked on " + info.getLockName()); } } } public ReentrantLock getLock() { return observedLock; } public void shutdown() { monitor.shutdown(); } public static void main(String[] args) throws InterruptedException { LockMonitor lm = new LockMonitor(); lm.startMonitoring(); Thread worker = new Thread(() -> { lm.getLock().lock(); try { System.out.println("Worker holding lock..."); Thread.sleep(8000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lm.getLock().unlock(); } }); worker.start(); worker.join(); lm.shutdown(); } }
Locked: true
Fair: false
Queued threads: 0
Has queued threads: false
Hold count (current thread): 0
Worker holding lock...
--- Lock Status ---
Locked: true
Fair: false
Queued threads: 0
Has queued threads: false
Hold count (current thread): 0
Virtual Threads and Project Loom: The New Reality
If you're writing Java in 2026, virtual threads (Project Loom, finalized in Java 21) change the calculus around locks. This section is essential reading.
The pinning problem with synchronized: When a virtual thread executes a synchronized block, it gets pinned to its carrier thread (the underlying OS thread). It cannot be unmounted during the synchronized block. If the synchronized block performs a blocking operation (I/O, Thread.sleep(), waiting on a Future), the carrier thread is blocked too. Under heavy load, all carrier threads can become pinned, and virtual threads lose their advantage — you're back to platform-thread-level concurrency.
This isn't theoretical. I migrated a microservice from synchronized to ReentrantLock specifically because of pinning. The service handled 10K concurrent HTTP requests using virtual threads, and each request acquired a synchronized lock to update a shared cache. Under peak load, all carrier threads were pinned, and throughput collapsed. Switching to ReentrantLock eliminated pinning entirely, and throughput recovered.
ReentrantLock does not pin virtual threads. When a virtual thread calls and has to wait, it gracefully unmounts from the carrier thread. The carrier thread is free to execute other virtual threads. This is the fundamental reason why lock.lock()ReentrantLock is now the preferred lock type for virtual-thread-based applications.
Practical guidance: - If you're using virtual threads (and you should be for I/O-bound workloads in 2026), prefer ReentrantLock over synchronized. - If you must use synchronized (e.g., for third-party libraries that use it internally), keep the synchronized block as short as possible. Never perform I/O inside a synchronized block with virtual threads. - Run your application with -Djdk.tracePinnedThreads=short or -Djdk.tracePinnedThreads=long to detect pinning issues during development. - ReentrantReadWriteLock, StampedLock, and Semaphore also do not pin virtual threads.
The irony: For years, the advice was 'use synchronized unless you need advanced features.' With virtual threads, the advice inverts for I/O-bound workloads: 'use ReentrantLock unless you have a specific reason to use synchronized.' The JVM's optimizations for synchronized don't matter if the lock is pinning your carrier threads.
package io.thecodeforge.concurrency; import java.util.concurrent.Executors; import java.util.concurrent.locks.ReentrantLock; public class VirtualThreadLockDemo { private final ReentrantLock lock = new ReentrantLock(); private int counter = 0; public void incrementWithReentrantLock() { lock.lock(); try { counter++; try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } finally { lock.unlock(); } } public synchronized void incrementWithSynchronized() { counter++; try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public static void main(String[] args) throws InterruptedException { VirtualThreadLockDemo demo = new VirtualThreadLockDemo(); int taskCount = 1000; long start = System.currentTimeMillis(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < taskCount; i++) { executor.submit(demo::incrementWithReentrantLock); } } long reentrantTime = System.currentTimeMillis() - start; System.out.println("ReentrantLock with virtual threads: " + reentrantTime + "ms (counter: " + demo.counter + ")"); demo.counter = 0; start = System.currentTimeMillis(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < taskCount; i++) { executor.submit(demo::incrementWithSynchronized); } } long syncTime = System.currentTimeMillis() - start; System.out.println("Synchronized with virtual threads: " + syncTime + "ms (counter: " + demo.counter + ")"); System.out.println("Note: Synchronized may be slower due to virtual thread pinning."); } }
Synchronized with virtual threads: 4156ms (counter: 1000)
Note: Synchronized may be slower due to virtual thread pinning.
-Djdk.tracePinnedThreads=short to catch them early.AQS: The Engine Under the Hood
Every lock in java.util.concurrent.locks — ReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch, Phaser — is built on AbstractQueuedSynchronizer (AQS). Understanding AQS isn't required to use these classes, but it demystifies their behavior and helps you reason about performance characteristics.
AQS manages a FIFO queue of threads waiting to acquire a synchronization state. It uses CAS (Compare-And-Swap) operations on a single volatile int state variable to track the lock status. For ReentrantLock, state=0 means unlocked, state=1 means locked by one thread, state=N means locked by one thread N times (reentrancy). For Semaphore, state=N means N permits are available. For CountDownLatch, state=N means N counts remain.
Why this matters: When you understand that all these primitives share the same queue management and CAS-based state transitions, you realize they have similar performance characteristics under contention. The differences come from how they interpret the state variable and which additional bookkeeping they do (e.g., ReentrantReadWriteLock splits the state int into upper 16 bits for read locks and lower 16 bits for write locks).
When to write your own AQS subclass: Almost never. The built-in primitives cover 99% of use cases. Custom AQS subclasses are appropriate when you need a synchronization primitive that doesn't map to any existing class — like a phaser with a specific phase-transition callback, or a latch that resets automatically. If you find yourself writing one, Doug Lea's java.util.concurrent source code is the definitive reference.
Real-World Patterns: Putting It All Together
Theory is useful, but here are three production patterns I've used repeatedly that combine multiple locking concepts.
Pattern 1: Thread-safe lazy initialization with ReentrantLock. The classic double-checked locking pattern with volatile works for simple cases, but when initialization is complex (involves I/O, can fail, needs retry), a ReentrantLock gives you cleaner control. The lock ensures only one thread performs initialization, and tryLock() lets other threads avoid blocking if initialization is already in progress.
Pattern 2: Rate limiter with tryLock(). A per-client rate limiter using tryLock() with a timeout. Each client gets a ReentrantLock (from a ConcurrentHashMap). On each request, tryLock(1, TimeUnit.SECONDS) — if it succeeds, process the request and release. If it fails, the client has exceeded their rate limit. The lock's natural release after the timeout window creates a sliding-window rate limiter without any scheduled tasks.
Pattern 3: Read-heavy config with ReadWriteLock and cache stampede prevention. A configuration service that reads from a database, cached in memory. Reads use the read lock. Cache refresh uses the write lock. To prevent cache stampede (hundreds of threads all detecting the cache is stale and hitting the database simultaneously), the first thread to detect staleness acquires the write lock and refreshes; all others fall back to the read lock and get the freshly cached value.
These patterns work because they combine the right lock type with the right access pattern. A single synchronized block would work for all three — but at the cost of unnecessary serialization.
package io.thecodeforge.concurrency; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; public class RealWorldPatterns { private final ReentrantLock initLock = new ReentrantLock(); private volatile String config; public String getConfig() { if (config == null) { initLock.lock(); try { if (config == null) { config = loadConfig(); } } finally { initLock.unlock(); } } return config; } private String loadConfig() { System.out.println("Loading config (should only happen once)..."); return "app-config-v2"; } private final ConcurrentHashMap<String, ReentrantLock> rateLimiters = new ConcurrentHashMap<>(); public boolean tryAcquirePermit(String clientId) throws InterruptedException { ReentrantLock clientLock = rateLimiters.computeIfAbsent( clientId, k -> new ReentrantLock() ); return clientLock.tryLock(1, TimeUnit.SECONDS); } public void releasePermit(String clientId) { ReentrantLock clientLock = rateLimiters.get(clientId); if (clientLock != null && clientLock.isHeldByCurrentThread()) { clientLock.unlock(); } } public static void main(String[] args) throws InterruptedException { RealWorldPatterns patterns = new RealWorldPatterns(); Thread t1 = new Thread(() -> System.out.println("Config: " + patterns.getConfig())); Thread t2 = new Thread(() -> System.out.println("Config: " + patterns.getConfig())); t1.start(); t2.start(); t1.join(); t2.join(); boolean acquired = patterns.tryAcquirePermit("client-42"); System.out.println("Rate limit permit for client-42: " + (acquired ? "granted" : "denied")); if (acquired) patterns.releasePermit("client-42"); } }
Config: app-config-v2
Config: app-config-v2
Rate limit permit for client-42: granted
| Feature | synchronized | ReentrantLock | ReentrantReadWriteLock | StampedLock |
|---|---|---|---|---|
| Automatic Release | Yes | No (manual unlock) | No (manual unlock) | No (manual unlock) |
| Try Lock (non-blocking) | No | Yes (tryLock()) | Yes (tryLock()) | Yes (tryOptimisticRead(), tryWriteLock()) |
| Lock with Timeout | No | Yes (tryLock(time, unit)) | Yes (tryLock(time, unit)) | Yes (tryWriteLock(time, unit)) |
| Interruptible Wait | No | Yes (lockInterruptibly()) | Yes | Yes |
| Fairness Control | No (JVM specific) | Yes (constructor true) | Yes | No for optimistic reads |
| Multiple Conditions | No (single wait set) | Yes (newCondition()) | N/A (split read/write) | No |
| Concurrent Reads | No | No | Yes (shared read lock) | Yes (optimistic or pessimistic) |
| Reentrant | Yes | Yes (hold count) | Yes | No (will deadlock) |
| Pins Virtual Threads | Yes | No | No | No |
| Lock Leak Risk | None (auto release) | High (developer responsibility) | High (developer responsibility) | High (developer responsibility) |
| Performance | Good (JVM optimized) | Good (more overhead) | Excellent for read-heavy | Highest for read-heavy (complex) |
🎯 Key Takeaways
- Use synchronized for basic mutual exclusion due to its simplicity and automatic release, preventing lock leaks.
- Opt for ReentrantLock when advanced features are needed: tryLock (non-blocking/timed), lockInterruptibly, fairness control, or multiple Condition variables.
- The lifeblood of ReentrantLock safety: always
lock()before try andunlock()unconditionally in finally. - When
await()ing on a Condition, always use a while loop to re-check the predicate due to potential spurious wakeups. - ReadWriteLock is your friend for read-heavy data structures, allowing concurrent reads while preserving write exclusivity.
- Fair locking (new ReentrantLock(true)) prevents starvation but often at a significant throughput cost; use judiciously.
- Lock stripping partitions shared data into independently locked segments, dramatically reducing contention for large data structures.
- StampedLock offers the highest read throughput for write-rare scenarios but is not reentrant and lacks Condition support.
- With virtual threads (Java 21+), prefer ReentrantLock over synchronized to avoid carrier thread pinning.
- Always handle tryLock() return values — returning false means the lock is unavailable; don't silently proceed without it.
- Monitor lock contention in production using ReentrantLock introspection methods, jstack thread dumps, JFR, and programmatic deadlock detection.
- Don't choose ReentrantLock because you think it's faster. Choose it because you need its features. Modern JVMs optimize synchronized aggressively.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the primary difference between synchronized and ReentrantLock?Reveal
- QWhat does 'reentrant' mean in ReentrantLock?Reveal
- QDescribe tryLock() and its use case.Reveal
- QWhat is ReadWriteLock and why would I use it over ReentrantLock?Reveal
- QWhy is it critical to call
unlock()inside a finally block?Reveal - QWhat problem do Condition variables solve that wait/notify don't adequately?Reveal
- QHow does ReentrantLock interact with virtual threads in Java 21+?Reveal
- QWhat is lock stripping and when would you use it?Reveal
- QWhat is StampedLock's optimistic read, and what are its risks?Reveal
- QHow do you prevent deadlocks when acquiring multiple locks?Reveal
Frequently Asked Questions
What is ReentrantLock in Java?
ReentrantLock is an explicit lock from the java.util.concurrent.locks package. It offers all the functionality of Java's synchronized keyword (mutual exclusion, reentrancy) but provides advanced features like interruptible lock acquisition, timed lock attempts, non-blocking lock attempts (tryLock), fairness policies, and multiple condition variables for complex thread signaling.
When should I use ReentrantLock over synchronized?
Use synchronized by default for its simplicity and safety. Switch to ReentrantLock when you need capabilities not offered by synchronized: non-blocking lock acquisition (tryLock), timed wait for locks (tryLock(time, unit)), interruptible waiting (lockInterruptibly), explicit fairness control, or managing multiple independent conditions on a single lock. Also prefer ReentrantLock when using virtual threads to avoid carrier thread pinning.
What is a Condition in Java concurrency?
A Condition object, created via lock.newCondition(), acts as an explicit replacement for Object.wait(), notify(), and notifyAll(). It's tied to a specific Lock and allows threads to wait for certain conditions to be met (await()) and to be signaled when those conditions might have changed (signal(), signalAll()). A single ReentrantLock can have multiple Condition objects, each managing its own set of waiting threads.
What is the difference between lock() and tryLock()?
lock() is a blocking call: it waits indefinitely until the lock is acquired. tryLock() is a non-blocking call: it attempts to acquire the lock immediately and returns true if successful, or false if the lock is held by another thread. tryLock(timeout, unit) waits up to the specified duration before giving up.
What is a fair lock in Java, and when is it used?
A fair lock (instantiated with new ReentrantLock(true)) attempts to grant the lock to threads in the order they requested it (roughly FIFO). This prevents thread starvation, where a thread might wait forever if others constantly acquire the lock before it. However, fair locks typically have lower throughput than non-fair locks due to increased overhead in managing the wait queue.
When should I use ReadWriteLock?
Use ReadWriteLock when you have a shared resource that is read significantly more often than it is written. It allows multiple threads to acquire a 'read lock' concurrently, dramatically increasing performance compared to an exclusive lock that would serialize all access. Writes still require an exclusive 'write lock'.
What is lock stripping?
Lock stripping is a concurrency pattern where a data structure is partitioned into segments, each protected by its own lock. Threads accessing different segments can proceed concurrently, reducing contention. ConcurrentHashMap uses this principle internally. The trade-off is that whole-structure operations (like computing total size) require acquiring multiple locks.
How do virtual threads affect lock choice in Java?
Virtual threads get pinned to carrier threads when executing synchronized blocks, which can cause throughput collapse under load. ReentrantLock, ReentrantReadWriteLock, and StampedLock do not pin virtual threads. For I/O-bound applications using virtual threads, explicit locks are strongly preferred over synchronized.
What is StampedLock and when should I use it?
StampedLock (Java 8+) provides optimistic reading: threads can read data without acquiring a lock, then validate that no write occurred. It offers the highest throughput for read-heavy, write-rare workloads. However, it is not reentrant, has no Condition support, and can cause reader starvation under frequent writes. Use it only when reads vastly outnumber writes and you've benchmarked it against ReadWriteLock.
How do I detect deadlocks in production?
Use ThreadMXBean.findDeadlockedThreads() to programmatically detect deadlocks on a schedule. Run jstack to get a thread dump that shows blocked threads and their lock dependencies. Java Flight Recorder (JFR) captures lock contention events for post-mortem analysis. For ReentrantLock-specific diagnostics, use isLocked(), getQueueLength(), and hasQueuedThreads() to monitor lock state.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.