用 AI 检测 Android 内存泄露:从 Perfetto Heap Graph 到自动化归因
内存泄露是 Android 开发中最棘手的问题之一。它不像 Crash 那样立刻爆发,而是像慢性病一样慢慢侵蚀应用性能------RSS 持续增长、GC 越来越频繁、最终 OOM 崩溃。传统的检测手段要么依赖 Debug 构建(LeakCanary),要么需要手动分析 hprof 文件,很难和生产环境的 Perfetto trace 数据关联起来。
这篇文章分享 SmartInspector 中的内存泄露检测方案:从 Perfetto trace 的 heap_graph 表提取 Java 堆数据,结合 SI$ 生命周期切片做误报过滤,再通过引用链追踪定位泄露根因。整个链路集成在自动化分析流水线中,不需要额外工具,一次 trace 采集就能拿到完整的内存诊断报告。
Perfetto Heap Graph:三张核心表
Perfetto 在采集 trace 时,如果配置了 android.java_hprof 数据源,会在 trace 中内嵌一份堆快照。这份快照以三张关系型表的形式存储在 trace_processor 中:
| 表名 | 内容 | 关键字段 |
|---|---|---|
heap_graph_object |
堆中的每个对象实例 | id, type_id, self_size, reachable, upid |
heap_graph_class |
类型信息 | id, name(类全限定名) |
heap_graph_reference |
对象间的引用关系 | owner_id, owned_id, field_name, deobfuscated_field_name |
第一个关键设计点:reachable 字段过滤 。Perfetto 在生成 heap_graph 时会标记每个对象的 GC 可达性。只查 reachable = 1 的对象,因为不可达的对象已经在 GC 的回收候选中,不算泄露。
一个典型的查询------堆对象按类分组 Top 20:
sql
SELECT
c.name AS class_name, -- 类全限定名,如 android.graphics.Bitmap
COUNT(*) AS obj_count, -- 该类的实例数
SUM(o.self_size) AS total_bytes -- 该类的总自我占用
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
WHERE o.reachable = 1 -- 只看 GC 可达对象
GROUP BY c.name
ORDER BY total_bytes DESC
LIMIT 20
这条查询的结果告诉你堆里"谁占得最多"。但仅靠这个结果做泄露检测是不够的------Activity 和 Fragment 本来就会出现在堆中,你怎么判断哪个是泄露、哪个是正常存活?
这就是生命周期感知过滤要解决的问题。
生命周期感知过滤:泄露检测的核心
传统的堆泄露检测有一个根本性缺陷:只看堆数据,不看运行时状态。
举个例子:用户当前正在 MainActivity 中操作,堆里有一个 MainActivity 实例,这是完全正常的。但如果用户已经 onDestroy 了一个 DetailActivity,堆里却还存在它的实例,那就是泄露。
SmartInspector 通过 SI$ 自定义切片体系解决了这个问题。Android 端的 TraceHook SDK 在 Activity/Fragment 的每个生命周期回调中注入 Perfetto 切片:
ini
SI$com.example.DetailActivity.onCreate ts=1000
SI$com.example.DetailActivity.onResume ts=1200
SI$com.example.DetailActivity.onDestroy ts=5000
SI$com.example.MainActivity.onCreate ts=5200
SI$com.example.MainActivity.onResume ts=5400
生命周期状态提取的逻辑很直接------按时间戳排序遍历所有 SI$ 生命周期切片,用最后的状态覆盖之前的状态:
python
# 创建/可见的生命周期方法
_CREATING = {"onCreate", "onStart", "onResume"}
# 销毁的生命周期方法
_DESTROYING = {"onPause", "onStop", "onDestroy", "onDestroyView"}
def _extract_lifecycle_state(tp) -> dict[str, str]:
"""从 SI$ 生命周期切片提取组件存活状态。"""
alive: dict[str, str] = {}
rows = tp.query("""
SELECT name, ts FROM slice
WHERE (name LIKE 'SI$%Activity.%' OR name LIKE 'SI$%Fragment.%')
ORDER BY ts ASC
""")
for r in rows:
tag = r.name[3:] # 去掉 SI$ 前缀
dot_idx = tag.rfind(".")
class_fqn = tag[:dot_idx] # com.example.DetailActivity
method = tag[dot_idx + 1:] # onDestroy
if method in _CREATING:
alive[class_fqn] = "alive"
elif method in _DESTROYING:
alive[class_fqn] = "destroyed"
return alive
为什么按时间正序遍历就够了? 因为 Android 生命周期是线性有序的。一个 Activity 只会走 onCreate → onStart → onResume → ... → onPause → onStop → onDestroy 这一条路。同一个类如果出现多次(用户反复进出),后面的切片一定比前面的时间戳大,所以最后记录的状态就是最终状态。
三态判定:alive / suspect / unknown
拿到生命周期状态后,泄露嫌疑人的判定逻辑就清晰了。对堆中每个 Activity/Fragment 实例,根据三种情况做分类:
python
normalized = _normalize_heap_class(raw_name) # 去掉 java.lang.Class<> 包装
if normalized in alive_components:
# 1. 存活状态:组件正在前台,堆中存在是正常的
entry["state"] = "alive"
alive_in_heap.append(entry)
elif normalized in destroyed_components or r.obj_count > 1:
# 2. 泄露嫌疑人:已销毁但还在堆中,或者同类型有多个实例
entry["state"] = "suspect"
leak_suspects.append(entry)
else:
# 3. 未知状态:没有生命周期数据(SDK 未注入该类)
# 多实例才算嫌疑,单实例假定正常
if r.obj_count > 1:
entry["state"] = "suspect"
leak_suspects.append(entry)
else:
entry["state"] = "unknown"
alive_in_heap.append(entry)
三个判定分支的设计意图:
- alive:组件在 trace 结束时还活着,堆里有实例是预期行为。这避免了最大的误报来源------当前正在使用的 Activity 被错误标记为泄露。
- suspect:组件已经走完了 onDestroy,但 GC 可达对象中仍然存在。或者,堆中出现了同一个 Activity 的多个实例(正常情况下一个 Activity 同一时间最多一个实例)。
- unknown:该类没有 SI$ 生命周期切片(可能 SDK 没有注入)。保守策略下,只有多实例才标记为嫌疑,单实例不报警。
还有一个细节:过滤掉基类 。android.app.Activity、androidx.appcompat.app.AppCompatActivity 这些基类在堆中必定存在,它们不是具体的泄露嫌疑人。代码中维护了一个排除列表:
python
_EXCLUDED_CLASSES = {
"android.app.Activity",
"android.app.Fragment",
"androidx.fragment.app.Fragment",
"androidx.activity.ComponentActivity",
"androidx.appcompat.app.AppCompatActivity",
# ... 等基类
}
引用链追踪:谁持有了泄露对象
检测到泄露嫌疑人之后,下一步是回答"谁持有了它"------这才是修复泄露的关键信息。
Perfetto 的 heap_graph_reference 表存储了所有对象间的引用关系,但直接大表 JOIN 解析全量引用链在性能上不可接受(一个中等规模堆的引用关系可能有几十万条)。
两步策略是性能和精度的平衡:
Step A:按 field_name 快速匹配 。泄露嫌疑人的类名通常会出现在引用的 field_name 中(比如 Activity 实例被某个字段引用,field_name 可能包含类名或内部类名)。用 GLOB 模式匹配,不需要 JOIN:
sql
SELECT ref.owner_id, ref.owned_id, ref.field_name,
ref.deobfuscated_field_name
FROM __intrinsic_heap_graph_reference ref
WHERE ref.field_name GLOB '*MemoryLeakActivity*'
LIMIT 30
这一步很快,因为只扫一张表,而且 GLOB 模式在 trace_processor 中有优化。
Step B:定向解析 owner class 。拿到 owner_id 后,再用一个小范围的 JOIN 解析持有者的类名:
sql
SELECT o.id, c.name AS class_name
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
WHERE o.id IN (12345, 67890, ...) -- 只有 Step A 命中的 owner_id
python
# Step A: 收集 owner_id
owner_ids = set()
for r in ref_rows:
oid = r.owner_id if r.owner_id and r.owner_id != 0 else None
if oid:
owner_ids.add(oid)
# Step B: 批量解析 owner 类名(最多 20 个 ID)
if owner_ids:
id_list = ",".join(str(i) for i in list(owner_ids)[:20])
owner_rows = tp.query(f"""
SELECT o.id, c.name AS class_name
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
WHERE o.id IN ({id_list})
""")
owner_map = {r.id: r.class_name for r in owner_rows}
为什么不用一次大 JOIN? 因为 heap_graph_reference × heap_graph_object × heap_graph_class 的三表 JOIN 在堆较大时会产生巨大的中间结果集。先缩小候选范围再做精准查询,把时间复杂度从 O(N×M) 降到 O(N+K),其中 K 是命中的 owner 数量。
另外,代码中还处理了混淆 问题。ProGuard/R8 混淆后的字段名是 a、b 这种无意义字符串,但 Perfetto 的 deobfuscated_field_name 保留了原始名称。引用链输出时优先使用 deobfuscated 名称:
python
field_display = deobfuscated if deobfuscated and deobfuscated != field else field
lines.append(f" {owner_short} --[{field_display}]--> {owned_short}")
Dominator 分析:找出内存大户
除了泄露检测,我们还需要识别堆中的"内存大户"------那些 self_size 占用最大的对象。这不需要复杂的 dominator tree 算法,一个简单的聚合查询就够了:
sql
SELECT
c.name AS class_name,
COUNT(*) AS obj_count,
SUM(o.self_size) AS self_bytes
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
WHERE o.reachable = 1
AND o.self_size > 1024 -- 过滤掉小于 1KB 的对象
GROUP BY c.name
ORDER BY self_bytes DESC
LIMIT 15
self_size > 1024 这个阈值过滤掉了大量的小对象(Java 对象头、空集合等),只关注真正占用内存的类。
内存趋势:RSS 增长的异常检测
堆数据是某一时刻的快照,但内存泄露是一个随时间演进的过程。SmartInspector 同时分析进程级 RSS 内存趋势,作为堆分析的补充。
RSS 时序采集
通过 process_counter_track 表查询目标进程的 RSS 时序数据:
sql
SELECT c.ts, c.value AS rss_kb
FROM process_counter_track pct
JOIN counter c ON c.track_id = pct.id
JOIN process p ON pct.upid = p.upid
WHERE pct.name = 'mem.rss'
AND p.name = 'com.smartinspector.hook'
ORDER BY c.ts ASC
增长率与跳跃检测
拿到时序数据后,计算三个关键指标:
python
# 1. 总增长率
delta_pct = (end_rss_mb - start_rss_mb) / start_rss_mb * 100
# 2. 增长斜率(MB/s)
slope = delta_mb / duration_s
# 3. 跳跃检测:相邻采样点 RSS 增长 > 10MB
for i in range(1, len(samples)):
jump = curr_mb - prev_mb
if jump > 10:
jumps.append({"delta_mb": jump, "rss_mb": curr_mb})
阈值设计:
- 增长率 > 50%:标记为"疑似泄露",需要关注
- 增长率 > 20%:标记为"增长较大",建议关注
- 跳跃 > 10MB:可能是大对象分配或 native 内存映射
匿名内存占比
RSS 中的匿名内存(mem.rss.anon)占比是一个重要的泄露指示器。如果匿名内存占 RSS 的 70% 以上,通常意味着有大量堆分配没有释放:
python
anon_ratio = anon_kb / rss_kb
if anon_ratio > 0.7:
entry["high_anon"] = f"匿名内存占比 {anon_ratio:.0%} --- 可能存在内存泄漏"
Peak/Avg 异常检测
Peak 和 Avg 的比值也能揭示问题。如果一个进程的 Peak RSS 是 Avg 的 2 倍以上,说明存在瞬时内存尖峰:
python
variance_ratio = max_rss_kb / avg_rss_kb
if variance_ratio > 2.0:
entry["anomaly"] = f"Peak/Avg ratio {variance_ratio:.1f}x --- possible memory spike"
实战:四种经典泄露模式
SmartInspector 的 Android 测试 App 提供了 MemoryLeakActivity,内置四种经典泄露场景,每种对应一种常见的泄露根因。
Leak 1:Static Reference
kotlin
companion object {
private var leakedActivity: MemoryLeakActivity? = null
}
private fun enableStaticLeak() {
leakedActivity = this // 静态字段持有 Activity 引用
}
最经典的泄露模式。静态变量的生命周期等于进程生命周期,Activity 被静态字段引用后无法被 GC 回收。检测时,堆中会出现多个 MemoryLeakActivity 实例(每次进出都创建一个新的,旧的无法回收),引用链指向 companion object 的 leakedActivity 字段。
Leak 2:Anonymous Inner Class
kotlin
private val anonymousRunnable: Runnable = object : Runnable {
override fun run() {
Log.i("MemoryLeak", "tick on ${this@MemoryLeakActivity}")
handler.postDelayed(this, 1000) // 每秒重复
}
}
匿名内部类隐式持有外部类的引用。这个 Runnable 被投递到主线程 Handler 后,Handler 的 MessageQueue 持有 Message,Message 持有 Runnable,Runnable 持有 Activity。即使 Activity 销毁了,只要消息还没处理完,Activity 就无法回收。
Leak 3:Unregistered BroadcastReceiver
kotlin
private fun enableReceiverLeak() {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
// 匿名 BroadcastReceiver 持有外部 Activity 引用
}
}
registerReceiver(receiver, IntentFilter("com.smartinspector.LEAK_TEST"))
// 故意不在 onDestroy 中 unregisterReceiver
}
动态注册的 BroadcastReceiver 如果没有在 onDestroy 中反注册,系统会通过 AMS 持有该 receiver 的引用,进而持有 Activity。
Leak 4:Singleton Callback
kotlin
companion object {
private var callback: (() -> Unit)? = null
}
private fun enableSingletonLeak() {
callback = {
// Lambda 捕获了 Activity 引用
Log.i("MemoryLeak", "Singleton callback on $this")
}
}
Kotlin Lambda 会捕获外部变量。如果 Lambda 中引用了 this(Activity),且 Lambda 被存储在单例中,就形成了 Activity → 单例的泄露路径。
检测结果
在测试 App 中启用全部 4 种泄露,反复进出 MemoryLeakActivity 5 次后采集 trace。SmartInspector 的确定性分析会输出:
ini
[内存分配分析]
堆内存Top对象:
MemoryLeakActivity: 6个, 180.0KB
...
疑似泄漏:
⚠ MemoryLeakActivity: 6个实例, 180.0KB [suspect]
泄漏引用链 (谁持有了泄漏对象):
MemoryLeakActivity$Companion --[leakedActivity]--> MemoryLeakActivity
MemoryLeakActivity$Companion --[callback]--> MemoryLeakActivity
MessageQueue --[mCallbacks]--> MemoryLeakActivity$anonymousRunnable$1
注意一个关键细节:只有 1 个实例被标记为 alive(当前正在显示的那个),其余 5 个被标记为 suspect(已走完 onDestroy 但还在堆中)。这就是生命周期感知过滤的价值------没有它,你会看到"6 个实例"的警告,但无法判断哪个是正常的、哪个是泄露的。
和 LeakCanary 的对比
| 维度 | SmartInspector | LeakCanary |
|---|---|---|
| 数据来源 | Perfetto trace(生产环境可采集) | Debug 构建 + hprof dump |
| 检测时机 | 离线分析 trace 文件 | 运行时实时检测 |
| 生命周期感知 | SI$ 切片记录精确的 destroy 时间点 | WeakReference + ReferenceQueue |
| 引用链 | heap_graph_reference SQL 查询 | hprof DOMinator tree |
| 适用环境 | 生产环境、CI、性能回归测试 | 开发阶段 Debug 构建 |
| 其他数据 | 同一 trace 包含 CPU/帧率/IO/锁竞争 | 仅内存 |
两者不是替代关系。LeakCanary 适合开发阶段快速定位泄露,SmartInspector 适合性能回归测试和生产问题分析。当你在 Perfetto trace 中发现 RSS 异常增长时,可以直接用同一份 trace 做 heap graph 分析,不需要切换工具或重新复现问题。
总结
这个方案的核心价值在于三个设计决策:
-
生命周期感知过滤:通过 SI$ 切片精确判断组件存活状态,避免"当前正在使用的 Activity 被误报为泄露"这个最常见的误报来源。
-
两步引用链策略:先 GLOB 快速匹配再定向 JOIN,在 O(N+K) 的时间复杂度内完成引用链解析,避免了堆数据全量 JOIN 的性能问题。
-
trace 统一数据源:堆快照、RSS 趋势、GC 事件、生命周期状态全部来自同一份 Perfetto trace,不需要额外的 hprof dump 或手动抓取,天然支持 CI 自动化。
整个内存泄露检测链路已经集成在 SmartInspector 的 /full 分析流水线中,从 trace 采集到报告生成完全自动化。对于有 CI 性能回归测试需求的团队,可以直接在流水线中调用 smartinspector --ci 获取结构化的内存分析结果。