Ansh Saxena

JVM Concurrency Benchmarks JMH results for lock contention, lock-free hand-off and allocation in Java 21

JVM Concurrency Benchmarks is an open-source repository by Ansh Saxena with three JMH microbenchmark suites for low-latency Java: what locks and atomics cost as threads are added, how much faster a hand-written lock-free single-producer / single-consumer ring buffer is than the JDK queues, and what one allocation per event costs under G1, Parallel and ZGC.

github.com/AnshSaxena05/jvm-concurrency-benchmarks

Setup

Java 21, JMH 1.37, Maven, MIT licensed. Measured on an Intel Core i7-1255U laptop (10 cores, 12 threads, hybrid P/E cores, 15.7 GB RAM), Windows 11, Microsoft OpenJDK 21.0.12, on 2026-10-02. It was a thin-and-light machine that was also running a browser, with no core pinning and no frequency control, so the ratios are the finding and the absolute figures should be re-run on your own hardware. Scores carry the 99.9% confidence interval JMH reports.

1. Lock contention: shared counter

Operations per microsecond (higher is better):

Primitive1 thread4 threads8 threads
AtomicLong.incrementAndGet152.3 ± 11.327.7 ± 4.422.8 ± 2.9
LongAdder.increment91.9 ± 1.7276.4 ± 49.5439.9 ± 22.1
ReentrantLock51.4 ± 2.828.6 ± 3.728.1 ± 4.3
synchronized40.4 ± 2.813.3 ± 1.612.5 ± 1.5

Uncontended, a single CAS (AtomicLong) wins. As threads pile onto one cache line it collapses (about 7x slower at 8 threads), while LongAdder, which stripes the count across cells, scales up (about 19x faster than AtomicLong at 8 threads). The price is that LongAdder.sum() is not an atomic snapshot, so it fits metrics and counters, not sequence numbers.

2. Lock-free hand-off: SPSC ring buffer against JDK queues

One producer, one consumer, hand-offs per microsecond (higher is better):

QueueThroughput
SpscRingBuffer (this repo, lock-free)77.4 ± 16.2
ConcurrentLinkedQueue26.9 ± 3.9
ArrayBlockingQueue10.8 ± 3.4
LinkedBlockingQueue8.9 ± 2.4

When exactly one thread writes and one reads, dropping the lock and the CAS loop gives roughly 3x over the best general-purpose queue and 7x to 9x over the blocking ones. Both sides spin, so this measures the hand-off itself and not park/unpark latency. The ring buffer uses release stores and a power-of-two mask, and is only correct under the single-producer / single-consumer rule; a test pushes 2,000,000 items across two threads and checks order and sum.

3. Allocation on the hot path

Average time per operation and GC cycles during 6 measured seconds, with -prof gc:

BenchmarkG1ParallelZGCBytes/op
newTickPerEvent5.2 ns, 74 GCs5.2 ns, 137 GCs6.8 ns, 48 GCs40
reusedTick1.1 ns, 0 GCs1.1 ns, 0 GCs1.5 ns, 0 GCs0
boxedLadderUpdate (HashMap<Long,Long>.merge)17.5 ns, 38 GCs11.9 ns, 42 GCs20.1 ns, 16 GCs45
primitiveLadderUpdate (long[])1.5 ns, 0 GCs1.5 ns, 0 GCs1.8 ns, 0 GCs0

Reusing one mutable event removes 40 bytes of garbage per tick and about 5x of the time; a long[] ladder removes 45 bytes and about 8x to 12x against a boxed map. With zero allocation there is nothing for any collector to do, so the collector choice stops mattering, which is the point of allocation-free design.

Two benchmarking lessons from this run

Links