Skip to main content

Broken Double-Checked Locking

Broken double-checked locking publishes a reference before the object's constructor finishes, so other threads see partially built objects and hit rare heisenbugs. Use a static holder, a publication-safe volatile/atomic field, or a built-in one-time initializer instead.

Double-checked locking (DCL) tries to make lazy initialization cheap: check whether the instance exists without locking, and only lock and create it if it does not.

if (instance == null) {            // first check, no lock
  synchronized (lock) {
    if (instance == null) {          // second check, locked
      instance = new Service();      // BROKEN without volatile
    }
  }
}
return instance;

The idiom is broken when the field is not declared with the right memory-visibility semantics. Without them, one thread can publish a reference to an object whose constructor has not finished, and another thread reads that reference and uses a half-built object.

Why It Happens

Locking on every access seems wasteful, so developers optimize it away with the outer check. The code looks obviously correct — two null checks, a lock between them — and works in single-threaded tests. The flaw lives in the memory model: writes inside a constructor can be reordered relative to the write that publishes the reference, so the publication becomes visible before the initialization does. This is invisible until a specific reordering occurs on a specific CPU under concurrency.

Why It Hurts

A thread can observe a non-null instance with default-valued or missing fields, causing rare NullPointerExceptions, corrupted state, or silent wrong results far from the actual bug. These are textbook heisenbugs: they vanish under a debugger, depend on hardware and JIT behavior, and survive years of testing before striking in production. Worse, the code appears to be a well-known correct pattern, so reviewers wave it through.

Warning Signs

  • A singleton or lazy field read twice around a synchronized/lock block.
  • The shared field is not volatile (Java) / lacks acquire-release semantics (C++ std::atomic), or the language's memory model is ignored.
  • Intermittent null/partial-state errors on an object assumed to be fully built.

Better Alternatives

  • Initialization-on-demand holder idiom (Java): a static nested holder class initialized by the classloader — lazy, thread-safe, lock-free.
  • volatile field (Java 5+) or acquire-release atomics (C++11): make DCL correct by ensuring the constructor's writes happen-before the publication.
  • Eager initialization when the object is cheap and always needed.
  • Lazy<T> / std::call_once / sync.Once: language-provided one-time initializers that are correct by construction.

How to Refactor Out of It

First decide whether laziness is even necessary; eager initialization or a static holder is simpler and correct. If you keep DCL, ensure the field uses the language's publication-safe mechanism: volatile in Java 5+, std::atomic with release on store and acquire on load in C++, and so on. Better still, replace hand-rolled DCL with a built-in one-time initializer (sync.Once in Go, std::call_once in C++, Lazy<T> in C#/Kotlin) that handles the memory barriers for you. Audit other singletons for the same flaw, since the pattern tends to be copied.