Java Tutorial
🔍

Lock & ReentrantLock

Lock & ReentrantLock

java.util.concurrent.locks.Lock, and its primary implementation ReentrantLock, offer an explicit alternative to the synchronized keyword — the same mutual-exclusion guarantee, plus the ability to attempt a lock without blocking forever, time out an attempt, respond to interruption while waiting, and choose a fairness policy.

What Is Lock?

Lock is an interface in java.util.concurrent.locks describing an explicit mutual-exclusion mechanism — lock() and unlock() calls the code controls directly, instead of a synchronized block's automatic acquire-and-release. ReentrantLock is the implementation almost every codebase actually uses, and it earns its name from a specific guarantee: the same thread can acquire it again while already holding it, without deadlocking itself.

Why Thread Safety Matters Here

synchronized handles a large share of everyday mutual exclusion perfectly well, but it offers no way to attempt a lock without blocking indefinitely, no fairness guarantee, and no way for a waiting thread to respond to interruption. A checkout flow, a booking counter, or any code that genuinely cannot afford to hang forever behind a busy lock needs a way to fail fast instead — and without that option, a single slow or stuck holder of a synchronized block can back up every other thread waiting on it with no visibility into what is actually happening.

How It Works

One sentence before the diagram: this shows one thread holding a ReentrantLock while two others queue up behind it.

Thread A: lock() --> acquires --> [ critical section ] --> unlock()
                                          |
Thread B: lock() --> blocks, waits ----->|
Thread C: lock() --> blocks, waits ----->|
                                          |
                            (once A calls unlock(), exactly one
                             waiting thread acquires the lock next)

Unlike synchronized's automatic release, unlock() is never called for you — it must be called explicitly, which is why every Lock acquisition needs a try/finally wrapping the critical section, so the lock is released even if an exception is thrown inside it.

Lock.unlock() is not automatic. Forgetting the surrounding try/finally is the single most common mistake with this API — covered in full below.

ReentrantLock is, as its name says, reentrant — the same thread can acquire it again while already holding it, without blocking itself, which matters the moment one locked method calls another locked method on the same object.

Code Examples

synchronized acquires and releases its lock automatically, which is simple and safe, but offers no way to attempt acquisition without blocking indefinitely.

1// File: BeforeLock.java 2 3public class BeforeLock { 4 private int counter = 0; 5 6 public synchronized void increment() { 7 counter++; 8 } 9 10 public synchronized int getCounter() { 11 return counter; 12 } 13 14 public static void main(String[] args) throws InterruptedException { 15 BeforeLock demo = new BeforeLock(); 16 Thread[] threads = new Thread[10]; 17 for (int i = 0; i < 10; i++) { 18 threads[i] = new Thread(() -> { 19 for (int j = 0; j < 1000; j++) { 20 demo.increment(); 21 } 22 }); 23 threads[i].start(); 24 } 25 for (Thread t : threads) { 26 t.join(); 27 } 28 System.out.println("Counter: " + demo.getCounter()); 29 } 30}
Output:
Counter: 10000

ReentrantLock provides the same mutual exclusion explicitly, through lock() and unlock() calls the code controls directly.

1// File: AfterLock.java 2import java.util.concurrent.locks.*; 3 4public class AfterLock { 5 private int counter = 0; 6 private final Lock lock = new ReentrantLock(); 7 8 public void increment() { 9 lock.lock(); 10 try { 11 counter++; 12 } finally { 13 lock.unlock(); 14 } 15 } 16 17 public int getCounter() { 18 return counter; 19 } 20 21 public static void main(String[] args) throws InterruptedException { 22 AfterLock demo = new AfterLock(); 23 Thread[] threads = new Thread[10]; 24 for (int i = 0; i < 10; i++) { 25 threads[i] = new Thread(() -> { 26 for (int j = 0; j < 1000; j++) { 27 demo.increment(); 28 } 29 }); 30 threads[i].start(); 31 } 32 for (Thread t : threads) { 33 t.join(); 34 } 35 System.out.println("Counter: " + demo.getCounter()); 36 } 37}
Output:
Counter: 10000

Both produce the identical, correct total — the difference only shows up once tryLock(), timeouts, and fairness enter the picture.

1// File: ReentrancyExample.java 2import java.util.concurrent.locks.*; 3 4public class ReentrancyExample { 5 private final Lock lock = new ReentrantLock(); 6 private int holdCount = 0; 7 8 public void outer() { 9 lock.lock(); 10 try { 11 holdCount++; 12 inner(); 13 } finally { 14 lock.unlock(); 15 } 16 } 17 18 public void inner() { 19 lock.lock(); 20 try { 21 holdCount++; 22 } finally { 23 lock.unlock(); 24 } 25 } 26 27 public static void main(String[] args) { 28 ReentrancyExample demo = new ReentrancyExample(); 29 demo.outer(); 30 System.out.println("Hold count: " + demo.holdCount); 31 } 32}
Output:
Hold count: 2

outer() acquires the lock, then calls inner(), which acquires the same lock again on the same thread — without reentrancy, this would deadlock a single thread against itself. tryLock() offers a non-blocking alternative to lock(), returning immediately with true or false instead of waiting.

1// File: TryLockExample.java 2import java.util.concurrent.locks.*; 3 4public class TryLockExample { 5 public static void main(String[] args) throws InterruptedException { 6 Lock lock = new ReentrantLock(); 7 lock.lock(); 8 9 Thread other = new Thread(() -> { 10 boolean acquired = lock.tryLock(); 11 System.out.println("Other thread acquired: " + acquired); 12 }); 13 other.start(); 14 other.join(); 15 16 lock.unlock(); 17 } 18}
Output:
Other thread acquired: false

The main thread holds the lock continuously from lock() until after other.join() returns, so other's tryLock() call is guaranteed to run while the lock is still held, making this deterministic — tryLock(timeout, unit) extends this idea by waiting up to a bounded time before giving up, covered next in this article's real-world example.

Real-World Example

A movie theater's seat reservation counter uses tryLock() with a timeout so a reservation request fails fast rather than blocking indefinitely if the counter is busy, while the lock itself keeps the seat count consistent under concurrent requests. This is the classic fail-fast resource acquisition pattern — giving up and reporting unavailability rather than blocking indefinitely — combined with the kind of complex control flow around a critical section that synchronized's block-scoped locking handles awkwardly.

1// File: SeatReservationCounter.java 2import java.util.concurrent.TimeUnit; 3import java.util.concurrent.locks.*; 4 5public class SeatReservationCounter { 6 private int availableSeats; 7 private final Lock lock = new ReentrantLock(); 8 9 public SeatReservationCounter(int availableSeats) { 10 this.availableSeats = availableSeats; 11 } 12 13 public String reserve(String customerName, long timeoutMillis) throws InterruptedException { 14 if (!lock.tryLock(timeoutMillis, TimeUnit.MILLISECONDS)) { 15 return customerName + ": booking counter busy, try again"; 16 } 17 try { 18 if (availableSeats <= 0) { 19 return customerName + ": sold out"; 20 } 21 availableSeats--; 22 return customerName + ": seat reserved, " + availableSeats + " left"; 23 } finally { 24 lock.unlock(); 25 } 26 } 27 28 public int getAvailableSeats() { 29 lock.lock(); 30 try { 31 return availableSeats; 32 } finally { 33 lock.unlock(); 34 } 35 } 36}
1// File: SeatReservationDemo.java 2import java.util.concurrent.*; 3import java.util.concurrent.atomic.*; 4 5public class SeatReservationDemo { 6 public static void main(String[] args) throws Exception { 7 SeatReservationCounter counter = new SeatReservationCounter(2); 8 AtomicInteger reservedCount = new AtomicInteger(); 9 AtomicInteger soldOutCount = new AtomicInteger(); 10 11 try (ExecutorService executor = Executors.newFixedThreadPool(3)) { 12 for (int i = 0; i < 3; i++) { 13 executor.submit(() -> { 14 try { 15 String result = counter.reserve("Customer", 5000); 16 if (result.contains("reserved")) { 17 reservedCount.incrementAndGet(); 18 } else if (result.contains("sold out")) { 19 soldOutCount.incrementAndGet(); 20 } 21 } catch (InterruptedException e) { 22 Thread.currentThread().interrupt(); 23 } 24 }); 25 } 26 } 27 28 System.out.println("Reserved: " + reservedCount.get()); 29 System.out.println("Sold out: " + soldOutCount.get()); 30 System.out.println("Remaining seats: " + counter.getAvailableSeats()); 31 } 32}
Output:
Reserved: 2
Sold out: 1
Remaining seats: 0

Regardless of which of the three concurrent requests happens to run first, the lock guarantees the check-and-decrement in reserve() is atomic — exactly two succeed against the two-seat pool and exactly one is told the show is sold out, every single run. A mistake that appears often in fresher pull requests is calling lock() without a matching try/finally, or misplacing unlock() so it never runs if an exception is thrown partway through the critical section — unlike synchronized, which releases its lock automatically no matter how the block exits, a Lock left locked after an exception can silently block every other thread that later calls lock() on it. Wrapping the seat-decrement logic in try/finally, exactly as reserve() does here, is what guarantees the lock is always released.

Best Practices

Always wrap a Lock's critical section in try/finally, with unlock() as the very first statement in the finally block, so it runs regardless of how the try block exits.

Prefer tryLock(timeout, unit) over a plain lock() call in latency-sensitive code, so a caller gets a clear failure instead of blocking indefinitely under contention.

Enable fairness only when genuinely needed — new ReentrantLock(true) trades throughput for more predictable, closer-to-FIFO ordering among waiting threads, and that tradeoff is not free.

Default to synchronized for straightforward mutual exclusion, and reach for Lock specifically when tryLock(), fairness, interruptibility, or multiple Condition queues are actually needed. Lock.newCondition() returns a Condition, the Lock-based equivalent of wait()/notify() covered in this section's dedicated wait, notify & notifyAll article — the key difference is that a single Lock can have multiple independent Condition queues, unlike a monitor's one implicit condition. ExecutorService, covered in this section's Executor Framework article, is the natural place to run the worker threads that contend for a Lock, and tryLock() with a timeout pairs naturally with graceful-degradation patterns that return a fallback result instead of making a caller wait indefinitely.

Common Mistakes

Forgetting the try/finally around a Lock's critical section means an exception thrown inside it leaves the lock held forever, since nothing else releases it automatically the way synchronized would.

1// Illustrative only - do not run: if increment() throws partway through, 2// unlock() never executes, and every other caller of lock() blocks forever 3public void increment() { 4 lock.lock(); 5 counter++; 6 lock.unlock(); 7}

Calling unlock() from a thread that never acquired the lock throws immediately, since a ReentrantLock tracks exactly which thread owns it.

1// File: WrongThreadUnlockMistake.java 2import java.util.concurrent.locks.*; 3 4public class WrongThreadUnlockMistake { 5 public static void main(String[] args) throws InterruptedException { 6 Lock lock = new ReentrantLock(); 7 lock.lock(); 8 9 Thread other = new Thread(() -> { 10 try { 11 lock.unlock(); 12 } catch (IllegalMonitorStateException e) { 13 System.out.println("Caught: " + e.getClass().getSimpleName()); 14 } 15 }); 16 other.start(); 17 other.join(); 18 19 lock.unlock(); 20 } 21}
Output:
Caught: IllegalMonitorStateException

The main thread holds the lock for this entire example, so other's attempt to unlock a lock it never acquired fails immediately and deterministically.

Interview Questions

Q1. What is the main difference between synchronized and Lock/ReentrantLock?

synchronized acquires and releases its lock automatically around a block or method. Lock requires explicit lock() and unlock() calls, in exchange for extra capabilities synchronized does not offer: tryLock(), timed acquisition, interruptible waiting, and a configurable fairness policy. The nuance interviewers are listening for is whether you know these are complementary tools, not that one universally replaces the other.

Q2. Why must Lock.unlock() always be called in a finally block?

Because nothing releases a Lock automatically — if the critical section throws an exception and unlock() is not in a finally block, the lock stays held forever, blocking every other thread that later tries to acquire it. Interviewers are checking that you understand this is a real production hazard, not a theoretical style preference.

Q3. What does tryLock() do differently from lock()?

lock() blocks until the lock becomes available. tryLock() returns immediately with true or false depending on whether the lock was acquired, and its timed overload, tryLock(timeout, unit), waits only up to the given duration before giving up. The nuance to surface is knowing both the no-argument and timed forms exist, and when each is appropriate.

Q4. What does it mean for ReentrantLock to be reentrant, and what would happen without it?

It means the thread that already holds the lock can acquire it again without blocking, exactly as outer() calling inner() does in this article's ReentrancyExample. Without reentrancy, that same scenario would deadlock a single thread against its own held lock — this is exactly the follow-up interviewers want you to reach for unprompted.

Q5. What is lock fairness, and what tradeoff does enabling it introduce?

A fair lock, created with new ReentrantLock(true), grants access to waiting threads in roughly the order they requested it, rather than an unspecified order. The tradeoff is reduced throughput, since enforcing strict ordering costs more than allowing whichever thread happens to be ready first to proceed — interviewers want to hear that you would not enable it by default.

Q6. What happens if a thread that does not hold a ReentrantLock calls unlock() on it?

It throws IllegalMonitorStateException immediately, exactly as demonstrated in this article's WrongThreadUnlockMistake example — a ReentrantLock always knows exactly which thread currently owns it. This distinguishes it clearly from a Semaphore, which tracks no such ownership at all.

Q7. Can a Lock be acquired with a timeout, and why is that useful?

Yes, via tryLock(timeout, unit). It is useful whenever blocking indefinitely is unacceptable — a fail-fast response, as SeatReservationCounter returns in this article's real-world example, is often far more useful to a caller than an indefinite wait. Product-company interviewers in particular tend to push on what the caller should do with a failed acquisition, not just whether you know the method exists.

FAQs

Does ReentrantLock replace synchronized entirely?

No. synchronized remains simpler and perfectly sufficient for straightforward mutual exclusion — ReentrantLock is worth reaching for specifically when its extra capabilities, like tryLock() or fairness, are actually needed.

Is ReentrantLock faster than synchronized?

Not reliably in modern JVMs, which have heavily optimized synchronized over the years. The real reason to choose ReentrantLock is its feature set, not a guaranteed performance advantage.

Can multiple threads hold the same ReentrantLock at once?

No, it is still exclusive — only one thread can hold it at a time. "Reentrant" means the same thread can acquire it again while already holding it, not that different threads can hold it simultaneously.

What is Condition, and how does it relate to Lock?

Condition, obtained via lock.newCondition(), is the Lock-based equivalent of wait()/notify(), offering await(), signal(), and signalAll(). Unlike a monitor's single implicit condition, a single Lock can have several independent Condition queues.

Does a fair ReentrantLock guarantee strict FIFO ordering in all cases?

It guarantees roughly first-come-first-served ordering among threads that are genuinely waiting, but not in every corner case — a thread calling tryLock() without a timeout, for instance, can still acquire the lock immediately if it happens to be free, bypassing the fair queue entirely.

Can lockInterruptibly() be interrupted while waiting?

Yes, that is its entire purpose — a thread blocked in lockInterruptibly() throws InterruptedException if interrupted while waiting, unlike a plain lock() call, which ignores interruption and keeps waiting.

Is it safe to use a single ReentrantLock instance shared across many objects?

Yes, as long as it consistently guards whatever shared state it is meant to protect — a single Lock protecting several related resources is a normal and safe pattern, as long as every access to that state goes through the same lock.

Summary

Lock and ReentrantLock provide the same mutual exclusion synchronized does, made explicit through lock() and unlock() calls the code controls directly — at the cost of needing a disciplined try/finally around every critical section, in exchange for tryLock(), timed acquisition, interruptibility, and configurable fairness that synchronized simply does not offer.

The habit worth carrying forward from this article's seat reservation example is treating try/finally as non-optional around any Lock usage, and reaching for tryLock(timeout, unit) whenever a caller is better served by a fast, clear failure than an indefinite wait.

What to Read Next