In my previous story, I shared how users received multiple emails. This article is a follow-up that delves deeper into race condition and deadlock, how we can simulate them, and how to fix them with simple Java code. So grab a coffee — it’s going to be a long one.
What is a Race Condition?
A race condition occurs when multiple threads access shared resources concurrently, and the final outcome depends on the timing of their execution. In other words, the behavior of the system is unpredictable and can vary depending on which thread wins the “race” to access the resource first. Race conditions often lead to subtle bugs that can be extremely hard to debug.
Example of a Race Condition in Java : Consider a scenario where multiple threads are trying to increment a shared counter. If the increment operation is not synchronized, the threads can interfere with each other, resulting in an incorrect final value of the counter. Here is a simple Java code example that simulates the race condition:
public class RaceConditionExample { private int counter = 0; public static void main(String[] args) { RaceConditionExample example = new RaceConditionExample(); example.runTest(); } public void runTest() { Thread t1 = new Thread(this::increment); Thread t2 = new Thread(this::increment); t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Final Counter Value: " + counter); } public void increment() { for (int i = 0; i < 10000; i++) { counter++; // This is not thread-safe, leading to a race condition } } }
Let’s run the above piece of code in our favorite IDE.
Since the incrementCounter()
method is not synchronized, it leads to a race condition, and the final value of counter
may not be 20000
as expected. Each thread could read the value of counter
at the same time, increment it, and write it back, causing overwrites and missed increments.
Fixing the Race Condition
To fix the race condition, you need to synchronize access to the shared resource. You can do this using the synchronized
keyword in Java:
public synchronized void increment() {
for (int i = 0; i < 10000; i++) {
counter++; // This is not thread-safe, leading to a race condition
}
}
Let’s go back to IDE again to see the output.
That’s how we fix the race condition in Java. Now, Let’s dive into another topic.
What is a Deadlock?
A deadlock occurs when two or more threads are waiting for each other to release resources, creating a circular dependency where none of the threads can proceed. This typically happens when multiple threads need multiple locks at the same time but acquire them in different orders.
Example of a Deadlock in Java : Here’s a simple Java example that simulates a deadlock scenario.
public class DeadlockExample {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public static void main(String[] args) {
DeadlockExample example = new DeadlockExample();
example.runTest();
}
public void runTest() {
Thread t1 = new Thread(this::task1);
Thread t2 = new Thread(this::task2);
t1.start();
t2.start();
}
public void task1() {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock 2...");
synchronized (lock2) {
System.out.println("Thread 1: Acquired lock 2");
}
}
}
public void task2() {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock 1...");
synchronized (lock1) {
System.out.println("Thread 2: Acquired lock 1");
}
}
}
}
Let’s see what the above piece of code outputs.
How to Avoid Deadlock
A system falls into Deadlock when these four Coffman conditions occur:
- Mutual Exclusion: Preventing mutual exclusion means that no process can have exclusive access to resources. Non-blocking synchronization algorithms help avoid mutual exclusion.
- Hold and Wait: To prevent holding and waiting for resources, processes can be required to request all needed resources at once or release all held resources before making new requests.
- No Preemption: Allowing preemption of resources can avoid deadlocks, but it’s often impractical due to the cost of rollback and potential inconsistency in processing outcomes. Lock-free, wait-free algorithms, and optimistic concurrency control are used to allow preemption.
- Circular Wait: Avoiding circular wait can be achieved by defining an order for resource requests or disabling interrupts during critical sections. Dijkstra’s solution and resource ordering are other methods used to avoid this condition.
To avoid deadlock, we can follow certain strategies:
- Lock Ordering: Always acquire locks in a particular order to avoid circular dependency.
- Timeouts: Use timeouts when trying to acquire locks, which helps in preventing threads from waiting indefinitely. I used this technique in case when EmailService failed in one environment.
- Deadlock Detection: Implement a mechanism to detect circular wait conditions and take corrective action such as Process termination andResource Preemption.
Too much theory !! Let’s see what is there in Java to implement above mentioned strategies.
ReentrantLock() : It provides mutual exclusion and has a fairness setting to ensure that waiting threads are granted access in order of their arrival time, reducing the risk of starvation.
public class DeadlockExampleFixedTryLock {
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
public static void main(String[] args) {
DeadlockExampleFixedTryLock example = new DeadlockExampleFixedTryLock();
example.runTest();
}
public void runTest() {
Thread t1 = new Thread(this::task1);
Thread t2 = new Thread(this::task2);
t1.start();
t2.start();
}
public void task1() {
boolean lock1Acquired = false;
boolean lock2Acquired = false;
try {
while (!lock1Acquired || !lock2Acquired) {
lock1Acquired = lock1.tryLock();
lock2Acquired = lock2.tryLock();
if (lock1Acquired && lock2Acquired) {
System.out.println("Thread 1: Acquired both locks");
}
}
} finally {
if (lock1Acquired) {
lock1.unlock();
}
if (lock2Acquired) {
lock2.unlock();
}
}
}
public void task2() {
boolean lock1Acquired = false;
boolean lock2Acquired = false;
try {
while (!lock1Acquired || !lock2Acquired) {
lock1Acquired = lock1.tryLock();
lock2Acquired = lock2.tryLock();
if (lock1Acquired && lock2Acquired) {
System.out.println("Thread 2: Acquired both locks");
}
}
} finally {
if (lock1Acquired) {
lock1.unlock();
}
if (lock2Acquired) {
lock2.unlock();
}
}
}
}
Let’s see its working in real time.
And here is how to use timeout mechanism with Reentrant() :
public class DeadlockExampleFixedWithTimeout {
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
public static void main(String[] args) {
DeadlockExampleFixedWithTimeout example = new DeadlockExampleFixedWithTimeout();
example.runTest();
}
public void runTest() {
Thread t1 = new Thread(this::task1);
Thread t2 = new Thread(this::task2);
t1.start();
t2.start();
}
public void task1() {
while (true) {
boolean lock1Acquired = false;
boolean lock2Acquired = false;
try {
lock1Acquired = lock1.tryLock(1, TimeUnit.SECONDS);
if (lock1Acquired) {
System.out.println("Thread 1: Holding lock 1...");
Thread.sleep(50);
lock2Acquired = lock2.tryLock(1, TimeUnit.SECONDS);
if (lock2Acquired) {
System.out.println("Thread 1: Acquired lock 2");
// Perform the required task
break; // Successfully acquired both locks, exit loop
} else {
System.out.println("Thread 1: Could not acquire lock 2, releasing lock 1");
}
} else {
System.out.println("Thread 1: Could not acquire lock 1");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock1Acquired) {
lock1.unlock();
}
if (lock2Acquired) {
lock2.unlock();
}
}
}
}
public void task2() {
while (true) {
boolean lock1Acquired = false;
boolean lock2Acquired = false;
try {
lock1Acquired = lock1.tryLock(1, TimeUnit.SECONDS); // allowing threads to attempt locking for a specified period
if (lock1Acquired) {
System.out.println("Thread 2: Holding lock 1...");
Thread.sleep(50);
lock2Acquired = lock2.tryLock(1, TimeUnit.SECONDS);
if (lock2Acquired) {
System.out.println("Thread 2: Acquired lock 2");
// Perform the required task
break; // Successfully acquired both locks, exit loop
} else {
System.out.println("Thread 2: Could not acquire lock 2, releasing lock 1");
}
} else {
System.out.println("Thread 2: Could not acquire lock 1");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock1Acquired) {
lock1.unlock();
}
if (lock2Acquired) {
lock2.unlock();
}
}
}
}
}
Let’s see the output on IDE.
This is the mechanism to avoid Deadlock in Java.
This definitely was a long read. But I hope I could explain the topics in simpler words. Do check out the complete code on Github.
For more details, see the following :
1. Deadlock on Wikipedia
2. Reentrant() in official documentation