Static Keyword — Professional (Under the Hood)¶
What's actually happening? Static fields live in a per-class data area allocated by the JVM in metaspace; static methods are dispatched via
invokestatic(a direct call); class initialization runs<clinit>exactly once under a class-init lock; compile-time constants are inlined byjavac; static initialization order, JMM guarantees, and class loading interact in subtle ways that explain most "why is this static field acting weird?" mysteries.
1. Where static fields actually live¶
In HotSpot, every loaded class has an InstanceKlass structure in metaspace (a native-memory area, not the heap). Attached to the InstanceKlass is a static field area — a contiguous block of memory holding all the class's static fields.
InstanceKlass for com.example.MyClass
├── metadata (constant pool, method tables, etc.)
└── static field area
├── offset 0: static field A
├── offset 8: static field B
└── ...
Reading a static field with getstatic resolves (once) to the offset, then performs a load from static_field_area + offset. Writing with putstatic is the same with a store.
Two consequences:
- Static fields are not on the GC-managed heap. They're in metaspace. Their contents (object references) point to the heap; the GC roots include all references in the static field area.
- Class unloading frees static state. When a class loader becomes unreachable and its classes are unloaded, the metaspace memory (including the static field area) is reclaimed.
You can monitor metaspace with:
2. Static fields and the GC roots¶
The garbage collector treats every reference field in a class's static area as a root. So a static Map<...> keeps its entries alive forever (or until someone clears the map), even if nobody else references those entries.
This is the classic "static cache memory leak":
public class Cache {
private static final Map<String, Object> cache = new HashMap<>();
public static void put(String k, Object v) { cache.put(k, v); }
}
Every entry put into this cache is reachable from the GC roots (via Cache.class → static field → map → entry). The objects never become eligible for collection unless explicitly removed.
Solutions:
WeakHashMap— entries become eligible when the key has no other references.- Manual eviction (LRU, time-based).
- Bounded caches like Caffeine.
- Don't use a static cache at all — use a DI-managed instance.
-Xlog:gc=trace and heap dumps (jmap -dump) reveal these leaks. Look for any class whose static fields hold large collections.
3. The <clinit> method¶
When the compiler sees:
public class Constants {
public static int A = compute();
public static int B = 10;
static {
System.out.println("init");
A = A + B;
}
}
It synthesizes a method named <clinit> (read "class init") containing all static initializer code, in source order:
static <clinit>()V
invokestatic compute()I
putstatic A
bipush 10
putstatic B
ldc "init"
invokestatic println(...)
getstatic A
getstatic B
iadd
putstatic A
return
The JVM ensures <clinit> runs exactly once per class loader, under a per-class init lock (JLS §12.4.2).
You don't write <clinit> directly. The compiler emits it from your static field initializers and static {} blocks.
4. Class initialization triggers (precisely)¶
Per JLS §12.4.1, initialization is triggered by:
new Foo()— instantiation.- Static method invocation on
Foo. - Static field assignment or read of a non-
finalstatic field. Class.forName("Foo")(defaultinitialize=true).- Initialization of a subclass of
Foo(parent must initialize first). - Designation as the
mainclass at JVM startup.
Not triggered by:
- Reading a
static finalconstant expression (the value is inlined; the class is not initialized). Foo.class(just gets theClass<?>mirror).Class.forName("Foo", false, loader).- An array creation:
new Foo[10]does not initializeFoo.
This explains most "why didn't my static block run?" mysteries.
5. The class init lock and deadlocks¶
Per JVMS §5.5, the JVM holds a per-class init lock during <clinit> execution:
acquire init lock for C
if C is fully initialized, release and return
if C is being initialized by current thread, release (recursive entry)
mark C as in-progress
release init lock
run <clinit>
acquire init lock
mark C as fully initialized
notifyAll waiters
release lock
If two classes A and B have static initializers that depend on each other, and two threads simultaneously trigger initialization of A and B, you get a deadlock — each thread holds one init lock and waits for the other.
Diagnose with jstack — look for two threads in Class init state, each waiting for a different class.
The fix is to break the dependency: compute everything from one class, or use lazy holder idioms, or defer the cross-class read to a method call.
6. Compile-time constant inlining¶
A static final field whose initializer is a compile-time constant expression (JLS §15.28) is treated specially by javac:
The consumer's bytecode does not contain a getstatic Limits.MAX. Instead, javac inlines the value:
Properties of compile-time constants:
- Type must be primitive or
String. - Initializer must be a constant expression (literals, other compile-time constants, narrowed/widened arithmetic).
- The
static finalfield is still emitted inLimits's class file, with aConstantValueattribute.
Implications:
- The consuming class file holds the literal
100. IfLimitsis recompiled withMAX = 200, consumers still see100until rebuilt. static finalarrays/Lists are not compile-time constants (their initializers aren't constant expressions). Sostatic final int[] PRIMES = {2, 3, 5}is a regular static field and is not inlined.- Reading an inlined constant does not trigger class initialization. (See §4.)
For library APIs, decide: do you want recompile-on-change semantics? If yes, leave constants static final. If no, expose values via a method (public static int max() { return 100; }`).
7. invokestatic — the cheapest call¶
Static methods are dispatched via invokestatic. The bytecode resolves once to a direct method address; subsequent calls jump straight to it.
Compared to instance dispatch:
| Bytecode | Resolution | Inlining ease |
|---|---|---|
invokestatic | Direct | Easiest — no receiver |
invokespecial | Direct (private/super/init) | Easy |
invokevirtual | Vtable lookup | Easy if monomorphic |
invokeinterface | Itable lookup | Easy if monomorphic |
invokedynamic | Bootstrap + CallSite | Easy after bootstrap |
For hot paths, invokestatic is the most JIT-friendly. The body is inlined unless it exceeds size limits (MaxInlineSize = 35, FreqInlineSize = 325).
Practical: a static helper called inside a tight loop almost always inlines completely. The JIT result is the same as if you wrote the body directly at the call site.
8. JMM and static fields¶
Static fields obey the same JMM rules as instance fields:
volatile static: each read/write establishes happens-before with the same field.static final: the JLS §17.5 freeze rule applies — if the class's<clinit>finishes without leaking the class reference, then any thread that observes the class as initialized sees fully-initializedstatic finalfields.- Plain
staticfields: no cross-thread visibility guarantee without synchronization.
The "without leaking" qualification is rarely an issue for statics because the class reference is implicit in every access — you can't easily "publish" a class. But you can publish the values the static fields hold; if those values are mutable and shared, normal JMM rules apply.
A subtle case: another thread observing a class during <clinit> execution. If thread T1 is initializing class A, and thread T2 then triggers initialization of A, T2 blocks on the init lock until T1 finishes. The blocking provides happens-before: T2 sees everything T1 wrote during <clinit>.
9. Lazy holder idiom — JMM perspective¶
public class Config {
private Config() { /* expensive */ }
private static class Holder {
static final Config INSTANCE = new Config();
}
public static Config getInstance() { return Holder.INSTANCE; }
}
This pattern works because:
Holderis not initialized until first use ofHolder.INSTANCE.- The first
getInstance()call triggers initialization ofHolder, which runs thestatic final INSTANCE = new Config()initializer. - The class init lock + the JLS §17.5 freeze rule guarantee that subsequent threads see the fully-initialized
Config.
No synchronization, no double-checked locking complexity. The JVM does the lazy + thread-safe work via class loading.
The holder class adds essentially zero cost — it's metadata in metaspace; it only initializes when first touched.
10. Static methods and reflection¶
Method.invoke works the same for static and instance methods. For static, pass null as the receiver:
Method m = Math.class.getDeclaredMethod("max", int.class, int.class);
int result = (int) m.invoke(null, 3, 5); // null because static
Performance: as with instance methods, reflection has a per-call overhead (~µs first call, ~ns after warmup). For frameworks, prefer MethodHandle:
MethodHandle max = MethodHandles.lookup()
.findStatic(Math.class, "max", MethodType.methodType(int.class, int.class, int.class));
int result = (int) max.invoke(3, 5);
After warmup, the MethodHandle is essentially as fast as a direct invokestatic. The JIT compiles the call site as if you'd written the call directly.
VarHandle provides the same modernization for static field access, with memory-ordered operations:
VarHandle COUNT = MethodHandles.lookup().findStaticVarHandle(MyClass.class, "count", int.class);
COUNT.getAndAdd(1); // atomic increment
COUNT.compareAndSet(0, 1); // atomic CAS
11. Static fields and class file attributes¶
In the class file (JVMS §4.5), a static field's field_info has:
ACC_STATICbit set inaccess_flags(0x0008).- A
ConstantValueattribute if the field isstatic finaland a compile-time constant. The JVM uses this attribute to initialize the field during class linking, before<clinit>runs. ACC_FINAL(0x0010) if final.
The order matters: static final int MAX = 100; causes:
- JVM sees
ConstantValueattribute → writes100to the field at link time. <clinit>runs; for this field, it has nothing to do (the value is already set).
For a non-final static or a static final without a ConstantValue attribute (e.g., a method-call initializer):
- Field is set to default during preparation.
<clinit>runs the initializer.
Inspect with javap -v MyClass.class. Look for ConstantValue: int 100 next to a field.
12. The assertion mechanism uses static + class init¶
Java's assert statement is implemented via static fields:
Compiles to roughly:
public class Foo {
static final boolean $assertionsDisabled = !Foo.class.desiredAssertionStatus();
public void method() {
if (!$assertionsDisabled && !(x > 0))
throw new AssertionError("x must be positive");
}
}
The $assertionsDisabled field is computed once, in <clinit>, by calling desiredAssertionStatus() (which returns whether assertions are enabled for that class via the -ea JVM flag).
If assertions are disabled (the default), the field is true; the JIT then constant-folds the if (!$assertionsDisabled && ...) check to if (false && ...) and eliminates the entire assertion code path. Cost in production: zero.
This is a beautiful example of static fields enabling zero-cost feature flags.
13. Static method handles, lambda metafactory, and invokedynamic¶
When you write a method reference to a static method:
The compiler emits an invokedynamic referring to LambdaMetafactory.metafactory. The first invocation runs the bootstrap, which produces a synthetic class implementing Function whose apply method calls Math.abs(int). The synthetic class is then cached in a ConstantCallSite, so subsequent uses are direct.
For static method references, the synthetic class is essentially a thin shell calling invokestatic. The JIT fuses this with the call site and the static method body, producing inlined code with no allocation cost.
Same for static factory methods used as Suppliers, IntSuppliers, etc. — modern Java's functional layer is built on top of invokedynamic + static methods.
14. Hidden classes and static fields¶
Java 15's hidden classes (JEP 371) are dynamically generated classes that the JVM does not link to a name in any class loader. Lambda implementations and bytecode-rewriting tools (byte-buddy, ASM) use them.
Hidden classes can have static fields like any class. The lifecycle is bound to the MethodHandles.Lookup that defined them — once the lookup is unreachable, the hidden class can be unloaded, freeing its metaspace (including its static fields).
This is how lambdas avoid the metaspace leak that plagued earlier dynamic-class systems (cglib, javassist) — modern lambdas use hidden classes that the JVM can clean up.
15. Tools you should know¶
| Tool | What it shows |
|---|---|
javap -v MyClass.class | Static field flags, ConstantValue attributes, <clinit> bytecode |
-Xlog:class+init | Class initialization events |
-Xlog:class+load | Class loading |
jcmd <pid> VM.metaspace | Metaspace usage by class loader |
jcmd <pid> Class.histogram | Live object counts (helps spot static-cache leaks) |
| Heap dump + Eclipse MAT | "Static path to GC root" analysis |
-XX:+PrintInlining | Confirm static methods are inlined |
MethodHandles.lookup().findStaticVarHandle(...) | Atomic / memory-ordered access to static fields |
JFR jdk.ClassInitialization events | Per-class init duration |
16. Professional checklist¶
For each static member on a hot path or in a long-lived service:
- Does it have a
ConstantValueattribute (compile-time inlining)? Confirm withjavap. - If it's a static cache, what's the eviction policy? Heap dumps clean?
- Is
<clinit>for this class quick?-Xlog:class+initto time it. - Are there cross-class init dependencies? Check for cycles.
- If invoked frequently: does the JIT inline it?
-XX:+PrintInlining. - If concurrent: is the field
volatile,final, or accessed viaVarHandle? - If a singleton: is it the lazy holder idiom or hand-rolled?
- If a constants class: are consumers required to recompile on change? Documented?
- For framework code:
MethodHandle.findStaticoverMethod.invoke? - For multi-classloader environments: does the static state correctly isolate per-loader?
Professional static use is rare and deliberate. The code that's still using it after senior review is genuinely class-scoped, runtime-cheap, JIT-friendly, and concurrency-safe — exactly the four properties static is supposed to deliver.