locks: synchronized vs ReentrantLock vs StampedLock companion code
JMH throughput sweep (1-64 threads), 9:1 read-heavy @Group benchmark, tryLock(timeout) deadlock-avoidance demo, and a JDK21-vs-JDK25 JEP 491 virtual-thread-pinning comparison. Also promotes LICENSE to repo root now that a second module exists.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# locks
|
||||
|
||||
Companion code for the ankurm.com post *"synchronized vs ReentrantLock vs StampedLock:
|
||||
Benchmarks and a Decision Table."* Second module in `java-core-examples`, the Java-core /
|
||||
concurrency series.
|
||||
|
||||
## Versions this was built and tested against
|
||||
|
||||
| Component | Version | Notes |
|
||||
|---|---|---|
|
||||
| JDK | 25.0.4.1+1 (Temurin, LTS) | Main build and benchmarks. |
|
||||
| JDK | 21.0.10 (Ubuntu) | Used only for `output/01`, the pre-JEP-491 pinning comparison. |
|
||||
| JMH | 1.37 | Latest on Maven Central at the time of writing. |
|
||||
| JUnit Jupiter | 5.11.0 | Correctness tests only - see the warning in each test's Javadoc. |
|
||||
| Maven | 3.9.11 | |
|
||||
| Hardware | 2 vCPU x86-64 VM | Same sandbox as the `jmm` module - matters a lot for the fair-lock numbers, see below. |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn package
|
||||
java -jar target/benchmarks.jar IncrementBenchmark -t 8
|
||||
```
|
||||
|
||||
`scripts/run-all.sh` regenerates every file in `output/`. `scripts/run.sh <BenchmarkClass> [args]`
|
||||
runs one benchmark ad hoc.
|
||||
|
||||
## What's in here
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| `src/main/java/.../Counter.java` | The interface all four locking strategies implement. |
|
||||
| `src/main/java/.../SynchronizedCounter.java` | Baseline: a plain `synchronized` method. |
|
||||
| `src/main/java/.../ReentrantLockCounter.java` | `ReentrantLock`, default (unfair) mode. |
|
||||
| `src/main/java/.../FairReentrantLockCounter.java` | The same lock, `fair = true`. |
|
||||
| `src/main/java/.../StampedLockCounter.java` | `StampedLock` with a real optimistic-read protocol (`tryOptimisticRead` + `validate`, falling back to `readLock`). |
|
||||
| `src/main/java/.../IncrementBenchmark.java` | JMH: write-only workload, run at a fixed thread count per JVM invocation via `-t N`. |
|
||||
| `src/main/java/.../ReadHeavyBenchmark.java` | JMH `@Group`/`@GroupThreads`: 9 reader threads : 1 writer thread, per lock type. |
|
||||
| `src/main/java/.../TryLockTimeoutDemo.java` | A real two-lock deadlock, avoided live by `tryLock(timeout)`. |
|
||||
| `src/main/java/.../VirtualThreadPinningDemo.java` | JEP 491 (JDK 24+): a virtual thread inside `synchronized` no longer pins its carrier. |
|
||||
| `src/test/java/.../CounterCorrectnessTest.java` | Lost-update sanity checks - does NOT prove throughput or fairness claims, see its Javadoc. |
|
||||
| `output/01` | `VirtualThreadPinningDemo` on JDK 21 - pins (`reason:MONITOR`). |
|
||||
| `output/02` | Same demo on JDK 25 - does not pin. |
|
||||
| `output/03` | `TryLockTimeoutDemo` run: real deadlock, resolved via timeout retries. |
|
||||
| `output/04` | `IncrementBenchmark` swept across 1, 2, 4, 8, 16, 32, 64 threads. |
|
||||
| `output/05` | `ReadHeavyBenchmark`, 9:1 read:write, all three lock types. |
|
||||
| `output/06` | JUnit correctness run. |
|
||||
|
||||
## Reading the numbers honestly (2-vCPU sandbox)
|
||||
|
||||
Two results below are exactly what the literature predicts. One is a real, reproducible surprise
|
||||
on this specific hardware, reported as measured rather than smoothed over.
|
||||
|
||||
**Fair locks pay a severe throughput tax under contention on this box** (`output/04`): the fair
|
||||
`ReentrantLock` drops from ~52,000 ops/ms at 1 thread to **under 1,000** ops/ms the moment a
|
||||
second thread shows up, and stays there through 64 threads. Fair mode forces strict FIFO handoff -
|
||||
every acquisition that isn't uncontended means parking the current thread and waking the *next*
|
||||
one specifically, which is a full context switch on every single lock/unlock pair. On a 2-vCPU box
|
||||
that handoff cost dominates completely. This is the expected direction of the fairness cost; the
|
||||
sandbox just makes it dramatic instead of moderate.
|
||||
|
||||
**`StampedLock`'s optimistic read genuinely wins big under a read-heavy load** (`output/05`):
|
||||
~915,000 ops/ms combined throughput against `ReentrantLock`'s ~45,000 and `synchronized`'s
|
||||
~16,000 - over 20x - because 9 of the 10 threads never take a lock at all; they just read a
|
||||
volatile-like stamp and validate it. This is the one number in this article that matters most
|
||||
for the decision table: it is *why* `StampedLock` exists.
|
||||
|
||||
**The one that needs a caveat**: in the write-only sweep (`output/04`), plain `synchronized`
|
||||
comes in noticeably below unfair `ReentrantLock` and `StampedLock`'s write lock at every
|
||||
contended thread count (roughly 7,000-12,000 ops/ms vs. 44,000-54,000) - a much bigger gap than
|
||||
most published JMH comparisons show on multi-core hardware. This repository does not have access
|
||||
to a machine with more than 2 vCPUs to check whether that gap narrows there. The likely cause is
|
||||
that this sandbox's 2 cores turn every contended benchmark into constant OS-level thread
|
||||
scheduling regardless of which primitive is used, and `synchronized`'s monitor-inflation path
|
||||
(unlike `AbstractQueuedSynchronizer`'s park/unpark queueing, which both `ReentrantLock` and
|
||||
`StampedLock` share) responds to that differently. Take the *direction* of this result seriously
|
||||
(unfair `ReentrantLock` and `StampedLock` writes both beat `synchronized` here) and the exact
|
||||
multiplier with real caution outside a 2-vCPU VM.
|
||||
|
||||
## License
|
||||
|
||||
MIT - see the [repo-wide LICENSE](../LICENSE).
|
||||
Reference in New Issue
Block a user