Atomics: AtomicInteger vs a Plain Counter, and a CAS Loop Actually Retrying
Part 7 of 8 in Java Concurrency: Deep Dive
AtomicInteger gives you atomic operations on a single value without a lock. This post runs two small Java programs, each compiled and run for real, to show a plain int counter losing updates under concurrent threads while AtomicInteger doesn’t, and a hand-written compareAndSet loop actually retrying when threads collide.
A plain counter vs AtomicInteger
AtomicCounterDemo runs 8 threads, each incrementing a shared counter 20,000 times (160,000 increments total, expected), first with a plain int field and counter++, then with an AtomicInteger and incrementAndGet().
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounterDemo {
static final int THREAD_COUNT = 8;
static final int INCREMENTS_PER_THREAD = 20_000;
static final int EXPECTED = THREAD_COUNT * INCREMENTS_PER_THREAD;
static int plainCounter = 0;
static AtomicInteger atomicCounter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
runThreads(() -> plainCounter++);
System.out.println("plain int++: expected " + EXPECTED + ", got " + plainCounter);
runThreads(() -> atomicCounter.incrementAndGet());
System.out.println("AtomicInteger: expected " + EXPECTED + ", got " + atomicCounter.get());
}
static void runThreads(Runnable incrementOnce) throws InterruptedException {
Thread[] threads = new Thread[THREAD_COUNT];
for (int t = 0; t < THREAD_COUNT; t++) {
threads[t] = new Thread(() -> {
for (int i = 0; i < INCREMENTS_PER_THREAD; i++) {
incrementOnce.run();
}
});
}
for (Thread th : threads) th.start();
for (Thread th : threads) th.join();
}
}
Real javac+java output, JDK 25 (Zulu):
plain int++: expected 160000, got 31968
AtomicInteger: expected 160000, got 160000
The plain int++ run landed on 31968 instead of the expected 160000 — 128032 increments were lost to threads reading and writing the same value without coordination. The AtomicInteger run landed on exactly 160000.
A compareAndSet loop actually retrying
CasRetryDemo runs the same 8 threads x 20,000 increments, but instead of calling incrementAndGet(), it hand-writes the retry loop incrementAndGet() uses internally: read the current value, compute the next one, then compareAndSet(current, next) — and loop back if that fails because another thread got there first. An AtomicLong counts every attempt, successful or not.
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class CasRetryDemo {
static final int THREAD_COUNT = 8;
static final int INCREMENTS_PER_THREAD = 20_000;
public static void main(String[] args) throws InterruptedException {
AtomicInteger counter = new AtomicInteger(0);
AtomicLong attempts = new AtomicLong(0);
Thread[] threads = new Thread[THREAD_COUNT];
for (int t = 0; t < THREAD_COUNT; t++) {
threads[t] = new Thread(() -> {
for (int i = 0; i < INCREMENTS_PER_THREAD; i++) {
int current;
int next;
do {
current = counter.get();
next = current + 1;
attempts.incrementAndGet();
} while (!counter.compareAndSet(current, next));
}
});
}
for (Thread th : threads) th.start();
for (Thread th : threads) th.join();
int expected = THREAD_COUNT * INCREMENTS_PER_THREAD;
long totalAttempts = attempts.get();
System.out.println("expected increments: " + expected + ", counter: " + counter.get());
System.out.println("total CAS attempts: " + totalAttempts + " (" + (totalAttempts - expected) + " failed and retried)");
}
}
Real javac+java output, JDK 25 (Zulu):
expected increments: 160000, counter: 160000
total CAS attempts: 706425 (546425 failed and retried)
The final counter landed on exactly 160000, matching the expected total. But it took 706425 compareAndSet attempts to get there — 546425 of those attempts failed because another thread had already changed the value between this thread’s get() and its compareAndSet(), forcing the loop to read the new value and try again.
Takeaway
The first run showed the same lost-update pattern as an unguarded lock: 160000 expected increments, only 31968 actually landed with a plain int++, while AtomicInteger.incrementAndGet() landed on exactly 160000. The second run showed why: under the hood, getting to that correct count took 706425 compareAndSet attempts, not 160000 — 546425 of them failed and retried because of contention between the 8 threads.