LongAdder vs AtomicLong: Why a Single Counter Collapses Under Contention
A counter is the “hello world” of concurrent programming. Count requests, count cache hits, count bytes sent. One shared number, one line of code, and an AtomicLong makes it correct.
Then you add threads, and something odd happens. Correct stops being the same as fast. At some point, adding cores to a program that does nothing but increment a counter does not help, and it can make things worse.
So what is going on? And why does LongAdder exist? I’ll walk through the mechanism, show what I could and could not measure, and be upfront about the limits of my numbers.
The deceptively simple counter
AtomicLong gives you a thread-safe long. Call incrementAndGet() from a hundred threads and you never lose an update. That guarantee is real, and for many programs it is all you need.
But notice what the guarantee costs. Every update must be applied to the same memory location, one after another, in some order. Atomicity is serialization, whether you meant to ask for it or not.
On a single core that is invisible. On many cores, it is the whole story.
Why one hot variable stops scaling
Caches own lines, not variables
Modern CPUs do not fetch single variables from memory. They move fixed-size blocks called cache lines, commonly 64 bytes, although the size varies by processor. Each core keeps its own copy of the lines it uses, and a coherence protocol keeps those copies consistent.
The rule that matters here is simple. To write to a line, a core must first own it exclusively. Every other core’s copy is invalidated.
The line ping-pong
Now put four cores on one AtomicLong. Core A increments it, so A holds the line. Core B wants to increment, so the line travels to B and A’s copy is invalidated. Then C asks for it, then D, then A again.
The line bounces between cores, and each hop costs far more than the arithmetic. Your increment is one instruction. Getting permission to execute it is the expensive part. More threads do not add throughput, because the line can only be in one place at a time.
I’m describing typical behavior on mainstream x86 and ARM machines. The exact cost of a hop differs by CPU and by how many sockets are involved, so I won’t put a number on it.
Retries make it worse
There is a second effect when updates are written as compare-and-set loops: read the value, compute the new one, try to swap it in, and start over if someone else got there first. Under contention, many attempts fail, and each failure burns a trip across the interconnect for nothing.
Note that incrementAndGet() on common hardware is usually compiled to a single hardware fetch-and-add, which does not fail and retry. It still serializes on the same cache line, though, so the collapse mechanism is the same. The failed-attempt counting I use later is a way to see contention, not a claim about how that method is implemented.
How LongAdder sidesteps the problem
Split the counter
LongAdder takes a different approach: stop sharing. It keeps one base value plus a table of cells. When threads collide, different threads update different cells, so they touch different cache lines.
Reading the total means adding base and every cell together. The Javadoc for LongAdder describes the same idea and notes that under low contention the two classes behave similarly, while under high contention LongAdder has significantly higher expected throughput at the cost of more memory.
One consequence is worth pausing on. In a quiet application, LongAdder behaves almost exactly like a plain atomic. You only pay for the extra machinery when threads actually collide.
The padding trick
Splitting the counter is not enough on its own. If two cells sat next to each other in memory, they could share a cache line, and two threads updating “different” cells would still fight over one line. This problem is called false sharing.
To prevent it, each cell is a variant of an atomic long padded with the @Contended annotation, as the source comments describe. Padding spreads the cells apart so they land on separate lines. That padding is also the reason LongAdder uses more memory than a single AtomicLong.
What I measured, and what I could not
I wanted to show you the collapse with real numbers. I could not, and I’d rather tell you why than invent a chart.
The machine I had for this article has exactly one virtual CPU. With one core there is no parallelism, so threads never truly collide on a cache line, and the effect we are discussing cannot appear. I ran the harness below anyway, because it still teaches something and because it verifies that the code works.
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public class CounterBench {
interface Counter { void inc(); long get(); }
static final class Atomic implements Counter {
final AtomicLong v = new AtomicLong();
public void inc() { v.incrementAndGet(); }
public long get() { return v.get(); }
}
static final class Adder implements Counter {
final LongAdder v = new LongAdder();
public void inc() { v.increment(); }
public long get() { return v.sum(); }
}
static double run(int threads, long opsPerThread, Counter c) throws Exception {
Thread[] ts = new Thread[threads];
for (int i = 0; i < threads; i++)
ts[i] = new Thread(() -> { for (long k = 0; k < opsPerThread; k++) c.inc(); });
long t0 = System.nanoTime();
for (Thread t : ts) t.start();
for (Thread t : ts) t.join();
double secs = (System.nanoTime() - t0) / 1e9;
if (c.get() != threads * opsPerThread) throw new AssertionError("lost updates");
return threads * opsPerThread / secs / 1e6; // million ops per second
}
// Explicit CAS loop that counts failed attempts, to make contention visible.
static double casFailureRatio(int threads, long opsPerThread) throws Exception {
AtomicLong v = new AtomicLong();
LongAdder failures = new LongAdder();
Thread[] ts = new Thread[threads];
for (int i = 0; i < threads; i++)
ts[i] = new Thread(() -> {
long local = 0;
for (long k = 0; k < opsPerThread; k++) {
long cur;
do { cur = v.get(); if (!v.compareAndSet(cur, cur + 1)) local++; else break; } while (true);
}
failures.add(local);
});
for (Thread t : ts) t.start();
for (Thread t : ts) t.join();
return (double) failures.sum() / (threads * opsPerThread);
}
public static void main(String[] args) throws Exception {
long ops = 20_000_000L;
System.out.println("availableProcessors = " + Runtime.getRuntime().availableProcessors());
for (int w = 0; w < 2; w++) { run(2, 2_000_000L, new Atomic()); run(2, 2_000_000L, new Adder()); }
System.out.printf("%-8s %-14s %-14s %-16s%n", "threads", "AtomicLong", "LongAdder", "CAS fail ratio");
for (int t : new int[]{1, 2, 4, 8, 16}) {
double a = run(t, ops, new Atomic());
double d = run(t, ops, new Adder());
double f = casFailureRatio(t, ops / 4);
System.out.printf("%-8d %-14.1f %-14.1f %-16.4f%n", t, a, d, f);
}
System.out.println("(throughput in million increments per second)");
}
}
I compiled and ran it on JDK 25 (Temurin 25+36) on that one-vCPU Linux VM. Here is the output, from a single run:
| Threads | AtomicLong (M ops/s) | LongAdder (M ops/s) | CAS failure ratio |
|---|---|---|---|
| 1 | 142.5 | 85.2 | 0.0000 |
| 2 | 115.5 | 82.7 | 0.0000 |
| 4 | 107.3 | 78.0 | 0.0000 |
| 8 | 135.7 | 82.9 | 0.0000 |
| 16 | 137.4 | 78.7 | 0.0000 |
Look at the last column. Not one failed compare-and-set across all thread counts. That is the contention signal, and on this machine it never fires, because threads are time-sliced on one core instead of racing.
Notice, too, that AtomicLong came out ahead of LongAdder here. I would not generalize from that. This is one run, on a virtual machine, with a hand-rolled harness that skips the warm-up rigor, fork isolation and dead-code protections of a proper tool like JMH. I also have not investigated why the gap appeared, so I won’t offer an explanation. What the table does support is a narrower point: without real parallelism, LongAdder has no advantage to show.
If you want the actual collapse, run this harness on a machine with several physical cores and watch the failure-ratio column rise as threads increase. I have no multicore numbers to give you, so this article contains none.
The costs of LongAdder
If LongAdder were strictly better, AtomicLong would have been retired years ago. It was not, because the trade-offs are real.
First, reading is not atomic. The Javadoc says sum() is not an atomic snapshot: with no concurrent updates it is accurate, but updates that happen during the summation might be missed. Reading also walks the whole table, so it costs more than reading one field.
Second, increment() returns nothing. With an AtomicLong you can use the value you get back, for example to hand out sequence numbers or to check a limit in the same step. A LongAdder cannot give you “the value after my increment”.
Third, there is memory. Padded cells take more space than one long, which matters if you create millions of counters.
So where does LongAdder shine? Metrics and statistics, where many threads write often and one thread reads occasionally. The Javadoc even suggests a frequency map, and you can write your own version in one line:
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>(); hits.computeIfAbsent(page, k -> new LongAdder()).increment();
Choosing between them
A few habits will keep you out of trouble:
- Reach for
LongAdderwhen the counter is write-heavy, shared by many threads, and read rarely or approximately. - Stay with
AtomicLongwhen you need the exact value back from the update, a precise limit, or a unique ID. - Measure on hardware that resembles production, with real core counts, before you decide.
Conclusion
Here is what we learned. A single shared counter does not scale because every update needs exclusive ownership of the same cache line, so cores take turns while the line bounces between them. Compare-and-set loops add failed attempts on top of that.
LongAdder avoids the queue by splitting the counter into a base plus padded cells, growing the table only after a failed update and capping it near the CPU count. In exchange, reads are costlier and approximate, you lose the returned value, and you spend more memory.
My own measurement was humbler than I hoped. On a one-core machine there was no contention at all, zero failed compare-and-set attempts, and AtomicLong was faster. That is a useful reminder that this is a many-core problem, and that you should test it where the cores are.



