Theory & Concept
The Java Memory Model and the Visibility Problem
To understand
volatile, we first need a quick recap of the
Java Memory Model (JMM). The JMM defines how threads interact through shared memory. Each thread can
keep a
local cache
(CPU registers, L1/L2 caches) of variables. Without any synchronization, a
write by one thread may never become visible to another – or it may become
visible in a completely unexpected order.
This is where
volatile
enters. According to the Java Language Specification, a
volatile
field imposes
two
key guarantees:
-
Visibility
– A write to a
volatilevariable happens‑before every subsequent read of that same variable. Every thread reading the field will see the most recent write, regardless of local caches. -
Ordering (no reordering)
– Reads and writes of other variables cannot be reordered across a
volatileread or write. This prevents subtle compiler/CPU instruction‑reordering issues that break multi‑threaded logic.
In short,
volatile
acts as a
lightweight synchronization mechanism
for a single variable. It’s much cheaper than using a full‑blown lock, but it
doesn’t provide
atomicity.
Why
volatile
Doesn’t Make Code Thread‑Safe
This is the single most misunderstood point:
volatile
ensures visibility of the
value of the variable
itself, but it does
not
protect
compound actions. An operation like
count++
is three steps:
- Read the current value.
- Increment it.
- Write the new value back.
Two threads can interleave these steps, resulting in lost updates.
volatile
guarantees that each read sees the latest write, but it doesn’t prevent
another thread from sneaking in between your read and your write. Thus,
volatile
alone is
thread‑safe only for simple assignment
(e.g., setting a flag) and not for check‑then‑act or read‑modify‑write
sequences.
When Should Developers Care?
- Flags / status indicators: a thread needs to signal another to stop or change state.
-
Double‑checked locking: the famous lazy‑initialization singleton pattern relies on
volatilesince Java 5. -
Lock‑free publish: publishing immutable objects safely (though often
finalfields are enough if properly constructed).
Any scenario that requires a happens‑before edge without the overhead of locking is a candidate.
Implementation Guide
1. A Simple Stop Flag (Visibility in Action)
The most common real‑world use is a thread‑safe flag.
Without
volatile, the JIT compiler may hoist the
running
read into a register and never re‑check the memory location. With
volatile, the write
running = false
is immediately visible, and the loop terminates as expected.
2. The Non‑Atomic Counter Problem (Why
volatile
Isn’t Enough)
Let’s see what happens when we naively use
volatile
for a counter.
You’ll consistently get values < 20,000. The
volatile
only guarantees that the read of
count
inside
increment()
sees the latest value, but the entire read‑increment‑write sequence is not
atomic.
3. The Fix:
AtomicInteger
to the Rescue
java.util.concurrent.atomic.AtomicInteger
provides atomic
getAndIncrement()
via hardware‑level compare‑and‑swap (CAS). It also ensures the same visibility
guarantees as
volatile.
AtomicInteger
not only makes the increment atomic but also provides methods like
compareAndSet
and
lazySet
for advanced lock‑free algorithms. Internally, its
value
field is itself
volatile; thus any
get()
sees the latest write.
4. Double‑Checked Locking with
volatile
Before Java 5, the double‑checked locking idiom was broken. The
volatile
guarantee fixed it by preventing the compiler from reordering the write to the
singleton reference with the constructor initialization.
Without
volatile, a thread might see a partially constructed object.
volatile
enforces a strict
happens‑before
between the write of the fully constructed object and the read of the
instance
variable.
Pros & Cons
Advantages of
volatile
- Lightweight visibility – Imposes no lock contention; reads/writes are at almost the same speed as normal variable access on modern CPUs.
- Simple to understand – For flags and simple state, the code remains concise and clean.
- Prevents reordering – Crucial for patterns like double‑checked locking and lock‑free messaging.
-
Works with long / double atomically
– A
volatile longorvolatile doubleis guaranteed to be read/written atomically (otherwise these 64‑bit types can be torn).
Disadvantages
- No mutual exclusion – It cannot protect a critical section. Compound actions remain non‑atomic.
- Limited use cases – It’s not a substitute for proper synchronization when state transitions depend on previous state.
-
Still requires understanding the JMM
– Misplaced
volatilegives a false sense of safety. -
Performance cost
– Although lighter than locks,
volatileprevents some CPU and compiler optimisations (e.g., hoisting), which may affect nearby code. In hot paths, measure carefully.
volatile
vs
AtomicInteger
– A Side‑by‑Side Comparison
| Aspect |
volatile
|
AtomicInteger
(and other atomics)
|
|---|---|---|
| Atomicity | No – only simple assignments are atomic. |
Yes – provides atomic
getAndIncrement,
compareAndSet, etc.
|
| Visibility | Yes – a write is always seen by a later read. |
Yes – underlying
value
is
volatile.
|
| Compound operations |
Not safe (count++
fails).
|
Safe (getAndIncrement()).
|
| Performance | Very low overhead. | Slightly higher due to CAS loops, but still lock‑free. |
| Typical use | Flags, state markers, double‑checked locking. | Counters, accumulators, non‑blocking data structures. |
Key takeaway: If you need to increment a counter shared between threads, forget
volatile– reach for anAtomicInteger(orLongAdderunder high contention).
volatile
vs
synchronized
– Choosing the Right Tool
synchronized
is a locking mechanism providing both
atomicity
and
visibility. When a thread exits a
synchronized
block, it flushes all cached variables to main memory. When another thread
enters a
synchronized
block on the same monitor, it reloads all variables, seeing the previous
thread’s changes.
volatile
|
synchronized
|
|
|---|---|---|
| Visibility |
Only the
volatile
variable itself + any variables written before it.
|
All variables written before the lock release are visible to the next locker. |
| Atomicity | None (except for single reads/writes of the variable). | Full mutual exclusion for the synchronized block. |
| Blocking | Non‑blocking. | Blocking – threads wait for the lock. |
| Use case | Simple signals, flags, read‑only volatile references. | Critical sections, complex state updates, consistent multi‑variable invariants. |
| Performance | Minimal impact. | Higher overhead, especially under contention. |
For a single variable that is only written by one thread and read by others,
volatile
is perfect. For anything involving
read‑modify‑write
or
multiple variables that must stay consistent together,
synchronized
or a
Lock
is the right answer.
Conclusion
volatile
is a sharp, specialised tool. It solves the
visibility
problem with minimal fuss, making it ideal for stop flags, status indicators,
and publishing safely constructed objects. But it’s not a silver bullet – it
cannot replace proper synchronization for compound actions. Knowing when to
escalate to
AtomicInteger
or
synchronized
is what separates a concurrency‑savvy developer from one that just sprinkles
volatile
everywhere and hopes for the best.
Remember the golden rule:
For simple writes and reads across threads,
volatile
wins. For anything else, choose the right tool and keep your code
predictable under load.
Post a Comment