2026-05-07
This ConfigLoader is supposed to lazily build an expensive Config object exactly once, even under heavy multi-threaded load. The classic double-checked locking idiom avoids the cost of synchronizing every read after the instance exists. It looks airtight — the inner null check inside the synchronized block guards against two threads racing to construct. Yet in production, callers occasionally read a Config whose port field is still 0 and whose endpoints list is null, even though the constructor clearly assigns both before returning.
public final class ConfigLoader {
private static Config instance;
public static Config getInstance() {
if (instance == null) {
synchronized (ConfigLoader.class) {
if (instance == null) {
instance = new Config();
}
}
}
return instance;
}
}
final class Config {
final int port;
final List<String> endpoints;
Config() {
this.port = loadPort(); // expensive
this.endpoints = loadEndpoints(); // expensive
}
}
The instance field is not volatile. Under the Java Memory Model, the statement instance = new Config() is not atomic. It decomposes into roughly three steps:
Config object.port and endpoints.instance.The JIT, the CPU, or the memory subsystem are all permitted to reorder steps 2 and 3, because from the constructing thread's single-threaded perspective the order is invisible. A second thread executing the outer if (instance == null) check performs an unsynchronized read. It can observe a non-null reference whose constructor has not yet finished — the object exists in memory but its fields still hold their default zero/null values. The reader skips the synchronized block entirely and hands the half-built Config back to the caller.
Worse, even final fields don't fully save you here. The JMM's freeze guarantee for final fields only applies if the reference itself is published safely. A racy non-volatile write is not safe publication, so the final guarantee is forfeited.
This bug is famously hard to reproduce. On x86, the strong memory model masks it most of the time. It surfaces on ARM, on heavily loaded JIT-compiled hot paths, or after a seemingly unrelated change perturbs inlining.
Mark the field volatile. A volatile write has release semantics and a volatile read has acquire semantics, so the constructor's writes happen-before any reader that sees the non-null reference:
public final class ConfigLoader {
private static volatile Config instance;
public static Config getInstance() {
Config local = instance; // read once
if (local == null) {
synchronized (ConfigLoader.class) {
local = instance;
if (local == null) {
local = new Config();
instance = local; // volatile write publishes safely
}
}
}
return local;
}
}
Reading into a local variable is a small but real win: it avoids a second volatile read on the happy path. Even better, prefer the holder idiom — a private static nested class whose loading is itself lazy and serialized by the JVM's class initialization lock. It needs no volatile, no double check, and no synchronized block.
volatile, double-checked locking lets readers see a published reference before the object's constructor has finished — a partially constructed object is the JMM's revenge for skipping synchronization on the fast path.
