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 – along 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
- Companion repo: locks/src/main/java (every class named in this article)
- Official reference: StampedLock (Java SE 25 API)
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.
$ 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, plainsynchronizedcomes in noticeably behind unfairReentrantLockandStampedLock‘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 forsynchronized– distinct from theAbstractQueuedSynchronizerpark/unpark queue that bothReentrantLockandStampedLockare 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
- Companion repo: the full 1-64 thread sweep
- Related on this site: From Raw Threads to Virtual Threads: A Developer’s Guide to Java Concurrency
Where StampedLock actually wins: read-heavy workloads
The write-only sweep above is a fair fight in name only – nothing there givesStampedLock 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
- Companion repo: ReadHeavyBenchmark.java
- Official reference: StampedLock.tryOptimisticRead(), Java SE 25 API
tryLock with a timeout: escaping a deadlock instead of preventing one
Neithersynchronized 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
- Companion repo: TryLockTimeoutDemo.java
- Official reference: ReentrantLock.tryLock(long, TimeUnit), Java SE 25 API
JEP 491: synchronized stopped pinning virtual threads in JDK 24
Before JDK 24, one of the standard reasons to avoidsynchronized 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.
$ 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
- Companion repo: VirtualThreadPinningDemo.java
- Official reference: JEP 491: Synchronize Virtual Threads without Pinning
The decision table
| Situation | Reach for | Why |
|---|---|---|
| Simple mutual exclusion, no special requirements | synchronized | Least code, JIT-friendly, and JEP 491 removed its main virtual-thread objection. |
Need tryLock, a timeout, or interruptible acquisition | ReentrantLock (unfair) | Only option here with a non-blocking or timed acquisition path at all. |
| Starvation is an observed, real problem | ReentrantLock(true) | Fixes it – at a throughput cost this article’s benchmark shows is severe under contention, not cosmetic. |
| Reads vastly outnumber writes | StampedLock (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 support | ReentrantLock or ReentrantReadWriteLock | StampedLock 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
- Companion repository for this article: java-core-examples, locks module
- Official reference: JEP 491: Synchronize Virtual Threads without Pinning
- Official reference: StampedLock (Java SE 25 API)
- Related on this site: Virtual Threads vs Platform Threads in Java: Real Benchmarks
- Related on this site: The Java Memory Model Explained – the happens-before rules every lock in this article ultimately relies on
No Comments yet!