Why Volatile Isn't Enough: Understanding Java Thread Visibility and Synchronization
This article explains how the volatile keyword guarantees visibility of shared variables across threads, demonstrates its limitations with non‑atomic operations, and shows how to achieve proper thread synchronization using atomic classes or synchronized blocks in Java.
Introduction
The volatile keyword ensures that a variable modified by one thread is immediately synchronized to main memory, making the change visible to all other threads instantly.
Thread Local Memory
Each thread has its own storage space.
The moment when a thread flushes its local data to main memory is nondeterministic.
Example
The diagram (from Google’s Jeremy Manson) shows two concurrently executing threads, with the code order Thread1 → Thread2.
Without volatile
If the ready field is not volatile, Thread 1’s modification may not be visible to Thread 2; visibility is uncertain. Even if Thread 1 leaks ready as true, the change to answer might not leak, causing Thread 2 to read an outdated value (e.g., output 0 while answer=42 is invisible).
Using volatile
When volatile is applied, the following occurs:
Each write to a volatile variable is flushed to main memory.
Each read of a volatile variable forces a fresh read from main memory, preventing JVM optimizations that would cache the value.
Writes to a volatile variable act like exiting a synchronized block, and reads act like entering one, making other variables written before the volatile write visible to the reading thread.
Thus, with volatile, Thread 2 reads ready=true and answer=42, though using volatile adds performance overhead.
Note
Volatile solves only the visibility problem of shared variables; it does not guarantee atomicity for operations such as i++ or ++i, which can still produce race conditions (e.g., one thread increments while another decrements, resulting in a non‑zero final value).
public class VolatileTest {
private static volatile int count = 0;
private static final int times = Integer.MAX_VALUE;
public static void main(String[] args) {
long curTime = System.nanoTime();
Thread decThread = new DecThread();
decThread.start();
System.out.println("Start thread: " + Thread.currentThread() + " i++");
for (int i = 0; i < times; i++) {
count++;
}
System.out.println("End thread: " + Thread.currentThread() + " i--");
while (decThread.isAlive());
long duration = System.nanoTime() - curTime;
System.out.println("Result: " + count);
System.out.format("Duration: %.2fs
", duration / 1.0e9);
}
private static class DecThread extends Thread {
@Override
public void run() {
System.out.println("Start thread: " + Thread.currentThread() + " i--");
for (int i = 0; i < times; i++) {
count--;
}
System.out.println("End thread: " + Thread.currentThread() + " i--");
}
}
}Output:
Start thread: Thread[main,5,main] i++ Start thread: Thread[Thread-0,5,main] i-- End thread: Thread[main,5,main] i-- End thread: Thread[Thread-0,5,main] i-- Result: -460370604 Duration: 67.37s
The reason is that i++ and ++i are not atomic; the bytecode shows multiple steps (load, increment, store), leading to race conditions when interleaved across threads. void f1() { i++; } Bytecode:
void f1();
Code:
0: aload_0
1: dup
2: getfield #2; //Field i:I
5: iconst_1
6: iadd
7: putfield #2; //Field i:I
10: returnInterleaved execution can look like:
Thread1 Thread2
r1 = i; r3 = i;
r2 = r1 + 1; r4 = r3 + 1;
i = r2; i = r4;Both threads may read 0, write 1, and the final i becomes 1 despite two increments. Hence, volatile alone cannot solve non‑atomic synchronization issues.
Solving Thread Synchronization
Java provides the java.util.concurrent.atomic package with atomic wrapper classes for thread‑safe operations. Example:
package com.qunar.atomicinteger;
import java.util.concurrent.atomic.AtomicInteger;
public class SafeTest {
private static AtomicInteger count = new AtomicInteger(0);
private static final int times = Integer.MAX_VALUE;
public static void main(String[] args) {
long curTime = System.nanoTime();
Thread decThread = new DecThread();
decThread.start();
System.out.println("Start thread: " + Thread.currentThread() + " i++");
for (int i = 0; i < times; i++) {
count.incrementAndGet();
}
while (decThread.isAlive());
long duration = System.nanoTime() - curTime;
System.out.println("Result: " + count);
System.out.format("Duration: %.2f
", duration / 1.0e9);
}
private static class DecThread extends Thread {
@Override
public void run() {
System.out.println("Start thread: " + Thread.currentThread() + " i--");
for (int i = 0; i < times; i++) {
count.decrementAndGet();
}
System.out.println("End thread: " + Thread.currentThread() + " i--");
}
}
}Output:
Start thread: Thread[main,5,main] i++ Start thread: Thread[Thread-0,5,main] i-- End thread: Thread[Thread-0,5,main] i-- Result: 0 Duration: 105.15
Conclusion
volatile solves the visibility problem of shared variables between threads.
Using volatile incurs performance overhead.
volatile does not solve thread‑synchronization (atomicity) issues.
To fix non‑atomic operations like i++ or ++i, use synchronized blocks or atomic classes, which also add some overhead.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
