Java ReentrantLock
Use ReentrantLock for explicit, flexible locking in Java — tryLock, lockInterruptibly, fairness policy, and the diagnostic API.
Java ReentrantLock
ReentrantLock is the standard implementation of the Lock interface. "Reentrant" means the same thread can acquire it multiple times — the same property synchronized has. Everything the previous chapter described — tryLock, lockInterruptibly, Condition — is delivered by this class. This chapter is the deep dive: the constructors, the fairness option, the diagnostic methods, and the cases where ReentrantLock is the right pick over plain synchronized.
Two constructors
Lock lock = new ReentrantLock(); // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true); // fair — FIFO wait queueNon-fair (the default) means when the lock becomes available, whichever waiting thread the scheduler runs first wins. New incoming threads can also "barge" — acquire the lock without waiting in line if it happens to be free at the moment of their call. This is fast: no queue manipulation, no scheduler hint. The downside is starvation possibility — a thread can sit in the wait queue for a long time while bargers keep grabbing the lock.
Fair means the lock is granted to whichever thread has been waiting longest. The wait queue is a true FIFO. This eliminates starvation. The cost: noticeably lower throughput, because every acquire involves a scheduler decision and the JVM can't take fast-path shortcuts.
The right default is non-fair. Use fair only when you've identified a real starvation problem (typically detected by a getWaitQueueLength query that keeps growing) or when the application's correctness depends on processing order.
Reentrancy and getHoldCount
Like synchronized, a ReentrantLock can be re-acquired by the thread that already holds it:
ReentrantLock lock = new ReentrantLock();
lock.lock();
lock.lock(); // same thread re-enters — fine
try {
doStuff();
} finally {
lock.unlock();
lock.unlock(); // must unlock as many times as locked
}Each lock() increments an internal hold count; each unlock() decrements. The lock is actually released — visible to other threads — only when the count reaches zero. The diagnostic call:
int n = lock.getHoldCount(); // how many times THIS thread has acquired without unlockinggetHoldCount is useful for assertions ("this method must be called with the lock held") and for verifying invariants in tests.
The matching unlock rule is strict. If you lock twice and unlock once, the lock stays held — silent leak. If you unlock more times than you locked, IllegalMonitorStateException is thrown immediately. Always pair acquisition and release inside the same method when possible; spread it across methods and the bookkeeping gets fragile fast.
Other diagnostic methods
ReentrantLock exposes a fair bit of introspection that synchronized doesn't:
lock.isLocked(); // is anybody holding it?
lock.isHeldByCurrentThread(); // do I hold it?
lock.getQueueLength(); // how many threads are waiting?
lock.hasQueuedThreads(); // are any waiting?
lock.hasQueuedThread(t); // is thread t waiting?
lock.getHoldCount(); // how many times have I re-entered?These are mostly for monitoring and testing — production logic shouldn't depend on "is anybody waiting" because the answer is racy by the time you check. But for metrics, "ratio of contended acquires" is a useful signal that your lock granularity is wrong.
When ReentrantLock beats synchronized
The four reasons to pick ReentrantLock:
- Deadline-respecting acquisition. You need to fail-fast or back off if the lock isn't available within a bounded time.
tryLock(timeout)does this;synchronizeddoesn't. - Cancellation. You need to interrupt a thread that's waiting for the lock.
lockInterruptiblydoes this;synchronizedignores interrupts during monitor entry. - Multiple condition variables. You need to separately signal different categories of waiters.
Lock.newCondition()does this; the intrinsic monitor has exactly one wait set. - Fairness. You need FIFO ordering of waiters.
new ReentrantLock(true)is the only built-in way.
Anything outside those four — sleep-quality typed-out code with no special needs — should stay on synchronized. The JVM's optimisations for intrinsic monitors are real, and the try/finally discipline Lock needs is something you can forget. Don't reach for Lock "because it's more modern."
The Condition pair, in detail
The producer/consumer pattern with a ReentrantLock and two conditions is the classic example. Repeated here in standalone form because it's also the shape ArrayBlockingQueue uses internally:
class BoundedBuffer<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Object[] items;
private int count, head, tail;
BoundedBuffer(int cap) { items = new Object[cap]; }
public void put(T x) throws InterruptedException {
lock.lock();
try {
while (count == items.length) notFull.await(); // release lock, park, re-acquire on wake
items[tail] = x;
tail = (tail + 1) % items.length;
count++;
notEmpty.signal(); // wake exactly one consumer
} finally { lock.unlock(); }
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0) notEmpty.await();
T x = (T) items[head];
items[head] = null;
head = (head + 1) % items.length;
count--;
notFull.signal(); // wake exactly one producer
return x;
} finally { lock.unlock(); }
}
}The win over wait/notifyAll: a producer wakes one consumer, not every waiting thread. Under heavy contention, that's the difference between notifyAll storms (every waiter wakes, races for the lock, all but one go back to sleep) and a clean hand-off.
signal vs signalAll follows the same rule as notify vs notifyAll: prefer signalAll unless you can prove all waiters on this condition are interchangeable. In this buffer, every waiter on notEmpty is a consumer wanting a slot — they're interchangeable; signal is safe.
The CAS-loop / monitor trade-off
A common question: when does AtomicInteger win over a ReentrantLock? Roughly:
- For a single field with a simple update, atomics win — they're CAS instructions, no kernel call, no parking.
AtomicInteger.incrementAndGetis faster thanReentrantLock.lock+int+++unlock. - For multiple fields that must update together or for blocking semantics (wait for a queue to be non-empty), the lock wins — you can group the work and signal across it.
A read-only check like "is the cache valid?" is volatile; an increment is atomic; a "swap one item for another in a queue" is a lock. Use the lightest tool the work requires.
tryLock for deadlock-free composition
The simplest pattern for combining two locks without ordering:
boolean ok = false;
while (!ok) {
lockA.lock();
try {
if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
doCriticalWork();
ok = true;
} finally { lockB.unlock(); }
}
// else: release lockA and try again — broke hold-and-wait
} finally {
if (!ok) lockA.unlock(); // released without doing the work
else lockA.unlock(); // released after doing the work
}
}(Both branches unlock; written redundantly here for clarity.)
This avoids the deadlock-by-ordering trap without requiring a global lock-order rule. The trade-off is more code, livelock potential under heavy contention, and worse cache behaviour. Use it for cross-cutting locks (locks on objects you didn't write); prefer ordering when you control both sides.
A worked example: contention, fairness, and reentrancy
The program below contrasts a fair vs non-fair ReentrantLock under high contention, then demonstrates reentrancy and hold-count accounting.
What to take from the run:
- The non-fair lock allocated acquisitions unevenly across threads — typically one thread accumulated a large lead while another lagged. The total throughput was high; the per-thread distribution was loose. That's the fast path: the JVM doesn't enforce ordering, so one thread can re-acquire repeatedly without yielding.
- The fair lock allocated acquisitions almost evenly — the spread between fastest and slowest worker was a fraction of the non-fair spread. The total wall-clock was noticeably longer. Fair-ordering trades throughput for predictable progress. Don't pay that cost unless you've measured a starvation problem.
- The reentrancy section showed the hold count rising and falling with each
lock/unlock. The lock is actually released only when the count drops to zero; until then other threads waiting on it remainBLOCKED. This is identical semantics tosynchronized; the difference isReentrantLockexposes the count to inspection. - The extra
unlock()after the hold count hit zero threwIllegalMonitorStateExceptionimmediately — there's no silent "double unlock" that succeeds. That's the JVM enforcing the lock's invariant: only the holder can release, and only as many times as they acquired. - The
getQueueLengthreading of 3 confirmed the three waiting threads were genuinely queued behind us. In production this method is useful for "is contention building up?" alerting — a queue length that grows over time is a sign that the work inside the lock is too slow.
What's next
The next chapter, Java ReadWriteLock, covers ReentrantReadWriteLock — the lock that splits acquisition into "many readers OR one writer," for the read-heavy workloads where exclusive locks are overkill.
Practice
You call `lock.lock()` twice from the same thread on a `ReentrantLock` and then call `lock.unlock()` once. Is the lock released so other threads can acquire it?