← Back to the section

When several threads touch the same counter, a plain counter++ breaks — not because the operation is wrong, but because it is not atomic. The AtomicInteger class and its relatives solve this without locks — through the hardware instruction CAS.

Why counter++ is dangerous in multithreaded code

Writing counter++ looks like a single operation, but the processor splits it into three steps:

  1. Read the current value from memory.
  2. Add one.
  3. Write the result back.

If two threads run these three steps interleaved, both can read the same value, increment it, and write it back — the final counter ends up one lower than it should be. This is the classic race condition.

One way to guard against it is to add synchronized. But a lock is an expensive operation: a thread that fails to acquire the monitor goes to sleep and waits to be woken up. For simple operations on a number this is overkill.

CAS — the "compare and swap" instruction

CAS (compare-and-swap) is a single indivisible processor instruction that does the following:

"If the current value at this address equals the expected one, replace it with the new one. Otherwise — do nothing, report failure."

Short formula: CAS(address, expected, new) → true/false

Because the instruction is atomic at the hardware level, no other thread can wedge itself in between the check and the write. No monitor is needed — no putting threads to sleep and waking them up.

The entire java.util.concurrent.atomic package is built on CAS.

AtomicInteger, AtomicLong, AtomicReference

These are the three most-used classes from the java.util.concurrent.atomic package.

AtomicInteger and AtomicLong are wrappers around int and long with a set of atomic operations:

AtomicInteger counter = new AtomicInteger(0);

// Add 1, return the new value
int next = counter.incrementAndGet();

// Add 1, return the old value
int prev = counter.getAndIncrement();

// Add an arbitrary number
counter.addAndGet(5);

// Manual CAS: if it is 10 right now — set 20
boolean success = counter.compareAndSet(10, 20);

Under the hood, each call uses CAS in a loop: if the swap fails (another thread got there first), the operation retries with the newly read value.

AtomicReference<V> lets you atomically change a reference to an object. Useful when you need to replace a node in a data structure or update a configuration without locking:

AtomicReference<String> config = new AtomicReference<>("v1");

// Replace "v1" with "v2" only if it has not changed yet
boolean updated = config.compareAndSet("v1", "v2");

What a lock-free increment looks like inside

To see how a CAS loop works without a lock, here is a simplified model of incrementAndGet:

// This is roughly how incrementAndGet works inside the JDK
int incrementAndGet(AtomicInteger ref) {
    while (true) {
        int current = ref.get();       // read the current value
        int next = current + 1;        // compute the desired new value
        if (ref.compareAndSet(current, next)) { // try to write
            return next;               // succeeded — return the result
        }
        // failed — retry with the new current
    }
}

Under low contention, CAS succeeds on the first try. Under high contention, threads "compete" in this loop without going to sleep. That is exactly what lock-free means: progress is guaranteed for at least one of the threads at any given moment.

The ABA problem

CAS has a subtle weakness: it compares only the value, not the "history" of changes.

Imagine: thread A read the value "X". While it is thinking, thread B changed "X""Y" → back to "X". When thread A runs a CAS with the expected "X", the check passes successfully — even though the value has already changed twice in the meantime.

For references this can lead to errors in complex data structures (for example, in lock-free queues). The solution is AtomicStampedReference: it holds a pair (reference + numeric stamp), and CAS checks both.

AtomicStampedReference<String> ref =
    new AtomicStampedReference<>("X", 0);

int[] stamp = new int[1];
String current = ref.get(stamp);       // get the value and the current stamp

// CAS on value AND stamp — ABA will not slip through
ref.compareAndSet(current, "Z", stamp[0], stamp[0] + 1);

In most application-level tasks the ABA problem does not come up, but in lock-free data structures you need to keep it in mind.

LongAdder — when contention is high

AtomicLong works great under moderate load. But if dozens of threads continuously increment the same object, they start "fighting" over it — the CAS loops get longer, and processor cores burn cycles for nothing.

LongAdder solves this differently: it holds not a single value, but an array of cells. Each thread usually works with its own cell, rarely colliding with the others. The final value is the sum of all cells, returned by sum().

LongAdder hits = new LongAdder();

// Across several threads — almost no contention
hits.increment();

// Read the total — the sum of all cells
long total = hits.sum();

The difference in practice: with 16 threads, LongAdder can be several times faster than AtomicLong. The price is that sum() does not guarantee accuracy at the moment of the call (the cells are updated independently), so LongAdder is a good fit for statistics and counters, but not for logic where a precise atomic read point matters.

AtomicLong            LongAdder
─────────────         ───────────────────────
 one cell             cell 1 | cell 2 | ...
 threads contend      threads work separately
 sum = exact          sum() = approximate

When to choose atomics, and when to choose locks

Atomic variables are good when:

  • The operation is simple: increment, reference swap, flag.
  • Contention is moderate (not many threads).
  • You need maximum performance on a hot path.

Locks (synchronized, ReentrantLock) are preferable when:

  • You need to protect a compound operation of several steps.
  • Several variables are updated at once, and consistency must hold between them.
  • The logic is complex — readability matters more than lock-free optimization.

Rule of thumb: start with synchronized or ReentrantLock if you are unsure. Introduce atomics where you have measured a bottleneck and know that a lock is unnecessary there.

In short

  • counter++ is not atomic — two threads can lose an update.
  • CAS is a hardware "replace if the value matches" instruction; it requires no lock.
  • AtomicInteger, AtomicLong, AtomicReference are built on CAS and provide atomic operations without synchronized.
  • The CAS loop retries under contention; the thread does not sleep, but it spends processor cycles.
  • The ABA problem: CAS does not see intermediate changes; AtomicStampedReference guards against it.
  • LongAdder is faster than AtomicLong under high contention — by spreading work across cells.
  • Atomics are for simple operations on a hot path; locks are for compound and complex scenarios.
  • The Java memory model (happens-before) — why the visibility of changes is a separate problem and how it relates to atomicity.
  • Locks: ReentrantLock and conditions — when you need explicit locks instead of atomic variables.
  • Concurrent collections — ready-made thread-safe data structures, built partly on CAS as well.