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.
jmm
Companion code for the ankurm.com post "The Java Memory Model Explained: volatile,
happens-before, and Why Your Double-Checked Lock Failed." First 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) | GA'd September 2025. docker/CI images still default to 21; this repo needs 25 on PATH or JAVA_HOME. |
| jcstress-core | 0.16 | Latest on Maven Central as of this writing (last published Feb 2023 - the tool is stable, not stale). |
| JUnit Jupiter | 5.11.0 | Sanity tests only - see the warning below. |
| Maven | 3.9.11 |
Quickstart
export JAVA_HOME=/path/to/jdk-25 # must be 25, see the trap below
mvn package
java -jar jmm/target/jcstress.jar -t PlainPublicationTest -jvmArgs "-Xmx256m" -f 2 -iters 15 -time 1000 -v
scripts/run-all.sh regenerates every file in output/ with the same commands used to
produce the ones committed here. scripts/run.sh <TestClass> runs one test ad hoc.
What's in here
| File | What it shows |
|---|---|
src/main/java/.../BrokenDclSingleton.java |
The classic double-checked-locking bug: plain (non-volatile) instance field. |
src/main/java/.../VolatileDclSingleton.java |
The JSR-133 fix: mark instance volatile. |
src/main/java/.../HolderIdiomSingleton.java |
The idiom to actually use: initialization-on-demand holder, no volatile needed. |
src/main/java/.../Payload.java / FinalPayload.java |
The two payload shapes the jcstress tests publish - plain fields vs. final fields. |
src/main/java/.../PlainPublicationTest.java |
jcstress test reproducing BrokenDclSingleton's unsynchronized publish. |
src/main/java/.../VolatilePublicationTest.java |
Same race, volatile reference field. |
src/main/java/.../FinalFieldPublicationTest.java |
Same race, plain reference field but final payload fields - tests the JLS 17.5 guarantee in isolation from volatile. |
src/test/java/.../SingletonBehaviorTest.java |
Ordinary JUnit sanity checks - does NOT prove the memory-visibility claims, see its Javadoc. |
output/01 - output/03 |
Captured jcstress runs for the three tests above. |
output/04 |
A real trap hit building this repo: JDK 25 silently stops discovering annotation processors on the plain classpath. |
output/05 |
The JUnit sanity run. |
The jcstress tests did not reproduce the bug on this hardware, and that's reported honestly
All three jcstress campaigns (output/01-03) ran to completion with zero occurrences of
the torn/reordered outcome, across roughly 5.2, 5.3, and 5.6 billion sampled interleavings
respectively. That is expected, not a negative result:
- x86-64's TSO memory model does not permit store-store reordering in hardware, which is half of what the classic "fails on ARM" claim depends on.
- This sandbox is a 2-vCPU x86-64 VM. The historically-reported failures on real hardware are on weak-memory architectures (ARM, POWER) or come from compiler-level store reordering, which is possible on any architecture but was not observed here in ~16 billion combined samples.
This repository does not claim to have reproduced the bug on ARM hardware - there was none
available to test on. The ARM claim in the post is attributed to the documented JSR-133
rationale and widely-reported real-world failures (cited in the post's further-reading list),
not to a run captured in this repo. What jcstress here does prove, directly and reproducibly:
the unsynchronized version has two outcomes jcstress classifies as merely Interesting rather
than ruled out, while the volatile and final-field versions have the same outcomes marked
Forbidden and jcstress would report a hard error if it ever saw one. It did not, in either
of those two, which is the actual evidence that both fixes work.
Traps hit building this
- jcstress tests must live in
src/main/java, notsrc/test/java.maven-shade-pluginonly shades the main artifact; test-classes never make it into the runnable jar, soMETA-INF/TestListends up empty andjcstress.jarthrows aNullPointerExceptioninTestList.getTests()on startup with no test classes found. (Costs a rebuild if you don't know to look for it - see the class-count check inscripts/run-all.sh's comments.) - JDK 25 needs
<annotationProcessorPaths>explicitly. JDK 21 still discovers annotation processors on the plain classpath, with a warning that this is deprecated (output/04-annotation-processing-jdk21-vs-jdk25.txthas the exact message). On JDK 25 that discovery is simply gone - no warning, no error, the*_jcstress.javaharness classes are never generated and the build "succeeds" with nothing to run. Verified directly withjavacon both JDKs while building this repo. jcstress-maven-plugindoes not exist. The dependency is justjcstress-coreplusmaven-shade-pluginwithorg.openjdk.jcstress.Mainas the shaded manifest's main class. (The Maven coordinate that does exist isjcstress-java-test-archetype, for scaffolding a fresh project viamvn archetype:generate- not needed once you have a working pom.)- jcstress's result classes are not
IntResult2/IntResult3. They're named by type-code, e.g. two ints isorg.openjdk.jcstress.infra.results.II_Result(fieldsr1,r2). - Auto-detected JVM configs multiply fork counts fast. Without
-jvmArgs, jcstress probes and iterates several compiler-flag combinations per fork, which is fine on real hardware but turned a "quick" run into an 8+ minute one on this sandbox's 2 CPUs. Passing-jvmArgs "-Xmx256m"(or anything) forces single-JVM-config mode and makes run time predictable. UseBiasedLockingprobe fails loudly but harmlessly. jcstress 0.16 (2023) still probes for biased locking, which JEP 374 removed in JDK 15. jcstress marks the probe[N/A]and moves on; it is not a build blocker.
License
MIT - see the repo-wide LICENSE.