Locks, Invariants & Deadlocks

ReentrantLock Without unlock() in finally

ReentrantLock Without unlock() in finally: practice a Java concurrency bug with symptoms like Threads block forever, Service appears stuck, Process alive...

  • Explicit lock discipline
  • ReentrantLock
  • Lock Leak
  • Java
  • Beginner

Production symptoms

  • Threads block forever
  • Service appears stuck
  • Process alive but no progress

Failure scenario

Code

Java example
class AccountService {
    private final ReentrantLock lock = new ReentrantLock();

    void update(Account account) {
        lock.lock();
        validate(account);
        save(account);
        lock.unlock();
    }
}

Prod Symptoms

A request fails after acquiring an explicit lock around account state. Later requests that need the same protected path stop making progress.

Key signal: ReentrantLock does not release itself when a method throws. Your code must release it.

  • One exception is followed by many stuck calls on the same code path
  • The JVM stays alive and health checks may still pass
  • CPU is usually low because threads are parked, not spinning
  • Logs show the original exception, then later calls hang at lock acquisition
  • Thread dumps show waiters parked under ReentrantLock or AbstractQueuedSynchronizer
  • This is a leaked explicit lock, not a circular Java monitor deadlock

Run Locally

  • first-call acquires the lock and throws before unlock()
  • first-call terminates, but the ReentrantLock remains held
  • second-call prints that it is trying to acquire the lock
  • second-call remains WAITING because the lock was never released
  • The process remains alive until you stop it

What to look for

  • second-call parked in ReentrantLock acquisition
  • The original exception path before unlock
  • No Java-level deadlock section because there is no circular wait
Run
javac ReentrantLockLeakDemo.java
java ReentrantLockLeakDemo
Inspect while stuck
jps
jstack <pid>
jcmd <pid> Thread.print
ReentrantLockLeakDemo.java
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockLeakDemo {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws Exception {
        Thread first = new Thread(() -> {
            lock.lock();
            System.out.println("first acquired lock");
            throw new RuntimeException("boom before unlock");
        }, "first-call");

        first.setUncaughtExceptionHandler((thread, error) ->
                System.out.println(thread.getName() + " failed: " + error));

        Thread second = new Thread(() -> {
            System.out.println("second trying to acquire lock");
            lock.lock();
            try {
                System.out.println("second acquired lock");
            } finally {
                lock.unlock();
            }
        }, "second-call");

        first.start();
        first.join();

        second.start();
        Thread.sleep(1_000);
        System.out.println("second state = " + second.getState());
    }
}

Note: The first thread throws after locking. The second thread then waits forever.

Diagnosis and fix

Explanation

Explicit locks are not scoped like synchronized blocks. A ReentrantLock stays held until unlock() is called.

Key signal: Every successful lock() must reach unlock(), normally by entering try/finally immediately after acquiring the lock.

  • lock.lock() updates the lock's internal state and records an owner
  • An exception exits the method before unlock() runs
  • The lock's internal AQS state remains held even though the thread that acquired it has already failed
  • Later threads park in LockSupport while trying to acquire the lock
  • This is indefinite blocking caused by a leaked lock, not a circular wait
  • synchronized blocks avoid this specific footgun because the monitor is released when the block exits

How to Diagnose

A thread dump shows the waiters. Logs and code review usually identify the earlier path that leaked the lock.

  • Capture a dump while requests are stuck
  • Look for application threads parked under ReentrantLock, LockSupport, or AbstractQueuedSynchronizer
  • Check whether the owner thread is absent or already failed
  • Correlate the stuck period with earlier exception logs from the same protected path
  • Inspect code for lock.lock() not followed immediately by try/finally
  • Remember that jstack may not report this as a deadlock
Commands
jps
jstack <pid>
Alternative
jcmd <pid> Thread.print
Expected dump shape
"second-call" #... WAITING (parking)
  at jdk.internal.misc.Unsafe.park(Native Method)
  - parking to wait for <...> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:...)
  at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:...)

Note: The useful clue is the parked waiter plus an earlier path that acquired but did not release the lock.

How to Fix

  • Call lock() immediately before a try block
  • Put all critical-section work inside that try block
  • Call unlock() in finally
  • Do not put any code that can throw or return between lock() and try; even logging belongs inside the try block
  • Keep the critical section small
  • Use tryLock(timeout) only as damage control; it does not fix a leaked lock
  • Consider synchronized when you do not need ReentrantLock features such as timed, interruptible, or conditional acquisition
ReentrantLockFinallyFixed.java
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockFinallyFixed {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws Exception {
        Thread first = new Thread(() -> update(true), "first-call");
        first.setUncaughtExceptionHandler((thread, error) ->
                System.out.println(thread.getName() + " failed: " + error));

        Thread second = new Thread(() -> update(false), "second-call");

        first.start();
        first.join();

        second.start();
        second.join();

        System.out.println("second completed because the lock was released");
    }

    private static void update(boolean fail) {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " acquired lock");
            if (fail) {
                throw new RuntimeException("boom inside critical section");
            }
            System.out.println(Thread.currentThread().getName() + " updated state");
        } finally {
            lock.unlock();
        }
    }
}

Note: The failing thread still fails, but it releases the lock before it dies.