Repository navigation
Add a referrers tool listing every object pointing at an object - #2993
Merged
Merged
Conversation
pyricau
force-pushed
the
py/referrers-tool
branch
from
October 7, 2026 10:57
db34f70 to
7259e95
Compare
pyricau
force-pushed
the
py/referrers-tool
branch
2 times, most recently
from
October 15, 2026 15:19
a3815dc to
8b5824b
Compare
pyricau
force-pushed
the
py/referrers-tool
branch
from
October 15, 2026 16:19
8b5824b to
b1b4287
Compare
pyricau
force-pushed
the
py/referrers-tool
branch
from
October 16, 2026 09:08
b1b4287 to
e4707bf
Compare
`ways_held` walks to the GC roots and keeps paths that share no object, so two holders under one parent come back as one way, and there was no way to ask an agent-side question as simple as "what points at this?". `referrers` answers it from `HeapDominatorTreemap.referrersOf`, which reads a second `ReferrerIndex` built over every reference rather than the ones the tree follows. The tree's index alone would have been wrong in the case that matters most: on large-dump.hprof the running MainActivity has 951 objects pointing at it and one in that index, because its views, context wrappers and window all lose to the ActivityThread that owns it. Each reference says whether the tree holds the object through it (`holds`), and the answer carries how many referrers hold it beside how many point at it. The second index is built on the first `referrersOf` and nothing the window draws asks for it. notes/referrer-index.md has its cost on eight dumps. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A referrer list capped at a limit left an agent no call that would hand over the rest, so referrers now lists every object pointing at the object, uncut. A single gcRootType said one root of an object that can have several, some holding it and some not. gcRoots lists each of them with its own holds, read the way a reference's is. A referrer is no step of a path, so it no longer carries a verdict or an isSuspect. It is the row find_objects answers with, plus the references it points through, which also means no inspector runs per referrer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A GC root on the object is a referrer like any object pointing at it, so it goes in the same list rather than a list of its own. holdingReferrerCount then counts a root that holds the object too, which is what makes it answer whether anything else keeps it in memory. The whole list for the running MainActivity of large-dump.hprof was 505,394 characters, well past what Claude Code hands a model inline. So referrers answers 30 at a time, limit up to 500, and nextOffset says where the next page starts. The order is the same on every call: holders first, a GC root ahead of an object, then the largest retained size. Sorting reads whether a referrer holds the object off the tree's own index, so only the objects of the page are read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d mark virtual references Shark reads a local variable of a thread the heap dump names twice: as a JAVA_FRAME root, and as a LOCAL reference from the Thread object. The tree followed the reference as a retaining one, so every such local was strong and an object a field also held had two holders, the thread and the field's owner. It was drawn under the whole heap dump. On large-dump.hprof that was 160 objects retaining 45 KB. The reference is now a weakening one at LOCAL, like the root, and the tree takes no JAVA_FRAME root a thread reads. An object only a stack holds is under its thread, and an object a field also holds is under the field. Every reference an agent is answered with now carries isVirtual, Shark's flag for a reference read off a structure rather than recorded in the dump. An ArrayList element has two referrers, the list through a virtual entry and the backing array through a real one, and the flag is what tells them apart. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The note said they were held by the main thread alone. Three are on HandlerThreads, and one has no native root: it is held by another of the seven. What they share is a JNI_LOCAL or NATIVE_STACK root that the wrong strength used to hide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A thread running native code holds objects through JNI_LOCAL and NATIVE_STACK roots, which name the thread by serial number the way a JAVA_FRAME root does. Shark reads a named thread's frames as LOCAL references from the Thread and drops the roots, and reads neither native kind that way. So an object a frame and native code of one thread both held had two holders, the thread and the native root, and was drawn under the whole heap dump. ReferenceStrengthReader now reads both native kinds as virtual LOCAL references from their thread, and the tree drops those roots the way Shark drops the frames. On large-dump.hprof that puts the 7 objects the previous change moved under the whole heap dump back under their thread, plus 3 Strings only a JNI_LOCAL root holds. Every dump here moves objects from under the whole heap dump to under a thread, and none the other way. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shark now reads a thread's JNI_LOCAL and NATIVE_STACK roots as LOCAL references from the Thread, through the JavaLocalReferenceReader this already reads frames with, and MatchingGcRootProvider drops the roots. So ReferenceStrengthReader's own reading of them would hand each one to the tree twice, and it goes. isReadAsAReference stays: the list of every GC root on an object comes from the heap dump rather than from the root provider, and it is what says such a root holds nothing. The tree is byte for byte what it was. On the production heap dump and the eight here, every node's dominator, strength and retained size match, and so do both referrer indexes, down to the order of each object's referrers and which of them are low priority. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The referrers tool and the investigation states from main put AgentTools past detekt's LargeClass threshold, and the config says the next tool to trip it should split the registry rather than raise the number again. list_devices, list_processes and dump_heap only need the open heap dumps and the key a new one is open under, so they are a class of their own, listed last as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The referrers of an object read its GC roots straight off the heap dump, which still has every root Shark moved onto a thread. So a local variable of a thread the dump has was listed twice: as the thread's virtual reference, and as a JAVA_FRAME, JNI_LOCAL or NATIVE_STACK root holding nothing. Telling the two apart took a copy of Shark's rule in ReferenceStrengthReader. TreeGcRootProvider now indexes what MatchingGcRootProvider keeps by object, and both the referrers and the root a path starts at read that. A local variable is listed once, as its thread's reference, and the copy of the rule is gone. On large-dump.hprof and on a 722 MB app dump every one of the 3,085 and 7,602 stack roots was on a thread the dump has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pyricau
force-pushed
the
py/referrers-tool
branch
from
October 16, 2026 09:21
e4707bf to
6f16df3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Takes over the
referrerstool builderbot drafted in this Slack thread. The thread started with "how do I get backward refs?":ways_heldwalks to the GC roots and keeps only paths that share no object, so it doesn't say which objects point at an object. builderbot posted the patch in three parts. Only part 1 arrived, and it was cut off partway through. That part had the docs row,AgentJson.referrers(), the session verb and the tool itself, and all four are in this PR in revised form. The core half,referrersOfand its model, never arrived, so I wrote it here.The last commits also change how the dominator tree reads a thread's local variables, which the referrers list made visible. See below.
Builds on #3015 and #3017, both merged. #3015 makes Shark read a thread's
JNI_LOCALandNATIVE_STACKroots as references from that thread, as it already read itsJAVA_FRAMEroots, and #3017 names those references<Native Local>. This PR used to read the native roots itself and now leaves that to Shark. The tree comes out the same.What changed from the draft
The draft read the
ReferrerIndexthat paths walk. That index only holds the references the dominator tree follows.WeakeningAwareReferenceReaderleaves out a weak reference to an object something holds strongly, and every reference to an owned object except the owner's. So for the objects people most often ask about, the draft would have reported one referrer as the complete list. Measured onlarge-dump.hprof:MainActivityActivityThread.activities)PaymentActivityMainActivitythe thread doesn't runTo get every referrer,
referrersOfreads a second index. That index is built overEveryReferenceReader, which returns every reference the tree's reader looks at, including the ones it drops. Each reference in the answer carriesholds, which says whether the tree holds the object through it.The answer
ObjectReferrersis the model andAgentJson.referrersthe JSON.limitup to 500, andoffsetto start further in.nextOffsetis the offset of the next page, null on the last. Anoffsetpast the end is refused with the count.find_objects(object,className,kind, the content fields, both sizes,strength) plus the references it points through. A GC root on the object is aGcRootReferrer:gcRootTypeandholds. The roots come fromTreeGcRootProvider.rootsOn, which lists what Shark'sMatchingGcRootProviderkeeps, andholdsfromTreeGcRootProvider.holds, which decides whether the tree hangs the object off that root. Nothing on a referrer is about a path, so there's no verdict and noisSuspect, and no inspector runs.referrerCountandholdingReferrerCountinclude GC roots, so "is this the only thing keeping it in memory?" is a count.kindisCLASS,INSTANCE,OBJECT_ARRAYorPRIMITIVE_ARRAY. A reference'slocationTypeisSTATIC_FIELD,INSTANCE_FIELD,ARRAY_ENTRY,LOCALorNATIVE_LOCAL. AndisVirtualis true when Shark reads the reference off a structure the heap dump doesn't record that way, such as anArrayListholding its elements. SeeisVirtual.The real first page from the window this PR leaves open, to
--cli referrers heapDumpKey=large-dump.hprof object=0x2c07c580, trimmed to its first two referrers of 30:{ "referrerCount": 951, "holdingReferrerCount": 1, "nextOffset": 30, "referrers": [ { "object": "0x2ac04100", "className": "android.app.ActivityThread", "kind": "INSTANCE", "shallowBytes": 190, "retainedBytes": 2154580, "strength": "STRONG", "references": [ { "name": "activities", "ownerClassName": "ActivityThread", "locationType": "INSTANCE_FIELD", "isVirtual": true, "holds": true } ] }, { "object": "0x2af21dc0", "className": "java.util.ArrayList", "kind": "INSTANCE", "shallowBytes": 20, "retainedBytes": 3248428, "strength": "STRONG", "references": [ { "name": "3", "ownerClassName": "ArrayList", "locationType": "ARRAY_ENTRY", "isVirtual": true, "holds": false } ] } ] }And an interned string,
object=0x71422ac8 limit=40, trimmed to its three holders and one of the 15 threads holding it in a local variable. Each thread is listed once, through its virtualLOCALreference. The heap dump also has aJAVA_FRAMEroot for each of those locals, and the list leaves those out:{ "referrerCount": 18, "holdingReferrerCount": 3, "nextOffset": null, "referrers": [ { "gcRootType": "VM_INTERNAL", "holds": true }, { "gcRootType": "INTERNED_STRING", "holds": true }, { "object": "0x7137b858", "className": "android.os.MessageQueue", "kind": "CLASS", "shallowBytes": 111, "retainedBytes": 236, "strength": "STRONG", "references": [ { "name": "TAG", "ownerClassName": "MessageQueue", "locationType": "STATIC_FIELD", "isVirtual": false, "holds": true } ] }, { "object": "0x75ace698", "className": "java.lang.Thread", "kind": "INSTANCE", "threadName": "????", "shallowBytes": 132, "retainedBytes": 7927, "strength": "STRONG", "references": [ { "name": "", "ownerClassName": "Thread", "locationType": "LOCAL", "isVirtual": true, "holds": false } ] } ] }A thread's local variable no longer holds what a field holds
When the heap dump names a frame's thread, Shark reads each
JAVA_FRAMEroot the dump records as aLOCALreference from theThread(JavaLocalReferenceReader). The tree followed that reference as a strong one, against whatGcRootStrength.ktsays a local is. So no object of any dump was everLOCAL. And an object a field also held had two holders, the thread and the field's owner, so it was drawn under the whole heap dump. Before this commit the interned string above had 18 holders, 15 of them threads.ReferenceStrengthReadernow builds the readersActualMatchingReferenceReaderFactorychains, lessJavaLocalReferenceReader, and reads a thread's locals as weakening references atLOCAL(localVariableReferencesOf). An object only a stack holds is under its thread. An object a field also holds is under the field.Native code a thread runs holds objects through
JNI_LOCALandNATIVE_STACKroots. These name their thread by serial number, as aJAVA_FRAMEroot does. Since #3015, Shark'sJavaLocalReferenceReaderreads all three kinds as references from the thread (LOCALfor a frame,NATIVE_LOCALfor the other two since #3017), andMatchingGcRootProviderdrops all three roots for a thread the heap dump has. So each local is one way to its object. The referrers list takes its GC roots from that same provider, so each local is also one referrer, its thread. It used to read them off the heap dump, which listed every local twice and took a copy of Shark's rule to mark the root as holding nothing. Onlarge-dump.hprofand on a 722 MB app dump, all 3,085 and 7,602 stack roots are on a thread the dump has.Measured against
mainwithout #3015, every object on aJAVA_FRAME,JNI_LOCALorNATIVE_STACKroot:LOCAL, wasSTRONGlarge-dump.hprofcompose_leak.hprofleak_asynctask_o.hprofOn
large-dump.hprofthe 160 are mostly what a parked thread waits on: 36 now under anAbstractQueuedSynchronizer$ConditionObject, 44 under aScheduledThreadPoolExecutoror itsDelayedWorkQueue, 16 under aLinkedBlockingQueue. The 3 areStrings only aJNI_LOCALroot on the main thread holds. Thedominator-tree.mdsection has the table.Reading the native roots is what keeps objects under their thread. With frames at
LOCALand the native roots left as roots, 7 objects oflarge-dump.hprofwent from under their thread to under the whole heap dump. Six have aJNI_LOCALorNATIVE_STACKroot besides their frame, on the same thread, and the seventh is held by one of the six. A strong local had made themSTRONG, so the strength rule dropped the native root. AtLOCALthe root was kept, a second holder beside the thread. Reading the native roots puts all 7 back under their thread, and likewise 5 oncompose_leak.hprofand 7 onleak_asynctask_o.hprof. Between reading them and not, no object of the eight dumps moved onto the whole heap dump. EveryJNI_LOCALandNATIVE_STACKroot of them names a thread the dump has. Leaving that reading to Shark changed nothing: on the eight dumps and a production one, every node's dominator, strength and retained size are the same, and so are both referrer indexes, down to the order of each object's referrers.One test fixture had to change.
taskHoldingItsOwnThreadHeapDumpbuilt its loop of objects that hold each other through a stack frame, and frames now hold nothing a field holds. It now closes the loop the way the framework does:SerialExecutor.mActiveholds the runnable, and the runnable holds the executor back throughthis$0. TheisAboveKDoc uses that example now. A newHeapVerdictTestcase pins that a stack frame closes no loop.isVirtualPathReference.isVirtualis Shark'sLazyDetails.isVirtual, andreferenceIntoputs it on every reference an agent is answered with, paths included. Shark sets it on a reference it reads off a structure:ArrayListholding its elements, read byInternalSharedArrayListReferenceReader;ActivityThread.activitiesholding the running activities, read byRunningActivityReferenceReader.What it goes through is still in the dump, with its own references, so an element of a list has both referrers you'd expect. The running
MainActivityabove has the list0x2af21dc0second on the first page, through a virtual[3]. ItselementDataarray0x2b42c318is a referrer too, at position 712 because it retains 40 bytes:{ "object": "0x2b42c318", "className": "java.lang.Object[]", "kind": "OBJECT_ARRAY", "arrayElementCount": 10, "shallowBytes": 40, "retainedBytes": 40, "strength": "STRONG", "references": [ { "name": "3", "ownerClassName": "Object[]", "locationType": "ARRAY_ENTRY", "isVirtual": false, "holds": false } ] }I named it
isVirtual, not "synthetic", to match Shark's field and the "virtual reference" that Shark Dive's code and notes already say. Happy to rename it.Cost
Memory and time. The second index is built the first time anything calls
referrersOf, and nothing the window draws calls it. So a session that never uses the tool pays nothing. Once built, it is up to 5% larger than the tree's index on seven of eight dumps, and 21% larger onunloaded_classes-stripped.hprof. Sorting a page reads whether each referrer holds the object off the tree's index, so only the page's objects are read. Onlarge-dump.hprof, the first call took 573 ms in the window this PR leaves open, building the index included. After it, a page of 30 took 6 to 17 ms and a page of 500 took 38 to 44 ms. The new section ofnotes/referrer-index.mdhas the table, re-measured once the native roots were read. Onlarge-dump.hprofthe tree's index lost 2,877 references when a frame stopped holding what a field holds. Reading the native roots then added 178 references to the index of every reference, and 9 to the tree's. It also explains why I didn't add a bit per reference to the existing index: that would change what the hover-path walk reads.Answer size. The running
MainActivity's first page is 16,734 characters, about 560 per referrer. Its whole list in one answer was 505,394 characters. Claude Code saves a Bash result over 30,000 characters to a file and hands the model a 2 KB preview, which I confirmed by making that call from a Claude Code session. A page of 500 is 281,799 characters, so a largelimitgoes past that cap again.notes/agent-surface.mdrecords this.Worth a look
shark-dive-agent/AGENTS.mdsays the agent surface and the window should match in both directions, and this PR is agent-only. The docs row says so. A "pointed at by" list in the details panel would be the window's version. I left it out because it's UI work with its own measuring to do before it can go in the hover path.WeakReference.referentrather thanReference.referent. That comes fromWeakeningReference.ofField, which paths already use, so this PR leaves it as it is. The tests pin the current behaviour.AgentToolswas split. The referrers tool and main's investigation states put the class past detekt'sLargeClassthreshold of 750 lines. The config says the next tool to trip it should split the registry rather than raise the number again, solist_devices,list_processesanddump_heapmoved toAgentDeviceTools, unchanged and still listed last.stepTo's reference-naming code is nowreferencesFrom, shared withreferrersOf, with no change in behaviour.AgentJson.pathReferenceand the referrer JSON sharereferenceInto, so the keys come out in the same order as before, withisVirtualafterlocationType.find_objectsandreferrersshareobjectListEntryInto.Tests
ReferrersTest(new) covers:LOCAL;JNI_LOCALand aNATIVE_STACKroot of that thread hold, held through its thread and listed as one<local variable>and two<native local>references, with no root;HeapVerdictTestreads its loop off the executor and its runnable, and adds a case pinning that a stack frame holding an object a field holds closes no loop.OwnerReferencesTestadds two cases:holdsfalse;View[]entry andInputMethodManager.mServedVieware listed withholdsfalse.AgentToolsTestcovers the JSON shape, including the absence ofverdictandisSuspect, a GC root as a referrer, and the refusal of anoffsetpast the end.:shark:shark-dive:shark-dive-core:test,:shark:shark-dive:shark-dive-agent:test,:shark:shark-dive:shark-dive-app:testanddetektpass locally on JDK 17, rebased onmain.There's no changelog entry because Shark Dive has none, and no ABI dump because these modules are in
modulesWithoutPublicApi.🤖 Generated with Claude Code