Heap Dump Explorer

Heap Dump Explorer 是 Perfetto UI 中用于分析 Android Java heap dump 的页面。对于每个可达对象,它显示类、浅大小和保留大小,以及从 GC root 到该对象的引用路径——因此你可以回答堆中有什么、是什么让每个对象存活、以及每个对象保留了多少内存。

本指南涵盖:

Heap dump 与 Heap Profile 的对比

Heap Dump Explorer 用于 dump。如果你需要分配调用路径分析,请改用 Heap Profile。

Heap Dump 适合的场景

Heap Dump 不适合的场景

采集 Heap Dump

支持两种格式。

Perfetto Heap Graph(轻量级)

采集对象图——类、引用、大小、GC root——但不包括字段值、字符串、原始数组字节或 Bitmap 像素。足以进行保留、支配者和类分解分析。

优点:

缺点:

对于泄漏调查、支配者分析和类分解选择此格式,特别是在从不可调试的生产构建采集时。

$ tools/java_heap_dump -n com.example.app -o heap.pftrace Dumping Java Heap. Wrote profile to heap.pftrace

使用 --wait-for-oomOutOfMemoryError 时触发,或使用 -c <interval_ms> 进行连续 dump。完整配置参见 Java heap dumps,OOM 触发变体参见 OutOfMemoryError heap dumps

ART HPROF(完整详情)

包含 heap graph 的一切,外加字段值、原始数组内容、字符串值和 Bitmap 像素缓冲区。Strings、Arrays 和 Bitmaps 标签页以及 Overview 标签页上的重复内容检测需要此格式。

优点:

缺点:

当你需要内容级别的细节时选择此格式:追踪重复 Bitmap、检查字符串值或导出到其他工具。

$ 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 将 Bitmap 像素缓冲区编码为指定格式(pngjpgwebp),Bitmaps 画廊渲染像素需要此选项。-g 在 dump 前强制 GC,因此不可达的实例不会出现在结果中——在追踪疑似泄漏时使用它。目标进程必须是 debuggable 的(userdebug/eng 构建,或 APK 设置了 android:debuggable="true")。

NOTE: 下面标记为 requires HPROF 的部分在使用 heap graph 格式采集的 trace 上是隐藏的。

将生成的 trace 拖放到 ui.perfetto.dev 或在侧边栏点击 "Open trace file" 来打开。

打开 Explorer

有两个入口:

  1. 侧边栏。 在当前 trace 下点击 _"Heapdump Explorer"_。此条目仅在 trace 包含 heap dump 时出现。

    Perfetto UI 加载了 heap dump;侧边栏在"Current Trace"下显示"Heapdump Explorer"。

  2. 从 Heap Graph 火焰图。"Heap Profile" Track 上点击菱形图标打开 heap graph 火焰图,点击节点选中它,然后点击节点详情弹出窗口中的菜单图标,选择 _"Open in Heapdump Explorer"_。这在从火焰图跳转中详细介绍。

    Heap graph 火焰图,`java.lang.String` 节点被选中;详情弹出窗口列出其 Cumulative size、Root Type 和 Self Count,溢出菜单已打开并显示"Open in Heapdump Explorer"。

Explorer 顶部以标签页形式组织。Overview_、_Classes_、_Objects_、_Dominators_、_Bitmaps_、_StringsArrays 是固定的。通过钻取特定对象或火焰图选择打开的标签页会附加在右侧,可以关闭。

标签栏显示七个固定标签页和一个为 `ProfileActivity 0x00032f52` 打开的动态对象标签页。

所有标签页共享底层的 heap_graph_* 表。蓝色链接——类名、对象 id、Copies 计数——导航到相应标签页并预过滤。

Overview

NOTE: 重复部分 _requires HPROF_。

Overview 是默认着陆页,汇总 dump 信息:

Overview 标签页:General Information(跨 app/image/zygote 堆的 437,681 个可达实例),Bytes Retained by Heap(总计 24.4 MiB,app 堆上 1.5 MiB),以及一个重复 Bitmap 组,同一 128×128 图像的 12 个副本浪费 785.8 KiB。

Classes

Classes 标签页列出 dump 中的每个类,按 Retained 降序排列:

Classes 标签页按 Retained 排序;`byte[]` 和 `java.lang.String` 在顶部,`com.heapleak.ProfileActivity` 较下方 Count 为 1。

当你有可疑的类,或想要自上而下查看哪些类拥有最多内存时,使用此标签页。点击类名打开按该类过滤的 Objects。

Objects

Objects 标签页列出可达实例。从 Classes 或重复组打开会自动应用过滤器;直接打开则显示所有对象。

每行有对象标识符(短类名 + 十六进制 id)、其类、浅大小和保留大小,以及其所在堆。java.lang.String 行带有值预览徽章,可以一目了然地扫描字符串。

Objects 标签页过滤到 `java.lang.String`;437,681 个中的 106,474 个实例,按保留字节数排序。

点击对象打开其对象标签页。典型用途:识别泄漏后的过期 Activity,或持有最大子图的数据类实例。

检查单个对象

Shortest Path from GC Root_、_Dominator Tree PathObjects with References to this Object 是大多数调查的关键部分。 最短路径显示保持对象存活的最少引用跳数;支配者树路径显示独占保留它的对象链;反向引用列出每个持有指向它的字段指针的对象。

点击任何标签页中的任何对象都会为该实例打开一个可关闭的标签页。多个对象标签页可以同时打开。

对象标签页包含关于该实例的所有已知信息:

`ProfileActivity 0x0004f1ae` 的对象标签页(顶部):Sample Path from GC Root 为 `Class<ProfileActivity> → com.heapleak.ProfileActivity.history → ArrayList → Object[0] → ProfileActivity`;保留 117.6 KiB,跨 1,604 个对象。

对象标签页(底部):来自 `android.app.Activity` 的实例字段,"Objects with References to this Object"(来自视图和 Context 包装器的反向引用),以及"Immediately Dominated Objects"——如果此实例变为不可达将释放的视图层次结构。

两个部分在大对象上自动折叠——点击标题展开。

Dominators

Dominators 标签页显示堆的支配者树。在有向图中,节点 a 支配 节点 b 当从 root 到 b 的每条路径都必须经过 a。应用于堆:如果你释放 a,它支配的一切——每个_仅_通过 a 可达的对象——也会被释放。支配者树将堆分组为这些"一起释放"的子树,使你容易看到哪些单个对象控制着最大的保留内存块。

Dominators 标签页按 Retained 排序;`Class<ProfileActivity>`(root 类型 `STATIC`)和一个 `ProfileActivity` 实例靠近顶部,各自保留一个大型子图。

_Root Type_(例如 THREADSTATICJNI_GLOBAL)标识每个支配者本身是如何被保持存活的。点击行打开其对象标签页并遍历引用路径。

当没有特定的可疑对象,问题仅仅是内存去了哪里时,使用此标签页。

Bitmaps

NOTE: 像素预览和重复检测 _requires HPROF_。

Bitmaps 标签页是 dump 中每个 android.graphics.Bitmap 的画廊。使用 HPROF 时,每个 Bitmap 的像素会内联渲染。

Bitmaps 画廊:15 个 Bitmap,971.2 KiB 保留。同一图像的 12 个 128×128 副本内联渲染,每个 64.2 KiB。

每张卡片显示渲染的像素、尺寸(px 和 dp)、DPI、保留内存和打开对象标签页的 Details 按钮。像素缓冲区可能是 RGBA、PNG、JPEG 或 WebP,取决于它们的存储方式。

画廊上方的路径下拉菜单选择要在每张卡片上覆盖的引用路径:_Shortest path_(从 GC root 的最少边数)、_Dominator path_(支配者链)或 _No path_。显示路径是发现持有泄漏 Bitmap 的 ActivityFragmentHandler 的最快方式。

启用了"Show Paths"的 Bitmaps 画廊;每张卡片下方的引用链为 `Class<FeedAdapter>.cache → ArrayList → Bitmap`,显示唯一的静态持有者。

底部的两个表列出有和没有像素数据的 Bitmap,带有过滤器、排序和导出控件。通过 Overview 上的 Copies 到达会按缓冲区内容哈希预过滤标签页,只留下该组中视觉上相同的 Bitmap。

Strings

NOTE: Strings 标签页 _requires HPROF_。

Strings 标签页列出每个 java.lang.String 及其值。摘要卡片报告字符串总数、不同值的数量和总保留内存。总数和不同值之间的差距是花在重复上的内存。

Strings 标签页:105,868 个总字符串,71,176 个唯一,4.9 MiB 保留。总数和不同值之间的差距(约 30k 重复)是花在重复值上的内存。

按值过滤以查找预期唯一的数据:用户 id、序列化的配置负载、重复数千次的错误消息。点击行打开其对象标签页,反向引用部分列出持有该字符串的每个对象。

Arrays

NOTE: Arrays 标签页 _requires HPROF_。

Arrays 标签页列出原始数组(byte[]int[]long[]、...)及其稳定的内容哈希。按 Content Hash 过滤返回具有相同字节的每个数组;这是 Overview 检测重复数组的方式。

Arrays 标签页按 Shallow 排序,Content Hash 列可见;按哈希过滤返回共享相同字节的每个数组。

两个常见用途:找到支持图像或序列化缓冲区的大型重复 byte[],以及从容器对象跳转到持有其数据的原始数组。

从火焰图跳转

Heap graph 火焰图有一个 Open in Heapdump Explorer 操作,可以在匹配选定分配路径的对象列表上打开 Explorer。使用它逐对象检查火焰图节点:

  1. "Heap Profile" Track 上点击菱形图标打开火焰图。

    顶部 Timeline,点击进程 Track 上的 heap dump 菱形后底部面板中的 heap graph 火焰图。

  2. 点击节点选中它,然后点击节点详情弹出窗口中的菜单图标。选择 _"Open in Heapdump Explorer"_。

    火焰图,`java.lang.String` 被选中。其详情弹出窗口列出 Cumulative size(2.48 MiB, 10.48%)、Root Type(`ROOT_INTERNED_STRING`)、Heap Type 和 Self Count(53,546)。弹出窗口的溢出菜单已打开,"Open in Heapdump Explorer"在"Copy Stack"和"Copy Stack With Details"下方可见。

    这会打开一个新的可关闭的 Flamegraph Objects 标签页,列出沿选定路径分配的每个对象。支配者火焰图节点产生基于支配者的选择;常规节点产生基于路径的选择。

    在 `java.lang.String` 上选择"Open in Heapdump Explorer"后打开的 Flamegraph Objects 标签页:53,546 行,每行有类、浅/保留大小和堆。标签页附加在固定七标签栏的右侧,右上角有"Back to Timeline"链接。

  3. 从那里,点击任何对象打开其对象标签页,或使用 Back to Timeline 返回火焰图视图。

多个火焰图选择可以同时打开,每个作为自己的标签页——对于并排比较两个调用栈很有用。

案例研究

查找泄漏的 Activity

一个 Kotlin 应用的开发者报告,旋转个人资料屏幕几次后 Java 堆持续上升且不会回落。这个屏幕很普通——一个 Activity、一个视图层次结构、一个头像——旋转_应该_销毁旧实例。但它没有。

快速 grep 发现了一个团队之前为崩溃报告添加的"面包屑"列表。它存储了每个创建的 ProfileActivity 实例,并且从未清除:

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 } }

初衷是为崩溃报告保留最近屏幕的轻量轨迹。它实际上做的是固定了每个创建过的 ProfileActivityonDestroy 在旧实例上运行,但类的静态 history 列表保持着强引用——连同旧 Activity 的整个视图层次结构。

采集。 Heap graph 格式足以追踪 Activity 泄漏;它承载完整的对象图和 GC root:

$ tools/java_heap_dump -n com.example.app -o /tmp/profile.pftrace Dumping Java Heap. Wrote profile to /tmp/profile.pftrace

先旋转设备几次以积累多个实例。将文件拖放到 ui.perfetto.dev 并在侧边栏点击 _Heapdump Explorer_。

确认泄漏。 打开 Classes 并找到 com.heapleak.ProfileActivity。用户导航离开后 Count 应该为 0;这里是 5,每次旋转一个:

Classes 标签页。com.heapleak.ProfileActivity 的 Count 为 5——每次旋转一个实例,没有被回收。

点击类名打开过滤到 ProfileActivityObjects。每行是一个存活实例:

Objects 标签页过滤到 com.heapleak.ProfileActivity:五个实例,每个保留约 116.6 KiB 和 1,566 个可达对象。

阅读引用路径。 点击顶部行打开其对象标签页。Sample Path from GC Root 是保持此实例存活的字段引用链:

泄漏 ProfileActivity 的对象标签页。Sample Path from GC Root:Class<ProfileActivity> → com.heapleak.ProfileActivity.history → ArrayList.elementData → Object[0] → ProfileActivity。保留 117.6 KiB,约 1,600 个可达对象。

从下往上读:运行时保持 java.lang.Class<ProfileActivity> 存活(就像每个已加载的类一样);该类有一个 companion-object 字段 history;该字段指向一个 ArrayList,其元素 0 是这个 ProfileActivity。从类对象到 history 的跳转点出了 bug——一个 Activity 的静态列表。

Object Size 块量化了代价:一个泄漏的 Activity 固定了 117.6 KiB 和约 1,600 个可达对象。乘以五(Count),泄漏已经是堆中约 600 KiB 的 Activity 图。同一标签页更下方是 Objects with References to this ObjectImmediately Dominated Objects 部分:

对象标签页底部。来自 android.app.Activity 的实例字段,"Objects with References to this Object"和"Immediately Dominated Objects"。

展开 Immediately Dominated Objects 显示随泄漏一起释放的所有内容——Activity 的视图层次结构和它传递保留的其余状态。这些都不应该比 Activity 活得更久;但它们都活着,因为一个 companion-object 列表持有 root。

修复。 永远不要在 static 或 companion-object 容器中存储 Activity。如果你想要崩溃报告的面包屑轨迹,请改为存储有界容量的字符串:

object Breadcrumbs { private const val CAPACITY = 16 private val trail = ArrayDeque<String>(CAPACITY) @Synchronized 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") } }

重新运行相同的复现步骤并重新 dump。Classes 标签页现在只显示一个 ProfileActivity——当前可见的屏幕——而不是每次旋转一个。

这个小演示节省了约 1.5 MiB 的 app 堆;具有活跃视图层次结构的真实屏幕会看到数十兆字节的差异。在用户导航离开后采集的 dump 中任何 Count > 0Activity 子类都是泄漏。

同样的方法可以找到其他常见的 Activity 泄漏形式——延迟消息 Handler、未注册的 Listener、超出其作用域的协程。引用路径中 Activity 之前的最后一跳总是指向持有者;修复是在正确的生命周期回调中清除该字段。

追踪重复 Bitmap

一个 Kotlin 信息流应用在长时间滚动时内存不足。dumpsys meminfo com.example.feed 报告的 Graphics: 行比屏幕上实际像素大好几倍,而应用内图片缓存看起来很小。有其他东西在持有像素。

嫌疑对象原来是一个 RecyclerView adapter,它在每次绑定时从资源解码每行的缩略图,并将结果附加到 companion-object 列表:

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) } // ... }

每次绑定都解码同一 PNG 的新副本。每个副本然后被 cache 永久持有。像素都哈希到相同的值,但它们是不同的 Bitmap 实例,具有不同的后备 byte[]

采集。 重复检测需要每个 Bitmap 像素缓冲区的哈希,只有 HPROF 格式才携带。-b png 编码像素以便 Bitmaps 画廊渲染预览:

$ adb shell am dumpheap -g -b png com.example.feed /data/local/tmp/feed.hprof $ adb pull /data/local/tmp/feed.hprof

在 dump 前滚动信息流足够长时间以复现膨胀——adapter 的 cache 仅在绑定时增长。

在 Overview 上分诊。 Overview 按像素缓冲区哈希对 Bitmap 分组。每行显示副本数、所有副本的总字节数和浪费的字节数——去重到单个副本将节省多少:

Overview 标签页。Duplicate Bitmaps 卡片有一个 128×128 组:12 个副本,770.0 KiB 总计,785.8 KiB 浪费——正是 adapter 缓存列表的形状。

该行显示积累的内容:一个 128×128 资产的 12 个副本,都具有相同的内容哈希。下面的 Duplicate StringsDuplicate Primitive Arrays 卡片工作方式相同——相同的分组、相同的大小计算——当浪费的内存是文本(例如重复数千次的配置负载)或原始缓冲区时很有用。所有三个重复检测器都需要 HPROF,因为它们哈希实际内容,而 heap graph 格式不携带这些内容。

钻入副本。 点击该行的 _Copies_。Bitmaps 打开并预过滤到该内容哈希组,因此只有那些副本渲染为卡片:

过滤到 128×128 组的 Bitmaps 画廊。12 个副本每个 64.2 KiB,标签页中共 971.2 KiB 保留。

找到持有者。 将路径下拉菜单设置为 _Shortest path_。每张卡片下方的引用链是保持该 Bitmap 存活的字段:

启用了 Show Paths 的 Bitmaps 画廊。每张卡片的链为 Class<FeedAdapter>.cache → ArrayList → Bitmap——companion-object 列表是唯一的持有者。

画廊中的每条链都是相同的:Class<FeedAdapter>.cache → ArrayList → Bitmap。所有 12 个副本共享一个持有者——一个缓存层的 bug,一个需要修复的字段。

链的形状就是诊断。在未来的调查中要注意的另外两种模式:

修复。 完全没有理由保留 Bitmap 的旁路列表——Android 已经有 LruCache<K, Bitmap>,作用域限定到应用,具有你控制的淘汰策略:

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) } // ... }

验证。 滚动信息流相同距离,重新 dump,重新打开。Overview 应该显示 No duplicate bitmaps found,app 堆保留字节数应相应下降:

修复后 trace 的 Overview 标签页。Duplicate Bitmaps 卡片现在显示"No duplicate bitmaps found",app 堆保留内存从 2.1 MiB 降至 580.2 KiB。

Overview 上所有组的 wasted bytes 总计是最清晰的单数字记分卡——观察它从一次 dump 到下一次 dump 下降,就是你确认每个修复和捕获回归的方式。

另见