> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# GC Roots in Java: How the JVM Decides What Is Garbage
- URL: https://www.ggorantala.dev/gc-roots-reachability/
- Published: 2026-10-13T02:53:00.000Z
- Updated: 2026-11-05T03:54:03.000Z
- Description: GC roots decide what the JVM keeps alive. Thread stacks, statics, JNI handles and references, measured on JDK 21, plus the JIT surprise that frees a live local.
- Author: Gopi Gorantala
- Tags: jvm, garbage-collection, jvm-internals

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.

```java
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());
    }
}

```

```log
$ 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](https://ggorantala.dev/card-tables-remembered-sets/?ref=ggorantala.dev) and [SATB marking](https://ggorantala.dev/satb-tri-color-marking/?ref=ggorantala.dev) 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`:

```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));
    }
}

```

```bash
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`:

```bash
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:

```bash
java Dump d.hprof && python3 hp.py d.hprof

```

**What you should see.**

```log
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`: the `java.lang.Thread` object 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](https://ggorantala.dev/java-memory-leak-heap-dump/?ref=ggorantala.dev), 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:

1. **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.
2. **Weak**: cleared at the next collection that finds the referent reachable only through weak edges. Lab 2: gone after 1 GC.
3. **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.
4. **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 what `Cleaner` is 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):

```log
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](https://ggorantala.dev/card-tables-remembered-sets/?ref=ggorantala.dev). 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

```log
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](https://ggorantala.dev/jvm-native-memory-tracking/?ref=ggorantala.dev)).

## 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](https://ggorantala.dev/minor-vs-major-vs-full-gc/?ref=ggorantala.dev)). 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](https://ggorantala.dev/zgc-colored-pointers/?ref=ggorantala.dev) 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. `-Xint` and 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](https://ggorantala.dev/java-gc-logs-xlog/?ref=ggorantala.dev).