Reentrancy
What reentrancy means (owner thread plus hold count), why synchronized must be reentrant, how to pair lock and unlock, and where reentrancy stops: Semaphore(1), StampedLock and the read-to-write upgrade.
On this page
- Picture it first: one key for every door
- Why Java needs this
- Under the hood: owner and hold count
- Pair every lock() with exactly one unlock()
- Reentrancy is per thread, not per task
- Locks that are not reentrant
- wait() lets go of every hold
- The hidden danger: re-entering a half-updated object
- Real world
- Advanced: how the JVM keeps count
- How to say it in the interview
🔑 What is reentrancy?
A lock is reentrant when the thread that already holds it can take it again without waiting. The lock remembers two things: who owns it, and how many times the owner has taken it. Every Java synchronized lock and every ReentrantLock works this way, so a synchronized method can call another synchronized method on the same object and walk straight in.
For this lesson, we'll pick up right where synchronized and monitors left off. You know one thread at a time gets the monitor. Now the question is: what happens when the thread that's already inside asks for the same lock again? Interviewers love this one because the first answer ("it works") is easy, and the follow-ups ("so can you upgrade a read lock?", "is StampedLock reentrant?") quietly separate people who've memorised a word from people who know how the lock keeps score.
Below the line (out of scope)
- Owner thread and hold count, why Java needs them, and pairing
lock()withunlock() - Locks that are not reentrant:
Semaphore(1),StampedLock, and the read-to-write upgrade - The full
LockAPI, "ReadWriteLock" and "Deadlock detection and prevention" get their own lessons
The 30-second answer
A reentrant lock lets the thread that already owns it acquire it again. It tracks an owner thread and a hold count: the first acquire sets the owner and count 1, each nested acquire adds 1, each release subtracts 1, and the lock is free only at 0. Java's intrinsic locks are reentrant, so a synchronized method can call another synchronized method on the same object, and a subclass override can call super. Without it, those calls would self-deadlock. Reentrancy is per thread, not per task. Semaphore(1) and StampedLock are not reentrant, and ReentrantReadWriteLock won't let you upgrade read to write: that deadlocks.
What you’ll learn
- Define a reentrant lock as an owner thread plus a hold count that is free only at 0
- Explain why Java's
synchronizedmust be reentrant: nested calls, recursion and an **override callingsuper** would self-deadlock otherwise - Watch the count with
getHoldCount(),isHeldByCurrentThread()andThread.holdsLock(obj) - Pair every
lock()with oneunlock()infinally, and predict what a missed unlock and a foreign unlock (IllegalMonitorStateException) do - Recognise that reentrancy is per thread, not per task, so pools and async callbacks don't inherit holds
- Spot non-reentrant locks:
Semaphore(1),StampedLock, and the read-to-write upgrade deadlock inReentrantReadWriteLock - Explain how
wait()andCondition.await()release every hold and restore the count - Describe how AQS
stateand HotSpot's monitor recursion count store holds, and the 2147483647 and 65535 limits
Interview radar
Asked very often: What is a reentrant lock? Why is synchronized reentrant, and what would break if it weren't?
Core follow-up: Can you upgrade a read lock to a write lock in ReentrantReadWriteLock?
Depth check: Is StampedLock reentrant? What does wait() do to a monitor you hold three times?
01Picture it first: one key for every door
Picture your house with a front door, a hallway door and a study door, all opened by the same key. You unlock the front door, walk to the hallway, and the same key opens that too. You don't wait outside your own hallway for yourself to come out. That would be silly.
Now add one rule. Your partner wants to come in, but only after you've left the house completely. So the house keeps a little tally card: "owner: you, doors passed: 3". Every door you walk through adds one. Every door you walk back out of subtracts one. When the tally hits 0, the key goes back on the hook and your partner can go in.
That's the whole idea of a reentrant lock, and here's the sketch.
One key opens every door, and the lock keeps a tally
In Java terms: the house is one object, the key is its lock, you're a thread, and the tally card is the hold count. The lock also remembers the owner thread. When a thread asks for the lock, the check is: "is it free? Then you're the owner, count 1. Is the owner you? Then count + 1 and walk in. Anyone else waits."
02Why Java needs this
You might be thinking: why would a thread ever ask for a lock it already holds? It happens all the time, usually without you noticing. Here are the three classic shapes.
class Inventory {
private int stock = 10;
public synchronized boolean reserve(int n) { // takes the lock on "this"
if (!hasStock(n)) return false; // calls another synchronized method
stock -= n;
return true;
}
public synchronized boolean hasStock(int n) { // same lock, same thread: walks in
return stock >= n;
}
public synchronized int countDown(int n) { // recursion: re-enters on every call
return n == 0 ? 0 : 1 + countDown(n - 1);
}
}
class AuditedInventory extends Inventory {
@Override
public synchronized boolean reserve(int n) { // lock on "this"
System.out.println("reserving " + n);
return super.reserve(n); // the parent's synchronized method, same "this"
}
}
AuditedInventory is the one that trips people up. The override and the parent method are both synchronized, and they lock the same object, because super.reserve runs on the same this. Java Concurrency in Practice uses this exact shape (a logging subclass calling super) to show why intrinsic locks must be reentrant: if they weren't, that super call would wait forever for a lock its own thread holds. No other thread involved. The program just stops.
So reentrancy isn't a nice-to-have: it's what lets you build objects out of small synchronized methods that call each other.
03Under the hood: owner and hold count
Try it yourself first
Before reading on, predict the output. outer() locks a ReentrantLock and calls inner(), which locks it again. What is getHoldCount() inside inner()? After inner() unlocks, is the lock free?
ReentrantLock shows us the tally directly. getHoldCount() returns how many times the current thread holds it, and isHeldByCurrentThread() answers "am I the owner?". For synchronized there's no counter you can read, but Thread.holdsLock(obj) tells you whether the current thread owns that object's monitor.
import java.util.concurrent.locks.ReentrantLock;
public class HoldCount {
static final ReentrantLock lock = new ReentrantLock();
static void outer() {
lock.lock();
try {
System.out.println("in outer: holdCount=" + lock.getHoldCount());
inner();
System.out.println("back in outer: holdCount=" + lock.getHoldCount());
} finally {
lock.unlock();
}
}
static void inner() {
lock.lock(); // same thread, lock already held: no wait
try {
System.out.println("in inner: holdCount=" + lock.getHoldCount()
+ ", mine=" + lock.isHeldByCurrentThread());
} finally {
lock.unlock();
}
}
static synchronized void a() { System.out.println("a(): holdsLock(HoldCount.class)=" + Thread.holdsLock(HoldCount.class)); b(); }
static synchronized void b() { System.out.println("b(): still got in, holdsLock=" + Thread.holdsLock(HoldCount.class)); }
public static void main(String[] args) {
System.out.println("before: holdCount=" + lock.getHoldCount() + ", mine=" + lock.isHeldByCurrentThread());
outer();
System.out.println("after: holdCount=" + lock.getHoldCount() + ", locked=" + lock.isLocked());
a();
System.out.println("after a(): holdsLock=" + Thread.holdsLock(HoldCount.class));
}
}
Running java HoldCount.java on JDK 21 printed this, the same every time:
before: holdCount=0, mine=false
in outer: holdCount=1
in inner: holdCount=2, mine=true
back in outer: holdCount=1
after: holdCount=0, locked=false
a(): holdsLock(HoldCount.class)=true
b(): still got in, holdsLock=true
after a(): holdsLock=false
Here's that count, step by step:
The hold count as outer() calls inner()
Notice three things.
- The inner
unlock()didn't free the lock. It took the count from 2 to 1. Another thread trying to get in at that moment would still wait. synchronizeddoes the same thing, invisibly.b()got in whilea()held the class lock. The JLS puts it plainly: a thread may lock a monitor more than once, and each unlock reverses one lock.static synchronizedlocks theClassobject, which is why I passedHoldCount.classtoholdsLock.
04Pair every lock() with exactly one unlock()
With synchronized, the compiler writes the release for you, on every exit path including exceptions. With ReentrantLock, you're the compiler. The rule is boring and absolute: one lock(), one unlock() in a finally, every time. That's why outer() and inner() above each have their own try/finally. What happens if you miss one?
Try it yourself first
A worker thread calls lock() twice and unlock() once, then finishes. The worker is dead. Can main get the lock now?
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class ForgotUnlock {
public static void main(String[] args) throws Exception {
ReentrantLock lock = new ReentrantLock();
Thread worker = new Thread(() -> {
lock.lock();
lock.lock(); // re-entered once
lock.unlock(); // ...but only released once
}, "worker");
worker.start();
worker.join();
System.out.println("worker alive=" + worker.isAlive() + ", lock still locked=" + lock.isLocked());
long t0 = System.nanoTime();
boolean got = lock.tryLock(500, TimeUnit.MILLISECONDS);
System.out.printf("main tryLock(500 ms) -> %s after %d ms%n", got, (System.nanoTime() - t0) / 1_000_000);
try {
lock.unlock(); // main never locked it
} catch (IllegalMonitorStateException e) {
System.out.println("main unlock() -> " + e.getClass().getSimpleName());
}
}
}
worker alive=false, lock still locked=true
main tryLock(500 ms) -> false after 502 ms
main unlock() -> IllegalMonitorStateException
This is the bit that surprises people. The worker is dead, and it still owns the lock. A ReentrantLock is not released when its owner thread dies. The count is stuck at 1 forever, and every other thread that calls plain lock() would wait forever. I used tryLock with a timeout so the demo finishes; in production you'd get a hung service and a thread dump full of WAITING (parking) on that lock.
And no, another thread can't "fix" it by calling unlock(). Only the owner may release. Anyone else gets IllegalMonitorStateException. The same exception appears if you call wait() or notify() without holding the monitor.
05Reentrancy is per thread, not per task
Reentrancy asks one question: is the caller the same thread as the owner? Not the same request, not the same user, not the same method chain. Same Thread object.
That matters as soon as work hops threads:
- Thread pools. If a task holding a lock submits a second task that needs the same lock, then waits for it with
future.get(), the second task runs on a pool thread. It's a different thread, so it waits for the lock, while the first task waits for it. That's a deadlock, and reentrancy doesn't help. - Async callbacks. A
CompletableFuturecallback may run on another thread. It does not inherit your holds. - Hand-offs. You can't
lock()on one thread andunlock()on another.ReentrantLockthrowsIllegalMonitorStateException. If you need a "lock" that any thread can release, you want aSemaphore, and that has its own catch, coming up next.
06Locks that are not reentrant
Not every lock-like thing in java.util.concurrent keeps an owner. A Semaphore only counts permits; it has no idea who took them. So new Semaphore(1), a popular "mutex", will happily block its own holder.
Same thread, same lock: reentrant vs non-reentrant
Try it yourself first
One thread takes the only permit of a Semaphore(1), then asks for another. And one thread holds a StampedLock write lock, then asks for it again. What happens in each case?
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.StampedLock;
public class NotReentrant {
public static void main(String[] args) throws Exception {
Semaphore mutex = new Semaphore(1);
mutex.acquire();
System.out.println("semaphore: first acquire ok, permits=" + mutex.availablePermits());
boolean again = mutex.tryAcquire(1, TimeUnit.SECONDS); // same thread, second time
System.out.println("semaphore: same thread tryAcquire(1 s) -> " + again);
mutex.release();
StampedLock sl = new StampedLock();
long stamp = sl.writeLock();
long second = sl.tryWriteLock(1, TimeUnit.SECONDS); // 0 means "did not get it"
System.out.println("stampedLock: same thread tryWriteLock(1 s) -> stamp " + second);
sl.unlockWrite(stamp);
}
}
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- wait() lets go of every hold
- The hidden danger: re-entering a half-updated object
- Real world
- Advanced: how the JVM keeps count
- How to say it in the interview
From ₹299 · one-time payment · No auto-renewal
Further reading (14)
- ReentrantLock (Java SE 21 API): owner and hold count, getHoldCount, isHeldByCurrentThread, maximum of 2147483647 recursive locks
- ReentrantReadWriteLock (Java SE 21 API): reentrancy rules, downgrading allowed, upgrading from read to write not possible, 65535 recursive read and write locks
- StampedLock (Java SE 21 API): StampedLocks are not reentrant; tryConvertToWriteLock
- Semaphore (Java SE 21 API): no notion of ownership; a binary semaphore can be released by a thread other than the owner
- Thread.holdsLock (Java SE 21 API): true if the current thread holds the monitor lock on the object
- Java Language Specification (Java SE 21) §17.1 and §17.2.1: a thread may lock a monitor more than once; wait performs n unlock actions and later n lock actions
- JVM Specification (Java SE 21): monitorenter: an owning thread re-enters the monitor, incrementing its entry count
- OpenJDK jdk21u: ReentrantLock.java: AQS state is the hold count; Error "Maximum lock count exceeded"
- OpenJDK jdk21u: objectMonitor.hpp: _owner and _recursions fields of an inflated monitor
- JDK-8319251: Change LockingMode default from LM_LEGACY to LM_LIGHTWEIGHT: lightweight locking became the default in JDK 23
- Java Concurrency in Practice (Goetz et al., 2006) §2.3.2 Reentrancy: a subclass synchronized override calling super would deadlock if intrinsic locks were not reentrant
- HDFS-16600: Fix deadlock of fine-grain lock for FsDatasetImpl of DataNode: createRbw holds the block-pool read lock while evictBlocks asks for the write lock; testSynchronousEviction hung
- Python threading docs: Lock and RLock objects: Lock is not reentrant; RLock may be re-acquired by the owning thread and must be released once per acquire
- Russ Cox on golang-nuts: Recursive (aka reentrant) mutexes are a bad idea: recursive mutexes do not protect invariants; why Go's sync.Mutex is not reentrant