Skip to content

Read an evicting cache as an evicting cache, for the caches an app really has - #2939

Merged
pyricau merged 6 commits into
mainfrom
create-ownership-rule-that-bit
Aug 6, 2026
Merged

Read an evicting cache as an evicting cache, for the caches an app really has#2939
pyricau merged 6 commits into
mainfrom
create-ownership-rule-that-bit

Conversation

@pyricau

@pyricau pyricau commented Aug 5, 2026

Copy link
Copy Markdown
Member

The explorer's CACHE strength exists so that an object only a cache holds reads as
what it is: bytes that go away on their own. Until now it knew about one cache, Coil 3's,
because Coil wraps each cached value in a class of its own and naming that class and its
field is the whole of it.

The caches an app is more likely to have don't wrap anything. This adds them, in two
steps, both measured against dumps that have them.

Glide's pools

LruBitmapPool keeps bitmaps to hand out again rather than allocate, LruArrayPool keeps
the arrays a decode reads through, and both drop everything on onTrimMemory. A pooled
object is in memory because it is free, so reading those bytes as strongly held says an
app is using memory it has already finished with.

Both keep what they hold in one GroupedLinkedMap, so one entry covers the two of them.
On an API 36 dump of an app with two bitmaps pooled and four decode buffers: 5.1 MB and
15 objects move out of STRONG and read as CACHE, still dominated by the pool, since
nothing else holds a free object.

The caches with a plain map: android.util.LruCache, Picasso's, Glide's

These keep what they cache in a java.util.HashMap, and the only thing between the cache
and the value is a HashMap$Node every map of the heap dump shares. Weakening that node's
value field by class name would weaken every map there is; weakening the cache's map
field would take the table, the entries and the keys down with it, and a cache's own
bookkeeping belongs where it is, strongly held and attributed to the cache.

So which entries belong to a cache is read off the heap dump instead. CachedMapValues
reads the map of each cache and remembers which node holds which value; that one reference
then reads as CACHE and everything above it stays STRONG. It costs one pass over the
classes and one over the instances — 30 to 45 ms on large-dump.hprof, the biggest dump
in the repo — and is built on first use, as a whole map assigned at once, like the
explorer's other indexes.

What it does, measured:

  • large-dump.hprof: 3.9 MB — the 760×1262 bitmap and a 126×126 one, with their
    pixels — move out of STRONG and read as the Picasso cache they sit in. That is 13% of
    everything that dump held strongly, and it used to sit at the top of the tree with
    nothing to blame.
  • android.util.LruCache moves nothing on either dump, and is in the list for the app's own
    caches rather than for a measurement of its own: all fourteen instances of it in large-dump.hprof
    are the framework's or a library's — nine SQLite prepared statement caches, a job cache, a typeface
    cache, two empty — and hold no image.
  • An API 36 dump of an app loading through Glide: the three images nothing is
    displaying any more read as its LruResourceCache, while the three a view shows stay
    STRONG under the view. Which is the rule doing its job in both directions — a cache
    only holds what nothing else does.

A map's entries, in shark

Reading a cache's map started out as a walk of its own here — a field called table, next
down each bucket, value off every node, matched by name against the whole instance because
which class declares them differs between runtimes. That is a walk Shark already has, written
a second time and against names it knows better.

So this adds the half of Shark's own walk that has no opinion. OpenJdkInstanceRefReaders and
ApacheHarmonyInstanceRefReaders read a map into references straight from the map to each key
and value, the HashMap$Node between them left out because a leak trace shouldn't name it —
which leaves nothing to say about the node, and holding its value weakly is exactly what this
PR needs to say. The new shark.MapEntryReader asks those readers which implementation the dump
was written by and hands back a shark.HeapMapEntry per entry: the node, the key and the value.
It covers exactly the maps they cover, and which field to read is answered once rather than
twice. Null for an object that is no map, which is worth telling apart from a map with nothing
in it — a map allocates its table on the first put, so an empty one reads as no entries.

Tested against heap dumps of the test JVM itself, so the maps read are the ones the JDK in use
writes: HashMap, LinkedHashMap and ConcurrentHashMap read as their entries, a node is the
node the map holds the entry in, a null value is still an entry, an empty map is no entries, and
a WeakHashMap, a set and an object that is no map each read as no map at all.

Tests

Two new cases on a dump built with the real class and field names of a LinkedHashMap,
table and next included, with two buckets and a chain of two entries so that a walk
that only looked at the buckets, or only at the head of a chain, would come up short. One
asserts that exactly the payloads read as CACHE and every piece of the cache's
bookkeeping stays STRONG; the other that a payload a tile also holds is the tile's, on a
path that doesn't go through the cache.

🤖 Generated with Claude Code

pyricau and others added 2 commits August 5, 2026 02:24
A pooled object is in memory because it is free: LruBitmapPool keeps bitmaps to
hand out again rather than allocate, LruArrayPool keeps the arrays a decode reads
through, and both drop everything on onTrimMemory. Reading those bytes as
strongly held says an app is using memory it has already finished with.

Both pools keep what they hold in one GroupedLinkedMap keyed by the size and
config asked for, so the one entry covers the two of them. Measured on an API 36
dump of an app with two bitmaps in the pool and four buffers in the array pool:
5.1 MB and 15 objects move out of STRONG and read as CACHE, and the pool still
dominates them, since nothing else holds a free object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The caches the explorer knew about wrap each cached value in a class of their
own, so naming that class and its field is the whole of it. The ones an app is
most likely to have don't: android.util.LruCache, Picasso's and Glide's each
keep what they cache in a java.util.HashMap, where the only thing between the
cache and the value is a HashMap$Node every map of the heap dump shares.
Weakening that node's value field by class name would weaken every map there is,
and weakening the cache's map field would take the table, the entries and the
keys down with it, which is where a cache's own bookkeeping belongs.

So which entries are a cache's is read off the heap dump instead: CachedMapValues
walks the map of each cache and remembers what its entries point at, and that one
reference then reads as CACHE while everything above it stays strongly held. One
pass over the classes and one over the instances, 30 to 45 ms on large-dump.hprof,
built on first use like the explorer's other indexes.

Measured on large-dump.hprof: 3.9 MB, the two biggest bitmaps of the dump, move
out of STRONG and read as the Picasso cache they sit in — 13% of everything that
dump held strongly. On an API 36 dump of an app loading through Glide, the three
images nothing is displaying any more read as its LruResourceCache while the
three a view shows stay STRONG under the view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyricau
pyricau force-pushed the create-ownership-rule-that-bit branch from 381eaa1 to d0dcbbf Compare August 5, 2026 12:39
`android.util.LruCache` keeps a `LinkedHashMap`, and a map allocates its table on
the first put, so an empty cache has a null one. Reading that as a shape with no
`table` to read logged a line saying the cache couldn't be read: an idle app's
dump has five of them, so opening one reported five defects where there were
none.

While here, what this moves, from a probe of the dumps rather than from memory:
every one of the fourteen `android.util.LruCache` instances of `large-dump.hprof`
is the framework's or a library's own — nine SQLite prepared statement caches, a
job cache, a typeface cache, two empty — and holds no bitmap. The cached bitmaps
of that dump are Picasso's, four of them, of which the two nothing else holds are
the 3,899,984 B that move.
@pyricau
pyricau force-pushed the create-ownership-rule-that-bit branch from 4ddf433 to 6b4dfcc Compare August 5, 2026 17:45
pyricau added 3 commits August 6, 2026 05:45
… out

Shark reads the maps of a heap dump already, and turns each into references
straight from the map to every key and value: the `HashMap$Node` between them is
an implementation detail a leak trace shouldn't name, so nothing surfaces it.
That leaves nothing to say about the node, and a tool that wants to say the node
holds its value weakly — a cache in the explorer — has to walk `table`, `next`
and `value` by name for itself, which is the same walk written a second time,
against field names the two runtimes disagree about.

`MapEntryReader` is the half of that walk with no opinion, built out of the
readers rather than beside them: `OpenJdkInstanceRefReaders` and
`ApacheHarmonyInstanceRefReaders` are asked which implementation this dump was
written by, and each map reader they hand back can now be asked for its entries
as well as for its references. So it covers exactly the maps they cover, and
which field to read is answered once.

A `HeapMapEntry` is the node, the key and the value. Null for an object that is
no map, which is worth telling apart from a map with nothing in it: a map
allocates its table on the first put, so an empty one reads as no entries.
`CachedMapValues` walked the map of each cache itself: read a field called
`table`, follow `next` down each bucket, take `value` off every node, matching
names against the whole instance because which class declares them differs
between runtimes. Shark answers that question already, so this asks it instead.

What goes with the hand-rolled walk is the guard against a bucket chain that
loops, which a heap dump being a file can say. Nothing is lost: the explorer
walks every map of the dump through Shark's own reader to build the tree in the
first place, so a dump that would loop here loops there first.
The cache section still described a walk `CachedMapValues` no longer makes itself.
@pyricau
pyricau merged commit f89c7e1 into main Aug 6, 2026
16 checks passed
@pyricau
pyricau deleted the create-ownership-rule-that-bit branch August 6, 2026 04:46
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