13.2 Memory Visibility, `volatile`, Intrinsic Locks, and Synchronization
Key Takeaways
Race conditions occur when multiple concurrent threads access shared mutable state without proper coordination, producing nondeterministic and incorrect results.
Every Java object possesses an intrinsic monitor; intrinsic locks are reentrant, allowing a thread that already holds a lock to re-acquire it without self-deadlock.
Synchronized instance methods lock on the current object instance (this), whereas synchronized static methods lock on the Class object (ClassName.class), operating on entirely distinct monitors.
The volatile modifier guarantees immediate memory visibility across CPU caches and prevents instruction reordering, but it does not provide atomicity for compound operations such as count++.
Deadlock arises when the four Coffman conditions (mutual exclusion, hold and wait, no preemption, circular wait) are met simultaneously; enforcing a consistent lock acquisition order breaks circular wait.
Memory Visibility, volatile, Intrinsic Locks, and Synchronization
When multiple threads read and modify shared memory simultaneously, software systems face two fundamental hazards: race conditions (threads interleaving execution in unpredictable orders) and visibility failures (one thread failing to see the memory writes executed by another thread due to compiler optimizations and CPU core caching). Java addresses these challenges through intrinsic synchronization, the volatile modifier, and the formal guarantees of the Java Memory Model (JMM).
Race Conditions, Critical Sections, and Data Races
- Race Condition: A flaw in software execution timing where the correctness of a program depends on the relative timing or interleaving of concurrent execution paths.
- Critical Section: A block of code that accesses shared mutable state and must be executed atomically by at most one thread at a time.
- Data Race: Occurs when two or more concurrent threads access the same memory location simultaneously without synchronization, and at least one of those accesses is a write.
The Non-Atomicity of Compound Operations
A classic trap involves incrementing an integer counter across threads:
public class Counter {
private int count = 0;
public void increment() {
count++; // SEEMINGLY ATOMIC, BUT ACTUALLY THREE DISTINCT OPERATIONS!
}
}
At the bytecode level, count++ breaks down into three non-atomic steps:
getfield: Read the current value ofcountfrom memory into a local operand stack.iadd: Add1to the loaded value on the operand stack.putfield: Write the incremented value from the operand stack back to the heap field.
If Thread A and Thread B read count simultaneously when its value is 5, both compute 6 independently, and both write 6 back to memory. Two increments occurred, but the counter advances by only 1—a lost update.
Intrinsic Monitors and Reentrancy
Every object in the Java heap possesses an associated intrinsic monitor (also called an intrinsic lock or monitor lock). The synchronized keyword uses this monitor to enforce mutual exclusion across critical sections.
Lock Reentrancy
Java's intrinsic locks are reentrant: lock acquisition is scoped to the thread, not the invocation. If a thread already holds the intrinsic monitor for Object X, it can enter any other code block or method that synchronizes on Object X without blocking:
public class ReentrantExample {
public synchronized void outerMethod() {
// Thread acquires lock on 'this' (counter = 1)
innerMethod();
}
public synchronized void innerMethod() {
// Same thread re-acquires lock on 'this' (counter = 2)
System.out.println("Inside innerMethod without deadlock!");
} // Lock counter decremented to 1 upon exit
} // Lock counter decremented to 0 and released upon exit
Without reentrancy, a synchronized method calling another synchronized method on the same object would deadlock with itself.
synchronized Methods vs. synchronized Blocks
Synchronization can be applied at the method level or scoped to explicit blocks.
Instance Methods vs. Static Methods
- Instance Synchronized Method: Locks on the intrinsic monitor of the calling instance (
this):public synchronized void performTask() { /* Locks on this */ } - Static Synchronized Method: Locks on the intrinsic monitor of the
java.lang.Classobject corresponding to that class in the JVM classloader hierarchy:public static synchronized void logEvent() { /* Locks on Logger.class */ }
Warning
The Cross-Monitor Locking Trap
An instance synchronized method and a static synchronized method in the same class do not serialize against each other. One thread can execute instance.performTask() while another thread simultaneously executes MyClass.logEvent(), because they synchronize on completely different monitor objects (instance vs. MyClass.class).
Synchronized Blocks and Explicit Lock Objects
Synchronized blocks provide fine-grained locking, minimizing the time a thread holds a monitor:
public class BankAccount {
private double balance;
// Best practice: private final dedicated lock object
private final Object balanceLock = new Object();
public void deposit(double amount) {
validateAmount(amount); // Executed outside lock (uncontended)
synchronized (balanceLock) {
balance += amount; // Critical section
}
}
}
Why private final Object is Best Practice
private: Prevents untrusted external code from synchronizing on the same lock object, eliminating unintended contention or external deadlocks.final: Guarantees that the reference cannot be reassigned. If a lock reference changes, subsequent threads synchronize on different instances, violating mutual exclusion.- Avoid String Literals and Boxed Primitives: Never synchronize on String literals (
synchronized ("LOCK")) or boxed primitives (Integer.valueOf(1)). String pooling and wrapper caching share instances across unrelated classes and libraries, causing JVM-wide lock contention.
Memory Visibility, Caching, and the volatile Keyword
Modern hardware employs hierarchical caches (L1, L2, L3) per CPU core. A thread running on Core 1 may cache a variable in L1 cache, while Core 2 modifies that variable in main memory. Without synchronization, Core 1 may continue reading stale data indefinitely.
+-----------------------+ +-----------------------+
| CPU Core 1 | | CPU Core 2 |
| [L1/L2 Cache: flag=0] | | [L1/L2 Cache: flag=1] |
+-----------------------+ +-----------------------+
| |
+-----------------+------------------+
v
+-----------------------+
| Main Memory (RAM) |
| flag = 0 (Stale!) |
+-----------------------+
The volatile Modifier
Declaring a field volatile establishes two guarantees:
- Memory Visibility: Every write to a
volatilevariable is immediately flushed to main memory, and every read is fetched directly from main memory. CPU register and local cache values are invalidated. - Instruction Reordering Prevention: The compiler and CPU hardware are forbidden from reordering instructions across memory barriers (fences) placed around
volatilereads and writes.
public class TaskRunner {
private volatile boolean active = true;
public void stop() {
active = false; // Immediately visible to all threads
}
public void run() {
while (active) {
// Safely polls volatile variable without caching
}
}
}
Warning
volatile Does NOT Ensure Atomicity!
While volatile guarantees that reads and writes of a variable are visible across all threads, it does not make compound operations atomic. Declaring private volatile int count = 0; does not make count++ thread-safe. For atomic compound mutations, use synchronized, explicit locks, or atomic classes (e.g., AtomicInteger).
The Java Memory Model (JMM) and Happens-Before
The JMM formalizes memory visibility through the happens-before relationship. If Action A happens-before Action B, then the memory writes performed by Action A are guaranteed to be visible to Action B.
Primary Happens-Before Rules
- Program Order Rule: Within a single thread, each action happens-before every action that appears later in the source code.
- Monitor Lock Rule: An unlock on an intrinsic monitor happens-before every subsequent lock on the same monitor.
- Volatile Variable Rule: A write to a
volatilefield happens-before every subsequent read of that same field. - Thread Start Rule: A call to
Thread.start()happens-before any action in the started thread. - Thread Termination Rule: Any action in a thread happens-before any other thread successfully completes a
join()on that thread or detectsisAlive() == false. - Transitivity: If A happens-before B, and B happens-before C, then A happens-before C.
Concurrency Hazards: Deadlock, Livelock, and Starvation
| Hazard | Definition | Typical Cause | Mitigation |
|---|---|---|---|
| Deadlock | Two or more threads are permanently blocked, each waiting for a lock held by another | Circular lock dependency | Enforce global lock ordering; use tryLock() |
| Livelock | Threads actively change states in response to each other, consuming CPU without making progress | Over-reactive contention resolution | Introduce randomized backoff delays |
| Starvation | A runnable thread is perpetually denied CPU time or lock acquisition | Greedy locks or unmanaged priorities | Fair locking (new ReentrantLock(true)) |
The Four Coffman Conditions for Deadlock
Deadlock occurs if and only if all four conditions hold simultaneously:
- Mutual Exclusion: At least one resource is held in a non-shareable mode.
- Hold and Wait: A thread holds a resource while requesting another resource.
- No Preemption: Resources cannot be forcibly confiscated from a thread.
- Circular Wait: A closed chain of threads exists where each thread holds a lock needed by the next thread in the chain.
A team inspects the following service implementation:
public class InventoryService {
private static int totalItems = 100;
private int reservedItems = 0;
public synchronized void reserveItem() {
reservedItems++;
}
public static synchronized void adjustTotal(int delta) {
totalItems += delta;
}
}
If Thread 1 executes serviceInstance.reserveItem() while Thread 2 concurrently executes InventoryService.adjustTotal(5), what is the synchronization behavior?
Thread 1 blocks until Thread 2 finishes because static synchronization overrides instance locks.
A runtime IllegalMonitorStateException is thrown due to mismatched monitor classes.
Thread 2 blocks until Thread 1 finishes because both methods are marked synchronized.
Both threads execute concurrently without blocking each other because they synchronize on different monitors.
A developer writes the following shared counter:
public class MetricsCollector {
private volatile int visitCount = 0;
public void recordVisit() {
visitCount++;
}
public int getVisitCount() {
return visitCount;
}
}
If 10 threads concurrently invoke recordVisit() 1,000 times each, what is the expected outcome?
A compilation error occurs because volatile cannot be applied to primitive int fields.
A NullPointerException or DataRaceException is thrown at runtime during concurrent updates.
visitCount is guaranteed to equal exactly 10,000 because volatile flushes every write to main memory.
visitCount may be significantly less than 10,000 because volatile does not ensure atomicity for compound operations.
Which strategy effectively prevents deadlocks caused by the circular wait condition when multiple threads acquire multiple intrinsic locks?
Increasing thread priority for threads that hold more than one intrinsic monitor.
Wrapping all synchronized blocks inside an infinite while loop that catches InterruptedException.
Enforcing a strict, globally consistent ordering in which locks must be acquired across all threads.
Declaring all shared fields as volatile instead of using synchronized blocks.
Sections you finish are checked off in the contents.