Build1 publisher3 min readPublished
Adding a second thread collapses the hand-rolled CAS counter by 9x
A JMH sweep at 1, 2, 4, 6 and 8 threads on four cores puts a synchronized counter ahead of a hand-rolled compare-and-swap loop once every thread shares one instance, and the confidence intervals never overlap.
The Engineer · Build desk

What happened
- A Java developer benchmarked a hand-rolled compare-and-swap counter against a plain synchronized one under JMH, sweeping 1, 2, 4, 6 and 8 threads on four-core hardware.
- Both classes declare @OperationsPerInvocation(1_000_000); omitting it on the CAS side alone would print a table saying the lock is about 476,000 times faster.
- Run through the same harness and normalized per increment, the two counters cross over, with 99.9% confidence intervals that do not overlap at any thread count.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Swap Scope.Benchmark for Scope.Thread and each thread gets its own counter, so code that looks like the same sweep would measure the uncontended fast path and leave the folk wisdom untested.
- exposure A reviewer reading an ops/s column cannot see a missing per-invocation count, so one absent annotation on one of two classes can ship a conclusion pointing the wrong way.
- contradiction The summary puts the uncontended CAS win at roughly an order of magnitude while the one-thread numbers in the same post divide out to 2.10x, so the margin a reader carries away depends on which line they trust.
The retry loop has no brake. incrementAndGet() reads the counter into a local, calls compareAndSet, and when the swap fails it re-reads and tries again, with nothing in between [4]. The lock version does counter++ inside a monitor [5]. Both were written by hand instead of calling AtomicInteger.incrementAndGet(), so the retry is visible in the source [3]. On the uncontended path the post reports that a compare-and-swap succeeding on the first try is cheaper than even an uncontended synchronized block, and that the missing backoff is what makes the same loop slow once threads compete [19].
Each benchmark method loops a million calls, and both classes declare @OperationsPerInvocation(1_000_000) [7]. Without it, JMH's throughput mode counts completions of the @Benchmark method [8]. CAS at one thread measures about 120.1M increments a second, so the method itself returns only about 120.1 times a second [9]. Drop the annotation on CAS alone, leave the lock's intact at 57,200,000 ops/s, and the table says the lock is roughly 476,000 times faster [10][11], while the measured one-thread gap runs the other way at about 2.1x [12]. The inversion factor is the loop count divided by the true ratio: 1,000,000 / 2.1 is about 476,000 [4]. A symmetric omission keeps the ratio and makes both absolute numbers six orders of magnitude wrong [13], which is the failure mode the JMH methodology primer warns about, a benchmark reporting a wrong number with complete confidence [14]. A counter that ticks 120 times a second would be a memorable thing to ship.
The first run measured the CAS counter on its own. One thread to two did not double aggregate throughput; it fell by about 9x [15]. At the one-thread rate that puts two threads near 13.3M increments a second [2], about 18x below the 240.2M a naive doubling predicts [3]. The author wrote that the collapse "tells us nothing about it by itself, before running the other harness" [16].
With both counters in the same harness and normalized per increment, the 99.9% confidence intervals do not overlap at any thread count [17]. The write-up summarises the shape as CAS winning uncontended by roughly an order of magnitude, with contention handing the win to the lock by a similar margin [18]. The one-thread pair it prints works out to 2.10x [1]. Per-increment figures appear in the text only for that one-thread case, so the size of the lock's lead at eight threads is not in the prose.
Two conditions have to hold for this crossover to show up in your service. The shared object has to be hit by every thread, the way the @State(Scope.Benchmark) instance is here; Scope.Thread would give each thread its own copy and measure effectively zero contention [6]. And the guarded work has to be as small as one increment [7], with 6 and 8 threads on 4 cores putting more runnable threads than cores on the counter [2]. Under those conditions most of the measured time goes to resolving conflicts, not to the increment itself. In my view the hand-rolled loop belongs only where contention is known to be rare, and a counter every thread touches belongs behind the monitor.
What to watch
- Per-thread tables at 2, 4, 6 and 8 threads would show whether the lock's lead grows past core count or plateaus at it.
- A variant that adds backoff to the retry loop would separate the cost of contention from the cost of spinning without one.
- The same sweep on hardware with more cores than the eight-thread top of the sweep would test whether the crossover point moves.