Course contents

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.

PremiumOften askedLesson22 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: one key for every door
  2. Why Java needs this
  3. Under the hood: owner and hold count
  4. Pair every lock() with exactly one unlock()
  5. Reentrancy is per thread, not per task
  6. Locks that are not reentrant
  7. wait() lets go of every hold
  8. The hidden danger: re-entering a half-updated object
  9. Real world
  10. Advanced: how the JVM keeps count
  11. 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() with unlock()
  • Locks that are not reentrant: Semaphore(1), StampedLock, and the read-to-write upgrade
  • The full Lock API, "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 synchronized must be reentrant: nested calls, recursion and an **override calling super** would self-deadlock otherwise
  • Watch the count with getHoldCount(), isHeldByCurrentThread() and Thread.holdsLock(obj)
  • Pair every lock() with one unlock() in finally, 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 in ReentrantReadWriteLock
  • Explain how wait() and Condition.await() release every hold and restore the count
  • Describe how AQS state and 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.

Fig. 1

One key opens every door, and the lock keeps a tally

One key opens every door, and the lock keeps a tallyA house with three doors in a row: front door, hallway and study. Thread A holds the one key and has walked through all three, so the tally card reads owner Thread A, count 3. Thread B waits outside the front door until the count drops back to zero.the house (one object, one lock)front doorouter()hallwaymiddle()studyinner()Thread Ahas the keyThread Bwaits outsideuntil count = 0tally cardowner: Thread Acount: 3each door: count + 1each exit: count - 1
One key, every door, one tally. Thread A walks through three doors with the same key. Thread B waits outside until the count drops back to zero.

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:

Fig. 2

The hold count as outer() calls inner()

The hold count as outer() calls inner()Five snapshots left to right for one thread and one ReentrantLock. Before: count 0, free. outer() calls lock(): count 1. inner() calls lock(): count 2, no waiting because the owner is the same thread. inner() unlocks: count 1, still held. outer() unlocks: count 0, the lock is free for other threads.1start0free2outer()lock()1owner = main3inner()lock()2same owner,no wait4inner()unlock()1still held5outer()unlock()0free againunlock() as many times as lock(). Only the last unlock() frees it.
0, 1, 2, 1, 0. The second lock() doesn't wait because the owner is the same thread. The lock is only free again after the last unlock().

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.
  • synchronized does the same thing, invisibly. b() got in while a() 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 synchronized locks the Class object, which is why I passed HoldCount.class to holdsLock.

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 CompletableFuture callback may run on another thread. It does not inherit your holds.
  • Hand-offs. You can't lock() on one thread and unlock() on another. ReentrantLock throws IllegalMonitorStateException. If you need a "lock" that any thread can release, you want a Semaphore, 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.

Fig. 3

Same thread, same lock: reentrant vs non-reentrant

Same thread, same lock: reentrant vs non-reentrantLeft side: a reentrant lock. The thread holds the lock in outer(), calls inner(), the lock sees the same owner, bumps the count to 2 and lets it in. Right side: a Semaphore with one permit. The thread took the only permit in outer(), then in inner() it waits for a permit that only it can give back. It waits on itself forever.ReentrantLock / synchronizedSemaphore(1) / StampedLockmainouter(): acquiregot itinner(): acquire againcallsmainouter(): acquiregot itinner(): acquire againcallsowner == me?yes: count 1 -> 2walk straight inany permits left?0 left: wait for a releasethe only releaser is... mestuck forever
Stuck at your own door. A reentrant lock checks "is the owner me?" and lets you in. A one-permit semaphore only checks "any permits left?", and the only thread that could release one is the one waiting.

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

Practise this topicSynchronization: interview questions & scenarios
Further reading (14)