diff --git a/.gitignore b/.gitignore index f2f577b..68f1383 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ target/ *.class -jmm/jcstress-results-*.bin.gz -jmm/dependency-reduced-pom.xml -jmm/post.html +*/jcstress-results-*.bin.gz +*/dependency-reduced-pom.xml +*/post.html .DS_Store diff --git a/jmm/LICENSE b/LICENSE similarity index 100% rename from jmm/LICENSE rename to LICENSE diff --git a/README.md b/README.md index 75ebe41..331f377 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ article; each module's own README has that article's version table, quickstart, | Module | Post | |---|---| | [`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 -MIT - see [LICENSE](jmm/LICENSE) (per-module; a repo-wide LICENSE will move here once a second -module needs it). +MIT - see [LICENSE](LICENSE). diff --git a/jmm/README.md b/jmm/README.md index f3ccd59..a845ab1 100644 --- a/jmm/README.md +++ b/jmm/README.md @@ -90,4 +90,4 @@ of those two, which is the actual evidence that both fixes work. ## License -MIT. +MIT - see the [repo-wide LICENSE](../LICENSE). diff --git a/locks/README.md b/locks/README.md new file mode 100644 index 0000000..9551e93 --- /dev/null +++ b/locks/README.md @@ -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 [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). diff --git a/locks/output/01-pinning-jdk21-pre-jep491.txt b/locks/output/01-pinning-jdk21-pre-jep491.txt new file mode 100644 index 0000000..c90e53a --- /dev/null +++ b/locks/output/01-pinning-jdk21-pre-jep491.txt @@ -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.) diff --git a/locks/output/02-pinning-jdk25-post-jep491.txt b/locks/output/02-pinning-jdk25-post-jep491.txt new file mode 100644 index 0000000..c3355b7 --- /dev/null +++ b/locks/output/02-pinning-jdk25-post-jep491.txt @@ -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.) diff --git a/locks/output/03-trylock-timeout-deadlock-avoidance.txt b/locks/output/03-trylock-timeout-deadlock-avoidance.txt new file mode 100644 index 0000000..7eecd07 --- /dev/null +++ b/locks/output/03-trylock-timeout-deadlock-avoidance.txt @@ -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. diff --git a/locks/output/04-increment-benchmark-sweep.txt b/locks/output/04-increment-benchmark-sweep.txt new file mode 100644 index 0000000..4f6883e --- /dev/null +++ b/locks/output/04-increment-benchmark-sweep.txt @@ -0,0 +1,52 @@ +$ java -jar target/benchmarks.jar IncrementBenchmark -t -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 + diff --git a/locks/output/05-read-heavy-benchmark.txt b/locks/output/05-read-heavy-benchmark.txt new file mode 100644 index 0000000..c2380da --- /dev/null +++ b/locks/output/05-read-heavy-benchmark.txt @@ -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 diff --git a/locks/output/06-counter-correctness.txt b/locks/output/06-counter-correctness.txt new file mode 100644 index 0000000..00b174a --- /dev/null +++ b/locks/output/06-counter-correctness.txt @@ -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 diff --git a/locks/pom.xml b/locks/pom.xml new file mode 100644 index 0000000..8f56d3b --- /dev/null +++ b/locks/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + locks + locks + synchronized vs ReentrantLock vs StampedLock: JMH throughput benchmarks, fairness, tryLock timeouts, optimistic reads, and the JEP 491 virtual-thread pinning change. + + + 1.37 + + + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + + + benchmarks + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + + + org.openjdk.jmh.Main + + + + + + + + + diff --git a/locks/scripts/run-all.sh b/locks/scripts/run-all.sh new file mode 100755 index 0000000..5760737 --- /dev/null +++ b/locks/scripts/run-all.sh @@ -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 -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" diff --git a/locks/scripts/run.sh b/locks/scripts/run.sh new file mode 100755 index 0000000..0b98272 --- /dev/null +++ b/locks/scripts/run.sh @@ -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 [extra jmh args]}" +shift || true + +JAR=target/benchmarks.jar +[ -f "$JAR" ] || { echo "Run 'mvn package' first."; exit 1; } + +java -jar "$JAR" "$BENCH" "$@" diff --git a/locks/src/main/java/com/ankurm/locks/Counter.java b/locks/src/main/java/com/ankurm/locks/Counter.java new file mode 100644 index 0000000..74d1792 --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/Counter.java @@ -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(); +} diff --git a/locks/src/main/java/com/ankurm/locks/FairReentrantLockCounter.java b/locks/src/main/java/com/ankurm/locks/FairReentrantLockCounter.java new file mode 100644 index 0000000..559ec42 --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/FairReentrantLockCounter.java @@ -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(); + } + } +} diff --git a/locks/src/main/java/com/ankurm/locks/IncrementBenchmark.java b/locks/src/main/java/com/ankurm/locks/IncrementBenchmark.java new file mode 100644 index 0000000..7387819 --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/IncrementBenchmark.java @@ -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(); + } +} diff --git a/locks/src/main/java/com/ankurm/locks/ReadHeavyBenchmark.java b/locks/src/main/java/com/ankurm/locks/ReadHeavyBenchmark.java new file mode 100644 index 0000000..b783d47 --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/ReadHeavyBenchmark.java @@ -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(); + } +} diff --git a/locks/src/main/java/com/ankurm/locks/ReentrantLockCounter.java b/locks/src/main/java/com/ankurm/locks/ReentrantLockCounter.java new file mode 100644 index 0000000..7472e47 --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/ReentrantLockCounter.java @@ -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(); + } + } +} diff --git a/locks/src/main/java/com/ankurm/locks/StampedLockCounter.java b/locks/src/main/java/com/ankurm/locks/StampedLockCounter.java new file mode 100644 index 0000000..c1acde1 --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/StampedLockCounter.java @@ -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 optimistic 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; + } +} diff --git a/locks/src/main/java/com/ankurm/locks/SynchronizedCounter.java b/locks/src/main/java/com/ankurm/locks/SynchronizedCounter.java new file mode 100644 index 0000000..e04f85f --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/SynchronizedCounter.java @@ -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; + } +} diff --git a/locks/src/main/java/com/ankurm/locks/TryLockTimeoutDemo.java b/locks/src/main/java/com/ankurm/locks/TryLockTimeoutDemo.java new file mode 100644 index 0000000..e217e46 --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/TryLockTimeoutDemo.java @@ -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; + } + } + } +} diff --git a/locks/src/main/java/com/ankurm/locks/VirtualThreadPinningDemo.java b/locks/src/main/java/com/ankurm/locks/VirtualThreadPinningDemo.java new file mode 100644 index 0000000..ad28cd0 --- /dev/null +++ b/locks/src/main/java/com/ankurm/locks/VirtualThreadPinningDemo.java @@ -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}: + *
    + *
  • 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.
  • + *
  • 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.
  • + *
+ * 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.)"); + } +} diff --git a/locks/src/test/java/com/ankurm/locks/CounterCorrectnessTest.java b/locks/src/test/java/com/ankurm/locks/CounterCorrectnessTest.java new file mode 100644 index 0000000..5ebf455 --- /dev/null +++ b/locks/src/test/java/com/ankurm/locks/CounterCorrectnessTest.java @@ -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()); + } +} diff --git a/pom.xml b/pom.xml index f76f0bf..cab5ec3 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,7 @@ jmm + locks