Concurrency
Double-checked locking without volatile
Written and reviewed by Sahil Srivastav
# Reproducer: 1 writer publishing a lazily-created config, 200 reader threads
$ java -XX:-TieredCompilation -XX:CompileThreshold=1000 Repro
iteration 4412: reader observed instance != null with limits=null
java.lang.NullPointerException: Cannot invoke "java.util.Map.get(Object)"
because "com.example.config.Settings.limits" is null
at com.example.config.Settings.limitFor(Settings.java:28)
at com.example.config.Repro$Reader.run(Repro.java:61)
# 4411 iterations passed. The failure appears only after C2 compiles the read path.What this error actually means
There is no exception for this bug and no failing assertion inside the synchronized block. The lock is correct: only one thread ever constructs the instance. What is broken is the *publication* of that instance to threads that read the field without the lock.
The mechanism is a reordering the Java memory model explicitly permits. Writing the object’s fields inside the constructor and writing the reference into the static field are two independent stores to a thread that has no happens-before edge to observe them in order. Both the compiler and the hardware are free to make the reference store visible first — and on top of that, the JIT may inline the constructor and allocate-then-publish-then-initialise, which is a legal transformation for a non-volatile field. A second thread taking the fast path then reads a non-null reference whose fields are still at their default values.
So the failure is not "two instances get created". It is one instance, observed in a state that never existed logically: non-null reference, null or zero fields. The `NullPointerException` fires in a getter on a fully-initialised-looking object, which is why the reported stack trace never points at the singleton code.
Declaring the field `volatile` fixes it because, since JSR-133 in Java 5, a volatile write cannot be reordered with earlier writes and a volatile read cannot be reordered with later reads: the write to the reference has a happens-before relationship with everything the constructor did. Before Java 5 the volatile fix did not work either, which is the origin of the widely repeated claim that double-checked locking is simply broken — it is broken without volatile, and correct with it, on Java 5 and later.
Note that `final` fields do not rescue you. Final-field freeze semantics guarantee that a thread which reads the reference *through a properly published path* sees the finals initialised — but they say nothing about a reference published by a racing non-volatile write, which is exactly the path in question.
Causes, most common first
- 1The instance field is not `volatile`. The entire bug, in one line. The unsynchronised first check reads a field with no ordering guarantee against the constructor’s writes. Everything else on this page is a variation on this.
- 2`volatile` on the wrong thing. The holder field is volatile but the object is mutable and its own fields are not, and the constructor hands `this` to something that publishes it early — a listener registration, a static registry, a callback. The volatile write happens after, but the reference escaped before.
- 3Copying the field into a local without re-reading correctly. The standard optimisation reads the volatile field once into a local to save a second volatile read. Done right, this is faster and still correct. Done wrong — reading the non-volatile field into the local, or reading the local outside the lock and the field inside — it reinstates the race.
- 4Lazy initialisation of a nested structure. The outer reference is volatile, but the object initialises an internal map or array *after* the constructor returns, in an `init()` that a caller is expected to invoke. Two-phase construction cannot be published safely, because there is no single write that covers both phases.
- 5The constructor itself publishes `this`. Registering with an event bus, starting a thread, or passing `this` to a builder from inside the constructor exposes the object before its fields are assigned. This breaks even with volatile and even with final fields, because another thread got the reference through a path with no publication barrier at all.
When you see it
- A `NullPointerException` on a field of an object that the code clearly initialises in its constructor
- Failures that only start after the process has been warm for a while, because the reordering is introduced by the JIT rather than the interpreter
- Zero-valued numeric fields or empty collections observed on an object whose constructor populates them
- Reproduces on one CPU architecture and not another — weakly ordered hardware such as AArch64 exposes it far more readily than x86
- Adding a log line near the read makes it disappear, because it perturbs the compiled code
- Rate measured in one failure per millions of reads, so it looks like cosmic-ray flakiness and gets retried away
How to diagnose it
Step 1
Read the field declaration before anything else
When an NPE names a field that the constructor assigns, go straight to how the enclosing object is published. A non-volatile static or instance reference written inside a synchronized block and read outside one is the bug, and this takes ten seconds to confirm.
grep -rn -B 2 -A 12 "synchronized" src/main/java/**/[A-Z]*.java | grep -E "private static [A-Z]| instance"Step 2
Make the JIT commit to the optimised code
Interpreted and tier-1 code often happens to be safe. Forcing compilation of the read path is what turns a theoretical reordering into an observed failure, which is the difference between a test that proves something and one that passes by luck.
java -XX:-TieredCompilation -XX:CompileThreshold=1000 -XX:+PrintCompilation Repro 2>&1 | grep SettingsStep 3
Run the reproducer on weakly ordered hardware
x86 has a strong memory model that hides many store-ordering bugs; AArch64 (Apple silicon, Graviton) does not. If a race reproduces on ARM and not on your x86 CI, that is evidence of a memory-model bug rather than flakiness.
Step 4
Use a harness that understands the JMM
JCStress is built for exactly this: it enumerates legal outcomes for a publication pattern and reports which ones actually occurred. It is the only tool that gives a defensible answer about whether a publication is safe, rather than an absence of observed failures.
./gradlew jcstress -Pjcstress.mode=stressStep 5
Check whether `this` escapes the constructor
Search the constructor for registration calls, thread starts, and anything taking `this`. An escape there defeats every publication fix downstream, so it must be ruled out before you conclude volatile is sufficient.
The fix
If you keep double-checked locking, declare the field `volatile` and read it into a local exactly once per check. The volatile write on publication gives you the happens-before edge that orders the constructor’s writes before any reader’s observation of the reference, and the local avoids the second volatile read on the fast path.
Prefer the lazy-initialisation holder idiom when the value has no constructor arguments. A private static nested class whose static field holds the instance is initialised by the classloader on first access to that class, and JVM class initialisation is specified to be thread-safe with a happens-before edge for every subsequent reader. There is no lock in your code, no volatile to forget, and the fast path is a plain static field read — this is both the simplest and the fastest option.
For a per-key lazy value, use `ConcurrentHashMap.computeIfAbsent`, which performs the check-and-create atomically per key and publishes the value safely. This replaces hand-written double-checked locking over a map, which is where most of these bugs actually live.
Never publish `this` from a constructor. Move registration, thread starts, and callback wiring into a separate `start()` step invoked by whoever created the object, so no reference escapes before construction completes.
Make lazily created objects immutable with final fields where you can. An immutable object is far more forgiving of a marginal publication path, and it removes the follow-on class of bugs where the object is published correctly and then mutated unsafely.
Do not reach for `synchronized` on the whole accessor as the fix unless you have measured that it matters. It is correct and it is usually fine — uncontended biased-free monitor acquisition is cheap — but the holder idiom is correct *and* has no acquisition at all, so there is rarely a reason to choose the lock.
// Broken: the reference may be visible before the constructor's writes
private static Settings instance;
static Settings get() {
if (instance == null) { // unsynchronised read
synchronized (Settings.class) {
if (instance == null) instance = new Settings();
}
}
return instance; // may have null fields
}
// Correct: volatile gives the publication a happens-before edge
private static volatile Settings instance;
static Settings get() {
Settings s = instance; // one volatile read
if (s == null) {
synchronized (Settings.class) {
s = instance;
if (s == null) instance = s = new Settings();
}
}
return s;
}
// Better, when there are no constructor arguments: class init does the work
private static final class Holder {
static final Settings INSTANCE = new Settings();
}
static Settings get() { return Holder.INSTANCE; }
// Per-key lazy values: atomic check-and-create, safely published
private final ConcurrentHashMap<String, Tenant> tenants = new ConcurrentHashMap<>();
Tenant tenant(String id) { return tenants.computeIfAbsent(id, Tenant::load); }How to stop it coming back
- Treat any mutable static or instance reference that is read without a lock and written with one as a review blocker unless it is `volatile`
- Default to the holder idiom for singletons so there is no publication code to get wrong
- Ban `this` escaping a constructor; enforce it with a static-analysis rule rather than convention
- Run concurrency-sensitive suites on AArch64 as well as x86 — a strongly ordered CPU is not a test environment for ordering bugs
- Use JCStress for any hand-written publication protocol; "we ran it a million times" is not evidence under the JMM
- Make lazily initialised objects immutable so a single safe publication is the whole correctness argument
FAQ
Is double-checked locking broken in modern Java?
No — it is broken *without* `volatile`, and correct with it on Java 5 and later. The JSR-133 memory model gave volatile writes and reads ordering semantics strong enough to make the idiom sound. The blanket "it is broken" advice dates from Java 1.4, where volatile did not provide those guarantees and there was no correct version.
Why did it pass a million iterations and then fail?
The reordering is introduced by the optimiser, not by the source. Early iterations run interpreted or tier-1 compiled, where the stores happen in program order. Once C2 compiles the path it may hoist, inline, and reorder, and only then can a reader observe the reference before the fields. Iteration count is not evidence of safety.
Do final fields make the object safe to publish?
Only through a correctly published reference. Final-field semantics guarantee a thread that obtains the reference via a safe publication sees the finals initialised; they do not create the edge for a racy non-volatile write. And if `this` escapes the constructor, even final fields can be observed uninitialised.
Is `volatile` expensive on the read path?
On x86 a volatile read is an ordinary load with compiler ordering constraints and no fence, so the cost is small. On weakly ordered architectures it implies an acquire barrier, which is measurable but rarely significant next to the work being guarded. If you genuinely cannot pay it, the holder idiom gives you a plain final field read instead.
What about `AtomicReference` with `compareAndSet`?
It is safe — CAS carries the same ordering guarantees as a volatile write — but it allows several threads to construct the object concurrently with all but one discarding theirs. That is fine for a cheap, side-effect-free value and wrong when construction opens a connection, registers a metric, or starts a thread.
Related
Other errors engineers hit next to this one
- QueuePool limit of size 5 overflow 10 reached, connection timed out
- DetachedInstanceError: instance is not bound to a Session
- RuntimeError: Event loop is closed
- Task was destroyed but it is pending!
- Executing <Handle ...> took 2.418 seconds (blocked event loop)
- SettingWithCopyWarning: A value is trying to be set on a copy of a slice
- celery.exceptions.WorkerLostError: Worker exited prematurely
- requests.exceptions.ReadTimeout: HTTPSConnectionPool read timed out