"Lock-free beats locks" is close to something of a folk wisdom in Java concurrency circles. Wanting a concrete answer, I implemented both a hand-rolled compare-and-swap (CAS) retry loop and a plain synchronized counter in a Java project — benchmarked the two against each other under JMH with a 1/2/4/6/8 thread sweep on 4-core hardware. The first result I'd gotten almost led me to a precisely the sort of over generalization that made me want to run the test in the first place.
The setup: two counters, same shape
Both of the benchmarked implementations increment and return in a single atomic call, implemented deliberately via hand-rolled code rather than directly via AtomicInteger.incrementAndGet(), to expose the retry mechanism:
// CASCounter — a hand-rolled CAS retry loop
public int incrementAndGet() {
int expected = counter.get();
while (!counter.compareAndSet(expected, expected + 1)) {
expected = counter.get();
}
return expected + 1;
}
// LockCounter — mutual exclusion via a monitor
public synchronized int incrementAndGet() {
counter++;
return counter;
}
The annotated (@State(Scope.Benchmark)) instances of these classes are shared between every thread in a given benchmark method, not per-thread — Scope.Thread would give each thread a distinct instance and make for effectively zero contention, as far as this benchmark is concerned.
A JMH trap that was hiding in the annotations
Each benchmark method loops 1,000,000 incrementAndGet() calls, and both classes thus have @OperationsPerInvocation(1_000_000). Here's what that annotation actually buys you, worked out in numbers rather than just asserted.
Without it, JMH's Mode.Throughput reports how many times the annotated @Benchmark method itself completes per second — not how many incrementAndGet() calls happen per second. Take the real, correctly-measured CAS number at 1 thread: ~120.1M increments/sec. Each call to call1() performs 1,000,000 of those increments before returning, so the method itself only completes about 120,100,000 ÷ 1,000,000 = 120.1 times per second. Strip the annotation, and that's the number JMH would report as "ops/s" — 120.1, not 120,100,000.
Now suppose that omission happened on only one side of the comparison — CASCounter's annotation missing, LockCounter's intact. JMH would report CAS at "120.1 ops/s" (loop completions) against Lock's correctly-annotated "57,200,000 ops/s" (actual per-increment rate). Read at face value, that makes Lock look roughly 57,200,000 ÷ 120.1 ≈ 476,000x faster than CAS — the exact opposite of reality, where CAS is actually ~2.1x faster at 1 thread. One missing annotation on one of two classes doesn't just blur the numbers, it can flip the conclusion entirely.
Even a symmetric mistake — both classes missing the annotation — wouldn't break the CAS-vs-Lock comparison (both numbers get divided by the same 1,000,000, so the ratio between them survives), but the absolute figures would become meaningless on their own: "120.1 ops/s" reads like a counter incrementing about 120 times a second, six orders of magnitude slower than what's actually happening. That's the specific failure mode the JMH methodology primer warns about — not vague noise, but a benchmark that reports a wrong number with total confidence.
First look — and the conclusion I almost drew
The first thing I ran benchmarked was CASCounter on its own, for the aggregate throughput of the threadCount-thread harness:
| Threads | Throughput (relative) |
|---|---|
| 1 | 129.6 (baseline) |
| 2 | 14.5 (not ~2x, ~9x worse) |
| 4 | 5.6 |
| 6 | 4.8 |
| 8 | 4.1 |
Increasing from 1 to 2 threads didn't roughly double the aggregated throughput of the program — it collapsed by ~9x. That would be an intuitively plausible opportunity for someone to conclude "well, obviously a lock would've done better here", having not actually run the other benchmark. Reading a conclusion about LockCounter out of results for CASCounter would be no different from concluding that "this program would be faster if it had a different set of inputs". The fact that CASCounter simply collapses under contention tells us nothing about it by itself, before running the other harness.
The real head-to-head
Running both counters through the same harness, normalized again to per-increment throughput (99.9% confidence intervals don't overlap on any thread count — this crossover is real):
| Threads | CAS (ops/s) | Lock (ops/s) | Winner |
|---|---|---|---|
| 1 | ~120.1 M | ~57.2 M | CAS, ~2.1× |
| 2 | ~14.6 M | ~31.2 M | Lock, ~2.1× |
| 4 | ~6.9 M | ~16.6 M | Lock, ~2.4× |
| 6 | ~5.6 M | ~11.8 M | Lock, ~2.1× |
| 8 | ~5.7 M | ~10.2 M | Lock, ~1.8× |
CAS wins uncontended by roughly an order of magnitude, but lock contention negates that advantage almost entirely and hands the victory to the lock by a similar margin. The uncontested CAS has a bare-metal CAS that has succeeded on the first try and thus is cheaper than even an uncontended synchronized block. Once there's any contention at all, the CAS's lack of backoff mechanism makes it much slower than the lock — the compareAndSet will always keep spinning in a tight loop without any pauses, whereas a thread blocked on a synchronized it doesn't hold waits entirely outside the coherence protocol.
Why the crossover happens: spin vs. park
Concrete trace of what happens when both Thread A and Thread B read the same value and CAS() fails, due to the cache line being invalidated by A's CAS() having succeeded:
sequenceDiagram
participant A as Thread A
participant Mem as counter (cache line)
participant B as Thread B
Mem-->>A: read counter = 5
Mem-->>B: read counter = 5
A->>Mem: CAS(5, 6) — succeeds
Note over Mem: cache line invalidated on every other core
B->>Mem: CAS(5, 6) — fails (live value is 6)
B->>Mem: re-read counter = 6
B->>Mem: CAS(6, 7) — succeeds
CASCounter.incrementAndGet() contains no backoff mechanism, and the moment a compareAndSet fails, will immediately proceed to re-read counter value and retry as many times as necessary in a spinloop until it manages to CAS() successfully. A thread that iblocked on a synchronized monitor it cannot acquire will instead enter park() and completely exit the cache-coherence picture until it manages to acquire the lock again.
The generalization — and it isn't "use locks"
The accurate generalization is a narrower, more specific one — a CAS retry loop in the absence of backoff mechanism loses to locks under contention. The resolution is an exponentially-increasing backoff with jitter, to prevent threads that collided from restarting at the same point in the spinloop and colliding again — but at this operation's nanosecond timescale, Thread.sleep() is the wrong level of granularity (nanoseconds vs. milliseconds) to have such a coarse-grained mechanism as sleep(). For smaller backoffs, Thread.onSpinWait() is appropriate (a CPU instruction on x86 telling the processor that this spinloop is waiting for an external event to happen and thus should occasionally yield to other threads), and eventually LockSupport.parkNanos() is more appropriate than sleep().
This is precisely the reason LongAdder exists and is able to outperform a single AtomicLong under contention: it does not pick a different mutual-exclusion primitive to handle the hot spot, but stripes the counter in such a way that threads collide less often in the first place. The same technique also powers the Disruptor's multi-producer case (though not the high-performance single-producer one): each producer operates on a cell of the array in turn, and each thread only collides with any other thread if they pick the same cell.
In fact, neither LongAdder nor Disruptor eliminates a shared touchpoint altogether, only reducing the frequency with which threads collide on it. The mechanisms described apply whenever threads do collide in spite of such measures and need to compete for access.
Takeaway
A benchmark's first result may not be a comparison. CASCounter's preliminary result, where increasing the number of threads from 1 to 2 caused a ~9x drop in throughput, did not say anything about LockCounter's behavior until both were actually benchmarked against each other. Once they had, the actual result turned out to be closer to a crossover than an outright rout, and the reason for this difference pointed directly to the means of resolution — backoff, and not a switch in primitives, was the appropriate action in this case.
Top comments (0)