Heap Dump Explorer
Heap Dump Explorer 是 Perfetto UI 中用于分析 Android ART heap dump 的页面。对于每个可达对象,它显示类、浅大小和保留大小,以及从 GC root 到该对象的引用路径——因此你可以回答堆中有什么、是什么让每个对象存活、以及每个对象保留了多少内存。
本指南涵盖:
- Heap dump 与 Heap Profile 的对比以及何时使用哪个。
- 采集 Heap Dump,包括轻量级的 Perfetto heap graph 和更完整的 ART HPROF 格式。
- 如何使用 Explorer 的每个标签页,从检查单个对象开始——大多数调查最终都会到达的视图。
- 火焰图的完整参考:每个节点和数字的含义(Cumulative、Self、Self Count)、四种指标、自上而下与自下而上、缩放、过滤器和透视。
- 实践案例研究:泄漏的
Activity和重复的 Bitmap。
Heap dump 与 Heap Profile 的对比
ART 分配 Profile 采样_随时间变化的分配_,以调用栈的火焰图呈现。它回答的是在采集 trace 期间哪些代码路径正在分配内存。参见 ART 分配 sampler。
ART Heap Dump 是_某一时间点堆的快照_。它采集每个可达对象、对象之间的引用、GC root,以及——取决于格式——字段值、字符串、原始数组字节和 Bitmap 像素缓冲区。
Heap Dump Explorer 用于 dump。如果你需要分配调用路径分析,请改用 ART 分配 Profile。
Heap Dump 适合的场景
- 内存泄漏。 一个不应该可达的对象却是可达的。从 GC root 出发的引用路径指向持有者——通常是静态字段、缓存的 Listener 或向已销毁的 Context 发送消息的
Handler。 - 保留大小意外。 一个对象本身很小,但通过其引用保留了许多兆字节。支配者树和 Immediately dominated objects 部分准确显示它持有什么。
- 重复内容。 同一 Bitmap、字符串或原始数组的多个副本。Overview 按内容哈希对它们分组,并显示浪费的字节数。
- Bitmap 统计。 哪些 Bitmap 是存活的、它们有多大以及是什么持有它们。
- 类分解。 哪些类拥有最大份额的保留内存。
Heap Dump 不适合的场景
- 分配调用路径。 Heap Dump 是快照,不是录制——它不会告诉你_是哪段代码_分配了一个对象。请使用 ART 分配 Profile。
- 纯 Native 内存。 Dump 覆盖的是 Java 堆。对于 native 分配,请使用 native heap profiler。
- 时间和性能。 Heap Dump 不涉及对象创建时间或操作耗时。
采集 Heap Dump
支持两种格式。
Perfetto Heap Graph(轻量级)
采集对象图——类、引用、大小、GC root——但不包括字段值、字符串、原始数组字节或 Bitmap 像素。足以进行保留、支配者和类分解分析。
优点:
- 隐私安全——没有字符串值、像素缓冲区或字段内容离开设备,因此可以在不泄漏敏感数据的情况下从真实用户采集。
- 不需要
debuggable进程。 - 与其他 Perfetto 工具集成:你可以在单个 trace 中同时采集 heap graph、Heap Profile、内存 Counter 和其他 DataSource。
缺点:
- 没有基于内容的分析——Strings、Arrays 和 Bitmaps 标签页以及 Overview 上的重复内容检测不可用。
对于泄漏调查、支配者分析和类分解选择此格式,特别是在从不可调试的生产构建采集时。
$ tools/java_heap_dump -n com.example.app -o heap.pftrace
Dumping Java Heap.
Wrote profile to heap.pftrace使用 --wait-for-oom 在 OutOfMemoryError 时触发,或使用 -c <interval_ms> 进行连续 dump。完整配置参见 ART heap dumps,OOM 触发变体参见 OutOfMemoryError heap dumps。
ART HPROF(完整详情)
包含 heap graph 的一切,外加字段值、原始数组内容、字符串值和 Bitmap 像素缓冲区。Strings、Arrays 和 Bitmaps 标签页以及 Overview 标签页上的重复内容检测需要此格式。
优点:
- 完整可见性——字段值、字符串内容、Bitmap 像素和原始数组字节全部可用。
- 启用重复内容检测和 Bitmaps 画廊。
- HPROF 格式也可被 Android Studio 等其他工具识别。
缺点:
- 采集速度慢得多,会使目标进程冻结数秒(Perfetto 在 fork 的副本上工作,因此主进程不受影响)。
- 产生更大的文件。
- 包含堆的完整内容,因此不适合从真实用户采集——它将包含内存中的任何敏感数据。
- 需要
debuggable进程。
当你需要内容级别的细节时选择此格式:追踪重复 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 像素缓冲区编码为指定格式(png、jpg 或 webp),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
有两个入口:
侧边栏。 在当前 trace 下点击 _"Heapdump Explorer"_。此条目仅在 trace 包含 heap dump 时出现。

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

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

所有标签页共享底层的 heap_graph_* 表。蓝色链接——类名、对象 id、Copies 计数——导航到相应标签页并预过滤。
Overview
NOTE: 重复部分 _requires HPROF_。
Overview 是默认着陆页,汇总 dump 信息:
- 常规信息。 可达实例数和 dump 中的堆列表(通常是
app、zygote、image)。 - 按堆保留的字节数。 每个堆的 Java、native 和总大小,顶部有总计行。使用此信息查看问题是在 Java 堆上、native 内存中还是两者都有。
- Out of Memory Error(仅限 OOM dump)。对于由
OutOfMemoryError触发的 dump,会细分失败的分配 — 分配大小、距离堆增长限制的空闲余量,以及原始错误消息。分配 stack 本身位于 Callstack 标签页。 - 重复的 Bitmap / 字符串 / 原始数组。 按内容哈希分组的重复内容。每行显示副本数量和浪费的字节数;点击 Copies 打开相关标签页并按该组过滤。

对于在 OutOfMemoryError 时捕获的 dump,Out of Memory Error 卡片位于 Bytes Retained by Heap 下方:

Flamegraph
Flamegraph 标签页一次性展示整个堆,按类聚合。如果说 Classes 和 Objects 回答"类 X 拥有多少内存",那么火焰图还会显示这些内存在引用图中的_位置_:从 GC root 开始的哪些引用链指向它。它通常是发现堆中某个子树异常庞大的最快方式。
同样的火焰图也会出现在 Timeline 中,当你点击 "Heap Profile" Track 上的 heap dump 菱形图标时——以下所有功能在那里完全相同。仅在 Timeline 变体中才有的几个额外功能在Timeline 火焰图末尾说明。

如何阅读火焰图
堆是一个_图_——对象可以自由地相互引用——但火焰图绘制的是_树_,因此图首先被转换:
- 从 GC root 开始,每个可达对象被放置在其从 root 出发的最短引用路径上(广度优先搜索;平局以确定性方式解决)。每个对象在树中恰好出现一次。
- 同一路径上的对象然后按类合并:每个类每个路径一个节点。名为
ArrayList且位于Class<ProfileActivity>之下的节点表示"所有最短路径从 root 出发经过ProfileActivity类对象的ArrayList实例"。
自上而下阅读:顶部的合成 root 行横跨整个 dump;其下方每一行距离 GC root 多一跳引用。节点的宽度与选定指标(字节数或对象数)在该节点整个子树中的值成比例。没有已知类名的对象显示为 [Unknown]。
一个容易误导人的注意事项:在这棵最短路径树中,节点的子树不等于它的保留大小。从两个地方引用的对象仅在其最短路径下绘制,但即使另一个引用被丢弃它也会存活。当你需要"实际会释放什么"时,切换到下面的支配者指标。
太窄而无法绘制的节点(约小于 3 像素)会折叠成一个灰色的 (merged) 节点。它们仍然计入每个总数;放大或添加过滤器即可单独查看。
选择指标
左上角的下拉菜单切换火焰图的度量方式:
| 指标 | 树结构 | 节点宽度表示 |
|---|---|---|
| Object Size | 最短路径 | 子树中所有对象的浅大小字节数 |
| Object Count | 最短路径 | 子树中的对象数量 |
| Dominated Object Size | 支配者树 | 如果子树顶部对象消亡将释放的字节数 |
| Dominated Object Count | 支配者树 | 如果子树顶部对象消亡将释放的对象数 |
两个 Dominated 指标从支配者树而非最短路径构建树:每个对象挂在_独占_保留它的对象下方。因此节点的 Cumulative 值就是它的真正保留大小——正是垃圾回收器在那些对象变为不可达时将回收的确切大小。使用 Object Size 来跟踪堆的实际引用结构,使用 Dominated Object Size 将内存归因于负责保留它的对象。
大小计算的是每个对象的 Java 浅大小。注册到 Java 对象的 Native 内存(例如现代 Android 上的 Bitmap 像素缓冲区)显示为标记为 [native] <ClassName> 的单独子节点,并计入所有 Cumulative 总数。未以此方式注册的 Native 内存根本不会出现在 dump 中;请使用 native heap profiler 来处理。
Cumulative、Self 和 Self Count
悬停或点击节点可查看其数值:

- Cumulative——此节点 及其下方所有内容 的指标总和。这与节点的宽度匹配。显示两个百分比:
all(占整个未过滤 dump 的份额)和parent(占父节点 Cumulative 值的份额)。 - Self——仅此节点 合并的对象的指标,不包括后代。对于 _Object Size_,即此节点自身实例的浅大小合计。Self 值大的节点本身就很重;Self 值小但 Cumulative 值大的节点是一个廉价的容器,持有一个昂贵的子树。
- Self Count——合并到此节点中的对象实例数量。在上面的截图中,
java.lang.String节点代表 53,546 个共享相同引用路径的独立字符串。(与大小指标一起显示;使用计数指标时主值本身已经是计数。) - Root Type——仅出现在本身是 GC root 的节点上:运行时如何固定它们,例如
ROOT_STATIC(静态字段)、ROOT_JNI_GLOBAL(JNI 全局引用)、ROOT_JAVA_FRAME(存活线程的栈)、ROOT_INTERNED_STRING。 - Heap Type——对象所在的 ART 堆:
app(进程自身的分配)、zygote(从 zygote fork 继承的)或image(预加载的 boot image)。泄漏几乎总是在app堆上。
由于节点仅在 Root Type 和 Heap Type 匹配时才合并,同一个类完全可能以多个兄弟节点的形式出现——例如每个堆一个 java.lang.String 节点。
Top Down 和 Bottom Up
右上角的单选按钮翻转聚合方向:
- Top Down(默认)——如上所述:root 在顶部,每一行距离 root 多一跳引用。
- Bottom Up——每个类的每次出现,无论其在树中的位置,都被合并到底部的单一行中;指向它的引用链堆叠在_上方_,最宽的引用者优先。用它来回答"所有
X总共持有多少,无论路径?"和"谁是X的最大引用者?"——这相当于整个堆上反向引用查询的火焰图等价物。
缩放
双击节点(或从其弹出窗口使用 _Zoom in_)将其拉伸到全宽。没有任何内容被过滤掉——祖先节点保持可见但变灰,总数不改变。双击 root 行即可缩小回原样。缩放纯粹是视觉上的;要实际削减数据,请使用过滤器。
过滤器
过滤器栏重塑树结构。直接输入,或按 + 按钮使用引导式表单。活跃的过滤器显示为芯片;双击芯片编辑它,点击其 x 删除它,或使用垃圾桶按钮清除全部。
模式是对类名进行区分大小写的正则表达式匹配;裸文本为子串匹配(String 匹配 java.lang.String),^/$ 将其锚定为精确匹配。模式也会匹配节点的 Root Type 和 Heap Type 值,因此 SS: ROOT_JNI_GLOBAL 或 SS: zygote 也同样有效。
有四种过滤器类型加上 Pivot。在过滤器栏中,在模式前加上短名称或全名作为前缀;没有前缀的文本成为 Show Stack 过滤器。多个过滤器可以一次性输入,用空格分隔:SS: main HF: alloc.*。
- Show Stack(
SS:)——仅保留包含匹配节点的路径;其他所有内容被移除。root行显示 dump 的剩余比例,例如root: 1.2 MiB (4.92%)。多个 Show Stack 过滤器 AND 组合:路径必须匹配所有。 - Hide Stack(
HS:)——反向操作:移除包含匹配节点的每条路径。在某些其他 profiler 中称为"Drop function"。 - Show From Frame(
SFF:)——仅保留匹配节点及其子树,丢弃其上方的祖先节点。用于单独研究一个类的子树,而不重新根图。在其他地方称为"Focus on subtree"。 - Hide Frame(
HF:)——删除匹配节点本身,并将其子节点拼接到其父节点上。这是折叠噪声行的工具:HF: java.lang.Object\\[\\]将数组合并掉,使容器内容直接附加到拥有该容器的任何内容上。在其他地方称为"Merge function"。
过滤器在切换指标或 Top Down/Bottom Up 时保持不变,栏旁边的复制按钮将活跃过滤器集复制为文本,以便分享或粘贴回来。
Pivot
Pivot(P: 在过滤器栏中,或从节点弹出窗口使用 _Pivot on matching frames_)将火焰图在匹配模式的每个节点处重新根图:
- 匹配的类成为中心行。
- 它引用的所有内容向下生长,与往常一样。
- 引用它的所有内容向上生长,方向反转。
这是"在一个画面中显示关于这个类的所有信息"的视图:它的总占用空间、由什么组成以及谁在持有它,无需逐个遍历对象。Pivot 显示为 Pivot: ... 芯片;一次只能有一个活跃(设置新的会替换旧的),Pivot 时 Top Down / Bottom Up 开关被禁用,移除芯片返回 Top Down。
对象标签页与 Pivot 直接集成:Shortest Path from GC Root 和 Dominator Tree Path 部分各有一个 View in Flamegraph 按钮,打开此标签页并 Pivot 到该特定实例的路径(芯片显示 ClassName (this instance)),分别使用 Object Size 或 Dominated Object Size 指标。
节点操作
点击节点打开其详情弹出窗口,包含四个菜单,汇集了从节点可做的所有操作:
- Focus——在不移除数据的情况下重新构图:_Zoom in_、_Focus on matching subtrees_(Show From Frame)和 _Pivot on matching frames_。
- Filter——重塑树结构:_Keep stacks matching name_(Show Stack)、_Hide stacks matching name_(Hide Stack)和 _Merge matching frames into caller_(Hide Frame)。
- Drill down——Show objects from this class 打开一个可关闭的 Flamegraph objects 标签页,列出节点背后的各个实例,从那里只需一次点击即可打开任何对象的对象标签页。(在 Timeline 火焰图中,此操作称为 _Open in Heapdump Explorer_。)
- Copy——Copy stack 将类名链从 root 到此节点复制为纯文本;Copy stack with details 将其复制为 markdown 表格,每行包含 Root Type、Heap Type、Cumulative、Self 和 Self Count——便于 bug 报告和代码审查评论。
从节点启动的操作精确匹配该节点(模式锚定为 ^name$),因此对 java.lang.String 过滤不会意外匹配 java.lang.StringBuilder。
Timeline 火焰图
Timeline 的 "Heap Profile" 详情面板中的火焰图有三个额外功能:
- 每个节点的 Drill down 菜单中有 _Open in Heapdump Explorer_,它会跳转到 Explorer 并打开该节点的 Flamegraph objects 标签页——参见从火焰图跳转。
root节点的菜单中有 _Reference paths by class_,打开一个聚合每条不同引用路径的表格:每个类和路径一行,包含路径数量、对象计数、总大小和总 Native 大小。它是火焰图的表格孪生体——相同的数据,但可排序和导出。- 如果 trace 中的 heap graph 不完整(dump 被截断),警告弹窗会提供显示导入错误的选项;火焰图仍用已到达的数据渲染。
Classes
Classes 标签页列出 dump 中的每个类,按 Retained 降序排列:
- Count——可达实例。
- Shallow / Shallow Native——所有实例的自大小合计。
- Retained / Retained Native——如果每个实例变为不可达将释放的字节数。
- **Retained #**——将随之释放的对象数。
![Classes 标签页按 Retained 排序;`byte[]` 和 `java.lang.String` 在顶部,`com.heapleak.ProfileActivity` 较下方 Count 为 1。](/perfetto-docs-zh-cn/docs/images/heap_docs/05-classes.png)
当你有可疑的类,或想要自上而下查看哪些类拥有最多内存时,使用此标签页。点击类名打开按该类过滤的 Objects。
Objects
Objects 标签页列出可达实例。从 Classes 或重复组打开会自动应用过滤器;直接打开则显示所有对象。
每行有对象标识符(短类名 + 十六进制 id)、其类、浅大小和保留大小,以及其所在堆。java.lang.String 行带有值预览徽章,可以一目了然地扫描字符串。

点击对象打开其对象标签页。典型用途:识别泄漏后的过期 Activity,或持有最大子图的数据类实例。
检查单个对象
Shortest Path from GC Root_、_Dominator Tree Path 和 Objects with References to this Object 是大多数调查的关键部分。 最短路径显示保持对象存活的最少引用跳数;支配者树路径显示独占保留它的对象链;反向引用列出每个持有指向它的字段指针的对象。
点击任何标签页中的任何对象都会为该实例打开一个可关闭的标签页。多个对象标签页可以同时打开。
对象标签页包含关于该实例的所有已知信息:
- 标题带有对象 id,以及当对象本身是
Class时的 Open in Classes 快捷方式。 - Bitmap 预览(对于 Bitmap 实例),带有下载按钮。
- Shortest Path from GC Root——从 GC root 到此对象的最短引用链。
- Dominator Tree Path——保持此对象存活的支配者链,每行一步,显示持有者和字段名。
- Object info——类、堆、root 类型。
- Object size——按 Java / native / 计数细分的浅大小、保留大小和可达大小。
- Class hierarchy——直到
java.lang.Object的完整继承链,加上类对象的实例大小。点击任何类打开按该类及其子类过滤的 Classes。 - Static fields(类对象)、instance fields(普通对象)或 array elements(数组)。引用值可点击并跳转到被引用对象。对于 byte 数组,Download bytes 导出原始数据。
- Objects with references to this object——反向引用。每个具有指向此对象字段的实例。
- Immediately dominated objects——如果此实例变为不可达将释放什么。
![`ProfileActivity 0x0004f1ae` 的对象标签页(顶部):Sample Path from GC Root 为 `Class<ProfileActivity> → com.heapleak.ProfileActivity.history → ArrayList → Object[0] → ProfileActivity`;保留 117.6 KiB,跨 1,604 个对象。](/perfetto-docs-zh-cn/docs/images/heap_docs/12-object-tab-top.png)

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

_Root Type_(例如 THREAD、STATIC、JNI_GLOBAL)标识每个支配者本身是如何被保持存活的。点击行打开其对象标签页并遍历引用路径。
当没有特定的可疑对象,问题仅仅是内存去了哪里时,使用此标签页。
Bitmaps
NOTE: 像素预览和重复检测 _requires HPROF_。
Bitmaps 标签页是 dump 中每个 android.graphics.Bitmap 的画廊。使用 HPROF 时,每个 Bitmap 的像素会内联渲染。

每张卡片显示渲染的像素、尺寸(px 和 dp)、DPI、保留内存和打开对象标签页的 Details 按钮。像素缓冲区可能是 RGBA、PNG、JPEG 或 WebP,取决于它们的存储方式。
画廊上方的路径下拉菜单选择要在每张卡片上覆盖的引用路径:_Shortest path_(从 GC root 的最少边数)、_Dominator path_(支配者链)或 _No path_。显示路径是发现持有泄漏 Bitmap 的 Activity、Fragment 或 Handler 的最快方式。

底部的两个表列出有和没有像素数据的 Bitmap,带有过滤器、排序和导出控件。通过 Overview 上的 Copies 到达会按缓冲区内容哈希预过滤标签页,只留下该组中视觉上相同的 Bitmap。
Strings
NOTE: Strings 标签页 _requires HPROF_。
Strings 标签页列出每个 java.lang.String 及其值。摘要卡片报告字符串总数、不同值的数量和总保留内存。总数和不同值之间的差距是花在重复上的内存。

按值过滤以查找预期唯一的数据:用户 id、序列化的配置负载、重复数千次的错误消息。点击行打开其对象标签页,反向引用部分列出持有该字符串的每个对象。
Arrays
NOTE: Arrays 标签页 _requires HPROF_。
Arrays 标签页列出原始数组(byte[]、int[]、long[]、...)及其稳定的内容哈希。按 Content Hash 过滤返回具有相同字节的每个数组;这是 Overview 检测重复数组的方式。

两个常见用途:找到支持图像或序列化缓冲区的大型重复 byte[],以及从容器对象跳转到持有其数据的原始数组。
Callstack
Callstack 标签页仅在较新 Android 版本上由 OutOfMemoryError 触发的 dump 中有数据;在其他任何 dump 上它都是空的。它显示触发 OOM 的线程的 Java 分配 stack — 请求堆无法满足的分配的路径 — 以 flamegraph 小部件展示,上方是失败分配的细分。

网格细分失败信息:
- Allocation size — 无法满足的分配大小。
- Free until OOM — 堆增长限制前仍然可用的字节数;失败时刻的余量。
- Error message — 来自 ART 的原始
OutOfMemoryError字符串。
stack 告诉你失败点分配了什么;其他标签页告诉你已经保留了什么。在余量充足的情况下进行大分配是分配点问题;在几乎没有余量的情况下进行 modest 分配是保留问题 — 从 Dominators 或 Flamegraph 追查。
从火焰图跳转
Timeline heap graph 火焰图(完整功能参考参见上方的 Flamegraph 部分)有一个 Open in Heapdump Explorer 操作,可以在匹配选定引用路径的对象列表上打开 Explorer。使用它逐对象检查火焰图节点:
在 "Heap Profile" Track 上点击菱形图标打开火焰图。

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

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

从那里,点击任何对象打开其对象标签页,或使用 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
}
}初衷是为崩溃报告保留最近屏幕的轻量轨迹。它实际上做的是固定了每个创建过的 ProfileActivity:onDestroy 在旧实例上运行,但类的静态 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,每次旋转一个:

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

阅读引用路径。 点击顶部行打开其对象标签页。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 个可达对象。](/perfetto-docs-zh-cn/docs/images/heap_docs/12-object-tab-top.png)
从下往上读:运行时保持 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 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)
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 > 0 的 Activity 子类都是泄漏。
同样的方法可以找到其他常见的 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 分组。每行显示副本数、所有副本的总字节数和浪费的字节数——去重到单个副本将节省多少:

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

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

画廊中的每条链都是相同的:Class<FeedAdapter>.cache → ArrayList → Bitmap。所有 12 个副本共享一个持有者——一个缓存层的 bug,一个需要修复的字段。
链的形状就是诊断。在未来的调查中要注意的另外两种模式:
- _每个副本有不同的链_→调用点 bug。没有缓存,或者调用者绕过了它。
- _链经过一个
Activity_→先修复 Activity 泄漏(上一个案例研究);Bitmap 会随之释放。
修复。 完全没有理由保留 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 堆保留字节数应相应下降:

Overview 上所有组的 wasted bytes 总计是最清晰的单数字记分卡——观察它从一次 dump 到下一次 dump 下降,就是你确认每个修复和捕获回归的方式。
另见
- ART heap dumps——采集配置、故障排除和 SQL schema 参考。
- Memory 案例研究——调查 Android 内存问题的端到端指南,涵盖
dumpsys meminfo、native Heap Profile 和 ART Heap Dump。 - OutOfMemoryError heap dumps——在 OOM 时自动采集 Heap Dump。
- Native heap profiler——用于分配调用路径分析而非堆内容。