Skip to main content

synchronized vs ReentrantLock vs StampedLock: Benchmarks and a Decision Table

Three ways to guard the same counter, benchmarked with JMH on JDK 25: what the fair ReentrantLock’s FIFO handoff actually costs under contention, why StampedLock’s optimistic read wins a 9:1 read-heavy workload by over 20x, a real deadlock resolved live by tryLock’s timeout, and a JDK21-vs-JDK25 side-by-side proving JEP 491 stopped synchronized from pinning virtual threads.

A service that used to handle its peak traffic fine started timing out under the same load, and the flame graph pointed at one place: a counter, guarded by a single synchronized block, that ninety percent of the calling code only ever read. The fix that got proposed in the incident review was “swap it for a ReentrantLock” – which would have made almost no difference, because the actual problem was a write-oriented lock being used for an overwhelmingly read-heavy job. This article is the benchmark that incident review should have run first: three ways to protect the same piece of state, measured under a write-only load and a read-heavy one, plus the two things that never show up in a throughput number at all – what a lock does when two threads deadlock on it, and what it does to a virtual thread that blocks while holding it.
Versions. Tested on JDK 25.0.4.1+1 (Temurin, LTS). JMH 1.37, the newest release on Maven Central at the time of writing. The JEP 491 pinning comparison also ran on JDK 21.0.10 (pre-JEP-491) for contrast. All benchmarks ran on a 2-vCPU x86-64 virtual machine – the same sandbox as the Java Memory Model article, and just as relevant here: one result below is genuinely surprising on this hardware, and is reported as measured rather than smoothed over.

Three ways to protect the same counter

Every implementation in this article guards the same thing – a long count – through the same two-method interface, so the benchmarks can swap locking strategy without changing anything else:
public interface Counter {
    void increment();
    long get();
}
The baseline is synchronized – one monitor, no fairness knob, no timeout:
public final class SynchronizedCounter implements Counter {
    private long count;
    public synchronized void increment() { count++; }
    public synchronized long get() { return count; }
}
Source: SynchronizedCounter.java. ReentrantLock replaces the monitor with an explicit lock object, in its default unfair mode – a thread that’s already running is allowed to barge in front of threads that have been parked longer, which is part of why it usually out-throughputs the alternative you’re about to see:
public final class ReentrantLockCounter implements Counter {
    private final ReentrantLock lock = new ReentrantLock();
    private long count;
    public void increment() {
        lock.lock();
        try { count++; } finally { lock.unlock(); }
    }
    public long get() {
        lock.lock();
        try { return count; } finally { lock.unlock(); }
    }
}
Source: ReentrantLockCounter.java, with a second version, FairReentrantLockCounter.java, differing only in new ReentrantLock(true). And StampedLock, used the way it’s meant to be used – an exclusive write lock, but an optimistic read path that takes no lock at all:
public long get() {
    long stamp = lock.tryOptimisticRead();
    long value = count;
    if (!lock.validate(stamp)) {
        stamp = lock.readLock();
        try { value = count; } finally { lock.unlockRead(stamp); }
    }
    return value;
}
Source: StampedLockCounter.java. A reader grabs a stamp, reads the field with no lock held at all, then asks the lock whether a writer ran in between. If one did, it falls back to a real, blocking read lock and reads again. All four classes pass the same lost-update sanity check – eight threads, fifty thousand increments each, exact final count asserted – in CounterCorrectnessTest.java (output/06): that test proves none of them lose an update, nothing more.

Going deeper on this section

What “fair” actually costs

ReentrantLock(true) promises strict first-in-first-out ordering: whichever thread has been waiting longest gets the lock next, no barging. That fixes starvation. It also means every contended acquisition – anything past the first, uncontended one – forces a full context switch: park the current thread, wake the specific thread at the head of the queue, and nothing else may proceed in between.
Unfair (default) lock released any waiting/running thread grabs it immediately Fair lock released queue head identified that one thread woken every fair hand-off is a targeted wake-up – a full context switch the unfair path can skip
$ java -jar target/benchmarks.jar IncrementBenchmark -t 8 -rf text

Benchmark                                Mode  Cnt      Score      Error   Units
IncrementBenchmark.reentrantLockFair    thrpt    5     77.622 ±    9.397  ops/ms
IncrementBenchmark.reentrantLockUnfair  thrpt    5  50180.739 ± 8707.699  ops/ms
IncrementBenchmark.stampedLockWrite     thrpt    5  54031.902 ± 4571.353  ops/ms
IncrementBenchmark.synchronized_        thrpt    5   7431.158 ± 2055.967  ops/ms
Output: 04-increment-benchmark-sweep.txt, the 8-thread slice of a sweep across 1, 2, 4, 8, 16, 32 and 64 threads. The fair lock goes from ~52,000 ops/ms at 1 thread (uncontended – there’s no queue to be fair about) to under 1,000 the instant a second thread shows up, and it never recovers, all the way to 64 threads. That’s not a rounding error; it’s the FIFO handoff tax, paid on literally every contended acquisition, and it is why ReentrantLock(true) is a starvation-prevention tool, not a default.
Reported honestly, not smoothed over. Across the same sweep, plain synchronized comes in noticeably behind unfair ReentrantLock and StampedLock‘s write lock at every contended thread count – roughly 7,000-12,000 ops/ms against 44,000-54,000, a much wider gap than most published JMH comparisons show on multi-core hardware. This sandbox has 2 vCPUs and no access to a bigger machine to check whether that gap narrows there. The likely cause: on 2 cores, every contended benchmark here is dominated by OS-level thread scheduling regardless of primitive, and the JVM’s monitor-inflation path for synchronized – distinct from the AbstractQueuedSynchronizer park/unpark queue that both ReentrantLock and StampedLock are built on – responds to that differently. Take the direction seriously; take the exact multiplier with real caution outside a 2-vCPU VM.

Going deeper on this section

Where StampedLock actually wins: read-heavy workloads

The write-only sweep above is a fair fight in name only – nothing there gives StampedLock a chance to use its actual advantage, because there’s no read path being exercised. ReadHeavyBenchmark.java fixes that with JMH’s @Group/@GroupThreads feature: nine threads call get() while one thread calls increment(), concurrently, per lock type – a realistic 9:1 read:write mix instead of a synthetic write-only one.
$ java -jar target/benchmarks.jar ReadHeavyBenchmark -rf text

Benchmark                                                  Mode  Cnt       Score       Error   Units
ReadHeavyBenchmark.reentrantLock                          thrpt    5   45225.344 ±  3890.955  ops/ms
ReadHeavyBenchmark.stampedLock                            thrpt    5  915584.241 ± 68486.471  ops/ms
ReadHeavyBenchmark.synchronizedCounter                    thrpt    5   16320.540 ±  4834.389  ops/ms
Output: 05-read-heavy-benchmark.txt, full per-side breakdown included. Combined throughput: ~915,000 ops/ms for StampedLock against ~45,000 for ReentrantLock and ~16,000 for synchronized – over 20x. This is the number that actually matters for the decision at the top of this article: nine of the ten threads never acquire a lock at all under StampedLock. They read a stamp, read the field, validate the stamp, and are done – no blocking, no queue, no context switch, ever, unless a write actually landed in that exact window.
Going deeper: why does an optimistic read ever need a fallback?

tryOptimisticRead() doesn’t lock anything – it just hands back a stamp representing “no write is currently in flight.” The reader then reads the field and calls validate(stamp), which is true only if no write started between the stamp and the check. If a write did land in that window, the field the reader just read might be torn – read mid-update – so the reader cannot simply trust it. StampedLockCounter.get() handles that by falling back to a real, blocking readLock() and reading again, this time genuinely protected. The rare case where a writer intrudes costs a full lock; the overwhelmingly common case where it doesn’t costs nothing but a stamp comparison. That asymmetry is the entire point of the primitive.

Going deeper on this section

tryLock with a timeout: escaping a deadlock instead of preventing one

Neither synchronized nor a plain lock() call gives a thread any way out once it’s stuck waiting on a lock another thread holds while that thread waits on a lock this one holds – the classic circular-wait deadlock. ReentrantLock.tryLock(long, TimeUnit) is the one thing in this article’s whole comparison that has no synchronized or StampedLock equivalent at all: a lock acquisition that can simply give up. TryLockTimeoutDemo.java builds the textbook deadlock on purpose – two threads, two locks, acquired in opposite order – and lets tryLock‘s timeout resolve it live:
$ java -cp target/classes com.ankurm.locks.TryLockTimeoutDemo

Thread-2: timed out waiting for second lock on attempt 1 - backing off and retrying.
Thread-1: timed out waiting for second lock on attempt 1 - backing off and retrying.
Thread-2: timed out waiting for second lock on attempt 2 - backing off and retrying.
Thread-1: timed out waiting for second lock on attempt 2 - backing off and retrying.
Thread-2: timed out waiting for second lock on attempt 3 - backing off and retrying.
Thread-1: acquired both locks on attempt 3.
Thread-2: acquired both locks on attempt 4.
Both threads finished in 808 ms - no deadlock.
Output: 03-trylock-timeout-deadlock-avoidance.txt. Both threads genuinely deadlock on their first several attempts – each holds the first lock the other needs – and each backs off, releases what it holds, and retries instead of waiting forever. Swap the timeout calls for plain lock() and this same program hangs permanently; that version isn’t in the repo because there would be nothing to capture.

Going deeper on this section

JEP 491: synchronized stopped pinning virtual threads in JDK 24

Before JDK 24, one of the standard reasons to avoid synchronized with virtual threads was pinning: a virtual thread that blocked while holding a monitor could not unmount from its carrier platform thread, so the carrier sat blocked too, defeating the entire point of using virtual threads for that code path. JEP 491, “Synchronize Virtual Threads without Pinning,” removed that restriction, with no flag required. The smallest possible demonstration: a virtual thread enters a synchronized block and then calls Thread.sleep(), run with -Djdk.tracePinnedThreads=full, which prints a trace whenever a virtual thread actually pins.
Before JDK 24 virtual thread (in sync block) carrier: also blocked pinned – carrier cannot serve anyone else JDK 24+ (JEP 491) virtual thread unmounts carrier freed for other virtual threads; remounts after
$ java -version
openjdk version "21.0.10" 2026-01-20
$ java -Djdk.tracePinnedThreads=full com.ankurm.locks.VirtualThreadPinningDemo

VirtualThread[#18]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
    java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
    java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
    java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
    java.base/java.lang.Thread.sleep(Thread.java:507)
    com.ankurm.locks.VirtualThreadPinningDemo.lambda$main$0(VirtualThreadPinningDemo.java:25) <== monitors:1
    java.base/java.lang.VirtualThread.run(VirtualThread.java:329)
Virtual thread finished. (No output above this line means it did not pin.)
Output: 01-pinning-jdk21-pre-jep491.txt – JDK 21, pre-JEP-491, pins every time, and the trace points straight at the synchronized block (<== monitors:1). The exact same class, recompiled for JDK 25 and run the same way:
$ java -version
openjdk version "25.0.4.1" 2026-08-18 LTS
$ java -Djdk.tracePinnedThreads=full com.ankurm.locks.VirtualThreadPinningDemo

Virtual thread finished. (No output above this line means it did not pin.)
Output: 02-pinning-jdk25-post-jep491.txt – no trace at all. Same code, same blocking call, same monitor; the only variable is the JDK. This is the practical reason “just use synchronized, don’t bother with ReentrantLock for virtual-thread code” stopped being controversial advice once JDK 24 shipped – the historical objection was pinning, and pinning specifically from monitors is gone.

Going deeper on this section

The decision table

SituationReach forWhy
Simple mutual exclusion, no special requirementssynchronizedLeast code, JIT-friendly, and JEP 491 removed its main virtual-thread objection.
Need tryLock, a timeout, or interruptible acquisitionReentrantLock (unfair)Only option here with a non-blocking or timed acquisition path at all.
Starvation is an observed, real problemReentrantLock(true)Fixes it – at a throughput cost this article’s benchmark shows is severe under contention, not cosmetic.
Reads vastly outnumber writesStampedLock (optimistic read)~20x combined throughput over the alternatives in this article’s 9:1 benchmark – the biggest gap measured anywhere in this article.
Need reentrancy inside the optimistic-read path, or Condition/interrupt supportReentrantLock or ReentrantReadWriteLockStampedLock is not reentrant and has no Condition support – a thread that already holds its write lock and calls writeLock() again deadlocks against itself.
Should you still reach for synchronized first? Yes, as the default. It’s the least code, the JIT has decades of optimization aimed at it specifically, and JEP 491 removed the one structural reason virtual-thread code needed to avoid it. Reach past it only when you need something it cannot do at all – a timeout, an interrupt, or (the biggest win this article measured) an optimistic read on a workload that is genuinely read-heavy. Don’t reach for the fair lock unless you’ve actually observed starvation; this article’s own numbers show what it costs when you don’t need it.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.