Double-Checked Locking — Middle Level¶
Source: POSA2 (Schmidt et al.) · Schmidt & Harrison — Double-Checked Locking · JSR-133 (Java Memory Model) Category: Concurrency — "Patterns for coordinating work across threads, cores, and machines." Prerequisite: junior
Table of Contents¶
- Introduction
- When to Use Double-Checked Locking
- When NOT to Use It
- Real-World Cases
- Code Examples — Production-Grade
- Why the Naive Version Is Broken
- The
volatileFix - Better Idioms
- Trade-offs
- Alternatives Comparison
- Pros & Cons (Deeper)
- Edge Cases
- Tricky Points
- Best Practices
- Tasks (Practice)
- Summary
- Related Topics
- Diagrams
Introduction¶
At junior level you saw the shape of DCL: cheap unlocked check, lock, second check, build, publish. At middle level the questions become engineering questions: when is the lock-avoidance actually worth the risk, what exactly the memory model is doing under instance = new X(), and which simpler idioms make DCL unnecessary most of the time. The honest framing: DCL is a micro-optimization with a high correctness cost. You should be able to write it correctly and argue for why you usually shouldn't.
When to Use Double-Checked Locking¶
Reach for DCL when all of these hold:
- The initialized value lives in an instance field, not a static (so the holder idiom doesn't apply).
- Initialization is expensive (I/O, large allocation, pool setup) — otherwise the lock-avoidance saves nothing meaningful.
- The field is read on a hot path, many times after the one-time write.
- You genuinely need laziness — eager init at construction or class-load is unacceptable (e.g., it would do forbidden work too early).
A textbook fit: a per-instance, lazily-built cache inside a long-lived service object that's read on every request.
When NOT to Use It¶
- Static singleton? Use Initialization-on-Demand Holder or enum — simpler, equally fast, no
volatileto forget. - Cheap object? Just initialize eagerly. The lock you're avoiding is cheaper than the bug risk.
- Read rarely? A plain
synchronizedaccessor is fine and obviously correct. - Can't guarantee
volatilesurvives refactors? Then the holder idiom protects you from a future maintainer. - You're on Java 1.4 or earlier. DCL is unfixable there — don't.
Real-World Cases¶
- JDBC / connection pools lazily created inside a service component the first time a query runs.
- Lazily-parsed configuration in framework code (Spring, Guice) where eager parsing at startup is too costly or has ordering constraints.
- Logger / metrics registry instance fields built once per component.
- Library singletons historically — many old libraries used broken DCL before Java 5 and were quietly fixed by adding
volatile.
Code Examples — Production-Grade¶
Correct DCL on an instance field (the legitimate use case)¶
public final class ReportService {
private final DataSource ds;
private volatile ExpensiveIndex index; // volatile is mandatory
public ReportService(DataSource ds) { this.ds = ds; }
public ExpensiveIndex index() {
ExpensiveIndex local = index; // single volatile read
if (local == null) {
synchronized (this) {
local = index;
if (local == null) {
local = buildIndex(ds); // expensive, lazy
index = local; // volatile publish
}
}
}
return local;
}
private ExpensiveIndex buildIndex(DataSource ds) { /* heavy work */ }
}
Note: locking on this is acceptable here because ReportService controls its own monitor; in library code prefer a private lock object so external code can't accidentally contend on your monitor.
Private-lock variant (defensive)¶
private final Object lock = new Object();
private volatile ExpensiveIndex index;
public ExpensiveIndex index() {
ExpensiveIndex local = index;
if (local == null) {
synchronized (lock) {
local = index;
if (local == null) index = local = buildIndex(ds);
}
}
return local;
}
Why the Naive Version Is Broken¶
Write out what the JVM actually does for index = new ExpensiveIndex():
1. mem = allocate(sizeof ExpensiveIndex) // raw memory
2. ExpensiveIndex.<init>(mem) // run the constructor (sets fields)
3. index = mem // publish the reference
Steps 2 and 3 have no data dependency the hardware is required to respect across threads, so a compiler or CPU may execute them as 1 → 3 → 2. Now consider two threads:
- Thread A runs
1 → 3(reference published) and is paused before2(constructor). - Thread B runs the unlocked first check, sees
index != null, returns it, and dereferences fields that are still default/garbage.
This is partial construction caused by reordering plus the visibility gap on the unlocked read. The inner second check does not help — the problem isn't duplicate creation, it's premature publication.
The volatile Fix¶
JSR-133 (Java 5) gave volatile two teeth that fix DCL:
- A write to a
volatilefield cannot be reordered with the writes that precede it. So step2(constructor) is guaranteed to complete before step3(the volatile publish). The reference is never published early. - A read of a
volatilefield establishes a happens-before edge with the write that set it. So a reader who sees the non-null reference is guaranteed to also see all writes that happened before the volatile store — i.e., the fully-constructed object.
In barrier terms: the volatile store acts as a release (everything before it is flushed and ordered first), and the volatile load acts as an acquire (the reader refreshes and sees those writes). That release/acquire pairing is exactly safe publication.
Better Idioms¶
For most cases you should not write DCL at all.
Initialization-on-Demand Holder (the go-to for statics)¶
public final class Config {
private Config() { /* expensive */ }
private static final class Holder {
static final Config INSTANCE = new Config();
}
public static Config getInstance() { return Holder.INSTANCE; }
}
Lazy (the Holder class loads only on first getInstance()), thread-safe (the JLS guarantees class initialization runs once under an internal lock), lock-free after load, and no volatile to forget. This is the idiom to reach for first.
Enum singleton (the most robust)¶
Thread-safe by the JVM, immune to reflection and serialization attacks, but eager (initialized when the enum class loads) and cannot extend a class.
Trade-offs¶
| Concern | DCL (volatile) | Holder idiom | Enum |
|---|---|---|---|
| Works on instance fields | ✓ | ✗ (statics only) | ✗ |
| Laziness | ✓ | ✓ | partial (class-load) |
| Lock-free fast path | ✓ | ✓ | ✓ |
volatile needed | ✓ (easy to forget) | ✗ | ✗ |
| Easy to get wrong | ✗ (very) | ✓ (hard to break) | ✓ |
| Serialization/reflection safe | n/a | n/a | ✓ |
Alternatives Comparison¶
synchronizedaccessor: simplest, always correct, lock on every call. Fine for cold paths.- Eager
static final: trivially correct and lock-free, but not lazy. AtomicReference+ CAS: lock-free build; can construct twice under race (last writer wins) unless you guard — acceptable only if double-build is harmless.- Holder idiom / enum: best default for statics.
- DCL: the right tool only for lazy instance fields on a hot path.
Pros & Cons (Deeper)¶
| ✓ Pros | ✗ Cons |
|---|---|
| Removes lock cost from the common read path | Correctness hinges on a single volatile keyword |
| Lazy + lock-free is a rare, valuable combo | A volatile read still has a (small) cost vs a plain read |
| Works where holder/enum can't (instance fields) | History of being shipped broken; reviewers distrust it |
| Great vehicle for understanding the JMM | Modern JITs make the holder idiom equally fast — DCL rarely "wins" |
Edge Cases¶
this-escape in the constructor: ifbuildIndex/constructor publishes the object somewhere else (a static registry, a listener) before returning, that's an unsafe publish independent of DCL.- Mutable singleton: DCL guarantees safe publication only; later mutations need their own synchronization.
- Field that can be reset to
null: DCL assumes write-once. If the field can go back tonulland be rebuilt, you have a different (harder) problem. - Nested DCL fields: each independent lazy field needs its own
volatile.
Tricky Points¶
- The inner check prevents double creation;
volatileprevents premature publication. Two different bugs, two different fixes — you need both. - Reading the volatile field into a local isn't cosmetic: it turns two volatile reads on the fast path into one.
finalfields inside the object get their own JMM guarantee, but that does not make a non-volatile DCL field safe.
Best Practices¶
- ✓ Default to the holder idiom for static singletons.
- ✓ If you must DCL, mark the field
volatileand comment why. - ✓ Use a local for the volatile read on the fast path.
- ✓ Keep both checks; keep the build inside the lock.
- ✗ Don't reach for DCL to save a lock on a cold path.
- ✗ Don't assume passing tests prove correctness.
Tasks (Practice)¶
- Convert a
synchronizedaccessor to correctvolatileDCL; explain each guaranteevolatileprovides. - Rewrite the same singleton with the holder idiom and with an enum; compare line counts and failure modes.
- Take a non-volatile DCL and write down the exact interleaving that breaks it.
- Add a private lock object to a DCL that currently locks on
this; argue why. - Benchmark
synchronizedvs DCL vs holder on a hot read path (note: differences are often tiny).
Summary¶
DCL is a legitimate but narrow optimization: lazy initialization of an instance field, read on a hot path, where you must avoid per-read locking. The naive version is broken by reordering of construct-vs-publish and the visibility gap on the unlocked first read, allowing another thread to see a partially constructed object. The volatile keyword fixes it via release/acquire safe publication. For static singletons, the Initialization-on-Demand Holder and enum idioms are simpler, equally fast, and far harder to get wrong — prefer them.
Related Topics¶
Diagrams¶
Decision guide:
In this topic
- junior
- middle
- senior
- professional