Read an evicting cache as an evicting cache, for the caches an app really has - #2939
Merged
Conversation
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
force-pushed
the
create-ownership-rule-that-bit
branch
from
August 5, 2026 12:39
381eaa1 to
d0dcbbf
Compare
`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
force-pushed
the
create-ownership-rule-that-bit
branch
from
August 5, 2026 17:45
4ddf433 to
6b4dfcc
Compare
… 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.
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.
The explorer's
CACHEstrength exists so that an object only a cache holds reads aswhat 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
LruBitmapPoolkeeps bitmaps to hand out again rather than allocate,LruArrayPoolkeepsthe arrays a decode reads through, and both drop everything on
onTrimMemory. A pooledobject 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
STRONGand read asCACHE, still dominated by the pool, sincenothing else holds a free object.
The caches with a plain map:
android.util.LruCache, Picasso's, Glide'sThese keep what they cache in a
java.util.HashMap, and the only thing between the cacheand the value is a
HashMap$Nodeevery map of the heap dump shares. Weakening that node'svaluefield by class name would weaken every map there is; weakening the cache'smapfield 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.
CachedMapValuesreads the map of each cache and remembers which node holds which value; that one reference
then reads as
CACHEand everything above it staysSTRONG. It costs one pass over theclasses and one over the instances — 30 to 45 ms on
large-dump.hprof, the biggest dumpin 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 theirpixels — move out of
STRONGand read as the Picasso cache they sit in. That is 13% ofeverything that dump held strongly, and it used to sit at the top of the tree with
nothing to blame.
android.util.LruCachemoves nothing on either dump, and is in the list for the app's owncaches rather than for a measurement of its own: all fourteen instances of it in
large-dump.hprofare the framework's or a library's — nine SQLite prepared statement caches, a job cache, a typeface
cache, two empty — and hold no image.
displaying any more read as its
LruResourceCache, while the three a view shows staySTRONGunder the view. Which is the rule doing its job in both directions — a cacheonly holds what nothing else does.
A map's entries, in
sharkReading a cache's map started out as a walk of its own here — a field called
table,nextdown each bucket,
valueoff every node, matched by name against the whole instance becausewhich 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.
OpenJdkInstanceRefReadersandApacheHarmonyInstanceRefReadersread a map into references straight from the map to each keyand value, the
HashMap$Nodebetween 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.MapEntryReaderasks those readers which implementation the dumpwas written by and hands back a
shark.HeapMapEntryper 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,LinkedHashMapandConcurrentHashMapread as their entries, a node is thenode 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,tableandnextincluded, with two buckets and a chain of two entries so that a walkthat 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
CACHEand every piece of the cache'sbookkeeping stays
STRONG; the other that a payload a tile also holds is the tile's, on apath that doesn't go through the cache.
🤖 Generated with Claude Code