Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate7 min readUpdated
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

  • volatile gives 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.
  • LongAdder beats AtomicLong for write-heavy counters by removing the single contention point.
  • Double-checked locking requires volatile on 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.

the canonical correct use — a flag
private volatile boolean running = true;
 
public void stop()  { running = false; }
public void loop()  { while (running) { work(); } }   // now guaranteed to terminate
the canonical incorrect use — a counter
private 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.

volatile makes every read current. It cannot make read-modify-write a single step.

Atomics and CAS

the atomic classes
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:

what CAS looks like
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

one hot location versus many cool ones
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 instant

With 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.

correct only with volatile
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

NeedUse
A flag or a published immutable referencevolatile
A counter or accumulator, low contentionAtomicInteger / AtomicLong
A counter, high write contentionLongAdder / LongAccumulator
Update several fields togetherA lock, or one immutable object in an AtomicReference
A field updated by CAS on many instancesAtomicIntegerFieldUpdater (saves the wrapper object)
Anything with a non-trivial critical sectionsynchronized

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:

two fields, one atomic update
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?
No. volatile guarantees that reads and writes of that field go to and from main memory and are not reordered, so every thread sees the latest value. It does nothing about compound operations. count++ is a read, an add and a write, and two threads can interleave between them, so increments are lost even on a volatile field. Use AtomicInteger or a lock for that.
What is compare-and-swap?
A single CPU instruction that atomically checks whether a memory location still holds an expected value and, if so, replaces it. The Atomic classes loop on it: read the current value, compute the new one, attempt the swap, and retry if another thread changed it meanwhile. It is lock-free — a thread never blocks — but it is not wait-free, since under heavy contention a thread can retry many times.
When is LongAdder better than AtomicLong?
Under high write contention. AtomicLong has a single memory location every thread CAS-es, so with sixteen threads incrementing you get sixteen threads fighting over one cache line and most CAS attempts fail. LongAdder spreads the count across padded cells, one per contending thread, and sums them only when you read. It is dramatically faster for counters and slower for read-heavy use, which is why AtomicLong remains the default.

Related tutorials