Atomic Variables & Compare-And-Swap
Learn compare-and-swap, the single hardware instruction that lets threads coordinate without ever blocking, and how AtomicInteger, AtomicReference, LongAdder, and AtomicStampedReference build on it to handle counters, references, high-contention metrics, and the ABA problem.
Learning objectives
- Explain what compare-and-swap does as a single hardware instruction and why it needs no lock
- Use AtomicInteger and AtomicReference correctly, including writing a manual CAS retry loop
- Choose LongAdder over AtomicLong for high-contention counters, and know when not to
- Recognize the ABA problem and fix it with AtomicStampedReference
- Decide when a problem needs a lock instead of an atomic variable
◆ Story
A busy bakery uses a numbered-ticket dispenser instead of a physical line. Nobody blocks anybody: you pull a ticket, and if two people reach for one at the exact same instant, the machine's own mechanism guarantees only one of them gets any given number — no queue, no "please wait here" sign, just an instantaneous, unmistakable resolution. That's the mental model behind compare-and-swap: instead of making threads wait for a lock, let every thread attempt the update, and have the hardware itself guarantee that only one attempt actually "wins" at a time.
Every lock covered so far solves thread safety the same way — by making some threads wait. Atomic variables solve the identical problem for simple values (counters, flags, single object references) without making anyone wait at all: each thread just retries until it succeeds, using one hardware instruction instead of an OS-level lock acquisition. This is the foundation everything called "lock-free programming" is built on top of.
The core primitive is compare-and-swap, or CAS: a single CPU instruction that atomically does "if the current value equals what I expect, replace it with my new value and report success; otherwise, report failure and change nothing." The entire check-and-update happens as one indivisible step, guaranteed by the processor itself — no other core can interleave a change partway through. Every class in java.util.concurrent.atomic is a different application of this one idea.
The pattern every CAS-based update follows is always the same three steps, usually inside a retry loop: read the current value, compute what you want the new value to be based on that snapshot, then attempt to atomically publish the new value with a compare-and-swap call that only succeeds if nothing else changed the value in between. If the CAS fails — meaning some other thread got there first — the loop simply starts over with a fresh read. Under low contention, this succeeds on the first try almost every time, with zero blocking anywhere. Under high contention, threads spin and retry more, but no thread can ever deadlock, because there is no lock to hold in the first place.
AtomicInteger is this pattern, pre-built. Internally it's backed by a volatile int field plus CAS support, which gives both visibility (every thread sees the latest value) and atomicity (compound read-modify-write operations happen as one indivisible step) without ever calling lock(). Methods like incrementAndGet(), addAndGet(delta), and getAndIncrement() are all implemented internally using exactly the read-compute-CAS-retry loop shown below by hand — you'll rarely need to write that loop yourself, but understanding it is what makes the rest of the atomic classes make sense.
◆ Under the hood
A failed compareAndSet() call is information, not an error — it simply means the value changed since it was last read, and the caller should recompute and try again (or decide not to). This is the essential mental shift from locking: a lock stops other threads from even attempting the update while one thread holds it; CAS lets every thread attempt it, and a boolean return value tells each thread whether it actually won.
💻 Code example
package com.crackedlabs.concurrency.atomics; import java.util.concurrent.atomic.AtomicInteger; public class RequestCounter { private final AtomicInteger count = new AtomicInteger(0); // Hand-written version of incrementAndGet(), to expose the CAS retry // loop that every built-in atomic method is really doing underneath. public int incrementManually() { int current; int next; do { current = count.get(); // snapshot the latest published value next = current + 1; // compute the desired new value } while (!count.compareAndSet(current, next)); // retry if it changed return next; } public static void main(String[] args) { RequestCounter counter = new RequestCounter(); System.out.println("Manual CAS increment: " + counter.incrementManually()); AtomicInteger balance = new AtomicInteger(100); System.out.println("After deposit: " + balance.addAndGet(50)); boolean applied = balance.compareAndSet(150, 200); System.out.println("CAS applied? " + applied + " | balance: " + balance.get()); } }
Compare-and-swap isn't limited to numbers. AtomicReference<T> applies the exact same "check and swap, retry on failure" idea to an object reference, and that alone is enough to build a genuinely lock-free data structure — one where operations never call synchronized, never call lock(), and never block a single thread.
A classic example is a lock-free stack (sometimes called a Treiber stack, after its inventor): each node points to the node below it, and the stack itself is just an AtomicReference to the current top node. push() builds a new node pointing at whatever the current top is, then attempts to CAS the top reference from the old top to the new node — if another thread pushed or popped in between, the CAS fails and the loop retries with a fresh read of the current top. pop() follows the same shape in reverse: read the current top, note what it points to next, and CAS the top reference down to that next node.
Notice that the new node in push() is built before the CAS attempt, entirely outside any shared state — that's safe because the node isn't visible to any other thread until the CAS actually publishes it. This is a recurring shape in lock-free code: do as much work as possible on private, thread-local data, and use CAS only for the single moment where that data becomes shared.
▲ Edge case
This exact stack shape is vulnerable to the ABA problem, covered in depth later in this topic — if a thread reads the top as node A, gets paused, and other threads pop A, push B, then push a reused node that happens to look identical to A, the paused thread's CAS could succeed even though the stack's actual structure changed underneath it. In garbage-collected Java this specific scenario is rare, since the JVM generally doesn't reuse object identities that way, but the underlying hazard — "the reference looks the same, therefore nothing happened" — is real and worth understanding precisely.
💻 Code example
package com.crackedlabs.concurrency.atomics; import java.util.concurrent.atomic.AtomicReference; public class LockFreeTaskStack<T> { private static class Node<T> { final T value; Node<T> next; Node(T value) { this.value = value; } } private final AtomicReference<Node<T>> top = new AtomicReference<>(); public void push(T value) { Node<T> newTop = new Node<>(value); Node<T> currentTop; do { currentTop = top.get(); newTop.next = currentTop; } while (!top.compareAndSet(currentTop, newTop)); // retry on contention } public T pop() { Node<T> currentTop; Node<T> newTop; do { currentTop = top.get(); if (currentTop == null) { return null; // stack is empty } newTop = currentTop.next; } while (!top.compareAndSet(currentTop, newTop)); return currentTop.value; } public static void main(String[] args) { LockFreeTaskStack<String> stack = new LockFreeTaskStack<>(); stack.push("task-A"); stack.push("task-B"); System.out.println("Popped: " + stack.pop()); // LIFO: task-B first System.out.println("Popped: " + stack.pop()); } }
▲ Common mistake
Treating a failed compareAndSet() as an error condition to handle defensively, rather than the normal signal it is. A false return simply means the value changed since it was last read — the correct response is almost always to recompute and retry, exactly as the CAS loop pattern does, not to log a warning or throw.
Using AtomicLong for a metrics counter under genuinely heavy contention — many threads incrementing the same counter simultaneously at high frequency — is a common performance mistake. Every thread is still fighting over the exact same memory location, and every failed CAS is a wasted retry; throughput degrades as contention increases. LongAdder (covered in the next section) exists specifically to fix this by spreading the count across multiple internal cells.
A subtler mistake is assuming atomic operations compose across multiple variables. Updating two separate AtomicInteger fields "together" — even back to back in the same method — is not atomic as a pair, even though each individual update is safe on its own. Another thread can observe a state where the first field has been updated but the second hasn't yet. If an invariant spans more than one variable, the fix is a lock around both updates, not two atomics used side by side.
Finally, calling LongAdder.sum() expecting the same O(1), instantaneous-snapshot guarantee that AtomicLong.get() provides is a real misunderstanding of what sum() actually does — it walks every internal cell and adds them together, which is not O(1) and is not a true atomic snapshot if writes are landing on other cells while it runs.
The ABA problem is the sharpest edge case in this whole family: Thread 1 reads a value — call it A. Before Thread 1 acts on it, Thread 2 changes A to B, then changes it right back to A. When Thread 1 finally attempts its CAS, the value is A again, exactly what it expected, so the CAS succeeds — but something did happen in between. For a plain counter, that's harmless; the value is correct either way. For a data structure like the lock-free stack above, where "the reference looks the same" doesn't necessarily mean "nothing happened to what it points to," this can silently corrupt state, particularly in structures where nodes get reused or removed and reinserted.
The standard fix mirrors exactly how you'd solve the same problem with a database row: pair the value with a version number that only ever increases. AtomicStampedReference<T> does exactly this — its compareAndSet takes both an expected reference and an expected stamp, and only succeeds if both match. Even if the reference round-trips back to something identical, the stamp won't, so a stale check-and-update gets caught and rejected instead of silently succeeding.
AtomicIntegerArray and AtomicReferenceArray extend the same idea to per-element atomicity within an array: incrementAndGet(i) and compareAndSet(i, expected, update) operate on a single index without touching or contending with any other index. This matters specifically when different threads genuinely touch different array slots — a per-bucket histogram, a fixed set of per-shard counters — since wrapping the whole array in one lock would serialize threads touching completely unrelated elements for no reason.
LongAdder's internal design trades exact, instant reads for write throughput: instead of one shared counter every thread contends on, it maintains a base value plus a lazily grown array of internal cells, and each thread is striped across a cell based on a per-thread hash, spreading contention out instead of concentrating it on one memory location.
Metrics and instrumentation libraries lean heavily on this family of classes — high-throughput counters for request rates, error rates, and latency histograms are a textbook case for LongAdder-style striping, since they're written far more often than they're read, and an occasional slightly-stale sum() for a dashboard is a perfectly acceptable tradeoff.
ConcurrentHashMap's own internal size tracking uses a LongAdder-like striped counter rather than a single shared field, for exactly the same reason: many threads inserting into different buckets concurrently shouldn't have to contend on one shared count just to keep it updated.
Lock-free queues and ring buffers in high-performance messaging libraries use CAS directly on sequence counters and slot references to hand off data between producer and consumer threads without ever calling into the OS scheduler — the same fundamental technique as the lock-free stack shown earlier, applied to a higher-throughput structure.
Optimistic-locking version columns in relational databases and ORMs (a @Version field in JPA, for instance) are conceptually identical to AtomicStampedReference's stamp: an update only succeeds if the version it expects still matches the current one, and a stale update fails cleanly instead of silently overwriting newer data.
Feature-flag toggles, connection-state flags, and simple id generators across many frameworks are commonly built on AtomicBoolean, AtomicLong, or AtomicReference directly — anywhere a single shared value needs safe concurrent access without the overhead of acquiring an actual lock for something this simple.
Q: What does compare-and-swap actually do, and why doesn't it need a lock?
A: It atomically checks whether a value still equals an expected value and, only if so, replaces it with a new value, returning true or false to report success. The CPU itself guarantees no other core can interleave a change partway through, so no OS-level lock is needed for the check-and-update to be safe.
Q: What's the general shape of a CAS retry loop, and why does it retry instead of failing outright?
A: Read the current value, compute the desired new value from that snapshot, then attempt a CAS. If it fails, another thread changed the value first, so the loop reads a fresh value and tries again. This retry-on-failure design is exactly why CAS-based code never deadlocks -- there's no lock being held to get stuck on.
Q: Why would LongAdder ever be preferred over AtomicLong?
A: Under high contention, AtomicLong's single shared CAS target becomes a bottleneck as many threads repeatedly fail and retry against the same memory location. LongAdder spreads increments across multiple internal cells, so most threads never collide, giving much better throughput for write-heavy counters -- at the cost of sum() being an approximate, non-O(1) aggregate rather than an exact instant snapshot.
Q: What is the ABA problem, and why can a plain CAS miss it?
A: A value changes from A to B and back to A between when a thread reads it and when that thread's CAS attempt runs. Because CAS only compares the current value to the expected value, it sees A and assumes nothing happened, even though the value was mutated and restored in between -- which can corrupt structures where identity, not just value, matters.
Q: When should you reach for a lock instead of an atomic variable?
A: Whenever more than one variable must change together as a single atomic unit, or a read-check-then-write invariant needs to hold across multiple steps (like ensuring a balance never goes negative after a withdrawal). Atomics guarantee atomicity for a single variable's update, not for a sequence of operations spanning several variables.
Want a visual for this concept?
Generate a diagram tailored to “Atomic Variables & Compare-And-Swap” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →