volatile, Atomics and the Visibility Problem
What volatile guarantees and what it does not, why count++ is broken even when volatile, how compare-and-swap works, when LongAdder beats AtomicLong, and the double-checked locking idiom.
On this page
volatile is the smallest synchronisation primitive Java offers, and it is routinely used to solve
problems it cannot solve. Knowing precisely where its guarantee stops is the point of this topic.
Key Takeaways
volatilegives visibility and ordering, not atomicity.count++on a volatile field still loses updates — it is three operations, not one.- Atomics are built on compare-and-swap: lock-free, but a retry loop under contention.
LongAdderbeatsAtomicLongfor write-heavy counters by removing the single contention point.- Double-checked locking requires
volatileon the field, and has been correct only since Java 5.
What volatile guarantees
Two things, and only two:
Visibility. A write to a volatile field is immediately visible to any thread that subsequently reads it. Reads always come from main memory rather than a core-local cache.
Ordering. The compiler and CPU may not reorder operations across a volatile access. Everything written before a volatile write is visible to a thread that reads that volatile and then reads those other fields — this is the happens-before edge, and it makes volatile useful as a publication mechanism for more than the field itself.
private volatile boolean running = true;
public void stop() { running = false; }
public void loop() { while (running) { work(); } } // now guaranteed to terminateprivate volatile int count;
public void increment() {
count++; // read, add, write — three steps, and another thread can
} // interleave between any two of them. Updates are lost.Ten threads each incrementing a million times will not produce ten million. volatile guarantees
each thread reads a fresh value; it does nothing to stop two threads reading the same fresh value,
both adding one, and both writing back the same result.
Atomics and CAS
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet(); // atomic ++
counter.addAndGet(5);
counter.compareAndSet(10, 20); // only if it is currently 10
counter.updateAndGet(x -> x * 2); // arbitrary function, retried on conflict
counter.accumulateAndGet(7, Math::max);
AtomicReference<Config> config = new AtomicReference<>(initial);
config.compareAndSet(old, updated);
config.updateAndGet(c -> c.withTimeout(Duration.ofSeconds(5)));Underneath, incrementAndGet is a loop:
public final int incrementAndGet() {
int current, next;
do {
current = get(); // read the current value
next = current + 1; // compute the new one
} while (!compareAndSet(current, next)); // swap only if unchanged; retry otherwise
return next;
}compareAndSet maps to one CPU instruction — LOCK CMPXCHG on x86 — which atomically compares and
conditionally writes. Because a failure means another thread made progress, the algorithm is
lock-free: the system as a whole always advances, and no thread can block another by being
descheduled while holding something. It is not wait-free, because an individual thread can lose
the race repeatedly.
The practical consequence: atomics are excellent under low to moderate contention and degrade under heavy contention, where most CAS attempts fail and the retry loop burns CPU.
LongAdder and contention
AtomicLong requests = new AtomicLong(); // every thread CASes ONE memory location
LongAdder requests = new LongAdder(); // each thread updates its own cell
requests.increment();
long total = requests.sum(); // adds the cells — only accurate at that instantWith sixteen threads incrementing an AtomicLong, all sixteen contend for a single cache line. Every
core that writes must take exclusive ownership of that line, invalidating it in the other fifteen
caches — a phenomenon called cache-line ping-pong. Throughput can drop below that of a single
thread.
LongAdder maintains an array of cells, each padded to its own cache line, and directs each thread
to a different one based on a thread-local probe. Under contention it is often five to ten times
faster. The cost is that reading requires summing every cell, and the sum is not a consistent
snapshot, so LongAdder is the right choice for metrics and counters, and the wrong one for a value
read as often as it is written.
Micrometer's counters use LongAdder internally, which is a good concrete example to cite.
False sharing is the general form of this problem: two unrelated fields that happen to share a
64-byte cache line cause the same invalidation traffic even though no variable is actually shared.
@Contended (with -XX:-RestrictContended) pads a field onto its own line; the JDK uses it inside
LongAdder.Cell and the ForkJoinPool work queues.
Double-checked locking
The idiom that was broken for a decade and is now correct — a favourite because the fix is a single keyword.
public class Registry {
private static volatile Registry instance; // volatile is MANDATORY
public static Registry getInstance() {
Registry local = instance; // one volatile read on the hot path
if (local == null) {
synchronized (Registry.class) {
local = instance;
if (local == null) {
instance = local = new Registry();
}
}
}
return local;
}
}Without volatile, the JIT may reorder new Registry() into: allocate memory, publish the
reference, then run the constructor. A second thread taking the fast path sees a non-null reference
to a half-constructed object. This is unsafe publication, and the failure is rare, load-dependent and
essentially impossible to reproduce.
The local variable is a real optimisation, not stylistic: it reduces the hot path from two volatile
reads to one, and volatile reads inhibit some JIT optimisations.
Choosing
| Need | Use |
|---|---|
| A flag or a published immutable reference | volatile |
| A counter or accumulator, low contention | AtomicInteger / AtomicLong |
| A counter, high write contention | LongAdder / LongAccumulator |
| Update several fields together | A lock, or one immutable object in an AtomicReference |
| A field updated by CAS on many instances | AtomicIntegerFieldUpdater (saves the wrapper object) |
| Anything with a non-trivial critical section | synchronized |
The row that catches people out is the fourth. Atomics protect one variable. If two fields must change together — a balance and a transaction count — no combination of atomics gives you atomicity across both. The fix is either a lock, or bundling both into an immutable object and CAS-ing the reference:
record Balance(BigDecimal amount, int transactions) { }
private final AtomicReference<Balance> state = new AtomicReference<>(new Balance(ZERO, 0));
public void apply(BigDecimal delta) {
state.updateAndGet(b -> new Balance(b.amount().add(delta), b.transactions() + 1));
}What gets asked
Almost always: "does volatile make count++ thread-safe?" The answer is no, with the read-modify-write
explanation. Then how CAS works, then AtomicLong versus LongAdder, then double-checked locking.
The detail that lands well is naming cache-line ping-pong as the reason LongAdder exists. It moves
the answer from "one is faster" to "here is the hardware behaviour that makes it faster", which is
what an advanced concurrency question is looking for.
Frequently Asked Questions
Does volatile make an operation atomic?
What is compare-and-swap?
When is LongAdder better than AtomicLong?
Related tutorials
- synchronized, Locks and the Java Memory ModelWhat the Java Memory Model guarantees, why reordering and visibility are separate problems, what synchronized actually does beyond mutual exclusion, and when ReentrantLock earns its extra complexity.
- ExecutorService and Thread-Pool SizingHow ThreadPoolExecutor decides whether to queue or grow, why newFixedThreadPool can exhaust the heap, sizing pools from measurements with Little law, and shutting down without losing work.
- Threads: Lifecycle, Creation and What One CostsThe six thread states and what moves between them, why calling run() directly is a no-op, what a platform thread actually costs in memory and switching, and the interrupt protocol done properly.
- CompletableFuture and Async CompositionComposing async work without blocking: thenApply versus thenCompose, which thread runs each stage, combining with allOf and anyOf, timeouts, and how exceptions propagate through a chain.