skip to content

The JVM guarantees a class's static initializer runs exactly once even when many threads touch the class at the same instant. How is that guarantee implemented, and what failure mode does the very same mechanism create in production?

level: seniorimportance: should knowfreq 34%

answer

  1. Per-class init lock + state machine
  2. Others block; same thread re-enters and proceeds
  3. Recursive entry can read default-valued statics
  4. Cycle across two threads = class-init deadlock
  5. No standard deadlock report; check <clinit> frames in thread dump

basics

~20 s

Each class has an initialization lock and a state (uninitialized, in progress by thread T, initialized, erroneous). One thread runs the initializer while others block; recursive entry by the same thread is allowed and proceeds. Two classes initializing each other from two threads therefore deadlock, and slow initializers stall every caller.

solid answer

~60 s

The JVM runs a specified procedure per class: acquire the class's **initialization lock**, inspect state, and either (a) block if another thread is initializing it, (b) return immediately if it is already initialized, (c) throw if it is erroneous, or (d) mark it 'being initialized by this thread', release the lock, run the superclass initialization then `<clinit>`, and finally mark it initialized and notify waiters. Two details matter. **Recursive entry is permitted**: if the initializing thread re-enters the same class it proceeds rather than deadlocking with itself - which is how a thread can observe *partially initialized* statics under circular dependencies. And blocking is real: any other thread touching the class waits. The failure mode is **class-initialization deadlock**. If class `A`'s initializer touches `B` while `B`'s touches `A`, and two threads start on `A` and `B` respectively, each holds one class in 'in progress' and waits for the other, forever. Long or blocking work in a static initializer - remote calls, file loading, waiting on another thread - produces the same shape of stall without a cycle.

code

java · 5 lines
java
class A { static final B b = B.instance(); static A instance() { return new A(); } }
class B { static final A a = A.instance(); static B instance() { return new B(); } }

// Thread 1: new A();   Thread 2: new B();   -> both block forever
// Single-threaded, it does not hang - it silently yields a null field instead.

go deeper

for a junior

Know that the JVM guarantees exactly-once, thread-safe initialization and that you should not add your own locking there.

for a middle

Describe the state machine and blocking behaviour, and be able to explain why circular static dependencies are dangerous.

for a senior

Show the deadlock scenario concretely, explain how it appears in a thread dump and why standard deadlock detection misses it, and give the prevention rules.

for a principal

Treat implicit class initialization as an uncontrolled concurrency and start-up ordering point; push critical setup into explicit lifecycle wiring where ordering and failure handling are designed rather than emergent.

## The specified procedure Class initialization is defined as a small state machine guarded by a per-class **initialization lock**. Each runtime class carries a state: *verified/prepared but uninitialized*, *being initialized by thread T*, *fully initialized*, or *erroneous*. A thread that reaches an active use performs roughly: 1. Acquire the class's initialization lock. 2. If the state is *being initialized by another thread*: wait on the lock until that changes, then re-check. 3. If *initialized*: release and proceed - no work. 4. If *erroneous*: release and throw `NoClassDefFoundError`. 5. If *being initialized by the current thread*: release and proceed - **recursive initialization is allowed**. 6. Otherwise: record 'being initialized by me', release the lock, initialize the superclass (and any superinterfaces declaring default methods) first, then run `<clinit>`. 7. On normal completion: re-acquire the lock, mark *initialized*, notify all waiters, release. 8. On abnormal completion: mark *erroneous*, notify waiters, and propagate. The lock is released while `<clinit>` runs, but the *state* keeps other threads out - which is why the guarantee holds without the initializer itself running under a lock other threads could contend on for arbitrary reasons. After completion the fast path is cheap: compiled code and the interpreter check the initialized state without meaningful cost, so 'is it initialized' is not a lasting overhead. ## Why recursive entry is allowed, and what it costs you Step 5 exists because self-reference is common and unavoidable: a static initializer that calls a static method on its own class must not deadlock with itself. The price is that during a cycle, a thread can **read static fields that are still at their default values**. Consider `A`'s initializer calling into `B`, whose initializer reads `A.CONFIG`. `A` is already marked 'being initialized by this thread', so `B` proceeds - and `A.CONFIG` is still `null` because `A`'s initializer had not reached that assignment yet. The result is a silent `null`, not an error. Circular static dependencies therefore produce order-dependent, hard-to-reproduce bugs even in the single-threaded case. ## Class-initialization deadlock The multithreaded case is worse. Suppose `A.<clinit>` touches `B` and `B.<clinit>` touches `A`. Thread 1 starts initializing `A`; thread 2 simultaneously starts initializing `B`. Thread 1 reaches `B`, finds 'being initialized by thread 2', and waits. Thread 2 reaches `A`, finds 'being initialized by thread 1', and waits. Neither can complete. This deadlock is: - **timing dependent** - the same code works fine when one thread happens to touch both classes - **invisible to most deadlock detection** - it is not a cycle of `synchronized` monitors that a standard deadlock report will name, though a thread dump does show threads parked in class initialization for the relevant classes - **fatal for the affected classes** - the process may keep serving other traffic while every request touching those types hangs The generalized version needs no cycle at all: a static initializer that performs a network call, reads a large file, waits for a background thread to signal, or acquires an application lock will make every thread touching that class wait for it - a stall at exactly the moment of first use, often the first request after deploy. ## Diagnosing it Take a thread dump. Look for threads whose stacks are inside a `<clinit>` frame or waiting to enter class initialization for the same types, especially two threads referencing each other's classes. The absence of a 'Found one Java-level deadlock' section does not clear you - the JVM's deadlock detector reports monitor and ownable-synchronizer cycles, not class-initialization cycles. ## Prevention - **Keep static initializers trivial**: assign constants, build small immutable structures. No I/O, no network, no waiting on other threads, no acquiring application locks. - **Eliminate circular static dependencies** between classes; if two types must know about each other, initialize the relationship explicitly at start-up rather than inside `<clinit>`. - **Do not start threads from a static initializer** and then wait on them - a classic self-inflicted deadlock, since the new thread may itself need the class being initialized. - **Use the lazy-holder idiom deliberately**, where the JVM's exactly-once guarantee is the *feature*: a private static nested holder class whose static field creates the instance on first access, with no locking code of your own. It is safe precisely because the holder's initializer does one simple thing. - **Initialize expensive subsystems explicitly** during start-up, where failures are visible and ordering is under your control, rather than implicitly on whichever request thread touches the class first.

  • A single-threaded program with a circular static dependency does not hang. What goes wrong instead?
    The thread is already recorded as the initializer of the first class, so re-entering it is allowed and initialization proceeds rather than blocking. During that window the fields not yet assigned still hold their default values, so the second class reads a null or a zero and stores it permanently. The result is a silently wrong value whose correctness depends on which class was touched first.
  • How would you confirm a hang is class-initialization deadlock rather than an ordinary monitor deadlock?
    Take a thread dump and look at the stacks: the blocked threads sit in class initialization for the classes involved, typically with <clinit> frames visible in the trace, and each thread's target class is the one the other thread is initializing. Notably the JVM's automatic deadlock detection section usually reports nothing, because it looks for cycles of monitors and ownable synchronizers rather than class-initialization states.
  • Why is the lazy-holder idiom safe without any synchronization of your own?
    Because the JVM already serializes class initialization with the per-class lock and publishes the results safely, so the holder's static field is created exactly once and every thread that later reads it sees the fully constructed object. Your code contributes no locking, so there is no chance of getting the double-checked pattern subtly wrong. It stays safe only while the holder's initializer does one trivial thing and touches nothing that could initialize back into it.

Like a one-time setup crew locking a room while they work: everyone else waits at the door, the crew can come and go themselves, and if two crews each wait at the other's door nobody ever finishes.

saying these in an interview costs you the question

  • Claiming class initialization can never deadlock because the JVM handles it
  • Saying the initializing thread deadlocks with itself on recursive entry
  • Adding a synchronized block inside a static initializer to make it thread-safe
  • Assuming the JVM's automatic deadlock report will name a class-initialization cycle
  • Treating a static initializer as a fine place for I/O, remote calls or starting and joining threads

context