9.2 Thread Safety, Synchronization, and the Java Memory Model
Key Takeaways
- Data races and race conditions occur when multiple threads access shared mutable state without proper synchronization, resulting in non-deterministic corrupted states.
- Every Java object possesses an intrinsic lock (monitor); synchronized instance methods lock on the instance (this), while synchronized static methods lock on the declaring Class object.
- Java intrinsic locks are fully reentrant: a thread holding a monitor can enter nested synchronized blocks guarded by that same monitor without causing a deadlock.
- The volatile keyword establishes a happens-before memory ordering relationship guaranteeing immediate cross-thread visibility and inhibiting instruction reordering, but does not provide atomicity for compound operations.
- Liveness hazards include deadlock (circular lock acquisition graph), livelock (active state changes without progress), and starvation (perpetual resource denial by higher-priority threads).
Thread Safety, Synchronization, and the Java Memory Model
Writing concurrent software in Java requires ensuring correctness across threads executing concurrently over shared data. Without synchronization, concurrent programs suffer from data races, memory visibility discrepancies, and thread liveness failures.
1. Race Conditions and Shared Mutable State
A class is thread-safe if it behaves correctly when accessed from multiple concurrent threads, regardless of scheduling interleavings, without requiring external synchronization by the caller.
The Root Cause: Shared Mutable State
Thread safety bugs arise when two or more threads access shared, mutable state and at least one thread performs a write operation.
Common Race Condition Patterns:
- Read-Modify-Write: A thread reads a value, modifies it locally, and writes it back (e.g.,
count++). If two threads interleave:- Thread A reads
count = 5 - Thread B reads
count = 5 - Thread A writes
count = 6 - Thread B writes
count = 6(One update is lost!)
- Thread A reads
- Check-Then-Act: A thread observes a condition and acts upon it, but the condition changes before the action completes:
// Unsafe Lazy Initialization (Check-Then-Act race condition) public class UnsafeSingleton { private static UnsafeSingleton instance; public static UnsafeSingleton getInstance() { if (instance == null) { // Thread A and B both see null! instance = new UnsafeSingleton(); // Two instances created! } return instance; } }
2. Intrinsic Locks and the synchronized Keyword
Java provides built-in mutual exclusion through Intrinsic Locks (also called Monitor Locks). Every object in Java has an associated monitor lock.
The Three Forms of synchronized:
A. Synchronized Instance Method
Locks on the instance itself (this):
public class Counter {
private int count = 0;
public synchronized void increment() { // Locks on 'this'
count++;
}
}
B. Synchronized Static Method
Locks on the java.lang.Class object corresponding to the enclosing class (Counter.class):
public class Counter {
private static int globalCount = 0;
public static synchronized void incrementGlobal() { // Locks on Counter.class
globalCount++;
}
}
[!IMPORTANT] A
synchronizedinstance method and asynchronized staticmethod do NOT block each other! They acquire completely different locks: one locks onthis(the specific object instance), while the other locks on theClassobject (Counter.class).
C. Synchronized Statement Block
Locks on an explicitly specified object reference. This is generally preferred because it minimizes lock scope and allows private dedicated lock objects:
public class BankAccount {
private double balance;
private final Object lock = new Object(); // Private final dedicated monitor
public void deposit(double amount) {
// Non-critical operations (logging, validation) outside lock
if (amount <= 0) throw new IllegalArgumentException();
synchronized (lock) { // Critical Section
balance += amount;
}
}
}
3. Monitor Reentrancy
Intrinsic locks in Java are Reentrant. If a thread already holds the intrinsic lock on an object, it can enter other synchronized blocks or methods guarded by the same lock without deadlocking itself.
The JVM implements reentrancy by tracking the owning thread and an acquisition count:
- When a thread enters a synchronized block, the count increments to 1.
- When the owning thread enters a nested synchronized block on the same object, the count increments to 2.
- As each block exits, the count decrements. When the count reaches 0, the monitor is fully released.
public class ReentrantDemo {
public synchronized void outer() {
System.out.println("In outer");
inner(); // Re-acquires monitor on 'this' without blocking!
}
public synchronized void inner() {
System.out.println("In inner");
}
}
4. The Java Memory Model (JMM) and volatile
Modern computer architectures feature multi-level CPU hardware caches (L1, L2, L3) and out-of-order execution pipelines. When thread A writes to a variable in its CPU register/cache, thread B running on another CPU core may not see the updated value in main memory, resulting in memory visibility discrepancies.
+----------------+ +----------------+
| CPU Core 1 | | CPU Core 2 |
| [L1/L2 Cache] | | [L1/L2 Cache] |
| flag = true | | flag = false |
+----------------+ +----------------+
| |
+-------------[ Bus ]-----------+
|
+-------------------+
| Main Memory |
| flag = false |
+-------------------+
The volatile Keyword
Declaring a field as volatile guarantees:
- Memory Visibility: Writes to a
volatilefield are immediately flushed to main memory, and reads always invalidate the local CPU cache to read directly from main memory. - Instruction Reordering Barriers: The compiler and CPU hardware are forbidden from reordering reads and writes across a volatile memory barrier.
volatile vs. synchronized (The Atomicity Trap!)
volatileguarantees visibility and ordering, but does NOT guarantee atomicity for compound operations!- Operations like
count++(read-modify-write) are NOT atomic, even ifcountisvolatile.
public class VolatileCounter {
private volatile int count = 0;
public void increment() {
count++; // NON-ATOMIC! Multiple threads can still lose updates!
}
}
5. Happens-Before Memory Relationship
The Java Memory Model defines formal ordering guarantees through the Happens-Before relationship. If action $X$ happens-before action $Y$, then the memory modifications of $X$ are guaranteed to be visible to $Y$.
Fundamental Happens-Before Rules:
- Program Order Rule: Each action in a single thread happens-before every action in that thread that comes later in program order.
- Monitor Lock Rule: An unlock on an intrinsic lock monitor happens-before every subsequent lock acquisition on that same monitor.
- Volatile Variable Rule: A write to a
volatilefield happens-before every subsequent read of that samevolatilefield. - Thread Start Rule: A call to
Thread.start()on a thread happens-before any action in the started thread'srun()method. - Thread Termination Rule: Any action in a thread happens-before any other thread successfully returns from a
Thread.join()on that thread. - Transitivity Rule: If $A$ happens-before $B$, and $B$ happens-before $C$, then $A$ happens-before $C$.
6. Liveness Hazards: Deadlock, Livelock, and Starvation
A. Deadlock
A Deadlock occurs when two or more threads are permanently blocked, each holding a lock that the other needs, waiting in a circular dependency.
// CLASSIC DEADLOCK EXAMPLE
Object lockA = new Object();
Object lockB = new Object();
// Thread 1 acquires lockA then tries to acquire lockB
Thread t1 = new Thread(() -> {
synchronized (lockA) {
try { Thread.sleep(50); } catch (Exception e) {}
synchronized (lockB) {
System.out.println("T1 acquired both");
}
}
});
// Thread 2 acquires lockB then tries to acquire lockA
Thread t2 = new Thread(() -> {
synchronized (lockB) {
try { Thread.sleep(50); } catch (Exception e) {}
synchronized (lockA) {
System.out.println("T2 acquired both");
}
}
});
t1.start();
t2.start(); // DEADLOCK OCCURS!
The Four Necessary Conditions for Deadlock (Coffman Conditions):
- Mutual Exclusion: Resources cannot be shared.
- Hold and Wait: Threads hold resources while waiting for others.
- No Preemption: Resources cannot be forcibly confiscated.
- Circular Wait: A closed chain of threads exists where each thread holds a resource needed by the next.
Deadlock Prevention:
Always acquire multiple locks in a globally strict, consistent canonical order (e.g., sort locks by system identity hash code or resource ID before acquiring).
B. Livelock
A Livelock occurs when threads continuously change their states in response to each other, but make no actual computational forward progress (analogous to two polite pedestrians trying to step around each other in a narrow hallway, stepping in the same direction indefinitely).
C. Starvation
Starvation occurs when a runnable thread is perpetually denied CPU execution time or lock access because other, more aggressive or higher-priority threads monopolize the shared resource.
7. Classic Coordination: wait(), notify(), and notifyAll()
The Object class provides low-level coordination methods:
wait(): Causes the current thread to release the object's monitor and enter theWAITINGstate until another thread invokesnotify()ornotifyAll()on the same monitor.notify(): Wakes up a single arbitrary thread waiting on this object's monitor.notifyAll(): Wakes up all threads waiting on this object's monitor.
[!CAUTION]
wait(),notify(), andnotifyAll()must only be called from inside asynchronizedblock/method on that exact object. Calling them without holding the monitor throwsIllegalMonitorStateException.- Always invoke
wait()inside awhileloop checking the predicate condition, never anifstatement, to guard against spurious wakeups!
// Correct wait/notify idiom
synchronized (lock) {
while (!conditionReady) { // Always loop on condition!
lock.wait(); // Releases lock while waiting
}
// Perform action once condition is true
}
Consider the following class designed to track request counts:
If 100 concurrent threads each invoke recordRequest() 1,000 times on a shared RequestTracker instance, what will be the final value of getCount()?public class RequestTracker {
private volatile int count = 0;
public void recordRequest() {
count++;
}
public int getCount() {
return count;
}
}
A class defines two synchronized methods as shown:
Thread 1 is actively executing processGlobal(). At the same instant, Thread 2 calls processInstance() on an instance of DataProcessor. What is the behavior?public class DataProcessor {
public static synchronized void processGlobal() {
// Step 1
}
public synchronized void processInstance() {
// Step 2
}
}
What happens when the following code is executed?
public class WaitDemo {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
lock.wait();
}
}
Which of the following strategies is the most effective approach to completely prevent Deadlock when multiple threads must acquire multiple shared locks?