Object Lifecycle — Senior¶
What? Lifecycle from the GC's point of view: how generational and region-based collectors actually run, what reachability analysis costs, what escape analysis does for short-lived objects, why finalizers are dead, and how
CleanerandPhantomReferencegive you safe, predictable cleanup. How? By understanding HotSpot's TLABs and bump-pointer allocation, the marking phases of G1 and ZGC, the JVM flags that surface this behavior, and the patterns the JDK itself uses (e.g.,CleanerinDirectByteBuffer, weak references in WeakHashMap).
1. The full lifecycle from the JVM's perspective¶
[allocation site] [TLAB bump] [eden] [survivor] [old] [unreachable] [reclaim]
│ │ │
young GC young GC old GC
(minor) (minor) (major / mixed)
Most objects live and die entirely in eden — never seen by an old-gen collection. This is the "weak generational hypothesis," empirically verified in production for decades.
| GC algorithm | Strategy | Pause goal |
|---|---|---|
| Serial | Stop-the-world copying young + mark/compact old | Single-threaded; small heaps |
| Parallel | Multi-threaded copying young | Throughput |
| G1 | Region-based, mostly-concurrent marking, copying evacuation | < 200ms typical |
| ZGC | Region-based, fully concurrent, colored pointers / load barriers | < 1 ms |
| Shenandoah | Region-based, fully concurrent, Brooks pointers | < 10 ms |
| Epsilon | No collection (testing only) | n/a |
G1 has been default since Java 9. ZGC is generational and production-ready since Java 21 — for low-latency services, prefer ZGC.
2. TLAB: where allocation actually happens¶
Each thread has a Thread-Local Allocation Buffer — a chunk (~512 KB by default) carved out of eden. Allocation is a pointer bump:
This is ~5–10 ns. No locks, no atomics. TLABs are why Java allocation often outperforms C malloc in throughput.
When the TLAB fills, the thread requests a new one (occasionally hitting a slow path that may trigger a young GC).
Useful flags: - -XX:+PrintTLAB (debug only) - -XX:TLABSize=... to override default sizing - -XX:-ResizeTLAB to lock the size (rarely useful)
3. Reachability analysis¶
A GC cycle starts by walking from GC roots:
- All static fields of loaded classes
- Local variables on every thread's stack (parsed via OopMaps)
- JNI globals/locals
- Live monitors and synchronizers
- The JVM's own internal references (class loaders, etc.)
Then it traces the object graph following reference fields. Anything not reached is garbage.
The key insight: the cost of GC is proportional to the live set, not the dead set. Allocating and discarding billions of short-lived objects is cheap; keeping a few thousand long-lived objects with deep reference graphs is expensive.
4. Escape analysis: when new is free¶
HotSpot's C2 (and JIT) performs escape analysis on hot methods:
- No-escape: the object is created, used, and discarded entirely within the method
- Arg-escape: it's passed to another method but never stored externally
- Global-escape: it leaks into a field, return value, or another thread
For no-escape objects, C2 may apply scalar replacement: instead of allocating, it splits the object's fields into local variables / registers. The new is eliminated.
double distance(double x, double y) {
Point p = new Point(x, y); // C2 may not allocate this at all
return p.magnitude();
}
If Point.magnitude() is inlined and p doesn't escape, after EA the method becomes equivalent to:
To verify: -XX:+UnlockDiagnosticVMOptions -XX:+PrintEscapeAnalysis -XX:+PrintEliminateAllocations.
EA is fragile — adding logging, throwing exceptions, or storing into a field defeats it. Don't rely on it; just don't fight it.
5. Finalizers: deprecated, deeply broken¶
Object.finalize() is deprecated for removal since Java 9. Reasons:
- No timing guarantee. Finalizers run on a low-priority background thread, possibly never.
- Resurrection.
finalize()can re-linkthisinto a reachable object, undoing GC's work. - GC pause hit. Finalizable objects need two GC cycles to collect.
- Security holes. A subclass's
finalize()can resurrect partially constructed objects, bypassing constructor invariants. - Threading. Finalizers run concurrently with other code on a special thread; locking is required.
Don't write finalize(). Ever. In new code, don't even override it.
6. The modern replacement: Cleaner¶
Java 9 introduced java.lang.ref.Cleaner — a safer, leaner mechanism for cleanup.
public final class Resource implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
private final State state;
private static class State implements Runnable {
long handle; // native resource
State(long handle) { this.handle = handle; }
@Override public void run() {
if (handle != 0) {
native_close(handle);
handle = 0;
}
}
}
public Resource() {
this.state = new State(native_open());
this.cleanable = CLEANER.register(this, state);
}
@Override public void close() {
cleanable.clean(); // explicit, prompt cleanup
}
}
Critical rules:
Statemust not hold a reference to the outerResource— that would prevent collection. Always use a static nested class.- The cleanup
Runnableshould be idempotent and fast. - Use
Cleaneras a safety net, not the primary cleanup. Always provideclose()and use try-with-resources.
DirectByteBuffer is the canonical example — its native memory is freed by a Cleaner.
7. Reference types: Soft, Weak, Phantom¶
| Type | Strength | Cleared by GC when… | Use case |
|---|---|---|---|
| Strong | normal | never (until unreachable) | default |
Soft | weak-ish | heap is under memory pressure | memory-sensitive caches |
Weak | weak | no strong references exist | canonicalizing maps |
Phantom | weakest | object has been finalized & unreachable; never returns the referent | post-mortem cleanup |
WeakHashMap: keys held by WeakReference. When a key is no longer strongly referenced anywhere else, the entry can be removed. Beware: if the value references the key, the entry never dies.
SoftReference: avoid as a "free cache." Modern JVMs are aggressive about clearing them under load, leading to thrashing. Use Caffeine with explicit size/time bounds instead.
PhantomReference: powers Cleaner internally. You can't access the referent; you only get notified when it's unreachable.
8. Reachability vs liveness — practical leaks¶
Common Java memory leaks, all of which are about reachability outliving usefulness:
- Static collections that grow unboundedly.
- Listeners/observers registered but never unregistered.
- ThreadLocals in pooled threads (e.g., Tomcat) — survive request lifetimes.
- Inner classes holding implicit
thisreferences (use static nested instead). - ClassLoader leaks — a single
Classin a static field of a parent loader pins the entire child loader and all its classes. - Caches without bounds or eviction.
Detection: heap dump + analyzer (Eclipse MAT, VisualVM, JFR + JOverflow). Look at "dominator tree" — objects that root a large amount of memory.
9. Allocation profiling¶
# Java Flight Recorder, low overhead in production
java -XX:StartFlightRecording=duration=60s,filename=app.jfr -jar app.jar
jfr print --events jdk.ObjectAllocationInNewTLAB,jdk.ObjectAllocationOutsideTLAB app.jfr
What to look for: - Allocation hotspots in tight loops - Large objects (OutsideTLAB) — these are slow-path allocations - Allocation rate > 1 GB/s sustained → GC will struggle
async-profiler for flame-graph allocation tracking:
10. Constructor performance: surprising costs¶
Things that look free but aren't:
- Defensive copying inside ctor:
this.list = new ArrayList<>(list)allocates and copies. - String formatting:
new RuntimeException("at index " + i)builds aStringBuildereven if exception is caught and ignored. - Anonymous classes capturing
this: everynew Runnable() { ... }allocates and pins outer instance. - Logging the constructor:
logger.debug("creating " + this)callstoString(), which may allocate.
Things that are nearly free: - Simple field assignment - Calling a final/private method - Reading a constant - Inheriting from Object
11. Object headers and memory layout¶
A typical 64-bit HotSpot object header:
+---------------------+----+
| mark word | 8 | identity hash, lock state, GC age
+---------------------+----+
| klass pointer | 4 | (with -XX:+UseCompressedClassPointers)
+---------------------+----+
| fields... | | packed by JVM, padded to 8-byte alignment
+---------------------+----+
Total per object: at least 16 bytes even for class Empty {}. Add fields, then pad to multiple of 8.
Project Lilliput (Java 24+) is shrinking the header to 4-8 bytes, which can save ~10% heap on data-heavy workloads.
Project Valhalla introduces value classes — no header, no identity. new Point(1, 2) would compile to two int registers. Aimed at Java 25-ish.
12. Lifecycle of immutable objects¶
public final class Money {
private final long cents;
private final String currency;
public Money(long c, String cur) { this.cents = c; this.currency = cur; }
}
- Final fields get a freeze action at the end of
<init>— guarantees that other threads see fully constructed values once they observe the reference (JLS §17.5). - Immutable objects can be safely shared without locks.
- They're naturally amenable to scalar replacement (since they have no mutating methods).
13. Practical checklist for production¶
- Use try-with-resources for any
AutoCloseableresource. - Don't override
finalize(). UseCleanerif you need a safety net. - Avoid leaking
thisfrom constructors (especially via listener registration). - Static collections are leak suspects — bound them.
- Use
WeakHashMapfor caches whose keys are tracked elsewhere. - Profile allocation with JFR or async-profiler before optimizing.
- Keep object graphs shallow — deep graphs make GC slower.
- Prefer immutable types for shared data.
14. Where to dig deeper¶
| Topic | File / source |
|---|---|
<init>/<clinit> bytecode | professional.md |
| GC algorithms in detail | OpenJDK gc/g1, gc/z source |
| JLS rules on initialization | specification.md |
| Common leak patterns + tasks | find-bug.md, tasks.md |
| Allocation tuning | optimize.md |
Memorize this: Lifecycle ≠ "is the variable in scope." Lifecycle = "is it reachable from a GC root." The GC owns reclamation; you own reachability. Modern GCs make allocation cheap and old-gen survival expensive — design for short-lived, immutable objects, and the JVM rewards you.