Repository navigation
Read object ids unsigned, and print each leak trace object's id - #2992
Merged
Merged
Conversation
pyricau
force-pushed
the
leak-trace-object-ids
branch
2 times, most recently
from
October 15, 2026 16:06
a6c4229 to
6501cc4
Compare
pyricau
force-pushed
the
leak-trace-object-ids
branch
from
October 15, 2026 18:02
623cda3 to
5484e8d
Compare
pyricau
force-pushed
the
leak-trace-object-ids
branch
from
October 16, 2026 07:33
5484e8d to
8dde034
Compare
An object id is the address the heap dump recorded the object at. Shark has read a 4 byte id as a signed Int widened to a Long since its first parser, copied from perflib with the comment "As long as we don't interpret IDs, reading signed values here is fine". So every object above the 2 GB mark of an Android heap dump got a negative id, 0xffffffff82000010 for the object at 0x82000010. Nothing else spells it that way. Android Studio, ahat, MAT, jhat and VisualVM all show that object as 0x82000010, and ahat and MAT find nothing given the 16 digits. Several workarounds grew around the negative ids: HeapObject.positiveObjectId, hex helpers that mask the low 32 bits back off, and in Shark Dive a second, exact spelling for its files, two readings of every address typed into a note, and a range check instead of a sign check to tell a pile from an object. Every reader of an id now reads it unsigned, which removes all of those. The in-memory index stores ids as offsets from the smallest one, so its keys are unchanged and so are the numbers of the perf tests. HprofWriter now refuses an id that doesn't fit the heap dump's id size instead of writing its low bytes, which would read back as a different id now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main already writes object ids in hex in leak traces, HeapObject.toString(), the not found errors, the Shark CLI and the heap explorer. This covers what was left: - ObjectDominators.renderDominatorTree() named objects by their position in Shark's index, which nothing else can look up. Nothing in the repo called it except HprofRetainedHeapPerfTest, to explain a failure, so it moves into that test and names objects by their hex id instead. - Two consistency checks of HprofInMemoryIndex spelled ids by hand, which for a class above the 2 GB mark came out as 0x-61544530 in a production failure before ids were read unsigned. They use the shared helper now. - Two Shark Dive tree errors wrote node ids in decimal. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A leak trace names an object by its class and the fields that lead to it, which is enough to read the leak but not to find that object again in the heap dump it came from. Each object's line now ends with its id, `├─ java.util.ArrayList instance 0x12d4cd48`, so that it can be handed straight to Shark Dive: in a `shark://<heap dump>/object?id=…` link, or as the `object` of a `shark-dive --cli` command. It's the same spelling ahat, MAT, jhat and Android Studio show for that object. That's in LeakTrace.toString(), the LeakCanary activity and the leakcanary-app leak screen, so every leak trace Shark builds has it. It's display only. LeakTraceObject carries the id in a new objectId property, and only the rendering reads it: the leak fingerprint is still built from class and reference names, so it doesn't change. The serialVersionUID is bumped so that an analysis serialized before this fails to deserialize, rather than reading back an id of 0. The leakcanary-app leak screen printed the leaking object on every row of the path instead of that row's own object, which would have printed the same id down the whole trace. The LeakCanary activity shares its hex helper with its heap explorer, which had a private copy. Android Studio's on-device parser of leak traces expects the last word of that line to be the type, so it needs updating for this as it already does for `Verdict:`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The labels naming another leaking object read `Also retains leaking object 0x12d36d08 (android.app.ContextImpl)`, a second spelling of what the leak trace above them writes as `android.app.ContextImpl instance 0x12d36d08`. They now use that one: `Also retains android.app.ContextImpl instance 0x12d36d08`, and `Also retained by … instance 0x…, which has its own leak trace`. The labels that group several objects of a class keep their wording. Both go through one internal function, so the object line and the labels can't drift apart again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On ART it would wait for the daemon that enqueues cleared references, because its finalization sentinel goes through that daemon. On HotSpot, which these tests run on, it only runs the finalizers already queued. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pyricau
force-pushed
the
leak-trace-object-ids
branch
from
October 16, 2026 09:02
4a5ce47 to
53b15d3
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.
Four commits for the change, plus a comment on a test:
Read 4 byte object ids unsigned, like every other heap dump tool. This is the root fix. Every other change to how ids are spelled had been working around it.
Write the remaining object ids in hex. This finishes what Read reasons and inspector labels the way a note is, link notes to source, write ids in hex #2994 started, including deleting
ObjectDominators.renderDominatorTree(). It was Write the remaining object ids in hex #3012, which this PR replaces.Print each object's id on its line of a leak trace,
├─ java.util.ArrayList instance 0x12d4cd48, so it can be handed straight to Shark Dive. This is the change this PR was opened for.Name related leaking objects the way a leak trace names its objects.
Also retains leaking object 0x12d36d08 (android.app.ContextImpl)is nowAlso retains android.app.ContextImpl instance 0x12d36d08, the same spelling as the object lines above it.Also retained by …, which has its own leak tracechanges the same way. Both go through one internal function with the object line, so the two can't drift apart. Fromshark-cli analyzeonleak_asynctask_o.hprof:The labels that group several objects of a class keep their wording.
Say why the WeakHashMap tests don't use
System.runFinalization(). This is test-only. #3018 madeclearWeakKeys()inOpenJdkInstanceRefReadersTestwait for the Reference Handler thread before dumping, because HotSpot dumps the reference pending list as a JNI global root.System.runFinalization()looks like it would do the same, and on ART it does: its finalization sentinel goes through the daemon that enqueues cleared references (FinalizerReference.java#118). On HotSpot, which these tests run on, it only runs the finalizers already queued (Finalizer.java#L127-L145). With the Reference Handler suspended for 300 ms around the dump,System.gc()plusSystem.runFinalization()passed 1 run in 10, the same asSystem.gc()alone, and waiting for the sentinels passed 10 in 10. The KDoc ofclearWeakKeys()now says so.Fixes #2866.
Reading 4 byte ids unsigned
An object id is the address the heap dump recorded the object at. Android heap dumps record it in 4 bytes, and Shark has read those as a signed
Intwidened to aLongsince its first parser, 5b8ced493 in 2019. That code was copied from perflib'sHprofParser.java#228, comment included: "As long as we don't interpret IDs, reading signed values here is fine." As a result, every object above the 2 GB mark got a negative id,0xffffffff82000010for the object at0x82000010.No other tool spells it that way:
0x82000010as0xffffffff82000010Foo@2181038096 (0x82000010). It reads signed like perflib but masks to 32 bits before displaying.Foo@82000010NumberFormatExceptionFoo @ 0x82000010Foo@0x82000010int0x82000010in its Object ID columnSeveral workarounds had grown around the negative ids, and this PR removes all of them:
HeapObject.positiveObjectIdis deleted. #2365 added it in 2022 to mask the id the way Android Studio displays it, and it would now return the same value asobjectId.hexObjectId()used to mask the id down to its low 32 bits. Each is now a plain unsigned hex conversion.exactHexObjectId), while labels used the 32 bit address. Now there is one spelling.0x82182c00could have been eitherLong. It now reads it one way.Int.MIN_VALUEto tell one of its piles of small objects from a real object. It's now a sign check.The fix is in every place Shark reads an id:
HprofRecordReader.readId(),FieldValuesReader,ClassFieldsReader,ByteSubArray,FieldIdReaderandHprofPrimitiveArrayStripper.What changes for consumers:
HeapObject.objectId,ReferenceHolder.valueand GC root ids are now the positive address. Objects below the mark keep the ids they had, and 8 byte heap dumps are unaffected.HeapObject.positiveObjectIdis gone. UseobjectId, which is now the same value.HprofWriternow throws on an id that doesn't fit in the heap dump's id size, where it used to write the low bytes. Such an id would now read back as a different, positive id. One test relied on that truncation:ObjectArrayReferenceReaderTestwroteLong.MAX_VALUEas its dangling reference, and now writes0xffffffff, which is the same bytes.HprofRetainedHeapPerfTest/HprofIOPerfTestnumbers are unchanged.notes/decisions.mdsays so, along with thesedthat fixes such a file. If an agent passes in one of those leftover ids, it's refused as "no object of this heap dump", not as a pile.How often this matters: across the 115 ART heap dumps on my machine (API 22 to 36), 22 have objects above 2 GB, and some of those objects are classes. None of the 3,737 objects in their 556 leak traces was up there. One production dump did fail with
Heap dump has an object of class 0x-61544530 …, which is the same bug showing up in an error message.The remaining hex
#2994 already writes ids in hex in leak traces,
HeapObject.toString(), errors, the Shark CLI and the heap explorer. What was left:ObjectDominators.renderDominatorTree()is deleted. It named objects by their position in Shark's index (#1234), which no other tool can look up. Nothing called it exceptHprofRetainedHeapPerfTest, which uses it to explain a failure, so the renderer moved into that test and now prints hex ids.buildDominatorTree()stays.HprofInMemoryIndexconsistency checks now go through the shared helper.The id on each line of a leak trace
A leak trace names an object by its class and the fields that lead to it. That's enough to read the leak, but not to find the same object again in the heap dump. Each object's line now ends with its id. It's in
LeakTraceObject.toString(), so every leak trace Shark prints has it, and in the leak screens of the LeakCanary activity andleakcanary-app.From the sample app on an Android 16 emulator, built from this branch:
The LeakCanary activity's leak screen shows the same line,
com.example.leakcanary.MainActivity instance 0x207b608, with the id in the grey ofinstance.An earlier version of this PR put the id in a label,
Object id: 0x12d6c588. On the API 34 emulator I opened that heap dump in Shark Dive and ranshark-dive --cli describe_object object=<id>on each id it printed:0x12d6c588came back as theMainActivity(STUCK),0x12d4cd98as theTextView(STUCK),0x12c823a8as theLeakingThread(UNKNOWN) and0x6f9541b0as theFontsContractclass (EXPECTED). The id printed now is the sameHeapObject.objectId, just in a different place.It's display only.
LeakTraceObjectcarries the id in a newobjectIdproperty, and only the rendering reads it:a49095c3373b957532aff14eb32987bb75ffe9d5they did before.owningClassNameandreferenceDisplayName, so its grouping doesn't change. Only theleakTracetext it attaches as metadata gains the ids.LeakTraceObject's constructor takesobjectIdafterclassName, which is a shark ABI change. The constructor already changed in this release whenleakingStatusbecameverdict, so code that builds one has to change anyway. TheserialVersionUIDis bumped again so that an analysis serialized before this fails to deserialize, rather than reading back an id of 0.Android Studio's on-device leak trace parser breaks on the new line, though it's broken already.
NodeParser.isClassTypeExistexpects the last word of├─ java.util.ArrayList instanceto beinstance,classorarray. That parser has failed on this unreleased version sinceLeaking: YESbecameVerdict: Stuck, because it requires aLeaking:line. So this adds one more line for Studio to update, not a new break.Two smaller things in the same commit:
leakcanary-appleak screen drew the leaking object on every row of the path instead of that row's own object, which would have printed the leaking object's id all the way down. It now draws the row's object.invalidObjectIdErrorMessagenamed a missing objectUnknownObject0x…. It's nowUnknownObject instance 0x…, since the id is on the line anyway.The docs' sample traces gained ids, set by hand to realistic ART addresses, the same way the
Verdictrename edited them.fundamentals-how-leakcanary-works.mdnow says the id isn't part of the fingerprint.Tests
HighAddressObjectIdsTest: a leak trace through a static field, an instance field and an array entry, all above0x82000000, finds the leak with every id positive. Writing a sign-widened id throws.LeakTraceRenderingTest,HeapAnalysisStringRenderingTestandUnreachableObjectRenderingTestexpect the id on every object line.HeapDiveTest,DeepLinkTest,NoteTest,NodeIdsTest) now assert the 32 bit address.AgentToolsTestchecks that a leftover 16-digit id is refused as no object.shark-hprof,shark-graph,shark,shark-android(including both perf tests),shark-cli,leakcanary-android-coreand the four Shark Dive modules, plusdetektandcheckKotlinAbi. The ABI dumps change by losingrenderDominatorTreeandgetPositiveObjectId, and byLeakTraceObjectgainingobjectId. Theleakcanary-appandleakcanary-android-coreandroid test sources compile.🤖 Generated with Claude Code