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:
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
target/
|
target/
|
||||||
*.class
|
*.class
|
||||||
jmm/jcstress-results-*.bin.gz
|
*/jcstress-results-*.bin.gz
|
||||||
jmm/dependency-reduced-pom.xml
|
*/dependency-reduced-pom.xml
|
||||||
jmm/post.html
|
*/post.html
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ article; each module's own README has that article's version table, quickstart,
|
|||||||
| Module | Post |
|
| Module | Post |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`jmm`](jmm/) | The Java Memory Model Explained: volatile, happens-before, and Why Your Double-Checked Lock Failed |
|
| [`jmm`](jmm/) | The Java Memory Model Explained: volatile, happens-before, and Why Your Double-Checked Lock Failed |
|
||||||
|
| [`locks`](locks/) | synchronized vs ReentrantLock vs StampedLock: Benchmarks and a Decision Table |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT - see [LICENSE](jmm/LICENSE) (per-module; a repo-wide LICENSE will move here once a second
|
MIT - see [LICENSE](LICENSE).
|
||||||
module needs it).
|
|
||||||
|
|||||||
+1
-1
@@ -90,4 +90,4 @@ of those two, which is the actual evidence that both fixes work.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT.
|
MIT - see the [repo-wide LICENSE](../LICENSE).
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
$ java -version
|
||||||
|
openjdk version "21.0.10" 2026-01-20
|
||||||
|
OpenJDK Runtime Environment (build 21.0.10+7-Ubuntu-124.04)
|
||||||
|
OpenJDK 64-Bit Server VM (build 21.0.10+7-Ubuntu-124.04, mixed mode, sharing)
|
||||||
|
|
||||||
|
$ 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.)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
$ java -version
|
||||||
|
openjdk version "25.0.4.1" 2026-08-18 LTS
|
||||||
|
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
|
||||||
|
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
|
||||||
|
|
||||||
|
$ java -Djdk.tracePinnedThreads=full com.ankurm.locks.VirtualThreadPinningDemo
|
||||||
|
Virtual thread finished. (No output above this line means it did not pin.)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$ 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.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
$ java -jar target/benchmarks.jar IncrementBenchmark -t <N> -rf text
|
||||||
|
(one JVM invocation per thread count; -t sets the JMH thread count for that run)
|
||||||
|
|
||||||
|
=== threads=1 ===
|
||||||
|
Benchmark Mode Cnt Score Error Units
|
||||||
|
IncrementBenchmark.reentrantLockFair thrpt 5 52315.361 ± 7000.813 ops/ms
|
||||||
|
IncrementBenchmark.reentrantLockUnfair thrpt 5 52462.260 ± 3295.127 ops/ms
|
||||||
|
IncrementBenchmark.stampedLockWrite thrpt 5 51466.587 ± 12682.048 ops/ms
|
||||||
|
IncrementBenchmark.synchronized_ thrpt 5 43053.756 ± 4276.407 ops/ms
|
||||||
|
|
||||||
|
=== threads=2 ===
|
||||||
|
Benchmark Mode Cnt Score Error Units
|
||||||
|
IncrementBenchmark.reentrantLockFair thrpt 5 984.984 ± 1624.886 ops/ms
|
||||||
|
IncrementBenchmark.reentrantLockUnfair thrpt 5 13316.608 ± 4428.282 ops/ms
|
||||||
|
IncrementBenchmark.stampedLockWrite thrpt 5 15243.885 ± 3805.083 ops/ms
|
||||||
|
IncrementBenchmark.synchronized_ thrpt 5 10285.533 ± 6953.617 ops/ms
|
||||||
|
|
||||||
|
=== threads=4 ===
|
||||||
|
Benchmark Mode Cnt Score Error Units
|
||||||
|
IncrementBenchmark.reentrantLockFair thrpt 5 149.465 ± 598.480 ops/ms
|
||||||
|
IncrementBenchmark.reentrantLockUnfair thrpt 5 43978.589 ± 8289.565 ops/ms
|
||||||
|
IncrementBenchmark.stampedLockWrite thrpt 5 50067.586 ± 4023.378 ops/ms
|
||||||
|
IncrementBenchmark.synchronized_ thrpt 5 7373.456 ± 5254.126 ops/ms
|
||||||
|
|
||||||
|
=== threads=8 ===
|
||||||
|
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
|
||||||
|
|
||||||
|
=== threads=16 ===
|
||||||
|
Benchmark Mode Cnt Score Error Units
|
||||||
|
IncrementBenchmark.reentrantLockFair thrpt 5 81.785 ± 23.399 ops/ms
|
||||||
|
IncrementBenchmark.reentrantLockUnfair thrpt 5 45460.231 ± 5190.390 ops/ms
|
||||||
|
IncrementBenchmark.stampedLockWrite thrpt 5 46998.078 ± 3362.893 ops/ms
|
||||||
|
IncrementBenchmark.synchronized_ thrpt 5 8185.253 ± 3341.605 ops/ms
|
||||||
|
|
||||||
|
=== threads=32 ===
|
||||||
|
Benchmark Mode Cnt Score Error Units
|
||||||
|
IncrementBenchmark.reentrantLockFair thrpt 5 71.593 ± 24.857 ops/ms
|
||||||
|
IncrementBenchmark.reentrantLockUnfair thrpt 5 45325.559 ± 1160.775 ops/ms
|
||||||
|
IncrementBenchmark.stampedLockWrite thrpt 5 46630.727 ± 4303.326 ops/ms
|
||||||
|
IncrementBenchmark.synchronized_ thrpt 5 9378.353 ± 7605.473 ops/ms
|
||||||
|
|
||||||
|
=== threads=64 ===
|
||||||
|
Benchmark Mode Cnt Score Error Units
|
||||||
|
IncrementBenchmark.reentrantLockFair thrpt 5 69.470 ± 5.015 ops/ms
|
||||||
|
IncrementBenchmark.reentrantLockUnfair thrpt 5 44724.521 ± 5166.580 ops/ms
|
||||||
|
IncrementBenchmark.stampedLockWrite thrpt 5 46973.404 ± 3639.073 ops/ms
|
||||||
|
IncrementBenchmark.synchronized_ thrpt 5 12510.129 ± 3496.906 ops/ms
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
$ java -jar target/benchmarks.jar ReadHeavyBenchmark -rf text
|
||||||
|
(9 reader threads : 1 writer thread per lock type, via JMH @Group/@GroupThreads)
|
||||||
|
|
||||||
|
Benchmark Mode Cnt Score Error Units
|
||||||
|
ReadHeavyBenchmark.reentrantLock thrpt 5 45225.344 ± 3890.955 ops/ms
|
||||||
|
ReadHeavyBenchmark.reentrantLock:reentrantLockRead thrpt 5 39936.906 ± 3728.160 ops/ms
|
||||||
|
ReadHeavyBenchmark.reentrantLock:reentrantLockWrite thrpt 5 5288.438 ± 1952.067 ops/ms
|
||||||
|
ReadHeavyBenchmark.stampedLock thrpt 5 915584.241 ± 68486.471 ops/ms
|
||||||
|
ReadHeavyBenchmark.stampedLock:stampedLockRead thrpt 5 914889.465 ± 69210.992 ops/ms
|
||||||
|
ReadHeavyBenchmark.stampedLock:stampedLockWrite thrpt 5 694.777 ± 1802.294 ops/ms
|
||||||
|
ReadHeavyBenchmark.synchronizedCounter thrpt 5 16320.540 ± 4834.389 ops/ms
|
||||||
|
ReadHeavyBenchmark.synchronizedCounter:synchronizedRead thrpt 5 15319.823 ± 5526.566 ops/ms
|
||||||
|
ReadHeavyBenchmark.synchronizedCounter:synchronizedWrite thrpt 5 1000.717 ± 860.855 ops/ms
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.locks.CounterCorrectnessTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.218 s -- in com.ankurm.locks.CounterCorrectnessTest
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>java-core-examples</artifactId>
|
||||||
|
<version>1.0</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>locks</artifactId>
|
||||||
|
<name>locks</name>
|
||||||
|
<description>synchronized vs ReentrantLock vs StampedLock: JMH throughput benchmarks, fairness, tryLock timeouts, optimistic reads, and the JEP 491 virtual-thread pinning change.</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<jmh.version>1.37</jmh.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjdk.jmh</groupId>
|
||||||
|
<artifactId>jmh-core</artifactId>
|
||||||
|
<version>${jmh.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjdk.jmh</groupId>
|
||||||
|
<artifactId>jmh-generator-annprocess</artifactId>
|
||||||
|
<version>${jmh.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.11.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>benchmarks</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
<configuration>
|
||||||
|
<release>25</release>
|
||||||
|
<!--
|
||||||
|
Same JDK-25-stops-discovering-annotation-processors-implicitly trap documented in
|
||||||
|
the jmm module: JMH's @Benchmark-method-to-*_jmh.java generator needs to be declared
|
||||||
|
explicitly here or the build silently produces a jar with nothing runnable in it.
|
||||||
|
-->
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.openjdk.jmh</groupId>
|
||||||
|
<artifactId>jmh-generator-annprocess</artifactId>
|
||||||
|
<version>${jmh.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.1</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals><goal>shade</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<mainClass>org.openjdk.jmh.Main</mainClass>
|
||||||
|
</transformer>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file in output/ using the same commands used to produce the ones
|
||||||
|
# committed here. Requires JDK 25 on PATH/JAVA_HOME for the main build, plus a JDK 21
|
||||||
|
# install for output/01 (see the comment above that command below).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
mvn -q package
|
||||||
|
|
||||||
|
echo "--- 02: pinning demo (JDK 25, post-JEP-491) ---"
|
||||||
|
java -cp target/classes -Djdk.tracePinnedThreads=full com.ankurm.locks.VirtualThreadPinningDemo \
|
||||||
|
> output/02-pinning-jdk25-post-jep491.txt 2>&1 || true
|
||||||
|
|
||||||
|
echo "--- 03: tryLock timeout deadlock avoidance ---"
|
||||||
|
java -cp target/classes com.ankurm.locks.TryLockTimeoutDemo \
|
||||||
|
> output/03-trylock-timeout-deadlock-avoidance.txt 2>&1
|
||||||
|
|
||||||
|
echo "--- 04: IncrementBenchmark thread-count sweep (takes a few minutes) ---"
|
||||||
|
{
|
||||||
|
echo '$ java -jar target/benchmarks.jar IncrementBenchmark -t <N> -rf text'
|
||||||
|
for t in 1 2 4 8 16 32 64; do
|
||||||
|
echo "=== threads=$t ==="
|
||||||
|
java -jar target/benchmarks.jar "IncrementBenchmark" -t "$t" -rf text -rff /tmp/jmh-increment-$t.txt
|
||||||
|
cat "/tmp/jmh-increment-$t.txt"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
} > output/04-increment-benchmark-sweep.txt
|
||||||
|
|
||||||
|
echo "--- 05: ReadHeavyBenchmark (9:1 read:write via @Group) ---"
|
||||||
|
java -jar target/benchmarks.jar "ReadHeavyBenchmark" -rf text -rff output/05-read-heavy-benchmark.txt
|
||||||
|
|
||||||
|
echo "--- 06: correctness tests ---"
|
||||||
|
mvn -q test
|
||||||
|
cp target/surefire-reports/com.ankurm.locks.CounterCorrectnessTest.txt output/06-counter-correctness.txt
|
||||||
|
|
||||||
|
echo "--- 01: pinning demo on JDK 21 (pre-JEP-491) - needs a separate JDK 21 install ---"
|
||||||
|
echo " javac --release 21 -d /tmp/pinning-jdk21-classes src/main/java/com/ankurm/locks/VirtualThreadPinningDemo.java"
|
||||||
|
echo " java -cp /tmp/pinning-jdk21-classes -Djdk.tracePinnedThreads=full com.ankurm.locks.VirtualThreadPinningDemo"
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Quick ad hoc JMH run against one benchmark class, printed to the terminal. Example:
|
||||||
|
# ./scripts/run.sh IncrementBenchmark -t 8
|
||||||
|
# ./scripts/run.sh ReadHeavyBenchmark
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
BENCH="${1:?Usage: run.sh <BenchmarkClassName> [extra jmh args]}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
JAR=target/benchmarks.jar
|
||||||
|
[ -f "$JAR" ] || { echo "Run 'mvn package' first."; exit 1; }
|
||||||
|
|
||||||
|
java -jar "$JAR" "$BENCH" "$@"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shared mutable counter, protected some way against concurrent increment().
|
||||||
|
* Every implementation in this module implements exactly this interface so the
|
||||||
|
* JMH benchmarks can swap the locking strategy without changing anything else.
|
||||||
|
*/
|
||||||
|
public interface Counter {
|
||||||
|
void increment();
|
||||||
|
long get();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same {@link ReentrantLock}, constructed with {@code fair = true}. Fair
|
||||||
|
* mode grants the lock to the longest-waiting thread, which bounds
|
||||||
|
* starvation but costs throughput - this class exists so the benchmark can
|
||||||
|
* put a number on that cost instead of just asserting it.
|
||||||
|
*/
|
||||||
|
public final class FairReentrantLockCounter implements Counter {
|
||||||
|
private final ReentrantLock lock = new ReentrantLock(true);
|
||||||
|
private long count;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void increment() {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
count++;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long get() {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
return count;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
import org.openjdk.jmh.annotations.*;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write-only workload: every thread just calls {@code increment()} as fast
|
||||||
|
* as it can. This is the benchmark that isolates pure lock-acquisition
|
||||||
|
* overhead - there is no read path here to give {@link StampedLockCounter}
|
||||||
|
* its usual advantage, so the honest expectation is that all four come out
|
||||||
|
* close, with {@code synchronized} and unfair {@link java.util.concurrent.locks.ReentrantLock}
|
||||||
|
* at the front and the fair lock and the write-locked {@link java.util.concurrent.locks.StampedLock}
|
||||||
|
* paying a small, measurable tax. Run at a fixed thread count per JVM
|
||||||
|
* invocation via {@code -t N}; {@code scripts/run-all.sh} sweeps 1, 2, 4, 8,
|
||||||
|
* 16, 32 and 64 and captures each into {@code output/}.
|
||||||
|
*/
|
||||||
|
@BenchmarkMode(Mode.Throughput)
|
||||||
|
@OutputTimeUnit(TimeUnit.MILLISECONDS)
|
||||||
|
@State(Scope.Benchmark)
|
||||||
|
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
|
||||||
|
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
|
||||||
|
@Fork(1)
|
||||||
|
public class IncrementBenchmark {
|
||||||
|
|
||||||
|
private final SynchronizedCounter synchronizedCounter = new SynchronizedCounter();
|
||||||
|
private final ReentrantLockCounter reentrantLockCounter = new ReentrantLockCounter();
|
||||||
|
private final FairReentrantLockCounter fairReentrantLockCounter = new FairReentrantLockCounter();
|
||||||
|
private final StampedLockCounter stampedLockCounter = new StampedLockCounter();
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public void synchronized_() {
|
||||||
|
synchronizedCounter.increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public void reentrantLockUnfair() {
|
||||||
|
reentrantLockCounter.increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public void reentrantLockFair() {
|
||||||
|
fairReentrantLockCounter.increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public void stampedLockWrite() {
|
||||||
|
stampedLockCounter.increment();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
import org.openjdk.jmh.annotations.*;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 9:1 read:write workload using JMH's {@code @Group} feature, which runs
|
||||||
|
* two benchmark methods concurrently at a fixed thread ratio and reports
|
||||||
|
* each side's own throughput. This is the workload {@link StampedLockCounter}
|
||||||
|
* is actually for: nine threads spin on {@code get()} while one thread
|
||||||
|
* spins on {@code increment()}. Fixed at 10 total threads per lock type
|
||||||
|
* (this sandbox has 2 vCPUs, so this is already a 5x-oversubscribed,
|
||||||
|
* contention-heavy point, not a scalability sweep - see {@link IncrementBenchmark}
|
||||||
|
* for the thread-count sweep on the write-only path).
|
||||||
|
*/
|
||||||
|
@BenchmarkMode(Mode.Throughput)
|
||||||
|
@OutputTimeUnit(TimeUnit.MILLISECONDS)
|
||||||
|
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
|
||||||
|
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
|
||||||
|
@Fork(1)
|
||||||
|
public class ReadHeavyBenchmark {
|
||||||
|
|
||||||
|
@State(Scope.Group)
|
||||||
|
public static class SynchronizedState {
|
||||||
|
final SynchronizedCounter counter = new SynchronizedCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@State(Scope.Group)
|
||||||
|
public static class ReentrantLockState {
|
||||||
|
final ReentrantLockCounter counter = new ReentrantLockCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@State(Scope.Group)
|
||||||
|
public static class StampedLockState {
|
||||||
|
final StampedLockCounter counter = new StampedLockCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark @Group("synchronizedCounter") @GroupThreads(9)
|
||||||
|
public long synchronizedRead(SynchronizedState s) {
|
||||||
|
return s.counter.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark @Group("synchronizedCounter") @GroupThreads(1)
|
||||||
|
public void synchronizedWrite(SynchronizedState s) {
|
||||||
|
s.counter.increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark @Group("reentrantLock") @GroupThreads(9)
|
||||||
|
public long reentrantLockRead(ReentrantLockState s) {
|
||||||
|
return s.counter.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark @Group("reentrantLock") @GroupThreads(1)
|
||||||
|
public void reentrantLockWrite(ReentrantLockState s) {
|
||||||
|
s.counter.increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark @Group("stampedLock") @GroupThreads(9)
|
||||||
|
public long stampedLockRead(StampedLockState s) {
|
||||||
|
return s.counter.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark @Group("stampedLock") @GroupThreads(1)
|
||||||
|
public void stampedLockWrite(StampedLockState s) {
|
||||||
|
s.counter.increment();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ReentrantLock} in its default, unfair mode. Unfair means a thread
|
||||||
|
* that is already running can barge in front of threads that have been
|
||||||
|
* parked waiting longer - which is exactly why it usually out-throughputs
|
||||||
|
* the fair variant: no bookkeeping to enforce arrival order, no forced
|
||||||
|
* context switch to wake the "correct" next thread.
|
||||||
|
*/
|
||||||
|
public final class ReentrantLockCounter implements Counter {
|
||||||
|
private final ReentrantLock lock = new ReentrantLock();
|
||||||
|
private long count;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void increment() {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
count++;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long get() {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
return count;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
import java.util.concurrent.locks.StampedLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link StampedLock} used the way it is meant to be used: writers take the
|
||||||
|
* exclusive write lock, but readers first try an <em>optimistic</em> read -
|
||||||
|
* no lock is acquired at all, the read just checks afterwards whether a
|
||||||
|
* writer slipped in while it was running, via {@link StampedLock#validate}.
|
||||||
|
* If a writer did, the reader falls back to a real (blocking) read lock.
|
||||||
|
* {@code get()} here is that full three-step optimistic-read protocol, not
|
||||||
|
* a simplified version of it.
|
||||||
|
*/
|
||||||
|
public final class StampedLockCounter implements Counter {
|
||||||
|
private final StampedLock lock = new StampedLock();
|
||||||
|
private long count;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void increment() {
|
||||||
|
long stamp = lock.writeLock();
|
||||||
|
try {
|
||||||
|
count++;
|
||||||
|
} finally {
|
||||||
|
lock.unlockWrite(stamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long get() {
|
||||||
|
long stamp = lock.tryOptimisticRead();
|
||||||
|
long value = count;
|
||||||
|
if (!lock.validate(stamp)) {
|
||||||
|
// A writer ran between the read above and the validate() call.
|
||||||
|
// Fall back to a real, blocking read lock and read again.
|
||||||
|
stamp = lock.readLock();
|
||||||
|
try {
|
||||||
|
value = count;
|
||||||
|
} finally {
|
||||||
|
lock.unlockRead(stamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The baseline: a plain {@code synchronized} method. One monitor, mutual
|
||||||
|
* exclusion for both the read and the write, no fairness knob, no timeout.
|
||||||
|
*/
|
||||||
|
public final class SynchronizedCounter implements Counter {
|
||||||
|
private long count;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void increment() {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized long get() {
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real deadlock, avoided in real time by {@link ReentrantLock#tryLock(long, TimeUnit)}.
|
||||||
|
* Two threads acquire two locks in opposite order - the textbook deadlock
|
||||||
|
* setup. {@code lock()} would hang both threads forever. {@code tryLock}
|
||||||
|
* with a timeout gives each thread a way out: back off, release what you
|
||||||
|
* hold, and retry. Run this and it always finishes; comment out the
|
||||||
|
* timeout path and call {@code lock()} instead, and it never does.
|
||||||
|
*/
|
||||||
|
public final class TryLockTimeoutDemo {
|
||||||
|
|
||||||
|
private static final ReentrantLock LOCK_A = new ReentrantLock();
|
||||||
|
private static final ReentrantLock LOCK_B = new ReentrantLock();
|
||||||
|
|
||||||
|
public static void main(String[] args) throws InterruptedException {
|
||||||
|
Thread t1 = new Thread(() -> worker("Thread-1", LOCK_A, LOCK_B), "Thread-1");
|
||||||
|
Thread t2 = new Thread(() -> worker("Thread-2", LOCK_B, LOCK_A), "Thread-2");
|
||||||
|
long start = System.nanoTime();
|
||||||
|
t1.start();
|
||||||
|
t2.start();
|
||||||
|
t1.join();
|
||||||
|
t2.join();
|
||||||
|
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||||
|
System.out.println("Both threads finished in " + elapsedMs + " ms - no deadlock.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void worker(String name, ReentrantLock first, ReentrantLock second) {
|
||||||
|
int attempts = 0;
|
||||||
|
while (true) {
|
||||||
|
attempts++;
|
||||||
|
try {
|
||||||
|
if (first.tryLock(200, TimeUnit.MILLISECONDS)) {
|
||||||
|
try {
|
||||||
|
// Force the interleaving that would deadlock under plain lock():
|
||||||
|
// give the other thread time to grab its own first lock before
|
||||||
|
// this thread tries for the second one.
|
||||||
|
Thread.sleep(50);
|
||||||
|
if (second.tryLock(200, TimeUnit.MILLISECONDS)) {
|
||||||
|
try {
|
||||||
|
System.out.println(name + ": acquired both locks on attempt " + attempts + ".");
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
second.unlock();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
System.out.println(name + ": timed out waiting for second lock on attempt "
|
||||||
|
+ attempts + " - backing off and retrying.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
first.unlock();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
System.out.println(name + ": timed out waiting for first lock on attempt " + attempts + ".");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest program that shows JEP 491 (Synchronize Virtual Threads
|
||||||
|
* without Pinning, delivered JDK 24) doing its job. A virtual thread enters
|
||||||
|
* a {@code synchronized} block and then blocks (a plain {@code Thread.sleep}).
|
||||||
|
* Run with {@code -Djdk.tracePinnedThreads=full}:
|
||||||
|
* <ul>
|
||||||
|
* <li>On JDK 21 (pre-JEP-491) this prints a pinned-thread trace pointing
|
||||||
|
* straight at the {@code synchronized} block below - the virtual
|
||||||
|
* thread cannot unmount because it is holding a monitor.</li>
|
||||||
|
* <li>On JDK 25 (post-JEP-491) it prints nothing: the virtual thread
|
||||||
|
* unmounts from its carrier for the sleep and remounts afterwards,
|
||||||
|
* monitor and all.</li>
|
||||||
|
* </ul>
|
||||||
|
* Both runs are captured verbatim in {@code output/}; nothing here is
|
||||||
|
* asserted, only observed.
|
||||||
|
*/
|
||||||
|
public final class VirtualThreadPinningDemo {
|
||||||
|
|
||||||
|
public static void main(String[] args) throws InterruptedException {
|
||||||
|
Thread vt = Thread.ofVirtual().start(() -> {
|
||||||
|
synchronized (VirtualThreadPinningDemo.class) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(200);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vt.join();
|
||||||
|
System.out.println("Virtual thread finished. (No output above this line means it did not pin.)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ankurm.locks;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.Timeout;
|
||||||
|
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordinary correctness checks: N threads each increment M times, the final
|
||||||
|
* count must be exactly N*M. This does NOT test throughput, fairness, or
|
||||||
|
* memory-visibility ordering - it only proves each locking strategy
|
||||||
|
* actually serializes increments (no lost updates). The benchmarks in
|
||||||
|
* this module measure the performance claims; this test just guards
|
||||||
|
* against a broken implementation slipping in.
|
||||||
|
*/
|
||||||
|
class CounterCorrectnessTest {
|
||||||
|
|
||||||
|
private static final int THREADS = 8;
|
||||||
|
private static final int INCREMENTS_PER_THREAD = 50_000;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Timeout(30)
|
||||||
|
void synchronizedCounterHasNoLostUpdates() throws InterruptedException {
|
||||||
|
assertNoLostUpdates(new SynchronizedCounter());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Timeout(30)
|
||||||
|
void reentrantLockCounterHasNoLostUpdates() throws InterruptedException {
|
||||||
|
assertNoLostUpdates(new ReentrantLockCounter());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Timeout(30)
|
||||||
|
void fairReentrantLockCounterHasNoLostUpdates() throws InterruptedException {
|
||||||
|
assertNoLostUpdates(new FairReentrantLockCounter());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Timeout(30)
|
||||||
|
void stampedLockCounterHasNoLostUpdates() throws InterruptedException {
|
||||||
|
assertNoLostUpdates(new StampedLockCounter());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertNoLostUpdates(Counter counter) throws InterruptedException {
|
||||||
|
CountDownLatch ready = new CountDownLatch(THREADS);
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
CountDownLatch done = new CountDownLatch(THREADS);
|
||||||
|
|
||||||
|
IntStream.range(0, THREADS).forEach(i -> new Thread(() -> {
|
||||||
|
ready.countDown();
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
|
||||||
|
counter.increment();
|
||||||
|
}
|
||||||
|
done.countDown();
|
||||||
|
}).start());
|
||||||
|
|
||||||
|
ready.await();
|
||||||
|
start.countDown();
|
||||||
|
done.await();
|
||||||
|
|
||||||
|
assertEquals((long) THREADS * INCREMENTS_PER_THREAD, counter.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user