On This Page
I used to believe a local variable keeps its object alive until the end of its block. So I wrote a ten-line test to prove it, and the test failed.
What I expected, and what it printed
Here's the program. It allocates a 1 MB array, hangs a WeakReference off it, reads the local once, then burns a couple of million loop iterations and calls System.gc(). The local o is still in scope the whole time.
import java.lang.ref.WeakReference;
public class Live2 {
static boolean probe() {
Object o = new byte[1024 * 1024];
WeakReference<Object> w = new WeakReference<>(o);
int h = o.hashCode(); // last read of 'o'
long x = 0;
for (int i = 0; i < 2_000_000; i++) x += i ^ h; // hot loop, gets compiled
System.gc();
boolean cleared = w.get() == null; // 'o' is still in scope here
if (x == 42) System.out.println(x);
return cleared;
}
public static void main(String[] a) {
for (int r = 0; r < 4; r++) System.out.println("round " + r + " cleared=" + probe());
}
}
$ java -Xint -XX:+UseSerialGC Live2
round 0 cleared=false
...
$ java -XX:+UseSerialGC Live2
round 0 cleared=true
round 1 cleared=true
round 2 cleared=true
round 3 cleared=true
OpenJDK 21.0.12.1, Linux x86-64, Serial collector. Same source, same flags except -Xint. In the interpreter the array survives. With the JIT on, it's collected while o is still lexically in scope. -XX:TieredStopAtLevel=1 (C1 only) gives the same true. Nothing is wrong. The JVM did exactly what the specification lets it do, and the reason is the whole subject of this article: GC roots, and what "reachable" means to a collector as opposed to what it means to you.
If you only have a minute
A garbage collector doesn't look for garbage. It looks for the living and treats everything else as garbage by default. It starts from a small set of references it trusts without question (the roots), follows every reference from there, and marks what it touches. Whatever was never touched is dead. That's reachability analysis, and it's the only thing HotSpot has ever done. There's no reference counting anywhere, which is why a cycle of two objects pointing at each other is not a leak in Java.
So the useful question is never "is anything using this object?" It's "is there a path from a root to this object?" Your local variable is a root only while the JVM thinks the frame still needs it. After the last read, a compiled frame stops needing it.
In my pieces on card tables and SATB marking you've met roots from the other side: they're the starting grey set of every marking cycle.
Four small programs, one roots map
This took me a long weekend, mostly because the first versions of two of the labs lied to me. It's written up so you don't spend yours.
What you need. OpenJDK 21.0.12.1 (Ubuntu build, java -version shows 21.0.12.1+1-1-24.04.4-Ubuntu), Python 3 for the heap-dump parser, 2 cores, 8 GB. Nothing else. I ran every command below on that exact JDK. I did not run JDK 17 or 25, so everything version-specific outside 21 comes from JEP pages and is marked that way.
Lab 1: liveness. Live2.java above. Run it with -Xint, -XX:TieredStopAtLevel=1, and with no flags. Forcing levers: the 2,000,000-iteration loop exists only to make the JIT compile the method (via on-stack replacement), and System.gc() stands in for "some GC happens eventually."
Lab 2: reference strength. Refs.java:
import java.lang.ref.*;
import java.util.*;
public class Refs {
static volatile int fin;
static class Fin { byte[] pad = new byte[64]; @SuppressWarnings("removal") protected void finalize() { Refs.fin++; } }
static class Plain { byte[] pad = new byte[64]; }
static int gcCountUntilGone(WeakReference<Object> w) throws Exception {
int n = 0;
while (w.get() != null && n < 10) { System.gc(); Thread.sleep(100); n++; }
return n;
}
public static void main(String[] a) throws Exception {
WeakReference<Object> wp = new WeakReference<>(new Plain());
System.out.println("Plain : weak cleared after " + gcCountUntilGone(wp) + " GC(s)");
ReferenceQueue<Object> q = new ReferenceQueue<>();
Object f = new Fin();
PhantomReference<Object> ph = new PhantomReference<>(f, q);
WeakReference<Object> wf = new WeakReference<>(f);
f = null;
int n = 0; Reference<?> got = null;
while (got == null && n < 10) { System.gc(); got = q.remove(100); n++; }
System.out.println("Fin : finalize ran=" + Refs.fin + " weak.get()==null? " + (wf.get()==null)
+ "; phantom enqueued after " + n + " GC(s)");
SoftReference<Object> s = new SoftReference<>(new byte[1<<20]);
WeakReference<Object> w = new WeakReference<>(new byte[1<<20]);
for (int i=0;i<3;i++) System.gc();
System.out.println("Soft survives 3 GCs w/o pressure: " + (s.get()!=null) + "; weak survives: " + (w.get()!=null));
List<byte[]> hog = new ArrayList<>();
try { while (true) hog.add(new byte[1<<20]); } catch (OutOfMemoryError e) { hog = null; }
System.out.println("Soft after pressure cleared: " + (s.get()==null));
}
}
javac Refs.java
for o in "-XX:+UseG1GC" "-XX:+UseG1GC --finalization=disabled" "-XX:+UseSerialGC" "-XX:+UseParallelGC" "-XX:+UseZGC"; do
echo "== $o"; java -Xmx64m $o Refs 2>&1 | grep -E "Fin |Plain"; done
-Xmx64m is there so the soft-reference-under-pressure step finishes in a second. Production heaps are bigger and that step takes proportionally longer.
Lab 3: what a collector scans first. Roots.java starts N daemon threads, each parked at the bottom of a recursion F frames deep, plus a 200,000-element static array. Then it allocates garbage until G1 runs young collections. The log tag is gc+phases=trace:
javac Roots.java
java -Xmx512m -Xmn64m -Xss1m -XX:+UseG1GC -Xlog:gc+phases=trace:stdout Roots 2 10
java -Xmx512m -Xmn64m -Xss1m -XX:+UseG1GC -Xlog:gc+phases=trace:stdout Roots 200 500
-Xmn64m forces young collections within seconds. G1 picks its own young size in production.
Lab 4: which root categories the JVM reports. Dump.java takes a heap dump of a tiny program with a static field, a thread blocked in Object.wait(), and a live local. Then a 35-line Python script (hp.py) walks the HPROF file and counts the root sub-records by type. The dump format has a record tag per root kind, which is the closest thing to a machine-readable list of categories you'll get without opening the source:
java Dump d.hprof && python3 hp.py d.hprof
What you should see.
Lab 2, G1, Serial, Parallel, ZGC:
Plain : weak cleared after 1 GC(s)
Fin : finalize ran=1 weak.get()==null? true; phantom enqueued after 2 GC(s)
With --finalization=disabled:
Fin : finalize ran=0 weak.get()==null? true; phantom enqueued after 1 GC(s)
Soft survives 3 GCs w/o pressure: true; weak survives: false
Soft after pressure cleared: true
Lab 3, sum of "Thread Roots" across GC workers (one young GC):
2 threads x 10 frames 0.09 ms
200 threads x 500 frames 4.08 ms (the other GCs in that run: 7.95, 4.03)
Lab 4:
{'ROOT_THREAD_OBJ': 7, 'ROOT_JAVA_FRAME': 45, 'ROOT_JNI_GLOBAL': 43, 'ROOT_STICKY_CLASS': 736}
If Lab 1 prints false on a JIT-enabled run, the method wasn't compiled before System.gc(). Raise the loop count. If Lab 2 shows finalize ran=0 without the disabling flag, the object got collected before the finalizer thread ran; add a Thread.sleep(200). The Lab 4 counts will differ by a few on your machine; the categories won't.
Cleanup. rm d.hprof. A heap dump of a real service is gigabytes.
What counts as a root, and why everything else is a path
Think of the heap as a graph. Objects are nodes, references are edges. A root is a node the collector assumes is alive because something outside the heap graph holds it: the machine, the VM, the class system. That's the whole definition.
In the HPROF file from Lab 4 the root kinds show up as record types:
ROOT_JAVA_FRAME: a reference sitting in a local variable or operand stack slot of a live Java frame. 45 here.ROOT_THREAD_OBJ: thejava.lang.Threadobject of a running thread. 7 here (main plus the JVM's helper threads, plus mine).ROOT_JNI_GLOBAL: a global handle created through JNI (or by the JDK's own native code). 43 here. I didn't write any JNI. The JDK did.ROOT_STICKY_CLASS: classes the VM never lets go of, core classes loaded by the boot loader. 736 here.
The format has more kinds (JNI locals, native stack, monitors in use). A thread blocked in wait() didn't produce a monitor root in my dump, and I haven't chased why.
Notice what's missing: your static fields. HPROF doesn't give them a root type. Statics live inside java.lang.Class mirrors, and classes are held by their class loader, so a static is reachable because its class is reachable because its loader is. That's why a classloader leak keeps every static of every class it defined alive at once. I cover that failure shape in the heap dump article, where "path to GC roots" is the screen you live in.
Everything else, which is nearly everything you've ever allocated, is alive only because a path exists. Remove the last edge and the object is garbage that no thread can name. No bookkeeping on the object itself. That's the elegance, and it's also the reason the collector has to find the roots exactly: miss one and it frees a live object, and you get a crash that looks like memory corruption because it is.
The four reference strengths are different edges
Plain references are strong edges. java.lang.ref adds three weaker ones, and the collector treats each in a fixed order after marking through the strong graph:
- Soft: cleared only when the collector decides memory is tight. HotSpot's policy is
-XX:SoftRefLRUPolicyMSPerMB, default 1000 on 21.0.12.1: roughly one second of "time since last access" per free megabyte of heap before a soft reference is eligible. In Lab 2 the soft array survived three explicit GCs with plenty of room, and was cleared once the heap filled. - Weak: cleared at the next collection that finds the referent reachable only through weak edges. Lab 2: gone after 1 GC.
- Final: the finalizer machinery. An object with a real
finalize()is registered, and when it becomes unreachable the collector resurrects it onto a queue instead of freeing it. - Phantom: enqueued after the object is otherwise unreachable and finalized;
get()always returns null. It's a notification that memory can be reclaimed, nothing more. This is whatCleaneris built on.
That finalizer step is the one that costs real time. The object needs one GC to be found unreachable and have its finalizer queued, the finalizer thread has to run, and only a later GC can actually free it. My lab shows exactly that: phantom enqueued after 2 GC(s) instead of 1, on all four collectors I tried.
My first version of this lab said 1. I'd written protected void finalize() { }, an empty method, and the phantom reference came back after one GC. I spent an evening convinced finalization had been quietly sped up, until I changed the body to increment a counter and got 2. HotSpot doesn't register an object for finalization if its finalize() is empty. I haven't found where that's specified; I only know I observed it on 21.0.12.1. It's a nasty one for anyone writing a test.
Roots in HotSpot: oop maps, handle tables, and the phases in your log
I haven't read the HotSpot root-scanning source for this piece. What follows is what the logs, the dump format and the JEPs support, and where I'm guessing I say so.
A thread's roots are its stack frames. For an interpreted frame HotSpot can tell which slots hold references from the bytecode; for compiled frames the JIT emits an oop map at every point where a GC can happen, listing exactly which registers and stack slots hold references right then. "Right then" is the key phrase. The map describes live references at that program counter. After the last use of o in Lab 1, the compiled code's oop map no longer lists the slot, so the frame isn't a root for that array anymore.
Why does -Xint keep it alive? My reading, not verified in source: the interpreter treats every reference-typed local slot as live for the whole method. The language spec allows both behaviours.
The classic bug it creates: you pass a raw native handle to a native method, nothing in Java touches the wrapper object again, and it's collected mid-call. Reference.reachabilityFence(obj) exists for that.
The rest of the roots show up by name in the G1 log. This is JDK 21 gc+phases=trace for one young GC (I trimmed the timings):
GC(6) Ext Root Scanning (ms): Min: 1.98, Avg: 2.00, Max: 2.01, Sum: 3.99, Workers: 2
GC(6) Thread Roots (ms): Min: 2.04, Avg: 2.04, Max: 2.04, Sum: 4.08, Workers: 2
GC(6) CLDG Roots (ms): Min: 0.02 ...
GC(6) CM RefProcessor Roots (ms): Min: 0.00 ...
GC(6) VM Global Roots (ms): Min: 0.00 ...
GC(6) Thread OopStorage Roots (ms): Min: 0.01 ...
GC(6) ThreadService OopStorage Roots (ms): Min: 0.00 ...
GC(6) JVMTI OopStorage Roots (ms): Min: 0.00 ...
GC(6) JNI Global Roots (ms): Min: 0.00 ...
GC(6) Scan Heap Roots (ms): Min: 0.63, Avg: 0.69, Max: 0.75, Sum: 1.38, Workers: 2
GC(6) Found Roots: Min: 89408, Avg: 100310.0, Max: 111212, Sum: 200620
Thread Roots is the stack scan. CLDG Roots is the class loader data graph, which is how statics get reached. The OopStorage lines are the JVM's own tables of native-held references (JNI globals, JVMTI, the thread service); they replaced a patchwork of special-case root sets in the JDK 9 to 13 era, and I'd trust the JEP and JBS history over my memory for the exact release. Scan Heap Roots is something different and it's where my 200,000-element static array reappeared: it's the old-to-young pointers found through card tables and remembered sets. Those 200,620 "found roots" are mostly the array's elements pointing at young objects. A young GC treats them as roots, which is why that article's whole job is making them cheap to find.
That's the real surprise for me in all this, and it's a modest one: in a generational collector, "roots" isn't one list. There are VM roots, stack roots, and old-generation cards, and the log reports them in separate lines with separate costs.
Scaling in my lab: two threads with ten frames, Thread Roots summed to 0.09 ms. 200 threads with 500 frames, 4.08 ms (7.95 in the first collection of that run), and the average young pause went from 8.6 to 11.3 ms. Six or seven pauses each on 2 vCPUs, so don't quote the numbers; the direction is the point. Roots are scanned on every collection, stop-the-world, and the cost grows with threads times stack depth. Virtual threads change the math, since a parked virtual thread's stack lives on the heap as an object. I haven't measured that.
Version notes
JDK 8 Parallel is default; finalizers always on; PermGen -> Metaspace
JDK 9 G1 default (JEP 248); unified logging
JDK 18 JEP 421 deprecates finalization for removal; --finalization=disabled added (JDK-8276773)
JDK 21 Everything in this article was run here (21.0.12.1)
JDK 22+ Check your own: I didn't run 22, 25, 26 or 27
The log names (Thread Roots, CLDG Roots) are G1's on 21. Don't script against them across a major upgrade without re-checking.
Four ways people misread reachability
"Setting a variable to null helps GC." In a method about to return it does nothing, and the JIT often treats the slot as dead after its last use anyway (Lab 1).
"WeakReference means the object will be collected soon." Weak means it's eligible. Lab 2 cleared at the first GC because I asked for one. In a running service the referent may survive until the next collection that happens to scan its region. In G1, an old-generation referent waits for the concurrent mark cycle or a mixed collection, which can be minutes.
"A WeakHashMap can't leak." The key is weak; the value isn't. If the value references its own key, the entry never clears.
"finalize() is a safe fallback for closing resources." It frees memory a GC cycle later, it can resurrect objects, it runs on a thread you don't control, and it's deprecated for removal (JEP 421). Move cleanup to try-with-resources, with Cleaner as a safety net for forgotten closes.
-XX:+DisableExplicitGC is the flag everyone copies here. It doesn't touch reachability; it only turns System.gc() into a no-op, which breaks code that relies on it to free direct buffers (see the native memory piece).
What you can change, and what each costs
Most of this is code, not flags.
| Lever | Default (21) | What it does | Cost |
|---|---|---|---|
--finalization=disabled |
off (finalizers run) | finalize() never runs; JDK 18+ |
Anything relying on finalizers silently leaks. Test first. Lab 2: finalize ran=0. |
-XX:SoftRefLRUPolicyMSPerMB |
1000 (product) | Milliseconds of idle time per free MB before a soft ref can be cleared | Lower clears caches sooner; higher keeps them until closer to OOM. Never use soft refs as your primary cache policy. |
Reference.reachabilityFence(o) |
n/a | Keeps o a root up to that call |
Nothing at runtime; it's a compiler barrier. Put it in a finally. |
Cleaner instead of finalize() |
n/a | Phantom-based cleanup, no resurrection | A cleaner thread; the cleanup action must not reference the object. |
-XX:+DisableExplicitGC |
off (product) | Makes System.gc() a no-op |
Breaks direct-buffer cleanup that depends on it. |
Order I'd go in: first find where strong references are retained (a heap dump, not a flag), then remove finalizers, then look at thread count. Collector flags come last, because no flag changes what's reachable.
Seeing roots in the logs, and upgrade notes
Leave -Xlog:gc*:file=gc.log:time,uptime,level,tags on in production; gc+phases=trace is a lab setting. Turn it on temporarily when pauses grow. If Thread Roots dominates Ext Root Scanning, you have too much stack, not too much heap.
Signals to watch: young-pause growth that tracks thread count rather than allocation rate; jdk.GCPhasePause events in JFR; and anything calling System.gc() (the cause string in the log will say so, see the minor/major/full piece). For jdk.FinalizerStatistics, JFR reports classes that still have finalizers; I haven't verified its default-profile status on 21, so check jfr summary on your own recording.
Moving LTS to LTS: the thing most likely to bite is not a root, it's finalization. If you're on JDK 17 today and plan 25, grep your dependencies for finalize() and run your test suite once with --finalization=disabled on 21 to see what leaks. And the next time a JIT upgrade "breaks" a test that relied on an object living to the end of its scope, remember Lab 1: the test was wrong. The ZGC and Shenandoah concurrent root processing isn't something I examined here; the ZGC article will cover how those collectors avoid stopping the world for most of it.
The short list
- Reachability from roots is the only liveness test the JVM has. No reference counts, so cycles are free to die.
- A compiled frame stops being a root for a local after its last use. The interpreter doesn't.
-Xintand C2 will disagree about when your object dies, and the spec lets them. - Weak is "eligible next GC," soft is "when memory is tight," phantom is "tell me when it's gone." Finalizers cost an extra GC cycle, and an empty
finalize()isn't even registered. - In a generational collector the roots are VM tables, thread stacks, class loaders, and old-to-young cards. They cost different amounts and show up on different log lines.
- Heap dump root types are the fastest way to see what a root is. Statics aren't one of them.
Questions people ask about GC roots
What are GC roots in Java? References the collector treats as live without having to find them through another object: live stack-frame locals, running thread objects, JNI global handles, loaded classes, and a few VM-internal tables. Everything reachable from them is live. On JDK 21 a heap dump shows ROOT_JAVA_FRAME, ROOT_THREAD_OBJ, ROOT_JNI_GLOBAL and ROOT_STICKY_CLASS among others.
Is a static field a GC root? Effectively yes, but not directly. A static lives in its Class object, which is held by its class loader. As long as the loader is reachable, the static keeps its value alive. This is why classloader leaks are so expensive.
Does Java use reference counting? No. HotSpot's collectors (Serial, Parallel, G1, ZGC, Shenandoah) all trace from roots. That's why two objects that reference each other, with nothing else pointing at them, are collected.
Should I still use finalize()? No. JEP 421 deprecated it for removal in JDK 18, and JDK 18 added --finalization=disabled so you can test without it. Use try-with-resources and, if you need a safety net, Cleaner. For how the extra GC cycle looks in a log, see the GC log field guide.