Concurrency Issues: Race Conditions and Deadlocks

Introduction

A race condition occurs in multithreaded applications when two or more threads access shared data concurrently and attempt to change it at the same time. The shared data can be variables, data structures, or system resources, all of which are typically defined within a section known as the critical section. The final value of the shared data is going to be unpredictable depending on the relative timing of the threads’ execution. For example, when multiple threads access and modify counters, collections, database connections, file handles.

  • Critical Sections: Block of code that contain shared variables/resources. This critical section needs to be protected when multiple threads access it and make changes to the shared variables/resources.
  • Shared Resources: Shared Resources include variables, data structures, or system resources such as counters, collections, database connections, file handles.

Example of a Race Condition

Consider the following simple counter implementation, many threads are accessing the counter variables and incrementing it.

The program creates 1,000 threads, and each thread increments a shared counter 1,000 times. In total, the counter should ideally reach 1,000,000 (1,000 threads × 1,000 increments).

Counter.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Counter {
    private int count = 0;

    public void increment(){
        this.count ++;
    }

    public int getCount(){
        return this.count;
    }
}

UnsafeDemo.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class UnsafeDemo{
    public static void main(String[]args) throws InterruptedException{
        Counter counter = new Counter();
        Thread[] threads = new Thread[1000];
        for (int i=0; i<threads.length; i++){
            threads[i] = new Thread(()  -> {
                for (int j = 0; j < 1000; j++){
                    counter.increment();
                }
            });
        }
        // start all threads
        for(Thread th: threads){
            th.start();
        }
        // Wait for all threads to finish execution
        for(Thread th: threads){
            th.join();
        }
        System.out.println(counter.getCount());
    }
}

Let’s compile and run the program three times:

$ javac *.java && java UnsafeDemo      
995059

$ javac *.java && java UnsafeDemo
995531

$ javac *.java && java UnsafeDemo
993496

Instead of getting the expected result (1,000,000), the program gives a different count every time you run it. This happens because multiple threads are trying to increment the counter at the same time, and the operation counter++ is not atomic.

What?! Why does counter++ result in a race condition?

Because the operation counter++ is not an atomic operation. Although it seems like a single operation, it actually involves three operations:

  • Read the current value of the counter.
  • Add 1 to the value.
  • Store the new value back to the counter.

When multiple threads execute this operation at the same time, they can interfere with each other. For example:

  • Thread A reads the counter value (e.g., 5).
  • Before Thread A writes the new value (6), Thread B also reads the counter value (still 5).
  • Both threads write 6, so the counter only increases by 1 instead of 2.
  • This problem is called race condition because the threads are “racing” to access and modify the shared variable.

Why Does the race condition happen?

  • Threads Run Concurrently:
    • In a multithreaded program, threads run at the same time, and their execution order is unpredictable.
  • No Synchronization:
    • The code does not have any mechanism to ensure that only one thread can access and modify (write) the counter at a time. Without synchronization, threads can interfere with each other.

How to Fix Race Condition issues?

To fix this issue, we need to make sure that only one thread can access and modify (write) the shared variable at a time or make the increment operation atomic. This can be done by using synchronization techniques in Java, such as:

  1. Synchronized Method
  2. Synchronized Block
  3. Reentrant Lock
  4. AtomicInteger

Method 1: Synchronized Method

We add the the synchronized keyword to the method signature. This will make sure that only one thread can execute the method at a time.

Counter.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Counter {
    private int count = 0;

    public synchronized void increment(){
        this.count ++;
    }

    public int getCount(){
        return this.count;
    }
}

SafeDemo.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SafeDemo{
    public static void main(String[]args) throws InterruptedException{
        Counter counter = new Counter();
        Thread[] threads = new Thread[1000];
        for (int i=0; i<threads.length; i++){
            threads[i] = new Thread(()  -> {
                for (int j = 0; j < 1000; j++){
                    counter.increment();
                }
            });
        }
        // start all threads
        for(Thread th: threads){
            th.start();
        }
        // Wait for all threads to finish execution
        for(Thread th: threads){
            th.join();
        }
        System.out.println(counter.getCount());
    }
}

Let’s compile and run the program three times:

$ javac *.java && java UnsafeDemo      
1000000

$ javac *.java && java UnsafeDemo
1000000

$ javac *.java && java UnsafeDemo
1000000

Pros:

  • Simple to implement
  • Automatically handles lock release upon exiting the method
  • Automatic/implicit locking on the object. You do not have to explicitly specify which object to lock on since it’s selected automatically.
    • The implicit locking happens automatically on this (the current object) for instance methods or on the class object for static methods.

Cons:

  • Locks the entire method
  • Can’t control lock granularity
  • May lead to performance overhead if method contains non-critical code
  • Can’t interrupt the lock acquisition process

Use Synchronized Method when:

  • You need simple thread safety for an entire method
  • The entire method needs to be atomic
  • Performance is not critical

Method 2: Synchronized Block

This is similar to the previous method, but instead of synchronizing an entire method, we add the synchronized keyword around a block of code in the method. We often refer to this synchronized block as the critical section.

Counter.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Counter {
    private int count = 0;

    public void increment(){
        synchronized(this){
            this.count ++;
        }
    }

    public int getCount(){
        return this.count;
    }
}

SafeDemo.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SafeDemo{
    public static void main(String[]args) throws InterruptedException{
        Counter counter = new Counter();
        Thread[] threads = new Thread[1000];
        for (int i=0; i<threads.length; i++){
            threads[i] = new Thread(()  -> {
                for (int j = 0; j < 1000; j++){
                    counter.increment();
                }
            });
        }
        // start all threads
        for(Thread th: threads){
            th.start();
        }
        // Wait for all threads to finish execution
        for(Thread th: threads){
            th.join();
        }
        System.out.println(counter.getCount());
    }
}

Let’s compile and run the program three times:

$ javac *.java && java UnsafeDemo      
1000000

$ javac *.java && java UnsafeDemo
1000000

$ javac *.java && java UnsafeDemo
1000000

Pros:

  • More fine-grained control than synchronized method
  • Better performance as only critical section is locked
  • Allows non-critical code to execute without synchronization
  • automatic lock handling just as the synchronized method approach

Cons:

  • Slightly more complex than synchronized method as it requires correctly selecting the critical section.
  • Still can’t interrupt lock acquisition
  • Still uses implicit/automatic lock handling locking mechanism but with explicit lock object selection. It requires you to choose which object to lock on (synchronized(this) or another object)
    • This makes it more flexible than synchronized methods, which can be viewed as a pro.
    • But also creates risk of choosing the wrong lock object, which can be viewed as a con.
  • Need to carefully choose the object to synchronize on.

Use Synchronized Block when:

  • You need to synchronize only part of a method
  • You want better performance than synchronized method
  • You have clear critical sections
  • You want to reduce lock contention

Method 3: Reentrant Lock

We explicitly lock and unlock the critical section, allowing greater control.

Counter.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private final ReentrantLock lock = new ReentrantLock();

    private int count = 0;

    public void increment(){
        lock.lock();
        try {
            this.count ++;
        } finally {
            lock.unlock();
        }

    }

    public int getCount(){
        return this.count;
    }
}

SafeDemo.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SafeDemo{
    public static void main(String[]args) throws InterruptedException{
        Counter counter = new Counter();
        Thread[] threads = new Thread[1000];
        for (int i=0; i<threads.length; i++){
            threads[i] = new Thread(()  -> {
                for (int j = 0; j < 1000; j++){
                    counter.increment();
                }
            });
        }
        // start all threads
        for(Thread th: threads){
            th.start();
        }
        // Wait for all threads to finish execution
        for(Thread th: threads){
            th.join();
        }
        System.out.println(counter.getCount());
    }
}

Let’s compile and run the program three times:

$ javac *.java && java UnsafeDemo      
1000000

$ javac *.java && java UnsafeDemo
1000000

$ javac *.java && java UnsafeDemo
1000000

Pros:

  • Most flexible and greater control
  • Supports lock interruption
  • Can implement timeout for lock acquisition
  • Allows try-lock operations
  • Provides more control over locking, fairness (e.g., FIFO ordering of waiting threads)

Cons:

  • Most verbose and complex approach
  • Requires explicit lock/unlock operations
  • Must remember to release lock in finally block or risk a deadlock
  • Risk of deadlock if locks not properly released

Use ReentrantLock when:

  • You need advanced locking features
  • You need timeout capabilities
  • You need to interrupt lock acquisition
  • You need multiple conditions
  • You need fairness control (FIFO order)
  • You need to try-lock without blocking

Method 4: AtomicInteger

We use a special atomic variable of type integer, which provides atomic operations for incrementing the counter.

Counter.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import java.util.concurrent.atomic.AtomicInteger;

class Counter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment(){
       count.incrementAndGet(); 
    }

    public int getCount(){
        return count.get();
    }
}

SafeDemo.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SafeDemo{
    public static void main(String[]args) throws InterruptedException{
        Counter counter = new Counter();
        Thread[] threads = new Thread[1000];
        for (int i=0; i<threads.length; i++){
            threads[i] = new Thread(()  -> {
                for (int j = 0; j < 1000; j++){
                    counter.increment();
                }
            });
        }
        // start all threads
        for(Thread th: threads){
            th.start();
        }
        // Wait for all threads to finish execution
        for(Thread th: threads){
            th.join();
        }
        System.out.println(counter.getCount());
    }
}

Let’s compile and run the program three times:

$ javac *.java && java UnsafeDemo      
1000000

$ javac *.java && java UnsafeDemo
1000000

$ javac *.java && java UnsafeDemo
1000000

Pros:

  • Thread-safe as atomic operations guarantee correctness without explicit synchronization.
  • Best performance for simple operations
  • No explicit locking required
  • No deadlocks
  • Perfect for single variable atomic operations
  • Lock-free implementation

Cons:

  • Limited to specific data types (e.g., AtomicInteger, AtomicBoolean, AtomicReference, and a few more)
  • Can’t protect multiple operations as one atomic unit
  • Limited to specific atomic operations
  • Not suitable for complex synchronization scenarios

Use AtomicInteger when:

  • You only need to synchronize a single numeric variable
  • You need best possible performance
  • You’re performing simple atomic operations
  • You want to avoid explicit locking

Deadlocks

A deadlock occurs when two or more threads are blocked forever, waiting for each other to release resources that they need. This is often is a circular dependency where Thread A holds Resource 1 and needs Resource 2, while Thread B holds Resource 2 and needs Resource 1. A resource can be a lock, database connection, file handle, memory allocation, network socket, or any shared system resource that can only be used by one thread at a time.

graph LR
    subgraph "Thread States"
        T1[Thread 1]
        T2[Thread 2]
    end
    
    subgraph "Resources"
        R1[Resource 1]
        R2[Resource 2]
    end
    
    T1 -->|Holds| R1
    T1 -.->|Wants| R2
    T2 -->|Holds| R2
    T2 -.->|Wants| R1
    
    style T1 fill:#f96,stroke:#333
    style T2 fill:#f96,stroke:#333
    style R1 fill:#9cf,stroke:#333
    style R2 fill:#9cf,stroke:#333

Example of a Deadlock

In this example, both threads attempt to acquire locks in different orders, causing a deadlock. Thread 1 first acquires lock1 then tries to get lock2, while Thread 2 does the opposite. When you run this code, Thread 1 will acquire lock1 and Thread 2 will acquire lock2. Then, each thread will wait indefinitely for the other lock, which is already held by the other thread, resulting in a deadlock and hanging the execution of the program indefinitely.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            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!");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            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!");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

To fix this deadlock, we need to ensure both threads acquire the resources in the same order or set a timeout when waiting for a resource.

Four Conditions for Deadlock

A deadlock situation on a resource can arise only if all of the following conditions occur simultaneously in a system (known as Coffman conditions):

  1. Mutual Exclusion: Resources cannot be shared simultaneously
  2. Hold and Wait: A thread holds at least one resource while waiting for others
  3. No Preemption: Resources cannot be forcibly taken from threads
  4. Circular Wait: A circular chain of threads, each waiting for a resource held by the next thread

Preventing Deadlocks

  1. Lock Ordering: Always acquire locks in a fixed, predetermined global order
1
2
3
4
5
6
// Good practice - consistent ordering
synchronized (resource1) {
    synchronized (resource2) {
        // Use both resources
    }
}
  1. Lock Timeouts: Use tryLock() with timeout when using ReentrantLock
1
2
3
4
5
6
7
if (lock.tryLock(1000, TimeUnit.MILLISECONDS)) {
    try {
        // Use the resource
    } finally {
        lock.unlock();
    }
}


Conclusion and Review Questions

  • Run all code examples and make changes to test each synchronization approach.
  • With fewer thread/iterations (e.g., 10 threads and 10 iterations) may not result in an inaccurate count making the race condition less likely to occur. Why?
  • Why did we use thread.join() in our examples? What would happen if we removed the join statements?
  • How to debug and test deadlocks?
  • What’s the difference between using synchronized blocks and ReentrantLock when it comes to deadlock prevention?
  • Modify the deadlock example to use ReentrantLock instead of synchronized blocks.

The complete source code is on the examples repository at https://gitlab.com/cpit305/code-examples/threads/03-race-condition