Skip to content

Read the heap dump's domination from the classes up - #2933

Open
pyricau wants to merge 1 commit into
mainfrom
build-it-from-the-leafs-so-all
Open

Read the heap dump's domination from the classes up#2933
pyricau wants to merge 1 commit into
mainfrom
build-it-from-the-leafs-so-all

Conversation

@pyricau

@pyricau pyricau commented Aug 4, 2026

Copy link
Copy Markdown
Member

A fourth view in Shark Explorer, and the first one that isn't the dominator tree read from the roots down: every object of the heap dump on the row of its class along the bottom, and above each row the classes of the objects dominating it. An icicle chart of a reverse call tree, with "what retains this" in place of "who called this".

A column reads byte[] at the bottom, Bitmap above it, LruCache above that, and how wide it is says how much of the heap's byte[] bytes that accounts for.

Which is the question the other three views can't answer. They start from one object and walk out, so "what holds all the byte[] arrays, by class" means finding 16,730 rectangles scattered over the picture and adding them up by eye. Here it's the widest row, and what holds them is the row above it.

What the picture looks like

large-dump.hprof, 39 MB, 387,971 objects — the opening view of the classes tab:

  • 16,730 × byte[] — 10 MB, a third of the heap
  • above it, 151 × Bitmap accounting for 7.7 MB of those bytes, and 14,874 × Class for 2.2 MB
  • above the bitmaps, 2 × LinkedHashMap$LinkedHashMapEntry, 1 × LinkedHashMap and 1 × LruCache, each holding 3.9 MB of them
  • and where a column stops: Nothing in particular, the objects the dominator tree hangs off the whole heap dump because they're held from more than one place

Six rows of one column, in the picture the view opens on, with no chain walked and nothing clicked.

The weighting, which is the whole design

A row is weighed by the objects at the bottom of its column, in their own bytes. That's the one weighting that makes rows comparable: retained size would count an object's bytes again on every row above it, so the rows would add up to several times the heap and "a fifth of the dump" would mean nothing.

Shallow bytes add up to the dump exactly once, so:

  • the reverse root weighs exactly what the dominator tree's root weighs (asserted)
  • a row's width is its share of the whole heap dump at every level
  • a row's children cover it to the byte

Two kinds of row stop a column rather than leaving width over: objects nothing in particular dominates, and objects only uncollected garbage does.

What it costs

No second dominator computation — only immediateDominatorOf, one object at a time, plus the tree's nodes for shallow sizes and to know what Shark folded. And nothing at all until the view is opened. On large-dump.hprof, which takes 2.4 s to open:

Gathering every object onto its class row 0.20 s, 8,679 rows
The level above the widest row 0.01 s, 44 rows

What it holds between reads is at most one entry per object of the heap dump, however many levels are open: expanding a row hands its entries to the rows above it and drops its own, so the live entries stand for disjoint sets of the objects at the bottom of the columns.

Three decisions worth reviewing

Going up is done to the finished layout. StackLayout gains a rowsGoUp flag and flips every row once rowCount is known, because a row's distance from the bottom isn't known until the last row has been placed. contentHeight is clamped to the viewport when growing up, so the root row sits on the bottom edge of the view rather than of the rows, and StackView scrolls it with reverseScrolling so scrollTo(0) still means "where it opens".

The two trees share the root node id, so switching shape substitutes the root in the view request rather than in the history — which is what leaves the path into the tree being left there to come back to. Every other node is told apart by a range check at the far end of the range the treemap takes its pile ids from.

Every cell here is a pile of objects, including the root, so a row is coloured and outlined like an object instead of like a pile: washing all of them out and dashing all of their edges would spend the whole picture saying something no cell of it contradicts, and lose the hues that make a column readable. The count in the label (151 × Bitmap) says "pile" instead, and the two rows that stop a column keep their own look — slate and dashed for Nothing in particular, purple for the garbage.

That shared root is also the one thing a node id can't answer for, so what a clicked cell is, is read off the shape being drawn: the whole heap dump is a row of this view and the root of the other, and the classes a row had no room for are named after the cell they were left out of, which on a dump with 8,679 classes is that very node.

Tests

  • ReverseDominatorTreeTest — 13 cases on the tree: the rows gather by class weighed by shallow bytes, they add up to what the forward root weighs, the row above one is its dominators split by class in proportion, every row's children cover it exactly, both kinds of terminal row, pathToOpen, the column read nearest-first, and that a node of one tree is no node of the other.
  • StackLayoutTest — four upward cases: the root row across the bottom with contentHeight clamped to the viewport, a stack taller than the viewport starting at its own bottom, "going up changes nothing but where a row is", and hit testing.
  • ClassesViewTest — the window: the bottom row is the whole heap dump, pointing at a row says which class it gathers, clicking one says what holds it going down, and the classes a row had no room for say they're classes.
  • TreeLayoutTest — switching to the classes view lays the tree out exactly once, since that first layout is a pass over every object of the dump.

notes/treemap-rendering.md and notes/dominator-tree.md carry the design and the numbers; the module's AGENTS.md gains the one trap — the shared root node.

Try it:

./gradlew :shark:shark-explorer:shark-explorer-app:run \
  --args="--title=\"Classes view\" leakcanary/leakcanary-android-instrumentation/src/androidTest/assets/large-dump.hprof"

Pick Classes in the Shape row, then read up a column: click a row to give what dominates it the whole width, and the details panel lists the rows under it, nearest first.

🤖 Generated with Claude Code

A fourth view beside the treemap, the rings and the stack, and the first one
that isn't the dominator tree read from the roots down. Every object of the
dump goes on the row of its class along the bottom, and above each row are the
classes of the objects dominating it — an icicle chart of a reverse call tree,
with "what retains this" in place of "who called this". So a column reads
`byte[]` at the bottom, `Bitmap` above it, `LruCache` above that, and how wide
it is says how much of the heap's `byte[]` bytes that accounts for.

Which is the question the other three views can't answer. They start from one
object and walk out, so "what holds all the byte[] arrays, by class" means
finding 16,730 rectangles scattered over the picture and adding them up by eye.
Here it is the widest row, and what holds them is the row above it.

A row is weighed by the objects at the bottom of its column, in their own
bytes. That is the one weighting that makes rows comparable: retained size
would count an object's bytes again on every row above it, so the rows would
add up to several times the heap and "a fifth of the dump" would mean nothing.
Shallow bytes add up to the dump exactly once, so the reverse root weighs
exactly what the dominator tree's root weighs, a row's width is its share of
the whole heap at every level, and a row's children cover it to the byte. Two
kinds of row stop a column rather than leaving width over: objects nothing in
particular dominates, and objects only uncollected garbage does.

It costs no second dominator computation — only immediateDominatorOf, one
object at a time — and nothing at all until the view is opened. On
large-dump.hprof (39 MB, 387,971 objects, 2.4 s to open) the class rows are
gathered in 0.2 s and the level above the widest row in 0.01 s. What it holds
between reads is at most one entry per object of the dump however many levels
are open, because expanding a row hands its entries to the rows above and drops
its own. On that dump the opening picture is `16,730 × byte[]` at 10 MB, with
`151 × Bitmap` accounting for 7.7 MB of it, and above the bitmaps two map
entries, a LinkedHashMap and an LruCache each holding 3.9 MB of those bytes.

Three things were needed to make it a view rather than a screen. `StackLayout`
takes a rowsGoUp flag, applied to the finished rows since a row's distance from
the bottom isn't known until the last one is placed, and StackView scrolls it
with reverseScrolling so that scrollTo(0) still means "where it opens". The two
trees share the root node id, so switching shape substitutes the root in the
view request rather than in the history, which leaves the path into the tree
being left there to come back to. And every cell here is a pile of objects,
including the root, so a row is coloured and outlined like an object instead —
washing all of them out would spend the whole picture saying something no cell
of it contradicts — with the count in the label saying "pile" and the two rows
that stop a column keeping their own look.

That shared root is also the one thing a node id can't answer for, so what a
clicked cell is, is read off the shape being drawn: the whole heap dump is a row
of this view and the root of the other, and the classes a row had no room for
are named after the cell they were left out of, which on a dump with 8,679
classes is that very node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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