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
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
javac ReentrantLockLeakDemo.java
java ReentrantLockLeakDemo
jps
jstack <pid>
jcmd <pid> Thread.print
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
jps
jstack <pid>
jcmd <pid> Thread.print
"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
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.