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
UnsafeDemo.java
|
|
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:
- Synchronized Method
- Synchronized Block
- Reentrant Lock
- 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
SafeDemo.java
|
|
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.
- The implicit locking happens automatically on
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
SafeDemo.java
|
|
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
|
|
SafeDemo.java
|
|
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
SafeDemo.java
|
|
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.
|
|
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):
- Mutual Exclusion: Resources cannot be shared simultaneously
- Hold and Wait: A thread holds at least one resource while waiting for others
- No Preemption: Resources cannot be forcibly taken from threads
- Circular Wait: A circular chain of threads, each waiting for a resource held by the next thread
Preventing Deadlocks
- Lock Ordering: Always acquire locks in a fixed, predetermined global order
- Lock Timeouts: Use
tryLock()
with timeout when usingReentrantLock
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 andReentrantLock
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