Skip to content

Gather the explorer's dominator rules into one rule set - #2923

Open
pyricau wants to merge 2 commits into
mainfrom
shark-explorer-dominator-rules
Open

Gather the explorer's dominator rules into one rule set#2923
pyricau wants to merge 2 commits into
mainfrom
shark-explorer-dominator-rules

Conversation

@pyricau

@pyricau pyricau commented Aug 1, 2026

Copy link
Copy Markdown
Member

The explorer applies a handful of curated rules on top of the exact dominator tree, so that a treemap says something true about an app: a view is held by its parent, an activity by the framework, a cached image by whatever is showing it. They had grown into four different places.

Rebased onto #2930, which landed the other half of this while it was open — see the last section.

One rule set

ExplorerRules now holds every curated list — the fields that weaken and how, and the OwnerRules — read by both halves of the edge set through ReferenceStrengthReader and OwnerReferences. One wiring point in HeapExplorer.open, and one file to read when asking whether a rule about a class we don't own is still true.

Why the explorer keeps its own copy of the java.lang.ref field names rather than importing shark's is worth writing down, because it isn't a preference: shark's matchers are a boolean. An IgnoredReferenceMatcher carries no payload, FieldInstanceReferenceReader drops what it matches with !is IgnoredReferenceMatcher, and LazyDetails carries only a matched library leak, so shark can't be asked why a reference was dropped and there is nowhere for "and it's weak" to live. Two ways out: add a strength-carrying matcher to shark — ReferenceMatcher is sealed, so an ABI change plus a reader that emits what it currently drops, on LeakCanary's hot path — or derive the ignore list from the strengths, which is what #2930 did.

One redundancy fell out: zombie on the four reference classes that don't declare it, which only Android's FinalizerReference has. That takes the matcher list from 12 to 8.

A/B on two real dumps, 12 matchers against 8: every per-strength byte and object count identical, totals identical, and the same number of children under the root weighing the same, on leak_asynctask_o.hprof and on the 39 MB large-dump.hprof.

The ActivityThread owns the activities it runs

The activity rule named ActivityThread$ActivityClientRecord.activity, which is the construct named one level too low: a record is an implementation detail of how the framework runs an activity, so a tree built on it draws every screen of an app under a different unnamed record instead of side by side under the one thread that runs them all.

ActivityThreadReferenceReader now gives the ActivityThread one virtual mActivities reference per running activity, read out of the ArrayMap it keeps them in, and the owner rule claims ownership through that. Same shape as a ViewGroup pointing at its children, and additive in the same way — the map, its array and each record are still nodes holding their own bytes, and it is the dominator tree that takes them out of the middle rather than any pruning.

Measured on leak_asynctask_o.hprof:

before after
live MainActivity retains 51,634 B 51,634 B
its dominator ActivityThread$ActivityClientRecord ActivityThread
the chain down to it mActivities → ArrayMap, mArray → Object[], 1 → ActivityClientRecord, activity → MainActivity mActivities → MainActivity
leaked MainActivity 211,038 B under MainActivity$2 unchanged

The leaked one being unchanged is the fallback doing its job: handleDestroyActivity takes the record out of mActivities, so a destroyed activity has no owner left and lands under whatever is leaking it.

The ArrayMap is read the way it reads itself — keys at the even slots, values at the odd ones, over the first mSize pairs. The bound matters more here than for a ViewGroup's children: an ArrayMap leaves the slots it gives up for the next put rather than nulling them, so past the count is exactly where the record of a destroyed activity is still written down, and attributing that activity to the framework would hide the leak. Pinned by an activity in a slot the map doesn't count is not one the process is running.

What #2930 changed about this PR

It answered the same question from the other end, and it found a bug doing it. This PR originally also gathered the prev/element/next links of Finalizer, FinalizerReference and Cleaner into the rule set as "fields followed for nothing", inherited from JdkReferenceMatchers.REFERENCES, with a note saying that following them at FINALIZER instead was a change worth measuring and nobody had measured it. #2930 measured it: those links are the only thing holding the finalizer and cleaner lists on Android, so ignoring them was calling 4773 of large-dump.hprof's 4774 FinalizerReferences garbage.

So that category is gone from ExplorerRules rather than gathered into it — a rule set is the wrong place for something that shouldn't exist — and weakeningReferenceMatchers derives from the weakening fields alone, which is what #2930 made it do. The A/B above was re-run against the post-#2930 baseline rather than carried over.

Notes

notes/dominator-tree.md gets the measurements above, a section on the rule set and on why shark's matchers can't carry a strength, and a subsection on the new reader beside the ViewGroup one. GcRoot.reachabilityStrength() is deliberately left as code and out of the rule set: it maps a sealed hierarchy, so making it data means mirroring that hierarchy in an enum of our own, which is more of exactly the duplication this PR is about.

ExplorerRules is internal, and HeapExplorer.open gained no rules parameter, because nothing would pass one yet. Surfacing the rules in the UI and letting them be edited is the follow-up this makes possible, and that is when the type earns being public.

Tested: :shark:shark-explorer:shark-explorer-core:check, …-app:check, …-jdwp:check, detekt included. #2930's JvmReferenceStrengthTest and its two HeapReachabilityTest list cases pass on the rebase.

🤖 Generated with Claude Code

pyricau and others added 2 commits August 3, 2026 23:31
Every curated list the explorer applies to a heap dump — which references
hold their target without retaining it, which reference is the one way an
object is held, which ones aren't worth following at all — is now in
ExplorerRules, read by both halves of the edge set.

The java.lang.ref classes used to be declared twice: their strengths in
ReferenceStrengthReader, and the ignoring of the same fields by importing
JdkReferenceMatchers.REFERENCES. That wasn't a design, it was the absence
of a place to put the strength — an IgnoredReferenceMatcher carries no
payload, FieldInstanceReferenceReader drops what it matches, and
LazyDetails carries only a matched library leak, so shark can't be asked
why a reference was dropped. Deriving the ignore list from the strengths is
what the cache and thread local entries already did.

Two entries fell out as redundant: KeyedWeakReference.referent, covered by
the WeakReference rule because an InstanceFieldPattern matches subclasses,
and zombie on the four classes that don't declare it. Measured before and
after on leak_asynctask_o.hprof and on large-dump.hprof: every per-strength
byte and object count identical, totals and root weight identical.

What stays inherited is now spelled out rather than imported: the finalizer
and cleaner queues' own list links, which are a decision about leak traces
rather than a strength, and which cost the explorer a queue with one
reachable entry and a tail of garbage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule said ActivityThread$ActivityClientRecord.activity owns an
activity, which is the construct named one level too low. A record is an
implementation detail of how the framework runs an activity, so a tree
built on it draws every screen of an app under a different unnamed record
instead of side by side under the one thread that runs them all.

So ActivityThreadReferenceReader gives the ActivityThread one virtual
reference per running activity, read out of the ArrayMap it keeps them in,
and the owner rule claims ownership through that — the same shape as a
ViewGroup pointing at its children, and additive in the same way: the map,
its array and each record are still nodes holding their own bytes, and it
is the dominator tree that takes them out of the middle.

Measured on leak_asynctask_o.hprof: the live MainActivity retains 51,634 B
either way, its dominator moves from ActivityThread$ActivityClientRecord to
ActivityThread, and the chain down to it goes from four steps to one. The
leaked MainActivity is untouched at 211,038 B under the MainActivity$2 that
leaks it, which is the fallback doing its job on a destroyed activity.

The ArrayMap is read the way it reads itself, keys at the even slots and
values at the odd ones over the first mSize pairs. The bound matters more
here than for a ViewGroup's children: an ArrayMap leaves the slots it gives
up for the next put rather than nulling them, so past the count is exactly
where the record of a destroyed activity is still written down, and
attributing that activity to the framework would hide the leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyricau
pyricau force-pushed the shark-explorer-dominator-rules branch from d8d70e4 to 9d2a314 Compare August 4, 2026 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant