Problems in Multithreading: Race Conditions, Atomicity, Visibility, and Ordering
Writing concurrent programs in Java allows applications to execute multiple tasks parallelly or concurrently, maximizing CPU utilization. However, introducing multiple threads that share memory introduces a suite of complex, non-deterministic bugs. This chapter explores the fundamental problems of multithreading—Race Conditions, Atomicity violations, Cache Visibility issues, and Instruction Reordering—and explains why they occur at the hardware and JVM levels.
1. The Non-Deterministic Nature of Multithreading
In a single-threaded application, code executes sequentially (from top to bottom, left to right). The state of the program is entirely predictable and deterministic.
In a multithreaded environment, the Operating System's thread scheduler determines when a thread is paused (context-switched) and when it runs. Because this scheduling depends on external factors like CPU load and system interrupts, the exact order of thread execution is non-deterministic.
When multiple threads access and modify shared mutable data without proper coordination, this non-deterministic scheduling leads to unpredictable application states.
2. Race Conditions & The Naive Counter Example
A Race Condition occurs when the final output of a program depends on the sequence or timing of the threads' execution. If the threads "race" to update a shared variable, the final state becomes corrupted.
The Naive Code Example
Consider a simple Counter class designed to increment a variable:
class Counter {
public int count = 0;
public void increment() {
count++;
}
}
If we spin up two threads to call this increment() method 10,000 times each, we intuitively expect the final count to be exactly 20,000.
public class Demo {
public static void main(String[] args) throws InterruptedException {
Counter c1 = new Counter();
// Thread 1 increments the counter 10,000 times
Thread t1 = new Thread(() -> {
for (int i = 1; i <= 10000; i++) {
c1.increment();
}
});
// Thread 2 increments the counter 10,000 times
Thread t2 = new Thread(() -> {
for (int i = 1; i <= 10000; i++) {
c1.increment();
}
});
t1.start();
t2.start();
// Wait for both threads to finish execution
t1.join();
t2.join();
System.out.println("Final Count: " + c1.count);
}
}
The Reality of Execution
Running this code multiple times yields unpredictable, incorrect results:
- Run 1:
Final Count: 12109 - Run 2:
Final Count: 19669 - Run 3:
Final Count: 11864
The counter consistently fails to reach 20,000 because of race conditions occurring during the execution of count++.
3. The Illusion of Simplicity: Deconstructing count++
The root cause of the race condition in the counter example is that count++ is not an atomic operation. Although it is written as a single line of Java code, the CPU compiles it into three distinct, low-level instructions:
Step-by-Step Interleaving Workflow
When two threads (T1 and T2) attempt to execute count++ concurrently, their low-level instructions can interleave due to context switching:
Thread 1 (Core 1) Thread 2 (Core 2)
| |
Reads count (initially 0) |
| |
[Context Switch / Paused] |
| Reads count (still 0)
| |
Increments local register to 1 |
| Increments local register to 1
| |
Writes 1 back to RAM |
| Writes 1 back to RAM
v v
Final Count in RAM = 1
Analysis of the Failure
Both threads executed their increment logic, which should have advanced the counter from 0 to 2. However, because T2 read the value of count before T1 could write its updated value back to memory, both threads read 0. They both calculated 1 and wrote 1 back to RAM, effectively losing one entire increment operation.
4. Critical Sections vs. Shared Resources
To design thread-safe systems, developers must distinguish between the code that causes concurrency issues and the data being corrupted.
- Critical Section: The block of code that accesses and modifies shared mutable state. Multiple threads must not execute this section concurrently. In our example, the body of the
increment()method containingcount++is the critical section. - Shared Resource: The actual variable, object, or system resource shared among threads. In our example, the
countvariable is the shared resource. Other examples include:- A shared
List<Integer> - A
BankAccountobject - A shared file handle or database connection
- A shared
5. The "Check-Then-Act" Pitfall
A classic non-atomic pattern that causes severe bugs in production systems is Check-Then-Act. This occurs when a program checks a condition and then performs an action based on that condition, assuming the condition remained true in the split second between the check and the act.
Real-Life Analogy: Banking Transactions
Imagine a bank account with a balance of $1,000. Two separate transaction requests arrive at the same time:
- Transaction 1 (T1): Withdraw
$500 - Transaction 2 (T2): Withdraw
$600
Total withdrawal requested: $1,100 (which exceeds the $1,000 balance).
Naive Code Implementation
if (balance >= amount) { // Check
balance = balance - amount; // Act
}
The Concurrency Failure Flow
If this code is executed by two threads concurrently without synchronization, the following disaster occurs:
Production Impact
Because the "Check" and "Act" steps were not bound together atomically, both threads entered the conditional block. The bank account ends up with a negative balance (-$100), which violates business rules and corrupts the system's financial integrity.
6. The Visibility Problem & CPU Cache Architecture
Even if an operation is atomic, multithreaded programs can fail due to Visibility Problems. This occurs when one thread updates a shared variable, but other threads continue to read an outdated cached copy of that variable.
CPU Cache Architecture
Modern computer systems use multi-core processors. To speed up execution, each core has its own ultra-fast local memory registers and caches (L1, L2, L3 caches). Main memory (RAM) is significantly slower.
When a thread reads a variable, it copies it from RAM into its local CPU cache. Subsequent reads and writes happen inside this fast local cache. The CPU eventually flushes these changes back to RAM, but there is no guarantee when this flush will happen.
Naive Code Demonstrating Visibility Issues
public class DemoVisibility {
public static boolean flag = false; // Shared variable
public static void main(String[] args) throws InterruptedException {
// Thread 2: Spins infinitely until flag becomes true
Thread t2 = new Thread(() -> {
while (!flag) {
// Do nothing (Busy spin)
}
System.out.println("Thread 2 Finished.");
});
// Thread 1: Sleeps for 1 second, then sets flag to true
Thread t1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("Thread 1 set flag to true.");
});
t2.start();
t1.start();
}
}
The Infinite Loop Behavior
If you run the code above, Thread 2 will spin infinitely and never terminate, even after Thread 1 prints "Thread 1 set flag to true."
- Why? Core 2 (running Thread 2) cached the value of
flag = falselocally. When Core 1 (running Thread 1) updatedflagtotrue, it updated its own cache and eventually RAM. However, Core 2 continues reading from its local cache, completely unaware of the update in RAM. The new value is invisible to Thread 2.
Note on
System.out.println: If you add a print statement inside Thread 2'swhileloop (e.g.,System.out.println("Running");), the loop will terminate. This is becauseSystem.out.printlninternally uses asynchronizedblock. Entering and exiting synchronized blocks forces the CPU to flush and refresh its local caches from RAM, masking the visibility bug.
7. Solving Visibility with the volatile Keyword
To solve visibility issues, Java provides the volatile keyword.
public static volatile boolean flag = false;
When a variable is declared volatile, the JVM guarantees that:
- Direct Writes: Any write to the variable is immediately written directly to main memory (RAM), bypassing local CPU caches.
- Direct Reads: Any read of the variable is fetched directly from main memory (RAM), ensuring the thread always sees the most up-to-date value.
Crucial Distinction: Volatile is NOT for Atomicity
While volatile guarantees visibility, it does not guarantee atomicity.
If you declare public static volatile int count = 0; and perform count++ across multiple threads, you will still get race conditions. This is because count++ still requires three separate operations (Read-Modify-Write). volatile only ensures that the Read and Write steps touch RAM directly; it does not prevent context switches from happening between those steps.
8. Instruction Reordering & The Ordering Problem
To maximize execution speed, the Java Compiler (JIT) and the CPU CPU-cores perform Instruction Reordering. They rearrange the execution order of instructions, provided the reordering does not change the outcome of a single-threaded execution.
The Reordering Scenario
Imagine two variables initialized in a class:
int x = 0;
boolean flag = false;
Thread 1 Execution:
x = 10; // Instruction 1
flag = true; // Instruction 2
Thread 2 Execution:
if (flag) {
System.out.println(x);
}
The Ordering Failure
In a single-threaded view of Thread 1, executing Instruction 2 before Instruction 1 makes no difference to the final state of Thread 1. Therefore, the compiler or CPU might reorder them:
Even though Thread 2 only prints x when flag is true, it prints 0 instead of 10 because the instructions in Thread 1 were reordered. This is the Ordering Problem.
Solution
Declaring a variable as volatile or wrapping the execution in a synchronized block establishes a Happens-Before Relationship. The JVM guarantees that instructions before a volatile write or synchronized boundary cannot be reordered past it.
9. Thread Interference & Data Inconsistency
All the problems discussed above culminate in Thread Interference and Data Inconsistency.
The Danger of Data Inconsistency
Data is the core of any software application. If thread interference corrupts your data:
- Financial ledgers balance incorrectly.
- Database states become mismatched.
- Internal application states (like collections, pointers, and flags) throw unexpected runtime exceptions (e.g.,
ConcurrentModificationException).
10. Summary of Multithreading Problems & Solutions
Summary and Key Takeaways
- Multithreading is inherently non-deterministic: The OS scheduler controls thread execution, meaning you can never rely on the order in which threads execute code.
count++is an illusion: It is a non-atomic, three-step operation (Read, Modify, Write). Without synchronization, concurrent execution of this statement always leads to lost updates.- Critical Sections must be protected: Any block of code that accesses a shared mutable resource is a critical section and must be guarded to prevent concurrent access.
- Check-Then-Act is a major bug vector: Checking a condition and acting on it must be performed as a single, atomic step to prevent state changes during context switches.
- CPU Caches cause blind spots: Cores cache variables locally for performance. Without explicit instructions, updates made by one core are invisible to other cores.
volatileguarantees visibility, not safety: Thevolatilekeyword forces threads to read and write directly to RAM. It fixes visibility and ordering issues but does not make compound operations (likecount++) atomic.- Instruction Reordering optimizes single threads but breaks multi-threads: The compiler and CPU rearrange instructions for speed. While safe in single-threaded code, this optimization can expose partially updated states to other threads.
- Data Inconsistency is the ultimate risk: Thread interference corrupts application state, making concurrency bugs the most difficult to debug, reproduce, and fix in production environments.