Concurrency Basics

Atomic Variables

15 min Lesson 7 of 13

Atomic Variables

In the previous lessons you saw that a bare int counter shared between threads produces incorrect results because the read-modify-write sequence is not atomic. You also saw that synchronized fixes this, but it comes with a cost: it serialises all threads through a lock, which can become a bottleneck under high contention.

Java provides a middle road through the java.util.concurrent.atomic package: lock-free, thread-safe variables that rely on hardware-level atomic instructions — specifically Compare-And-Set (CAS) — instead of a mutex. This lesson covers the most important members of that package and explains when and why to reach for them.

The Compare-And-Set (CAS) Primitive

CAS is a single CPU instruction that does three things atomically:

  1. Read the current value of a memory location.
  2. Compare it to an expected value.
  3. If they match, write a new value; if they do not match, do nothing.

The operation returns whether the swap succeeded. The caller loops — spinning — until it wins the race. Because the check and the swap happen as a single hardware instruction, no other thread can interleave between them.

CAS vs. a lock — A lock causes a losing thread to block (hand back the CPU). CAS causes a losing thread to retry (spin). For very short operations, spinning is faster because it avoids context-switch overhead. For long operations, spinning wastes CPU cycles; a lock is better.

AtomicInteger — the Workhorse

AtomicInteger wraps an int and exposes every common mutation as an atomic operation. The most useful methods are:

  • get() / set(int) — read or write with full memory visibility.
  • incrementAndGet() — atomically adds 1, returns the new value.
  • getAndIncrement() — atomically adds 1, returns the old value (like i++).
  • addAndGet(int delta) — atomically adds delta, returns the new value.
  • compareAndSet(int expected, int update) — performs the raw CAS; returns true on success.
  • updateAndGet(IntUnaryOperator) — applies a lambda atomically (Java 8+).

Here is the same counter you saw break earlier, now written with AtomicInteger:

import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); // single atomic step — no lock needed } public int get() { return count.get(); } public static void main(String[] args) throws InterruptedException { AtomicCounter counter = new AtomicCounter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Final count: " + counter.get()); // always 200000 } }

No synchronized, no Lock, yet the result is always exactly 200 000.

Manual CAS Loop with compareAndSet

compareAndSet is the building block for more complex lock-free logic. Suppose you want to atomically cap a counter at a maximum:

import java.util.concurrent.atomic.AtomicInteger; public class BoundedCounter { private final AtomicInteger value = new AtomicInteger(0); private final int max; public BoundedCounter(int max) { this.max = max; } /** * Increment only if current value is below max. * Returns true when the increment was applied. */ public boolean tryIncrement() { while (true) { int current = value.get(); if (current >= max) return false; // CAS: only write if the value is still 'current' if (value.compareAndSet(current, current + 1)) return true; // if CAS lost the race, loop and read the new current } } public int get() { return value.get(); } }
The spin-retry pattern is the heart of every lock-free algorithm: read the current value, compute the desired new value, call compareAndSet. If CAS fails, another thread changed the value between your read and your write, so start over. For in-memory arithmetic, the retry loop almost never runs more than once under realistic contention.

The Rest of the Atomic Family

The package contains analogues for other primitive types and for references:

  • AtomicLong — same API as AtomicInteger but for long.
  • AtomicBoolean — useful for a flag that many threads read but only one should flip.
  • AtomicReference<V> — CAS on an object reference; perfect for swapping an immutable snapshot atomically.
  • AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray<E> — per-element atomic access inside an array.

An AtomicBoolean is commonly used as a once-only flag:

import java.util.concurrent.atomic.AtomicBoolean; public class OneTimeAction { private final AtomicBoolean fired = new AtomicBoolean(false); public void runOnce(Runnable action) { // compareAndSet(false, true) only succeeds for the FIRST thread that calls it if (fired.compareAndSet(false, true)) { action.run(); } } }

LongAdder — for High-Throughput Counters

Under extreme contention many threads spinning on the same AtomicLong still cause cache-line bouncing — every successful CAS invalidates the cache line in every other CPU core, forcing retries. Java 8 introduced LongAdder (and DoubleAdder) to solve this: it maintains a cell per thread internally and adds all cells together only when you call sum(). Writes never contend with each other; reads pay a small aggregation cost.

import java.util.concurrent.atomic.LongAdder; LongAdder hits = new LongAdder(); // called from thousands of threads — essentially zero contention hits.increment(); // called infrequently to read the metric long total = hits.sum();
LongAdder is not a drop-in replacement for AtomicLong. It does not support compareAndSet, and sum() is not a consistent snapshot if increments are still happening concurrently. Use LongAdder when you only need a running total; use AtomicLong when you need to read and conditionally update in one step.

AtomicReference and the ABA Problem

CAS on references has a subtle pitfall: if a reference changes from A to B and then back to A, a CAS that expects A will succeed even though the object was replaced in the meantime. This is the ABA problem. The fix is AtomicStampedReference<V>, which pairs the reference with an integer stamp (version counter) — both the reference and the stamp must match for the CAS to succeed.

import java.util.concurrent.atomic.AtomicStampedReference; AtomicStampedReference<String> ref = new AtomicStampedReference<>("initial", 0); int[] stampHolder = new int[1]; String current = ref.get(stampHolder); // fills stampHolder[0] with current stamp // CAS succeeds only when BOTH the reference and the stamp match boolean ok = ref.compareAndSet( current, "updated", stampHolder[0], stampHolder[0] + 1);

When to Choose Atomics over synchronized

  • Use atomics when the protected operation is a single arithmetic or reference update. They are simpler and faster under low-to-moderate contention.
  • Use synchronized or ReentrantLock when you need to guard a multi-step sequence as a single unit (e.g., check a condition and update two fields together).
  • Use LongAdder when you have a pure increment counter under very heavy parallel load and you do not need CAS semantics.

Summary

Atomic variables replace locks for single-variable updates by using the CPU-level CAS instruction. AtomicInteger and AtomicLong cover arithmetic; AtomicBoolean handles flags; AtomicReference swaps object pointers safely. The spin-retry loop — read, compute, compareAndSet, repeat on failure — is the universal pattern for building lock-free logic. For pure high-volume counters, LongAdder goes further by eliminating contention through per-thread cells. Choose the right tool for the operation size: atomics for single-variable logic, locks for multi-step critical sections.