这是「Android 内存泄漏自动检测」系列的第 1 篇,共 3 篇。 本篇聚焦:传统内存泄漏检测的 5 个致命问题、整体架构设计、采集层如何做到 50ms 级低干扰采集。
压测 3 天,上线就崩?
不知道你有没有经历过这样的场景——稳定性压测跑了整整 3 天,测试报告上写着"内存正常"。结果上线不到两周,用户反馈 App 越用越卡,最后 OOM 崩溃。
复盘发现,压测确实跑了,但内存泄漏检测还停留在**"测完了对比首末内存"**的原始阶段。3 天的压测过程中没有任何实时监控,内存从 200MB 慢慢涨到 800MB,全程无人感知。
这不是个例。跟不少做测试的同行交流,大多数团队的内存泄漏检测都面临同样的困境:
| 痛点 | 说一个大家都懂的场景 |
|---|---|
| 发现滞后 | 压测跑了 3 天,测完才发现泄漏。但此时进程已经重启过了,现场没了 |
| 诊断困难 | 知道泄漏了,但没有过程数据。只能再跑一遍,蹲在旁边手动抓 hprof |
| 类型不分 | 不管 Java 泄漏还是 Native 泄漏还是 GPU 泄漏,统一抓 hprof——但 hprof 只能看 Java 堆,对其他类型完全无效 |
| 采集太重 | 每 17 秒跑一次 dumpsys meminfo,每次占 system_server 5~15 秒,采集行为本身就在拖慢 App |
| 多进程超时 | 5 个进程串行 dumpsys 要 50~75 秒,直接超过采集周期 |
一句话总结核心矛盾:你想高频采集以便尽早发现泄漏,但传统的 dumpsys 方式太重了,频率一高就反过来干扰被测应用。
我想做到什么效果
压测跑着的时候,工具自动发现泄漏、判断泄漏类型、抓对应的诊断文件。测完直接给开发一份可分析的文件,不需要人盯。
拆开来说:
- 采集要轻——高频采集不超过 100ms,不影响被测 App
- 检测要准——不需要人工配阈值,20 MB/h 的慢泄漏也能抓到
- 诊断要对症——Java 泄漏抓 hprof,Native 泄漏抓 showmap,GPU 泄漏抓 gfxinfo
- 多进程要稳——监控 10 个进程也不超时
整体设计:三层流水线
整个方案分三层,形成 "采数据 → 判泄漏 → 抓文件" 的完整链路:
graph TB
subgraph CJ ["采集层"]
H["高频轻量通道<br>smaps_rollup 或 smaps awk<br>每 15-30s, 耗时 50ms-3s<br>产出: PSS 总量"]
L["低频详细通道 - 轮转制<br>dumpsys meminfo<br>每 30s 轮转 1 个进程, 耗时 5-15s<br>产出: 8 维度 + TOTAL PSS + GPU 校准"]
end
subgraph JC ["检测层"]
A1["线性回归 + t 检验 + R2<br>斜率显著性判断"]
A2["P25 基线递增确认<br>底线水位持续上涨 = 泄漏"]
A3["多维度交叉验证<br>8 维度独立回归, 泄漏分类"]
SM["四级状态机<br>NORMAL - SUSPICIOUS - CONFIRMING - LEAKING"]
AUX["辅助路径: 突增检测, GPU 泄漏检测"]
end
subgraph XY ["响应层"]
RT["分类诊断路由<br>java: hprof / native: showmap<br>gpu: gfxinfo / thread: proc-task"]
CD["冷却控制: 每进程 30min + 全局锁"]
end
H -->|"PSS 时间序列"| A1
L -->|"8 维度数据"| A3
A1 --> SM
A2 --> SM
A3 --> SM
AUX --> SM
SM -->|"泄漏类型"| RT
RT --> CD
三层各干一件事:
- 采集层:两条通道并行。高频通道读内核文件拿 PSS 总量(50ms),低频通道轮流对每个进程 dumpsys 拿 8 维度明细
- 检测层:线性回归 + t 检验判趋势,P25 基线确认不是虚涨,四级状态机逐步升级证据
- 响应层:根据泄漏类型走不同诊断路径——只有 Java 泄漏才抓 hprof,其他用轻量命令
本篇重点展开采集层。检测层和响应层分别在后续两篇详解。
采集层设计:拆通道解决核心矛盾
思路:把"看趋势"和"看明细"分开
内存泄漏检测需要高频数据——数据点越多,统计检验灵敏度越高,慢泄漏才能被发现。但每次都用 dumpsys meminfo 采完整明细,单次耗时 5~15 秒,频率一高 system_server 就扛不住。
解法是拆成两条通道:
| 通道 | 采什么 | 怎么采 | 多久一次 | 耗时 | 用途 |
|---|---|---|---|---|---|
| 高频 | PSS 总量(1 个数字) | 读内核文件 /proc/{pid}/smaps_rollup | 15-30 秒 | 50ms | 趋势检测 |
| 低频 | 8 维度明细 | dumpsys meminfo | 轮转,每进程 90 秒+ | 5-15 秒 | 泄漏分类 |
高频通道直接读 Linux 内核文件,完全不经过 system_server,一个数字足以做线性回归。低频通道走 dumpsys 拿 Java Heap / Native Heap / Graphics 等 8 个维度的详细数据,但不需要高频——只在确认泄漏后用来分类。
两条通道独立运行,互不干扰。
三级设备适配
不是所有设备都支持 smaps_rollup。Android 10 以上才有这个内核文件,老设备需要降级方案。我们在设备首次连接时做一次自动探测:
flowchart TD
A["设备连接"] --> B{"读取 smaps_rollup<br>成功且含 Pss ?"}
B -->|"是"| L1["Level 1: Android 10+<br>耗时 约50ms, 直读内核"]
B -->|"否"| C{"读取 smaps 前 20 行<br>成功且含 Pss ?"}
C -->|"是"| L2["Level 2: Android 5-9<br>耗时 1-3s, awk 聚合"]
C -->|"否"| L3["Level 3: 兜底<br>仅 dumpsys meminfo, 5-15s"]
L1 --> CACHE["结果缓存, 后续直接使用"]
L2 --> CACHE
L3 --> CACHE
| Level | 适用版本 | 高频方式 | 耗时 | 原理 |
|---|---|---|---|---|
| 1 | Android 10+ | cat smaps_rollup | ~50ms | 内核预聚合的 PSS 汇总,绕过 system_server |
| 2 | Android 5-9 | awk '/^Pss/' smaps | 1-3s | 从 VMA 列表手动聚合,同样不走 system_server |
| 3 | 极老设备 | 无高频,仅低频 | — | 回退到 dumpsys,牺牲灵敏度保证兼容 |
探测结果缓存到设备维度,后续所有采集直接按对应方式执行。
高频通道:自适应采集频率
高频通道并不是固定频率的。它会根据检测层状态机的当前阶段自动调速——正常巡逻时慢一点省资源,发现可疑时加快积累数据:
| 状态 | 采集间隔 | 说明 |
|---|---|---|
| NORMAL(巡逻) | 30 秒 | 低开销,够发现中快速泄漏 |
| SUSPICIOUS(可疑) | 15 秒 | 加密采集,t 检验灵敏度提高约 2.8 倍 |
| LEAKING(已确认) | 60 秒 | 减少干扰,此时在做诊断操作 |
采到的数据以 (timestamp, pss) 元组存入固定大小的滑动窗口 deque(maxlen=240),窗口满了自动淘汰最老的数据,内存占用恒定。
关键细节:数据带了真实时间戳。即使采集间隔从 30s 变为 15s(加密采集),线性回归以时间为 x 轴,斜率计算不受变频影响。
低频通道:轮转制解决多进程超时
这是解决多进程超时的核心设计。
传统做法:每 30 秒对 5 个进程全部串行 dumpsys → 总耗时 25~75 秒 → 超时。
轮转制:每 30 秒只对 1 个进程 dumpsys,下次换下一个 → 单次永远只要 5~15 秒 → 永不超时。
sequenceDiagram
participant Timer as 低频定时器 每30s
participant A as 进程A
participant B as 进程B
participant C as 进程C
Timer->>A: dumpsys meminfo A 约10s
Note over Timer: t = 0s
Timer->>B: dumpsys meminfo B 约8s
Note over Timer: t = 30s
Timer->>C: dumpsys meminfo C 约12s
Note over Timer: t = 60s
Timer->>A: dumpsys meminfo A 约10s
Note over Timer: t = 90s 回到A
| 对比项 | 串行全采 | 轮转制 |
|---|---|---|
| 3 进程单轮耗时 | 15-45s(可能超时) | 5-15s(永不超时) |
| 5 进程单轮耗时 | 25-75s(大概率超时) | 5-15s |
| 10 进程单轮耗时 | 50-150s(必定超时) | 5-15s |
| system_server 峰值负载 | 连续高负载 | 均匀分布 |
代价是每个进程的详细数据间隔变成了 30s × 进程数,但低频数据只用于泄漏分类,不用于趋势检测,这个频率完全够用。
GPU 校准:别漏了显存
一个容易忽略的盲区:smaps 看不到 GPU 显存。
GPU 纹理和帧缓冲区由 GPU 驱动通过 DMA-BUF 或 ION 机制分配,不在进程虚拟地址空间内。如果只看高频 PSS,纯 GPU 泄漏完全检测不到。
解决方法:每次低频 dumpsys 时,从 App Summary 中提取 Graphics 字段值作为 GPU 偏移量:
校准后 PSS = smaps_pss + Graphics 字段值
Graphics 值和 TOTAL PSS 来自同一次 dumpsys 调用,时间严格对齐。泄漏检测看的是增长趋势(斜率),不是绝对值,所以即使 GPU 偏移量在两次低频采集之间有微小变化,也不影响趋势判断。
小结
采集层通过双通道 + 三级适配 + 轮转制 + GPU 校准,解决了"高频 vs 开销"的核心矛盾:
| 能力 | 效果 |
|---|---|
| 高频 PSS 采集 | 50ms 一次,不走 system_server |
| 多进程支持 | 10 个进程也不超时 |
| 设备兼容 | Android 5 到最新版本自动适配 |
| GPU 覆盖 | 校准后 PSS 包含 GPU 显存 |
但拿到数据只是第一步。一条波动的 PSS 曲线,怎么判断它到底是在泄漏,还是只是正常的业务抖动?
下篇预告
中篇:用统计学替代"拍脑袋阈值"
传统方案的做法是"切两半比均值,差值超过 50MB 就算泄漏"——这个 50MB 是怎么来的?拍脑袋。
下一篇讲检测层如何用线性回归 + t 检验 + P25 基线 + 四级状态机,做到零配置阈值、自适应不同进程的波动特征。
系列目录
- 本篇(上):为什么传统方案不靠谱 + 采集层设计
- 中篇:用统计学替代"拍脑袋阈值"——检测层设计
- 下篇:对症下药,5 种泄漏 5 种抓法——响应层设计
我是测试工坊,专注自动化测试和性能工程。 如果觉得有用,点个赞让更多人看到 👍 关注我,后续更新不迷路。