In Java, the most common way to coordinate threads is using the synchronized keyword. By locking a shared object monitor, you prevent multiple threads from accessing it at the same time. However, nesting these synchronized blocks can easily create a deadlock if different threads acquire the monitors in different orders.
In this guide, we will explore this classic synchronization deadlock scenario using a bank transfer analogy and trace its execution flow.
Imagine a bank with two client accounts: **Account A** (lock) and **Account B** (lock2). To prevent mistakes, the bank requires that whenever a transfer is made, both the sending and receiving accounts must be locked at the same time.
Two operators start a transfer at the exact same split-second:
- Operator 1 wants to transfer $10 from Account A to Account B. They lock Account A first.
- Operator 2 wants to transfer $5 from Account B to Account A. They lock Account B first.
- Now, Operator 1 tries to lock Account B to complete the transfer, but finds it locked. They wait.
- Meanwhile, Operator 2 tries to lock Account A to complete the transfer, but finds it locked. They also wait.
Since neither operator will release their locked account until the transaction completes, the system locks up. The transfers are frozen forever!
Walkthrough of the Main Method Scenario
Let's trace how the program executes step-by-step from the entry point:
The program enters the main method, sets up the transaction coordinator, and triggers two independent worker threads:
DeadlockWithoutReEntrant coordinator = new DeadlockWithoutReEntrant();
new Thread(() -> coordinator.test1()).start();
new Thread(() -> coordinator.test2()).start();
Thread 1 executes test1(), and Thread 2 executes test2().
The threads execute their first synchronized blocks in parallel:
- Thread 1 locks the monitor of the `lock` object:
synchronized (lock). - Thread 2 locks the monitor of the `lock2` object:
synchronized (lock2). - Inside the nested blocks, the threads sleep for 5 seconds (
Thread.sleep(5000)). Just like our explicit lock example, this delay ensures that both threads successfully acquire their first object monitor before attempting to lock the second one.
After waking up from their sleep:
- Thread 1 reaches the inner block
synchronized (lock2). Since Thread 2 holds the monitor lock of `lock2`, Thread 1 is blocked. - Thread 2 reaches the inner block
synchronized (lock). Since Thread 1 holds the monitor lock of `lock`, Thread 2 is blocked.
Both threads are blocked at the gates of the inner synchronized blocks, waiting for monitors that the other thread will never release. The program freezes indefinitely.
Java Implementation
Below is the complete Java code demonstrating a nested synchronized monitor deadlock:
package io.practise.accolite;
public class DeadlockWithoutReEntrant {
private Object lock = new Object();
private Object lock2 = new Object();
public static void main(String[] args) {
DeadlockWithoutReEntrant deadlockWithoutReEntrant = new DeadlockWithoutReEntrant();
new Thread(() -> {
try {
deadlockWithoutReEntrant.test1();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
new Thread(() -> {
try {
deadlockWithoutReEntrant.test2();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
}
public void test1() throws InterruptedException {
synchronized (lock) {
synchronized (lock2) {
System.out.println("test1");
Thread.sleep(5000);
}
}
}
public void test2() throws InterruptedException {
synchronized (lock2) {
synchronized (lock) {
System.out.println("test2");
Thread.sleep(5000);
}
}
}
}
Conclusion
Deadlocks inside synchronized blocks are extremely common when locking multiple objects. To avoid them, you must establish a consistent, global order for locking monitor objects across all threads in your application. Designing your programs to avoid nesting synchronized blocks is also an effective architectural way to eliminate deadlocks completely.