Root Causes of Concurrency Issues
Visibility
Visibility refers to the ability of a thread to immediately observe changes made to shared variables by other threads. The fundamental problem stems from CPU cache architectures.
// Thread A executes
int counter = 0;
counter = 42;
// Thread B executes
int result = counter;
- When Thread A executes
counter = 42, it loads the initial value into CPU A's cache and updates it to 42. However, this value isn't immediately writen to main memory. - When Thread B executes
result = counter, it reads from main memory where the value remains 0. Consequently,resultbecomes 0 instead of 42. - The visibility problem: modifications made by Thread A aren't immediately visible to Thread B.
Using volatile on a shared variable ensures changes are immediately flushed to main memory, forcing other threads to read latest value. Regular variables lack this guarantee because the timing of writes to main memory is unpredictable. Additionally, both synchronized blocks and Lock implementations guarantee visibility by flushing all modifications before releasing the lock.
Atomicity
Atomicity ensures a sequence of operations executes as a single indivisible unit. Either all operations complete or none execute. The issue arises from time-slicing in modern operating systems that multiplex CPU time among multiple processes and threads.
Consider a bank transfer scenario: Account X transfers 500 to Account Y. This requires two distinct operations: deduct 500 from Account X, then add 500 to Account Y. Without atomicity, if the operation is interrupted after the first step, a subsequent operation could retrieve stale data, causing inconsistencies.
balance = 100; // Operation 1: Direct assignment is atomic
temp = balance; // Operation 2: Involves read then write—two separate steps
balance++; // Operation 3: Involves read, increment, then write
balance = balance + 1; // Operation 4: Same as operation 3
The Java Memory Model guarantees only simple assignments and reads are atomic (int x = 1;). For larger atomic operations, synchronization primitives like synchronized or Lock ensure exclusive access, preventing interleaving from other threads.
Ordering
Ordering ensures program execution follows the sequence defined in source code. The problem arises from compiler optimizations that reorder instructions for performance improvements.
public class ResourceFactory {
private static ResourceFactory instance;
private ResourceFactory() {
// Private constructor prevents external instantiation
}
public static ResourceFactory getInstance() {
if (instance == null) {
synchronized (ResourceFactory.class) {
if (instance == null) {
instance = new ResourceFactory();
}
}
}
return instance;
}
}
Object instantiation actually involves three distinct steps:
- Allocate memory space
- Initialize the object
- Assign the reference to the allocated memory
However, the compiler may reorder these operations:
- Allocate memory space
- Assign the reference to the allocated memory
- Initialize the object
If reordering occurs, a thread may receive a partially constructed object with uninitialized fields, leading to unpredictable behavier.
The volatile keyword prevents certain types of reordering by establishing happens-before relationships. Synchronization primitives also enforce ordering by ensuring only one thread executes synchronized code blocks at a time, effectively serializing execution order. The Java Memory Model further guarantees ordering through happens-before rules.