Heap Dump Explorer
The Heap Dump Explorer is a page in the Perfetto UI for analyzing Android Java heap dumps. For every reachable object it shows the class, the shallow and retained sizes, and the reference path from a GC root — so you can answer what is in the heap, what is keeping each object alive, and how much memory each one retains.
This guide covers:
- Heap dumps vs. heap profiles and when to use which.
- Capturing a heap dump, both the lightweight Perfetto heap graph and the fuller ART HPROF formats.
- How to use each tab of the explorer, starting with inspecting a single object — the view most investigations end up at.
- Worked case studies: a leaked
Activityand duplicate bitmaps.
Heap dumps vs. heap profiles
A Java heap profile samples allocations over time as a flamegraph of call stacks. It answers which code paths are allocating memory while the trace is recorded. See the Java heap sampler.
A Java heap dump is a snapshot of the heap at one point in time. It captures every reachable object, the references between them, GC roots and — depending on the format — field values, strings, primitive array bytes and bitmap pixel buffers.
The Heap Dump Explorer is for dumps. Use a heap profile instead for allocation call-path analysis.
What heap dumps are good for
- Memory leaks. An object is reachable that shouldn't be. The
reference path from a GC root points at the holder — typically a
static field, a cached listener, or a
Handlerposting to a destroyed context. - Retention surprises. An object is small itself but retains many megabytes through its references. The dominator tree and the Immediately dominated objects section show exactly what it is holding on to.
- Duplicate content. Multiple copies of the same bitmap, string or primitive array. The Overview groups them by content hash and shows the wasted bytes.
- Bitmap accounting. Which bitmaps are alive, how large they are and what is holding them.
- Class breakdowns. Which classes own the largest share of retained memory.
What heap dumps are not good for
- Allocation call paths. A heap dump is a snapshot, not a recording — it doesn't tell you which code allocated an object. Use a Java heap profile for that.
- Native-only memory. The dump covers the Java heap. For native allocations use the native heap profiler.
- Timing and performance. Heap dumps say nothing about when objects were created or how long operations took.
Capturing a heap dump
Two formats are supported.
Perfetto heap graph (lightweight)
Captures the object graph — classes, references, sizes, GC roots — but not field values, strings, primitive array bytes or bitmap pixels. Enough for retention, dominator and class-breakdown analysis.
Pros:
- Privacy-safe — no string values, pixel buffers or field contents leave the device, so it can be captured from real users in the field without leaking sensitive data.
- Does not require a
debuggableprocess. - Integrates with the rest of the Perfetto tooling: you can capture a heap graph alongside heap profiles, memory counters and other data sources in a single trace.
Cons:
- No content-based analysis — the Strings, Arrays and Bitmaps tabs and the duplicate-content detection on the Overview are unavailable.
Choose this format for leak investigations, dominator analysis and class breakdowns, especially when capturing from non-debuggable production builds.
$ tools/java_heap_dump -n com.example.app -o heap.pftrace
Dumping Java Heap.
Wrote profile to heap.pftraceUse --wait-for-oom to trigger on OutOfMemoryError, or
-c <interval_ms> for continuous dumps. See
Java heap dumps for the
full config and
OutOfMemoryError heap dumps
for the OOM-triggered variant.
ART HPROF (full detail)
Everything the heap graph has, plus field values, primitive array contents, string values and bitmap pixel buffers. Required for the Strings, Arrays and Bitmaps tabs and for the duplicate-content detection on the Overview tab.
Pros:
- Full visibility — field values, string contents, bitmap pixels and primitive array bytes are all available.
- Enables duplicate-content detection and the Bitmaps gallery.
- The HPROF format is also understood by other tools such as Android Studio.
Cons:
- Much slower to capture and freezes the target process for several seconds (Perfetto works on a forked copy so the main process is unaffected).
- Produces larger files.
- Contains the full contents of the heap, so it is not suitable for capturing from real users — it will contain any sensitive data in memory.
- Requires a
debuggableprocess.
Choose this format when you need content-level detail: hunting duplicate bitmaps, inspecting string values, or exporting to other tools.
$ adb shell am dumpheap -g -b png com.example.app /data/local/tmp/heap.hprof
$ adb pull /data/local/tmp/heap.hprof
File: /data/local/tmp/heap.hprof-b encodes bitmap pixel buffers as the given format (png, jpg,
or webp) and is required for the Bitmaps gallery to render pixels.
-g forces a GC before the dump, so unreachable instances don't
appear in the result — use it when hunting a suspected leak. The
target process must be debuggable (a userdebug/eng build, or an
APK with android:debuggable="true").
NOTE: Sections marked requires HPROF below are hidden on traces captured with the heap graph format.
Open the resulting trace by dragging it onto ui.perfetto.dev or clicking "Open trace file" in the sidebar.
Opening the explorer
There are two entry points:
Sidebar. Click "Heapdump Explorer" under the current trace. The entry only appears when the trace contains a heap dump.

From a heap graph flamegraph. Click a diamond in a "Heap Profile" track to open the heap graph flamegraph, click a node to select it, then click the menu icon in the node's details popup and pick "Open in Heapdump Explorer". This is covered in detail under Jumping from a flamegraph.

The explorer is organized as tabs across the top. Overview, Classes, Objects, Dominators, Bitmaps, Strings and Arrays are fixed. Tabs you open by drilling into a specific object or flamegraph selection are appended on the right and can be closed.

All tabs share the underlying heap_graph_* tables. Blue links — a
class name, an object id, a Copies count — navigate to the
corresponding tab pre-filtered.
Overview
NOTE: The duplicate sections require HPROF.
The Overview is the default landing page and summarizes the dump:
- General information. Reachable instance count and the list of
heaps in the dump (typically
app,zygote,image). - Bytes retained by heap. Java, native and total sizes per heap, with a total row at the top. Use this to see whether the problem is on the Java heap, in native memory, or both.
- Duplicate bitmaps / strings / primitive arrays. Duplicated content grouped by content hash. Each row shows the copy count and the wasted bytes; clicking Copies opens the relevant tab filtered to that group.

Classes
The Classes tab lists every class in the dump, sorted by Retained descending:
- Count — reachable instances.
- Shallow / Shallow Native — combined self-size of all instances.
- Retained / Retained Native — bytes freed if every instance became unreachable.
- Retained # — the number of objects that would go with them.
![Classes tab sorted by Retained; `byte[]` and `java.lang.String` at the top, `com.heapleak.ProfileActivity` further down with Count 1.](../images/heap_docs/05-classes.png)
Use this tab when you have a suspect class, or want a top-down view of which classes own the most memory. Clicking a class name opens Objects filtered to that class.
Objects
The Objects tab lists reachable instances. Opening it from Classes or from a duplicate group applies the filter automatically; opening it directly shows every object.
Each row has the object identifier (short class name + hex id), its
class, shallow and retained size, and its heap. java.lang.String
rows carry a badge with a preview of the value, so strings can be
scanned at a glance.

Clicking an object opens its object tab.
Typical uses: identifying a stale Activity after a leak, or the
instance of a data class holding the largest subgraph.
Inspecting a single object
The Shortest Path from GC Root, Dominator Tree Path and Objects with References to this Object are the key sections for most investigations. The shortest path shows the fewest reference hops keeping the object alive; the dominator tree path shows the chain of objects that exclusively retain it; the reverse references list every object holding a field pointer to it.
Clicking any object in any tab opens a closable tab for that instance. Multiple object tabs can be open at once.
The object tab contains everything known about the instance:
- Header with the object id, plus an Open in Classes shortcut
when the object is itself a
Class. - Bitmap preview for bitmap instances, with a download button.
- Shortest Path from GC Root — the shortest chain of references from a GC root to this object.
- Dominator Tree Path — the chain of dominators keeping this object alive, one step per row with the holder and the field name.
- Object info — class, heap, root type.
- Object size — shallow, retained and reachable sizes split by Java / native / count.
- Class hierarchy — the full inheritance chain up to
java.lang.Object, plus the instance size for class objects. Clicking any class opens Classes filtered to that class and its subclasses. - Static fields (for class objects), instance fields (for ordinary objects) or array elements (for arrays). Reference values are clickable and jump to the referenced object. For byte arrays, Download bytes exports the raw data.
- Objects with references to this object — the reverse references. Every instance that has a field pointing at this one.
- Immediately dominated objects — what would be freed if this instance became unreachable.
![Object tab (top) for `ProfileActivity 0x0004f1ae`: Sample Path from GC Root goes `Class<ProfileActivity> → com.heapleak.ProfileActivity.history → ArrayList → Object[0] → ProfileActivity`; retained 117.6 KiB across 1,604 objects.](../images/heap_docs/12-object-tab-top.png)

Both sections auto-collapse on large objects — click the header to expand.
Dominators
The Dominators tab shows the
dominator tree
of the heap. In a directed graph, node a dominates node b when
every path from a root to b must pass through a. Applied to a heap:
if you free a, everything it dominates — every object reachable
only through a — is also freed. The dominator tree groups the heap
into these "freed-together" subtrees, making it easy to see which
single objects gate the largest chunks of retained memory.

Root Type (e.g. THREAD, STATIC, JNI_GLOBAL) identifies how each
dominator is itself kept alive. Click a row to open its object tab and
walk the reference path.
Use this tab when there is no specific suspect and the question is simply where the memory has gone.
Bitmaps
NOTE: Pixel previews and duplicate detection require HPROF.
The Bitmaps tab is a gallery of every android.graphics.Bitmap in the
dump. With an HPROF, each bitmap's pixels are rendered inline.

Each card shows the rendered pixels, dimensions (px and dp), DPI, retained memory and a Details button that opens the object tab. Pixel buffers may be RGBA, PNG, JPEG or WebP depending on how they were stored.
The Show Paths toggle adds the reference path from the GC root to
each card — the fastest way to spot an Activity, Fragment or
Handler holding leaked bitmaps.

Two tables at the bottom list bitmaps with and without pixel data, with filter, sort and export controls. Arriving via Copies on Overview pre-filters the tab by buffer content hash, leaving only the visually identical bitmaps in that group.
Strings
NOTE: The Strings tab requires HPROF.
The Strings tab lists every java.lang.String with its value. The
summary card reports the total number of strings, the number of
distinct values and the total retained memory. The gap between total
and distinct is memory spent on duplicates.

Filter by value to find data that was expected to be unique: a user id, a serialized config payload, an error message repeated thousands of times. Clicking a row opens its object tab, where the reverse-references section lists every object holding that string.
Arrays
NOTE: The Arrays tab requires HPROF.
The Arrays tab lists primitive arrays (byte[], int[], long[],
...) together with a stable content hash. Filtering by Content Hash
returns every array with the same bytes; this is how the Overview
detects duplicate arrays.

Two common uses: finding a large duplicated byte[] that backs an
image or serialized buffer, and jumping from a container object to
the primitive array holding its data.
Jumping from a flamegraph
The heap graph flamegraph has an Open in Heapdump Explorer action that opens the explorer on the list of objects matching a selected allocation path. Use it to inspect a flamegraph node object-by-object:
Click a diamond in a "Heap Profile" track to open the flamegraph.

Click a node to select it, then click the menu icon in the node's details popup. Pick "Open in Heapdump Explorer".

This opens a new closable Flamegraph Objects tab listing every object allocated along the selected path. Dominator flamegraph nodes produce a dominator-based selection; regular nodes produce a path-based selection.

From there, click any object to open its object tab, or use Back to Timeline to return to the flamegraph view.
Multiple flamegraph selections can be open at once, each as its own tab — useful for comparing two call stacks side by side.
Case studies
Finding a leaked Activity
A developer on a Kotlin app reports that rotating their profile
screen a few times drives the Java heap upward and never comes back
down. The screen is unremarkable — an Activity, a view hierarchy,
one avatar — and rotating should destroy the old instance. It
doesn't.
A quick grep turns up a "breadcrumb" list the team added a while
ago for crash reporting. It stores every ProfileActivity instance
created, and is never cleared:
class ProfileActivity : Activity() {
companion object {
val history = mutableListOf<ProfileActivity>() // never cleared
}
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setContentView(R.layout.profile)
history += this // <-- the bug
}
}The intent was to keep a lightweight trail of recent screens for
crash reports. What it actually does is pin every ProfileActivity
ever created: onDestroy runs on the old one, but the class's
static history list keeps a strong reference — along with the old
Activity's entire view hierarchy.
Capturing. The heap graph format is enough to chase an Activity leak; it carries the full object graph and GC roots:
$ tools/java_heap_dump -n com.example.app -o /tmp/profile.pftrace
Dumping Java Heap.
Wrote profile to /tmp/profile.pftraceRotate the device a handful of times first so multiple instances accumulate. Drag the file onto ui.perfetto.dev and click Heapdump Explorer in the sidebar.
Confirming the leak. Open Classes and find
com.heapleak.ProfileActivity. Count should be 0 after the user
has navigated away; here it's 5, one per rotation:

Clicking the class name opens Objects filtered to
ProfileActivity. Every row is one live instance:

Reading the reference path. Click the top row to open its object tab. The Sample Path from GC Root is the chain of field references keeping this instance alive:
![Object tab for a leaked ProfileActivity. Sample Path from GC Root: Class<ProfileActivity> → com.heapleak.ProfileActivity.history → ArrayList.elementData → Object[0] → ProfileActivity. Retained 117.6 KiB, ~1,600 reachable objects.](../images/heap_docs/12-object-tab-top.png)
Read bottom-up: the runtime keeps the java.lang.Class<ProfileActivity>
alive (as it does for every loaded class); that class has a
companion-object field history; that field points at an ArrayList
whose element 0 is this ProfileActivity. The hop from the class
object to history names the bug — a static list of Activities.
The Object Size block quantifies the cost: one leaked Activity is
pinning 117.6 KiB and ~1,600 reachable objects. Multiply by
five (the Count) and the leak is already ~600 KiB of Activity
graphs sitting in the heap. Further down the same tab are the
Objects with References to this Object and Immediately Dominated
Objects sections:

Expanding Immediately Dominated Objects shows everything going
down with the leak — the Activity's view hierarchy and the rest
of the state it transitively retains. None of it is supposed to
outlive the Activity; all of it does, because one companion-object
list is holding the root.
Fix. Never store an Activity in a static or companion-object
container. If you want a breadcrumb trail for crash reports, store
strings with a bounded capacity instead:
object Breadcrumbs {
private const val CAPACITY = 16
private val trail = ArrayDeque<String>(CAPACITY)
fun record(event: String) {
while (trail.size >= CAPACITY) trail.removeFirst()
trail.addLast("${System.currentTimeMillis()} $event")
}
}
class ProfileActivity : Activity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setContentView(R.layout.profile)
Breadcrumbs.record("ProfileActivity.onCreate")
}
}Re-run the same repro and re-dump. The Classes tab now shows
exactly one ProfileActivity — the currently visible screen —
instead of one per rotation.
This tiny demo saves ~1.5 MiB of app heap; a real screen with a
live view hierarchy sees the difference in tens of megabytes. Any
Activity subclass showing Count > 0 in a dump captured after
the user navigated away is a leak.
The same recipe finds the other common shapes of Activity leak —
delayed-message Handlers, unregistered listeners, coroutines that
outlived their scope. The last hop before the Activity in the
reference path always names the holder; the fix is to clear that
field at the right lifecycle callback.
Tracking down duplicate bitmaps
A Kotlin feed app is running out of memory on long scrolls. dumpsys meminfo com.example.feed reports a Graphics: line several times
bigger than the pixels actually on screen, and the in-app image
cache looks small. Something else is holding pixels.
The suspect turns out to be a RecyclerView adapter that decodes
each row's thumbnail from resources on every bind, and appends the
result to a companion-object list:
class FeedAdapter(private val res: Resources) : RecyclerView.Adapter<VH>() {
companion object {
val cache = mutableListOf<Bitmap>() // grows without bound
}
override fun onBindViewHolder(holder: VH, position: Int) {
val bmp = BitmapFactory.decodeResource(res, R.drawable.thumb)
cache += bmp // "cache" — actually just accumulates
holder.image.setImageBitmap(bmp)
}
// ...
}Every bind decodes a fresh copy of the same PNG. Every copy is then
held forever by cache. The pixels all hash to the same value, but
they're different Bitmap instances with different backing
byte[]s.
Capturing. Duplicate detection needs the hash of each bitmap's
pixel buffer, which only the HPROF format carries. -b png encodes
the pixels so the Bitmaps gallery can render previews:
$ adb shell am dumpheap -g -b png com.example.feed /data/local/tmp/feed.hprof
$ adb pull /data/local/tmp/feed.hprofScroll the feed long enough to reproduce the bloat before dumping —
the adapter's cache only grows on bind.
Triage on the Overview. The Overview groups bitmaps by pixel-buffer hash. Each row shows copy count, total bytes across all copies, and wasted bytes — what deduplicating to a single copy would save:

The row shows what was accumulated: twelve copies of one 128×128 asset, all with the same content hash. The Duplicate Strings and Duplicate Primitive Arrays cards below work the same way — same grouping, same sizing — and are useful when the wasted memory is in text (e.g. a config payload duplicated thousands of times) or primitive buffers. All three duplicate detectors require HPROF because they hash the actual content, which the heap graph format doesn't carry.
Drill into the copies. Click Copies on that row. Bitmaps opens pre-filtered to that content-hash group, so only those copies render as cards:

Find the holder. Toggle Show Paths. The reference chain below each card is the fields keeping that bitmap alive:

Every chain in the gallery is identical: Class<FeedAdapter>.cache → ArrayList → Bitmap. All twelve copies share one holder — a
cache-layer bug, one field to fix.
The shape of the chains is the diagnostic. Two other patterns to recognize on future investigations:
- Each copy has a different chain → call-site bug. There's no cache, or callers are bypassing it.
- The chain passes through an
Activity→ fix the Activity leak first (previous case study); the bitmaps will follow.
Fix. There's no real reason to keep a side list of Bitmaps at
all — Android already has a LruCache<K, Bitmap>, scoped to the
application, with eviction you control:
class FeedAdapter(private val res: Resources) : RecyclerView.Adapter<VH>() {
companion object {
private val cache = object : LruCache<Int, Bitmap>(4) {
override fun sizeOf(key: Int, value: Bitmap) = 1
}
}
override fun onBindViewHolder(holder: VH, position: Int) {
val key = R.drawable.thumb
val bmp = cache[key] ?: BitmapFactory.decodeResource(res, key).also { cache.put(key, it) }
holder.image.setImageBitmap(bmp)
}
// ...
}Verify. Scroll the feed the same distance, re-dump, re-open.
The Overview should declare No duplicate bitmaps found, and the
app-heap retained bytes should drop accordingly:

The wasted bytes total across all groups on the Overview is the cleanest single-number scorecard — watching it drop from dump to dump is how you confirm each fix and catch regressions.
See also
- Java heap dumps — recording config, troubleshooting and SQL schema reference.
- Memory case study — end-to-end guide
to investigating Android memory issues, covering
dumpsys meminfo, native heap profiles and Java heap dumps together. - OutOfMemoryError heap dumps — capturing a heap dump automatically on OOM.
- Native heap profiler — for allocation call-path analysis rather than heap contents.