Find the static that holds a cache¶
The problem¶
A suspect report names a collection that retains most of the heap, and its path to a GC root says orphan. Nothing in the path points at a root, but a collection that big
did not appear from nowhere. Something holds it, and the path walker cannot see what.
The solution¶
This recipe uses the demo dump from the repository: a JVM program with a session cache that never evicts anything.
Download it from Capture: JVM dumps and unpack it as demo.hprof.
-
Run the suspects report.
Heap: 16.97MiB total, 16.68MiB retained-reachable. Threshold: 5.0 % retained share. Showing 1 suspect(s) (1 above threshold). Suspect 1 — 15.79MiB (94.7 % of heap) dominator: java.util.HashMap (object_id=34095644696) accumulating: 4000 instances of byte[], total 15.65MiB path to GC root: Path from object_id=34095644696 (depth 0 step(s)): start ── id=34095644696 → orphan: no holder found in dumpOne
HashMapretains 94.7 % of the reachable heap, mostlybyte[]. Its path is empty:orphan. -
Confirm the path by hand, and note that it also ends at once:
-
Ask find-referrers who holds the map. Unlike the path walker, it includes class static fields.
Found 1 target instance(s) for id:34095644696 === Direct referrers (1-hop) === holder.field (or class[] for arrays) ref count shop.SessionCache.BY_ID 1shop.SessionCache.BY_IDis a static field. The map is a field of the class, so it lives as long as the class does, which is as long as the process. -
See what is in it. Ask who holds the entries:
Found 2000 target instance(s) for shop.Session === Direct referrers (1-hop) === holder.field (or class[] for arrays) ref count java.util.HashMap$Node.value 2000There are 2,000
Sessionobjects, each held as aHashMap$Node.value: one map entry each.
How it works¶
The path walker follows instance fields and array slots. A class static field is not an instance field, so a path that ends at a static ends in orphan, as if nothing held the object. The referrer mode scans the
statics as well, which is why it finds the owner in one step. The retained-size graph treats statics as roots, so a suspect report finds the map even though its own path section cannot explain it.
The fix lives in the code, not in heaptrail: the cache needs a limit. Bound it by size or age (an LRU map, or a weak or soft reference to the values), or remove entries when sessions end.
Variations¶
- No static, no root, no referrer. If find-referrers reports
(none)and the path saysorphan, the object is not reachable. It is garbage that the runtime has not collected yet. See Find duplicate payloads. - The static is a singleton. On Android, the holder is often
CompanionorINSTANCEof a Kotlin object, or a Dagger or Hilt component. The answer is the same: a long-lived owner. Check what it should be allowed to keep. - The path loops instead of ending. Repeated ids mean a cycle. Ask find-referrers about one of them. See Paths to a GC root.
- Obfuscated names. Add
--mappingso the static reads as a real class and field.
Related¶
- Find referrers lists statics among the holders.
- Paths to a GC root explains orphans.
- Leak suspects finds the collection in the first place.