这是「Android 内存泄漏自动检测」系列的第 2 篇,共 3 篇。 上篇讲了采集层如何以 50ms 的开销拿到高频数据。本篇讲检测层——拿到数据后,怎么判断是泄漏还是正常波动。
传统方案怎么判泄漏?拍脑袋
大多数团队做内存泄漏检测,用的都是这个逻辑:
- 把数据分成前半段和后半段
- 分别算均值
- 后半段均值 - 前半段均值 > 50MB → 判定泄漏
这个 50MB 是怎么来的?拍脑袋拍的。
这个方案有 5 个根本缺陷:
| 缺陷 | 后果 |
|---|---|
| 硬编码阈值 | 50MB 是个"万金油"数字,高波动进程天天误报,低波动进程漏报 |
| 慢泄漏盲区 | 20MB/h 的泄漏跑 30 分钟只涨 10MB,永远过不了 50MB |
| 阶梯误判 | 打开一个大页面内存跳涨 150MB 后稳定不动——均值差 > 50MB,误判为泄漏 |
| 丢失时间信息 | 只比较两个均值,丢掉了每个数据点的时间位置 |
| 不区分状态 | 不知道是"正在涨"还是"已经稳在高位" |
本质问题:它只比较了两个数字的差值,没有利用数据中丰富的时间序列信息。
我想要的是:不需要人工调阈值,不同进程自动适配,慢泄漏也能抓到。
检测层的三个递进问题
检测层不是一步到位做判定的。它回答三个逐步深入的问题,像过滤漏斗一样逐层筛选:
graph LR
Q1["1. 内存是否在涨?"] -->|"线性回归斜率"| Q2["2. 涨是真的还是噪声?"]
Q2 -->|"t 检验"| Q3["3. 是持续泄漏还是临时波动?"]
Q3 -->|"P25 基线递增"| CONFIRM["确认泄漏, 进入分类"]
| 问题 | 工具 | 判据 |
|---|---|---|
| 内存是否在涨? | 线性回归斜率 slope | slope > 0 |
| 涨是真的还是噪声? | t 检验 | t > 2.0(95% 置信度) |
| 是持续泄漏还是临时波动? | P25 基线递增 + R² 线性度 | R² > 0.6 且 P25 多段递增 |
下面逐一展开。
核心算法:线性回归 + t 检验
基本原理
把高频 PSS 时间序列看作一组 (时间, 内存) 数据点,用最小二乘法拟合一条直线:
y = slope × x + intercept
如果内存在持续增长,数据点会大致沿一条向上的直线分布。slope(斜率)就是内存增长的速率,单位 MB/秒。
拟合完成后,我们同时拿到三个指标,各回答一个独立的问题:
slope(斜率)——内存在涨还是在降?涨多快?
这是最直觉的指标。slope > 0 说明整体趋势是上涨的。
R²(决定系数)——数据真的在线性增长吗?
取值 0~1。R² = 1.0 表示数据完美落在一条直线上,R² = 0.6 表示 60% 的变化可以用线性趋势解释。
R² 的关键作用是过滤"阶梯跳变"。比如打开一个大页面后内存一次性跳升 100MB 然后稳定不动——此时 slope > 0,但数据分布像"台阶"而不是"斜坡",R² 会很低(< 0.6),被正确排除。
t(t 统计量)——这个斜率是真实趋势还是噪声凑出来的?
计算方式是 t = slope / se_slope,斜率除以斜率的标准误差。通俗地说:斜率是它自身不确定性的多少倍。
判定条件:三条同时满足
① slope > 0 → 内存在涨(方向判断)
② t > 2.0 → 涨幅在统计上显著(95% 置信度)
③ R² > 0.6 → 数据是线性增长(不是阶梯跳变)
为什么是"零配置"?
这是最关键的设计。用一个数值例子说明 t 检验的自适应性:
假设两个进程的 PSS 都以 50 MB/h 的速率增长(slope 完全相同):
稳定后台进程(业务波动 σ=5MB):数据点紧密围绕直线 → 标准误差小 → t = slope / se_slope 得到 t=8.5 → 显著。50 MB/h 对这个进程来说是很明显的异常。
高波动 UI 进程(业务波动 σ=50MB):数据点散乱分布 → 标准误差大 → 同样的 slope 得到 t=0.85 → 不显著。50 MB/h 对这个进程来说就是正常的业务抖动。
t 检验会根据进程自身的波动水平自动调整灵敏度:对稳定进程更灵敏(小增长也很突出),对波动进程更保守(需要更强的趋势才能和噪声区分)。不需要人工为不同进程设不同阈值。
还有一个重要的自适应特性:数据越多,检测越灵敏。标准误差按 1/n^(3/2) 下降,同样的泄漏速率在更长时间内会产生越来越大的 t 值。也就是说——不存在"永远检测不到"的死区,只要泄漏真实存在,观察足够久总能发现。
t > 2.0 和 R² > 0.6 都是统计学标准值,不是业务参数,无需针对不同设备或进程调整。
参考实现
def linear_regression_with_test(data_window):
"""
对 (timestamp, pss) 序列做线性回归 + t 检验
返回 (slope, r_squared, t_stat)
"""
data = list(data_window)
n = len(data)
if n < 10:
return 0, 0, 0
t0 = data[0][0]
x = [d[0] - t0 for d in data] # 相对化时间戳,避免浮点精度问题
y = [d[1] for d in data]
x_mean = sum(x) / n
y_mean = sum(y) / n
ss_xx = sum((xi - x_mean) ** 2 for xi in x)
ss_yy = sum((yi - y_mean) ** 2 for yi in y)
ss_xy = sum((x[i] - x_mean) * (y[i] - y_mean) for i in range(n))
if ss_xx == 0 or ss_yy == 0:
return 0, 0, 0
slope = ss_xy / ss_xx
intercept = y_mean - slope * x_mean
r_squared = (ss_xy ** 2) / (ss_xx * ss_yy)
sse = sum((y[i] - (slope * x[i] + intercept)) ** 2 for i in range(n))
mse = sse / (n - 2)
se_slope = (mse / ss_xx) ** 0.5 if mse > 0 else 0
t_stat = slope / se_slope if se_slope > 0 else 0
return slope, r_squared, t_stat
P25 基线确认:第二道防线
线性回归 + t 检验反应快,但可能因为持续几分钟的高负载导致短暂的假阳性。我们需要第二道确认机制。
什么是 P25
P25 是第 25 百分位数。把一段时间内的 PSS 值从小到大排,排在 25% 位置的值就是 P25。
直观理解:P25 代表内存的"底线水位"——进程空闲时的内存占用。
为什么选 P25 而不是均值
| 指标 | 问题 |
|---|---|
| 均值 | 容易被业务高峰拉高。批量操作时飙到 500MB、空闲回到 200MB,均值 350MB,完全不反映底线 |
| 中位数(P50) | 比均值好,但仍受负载分布影响 |
| P25 | 只看最低 25% 的数据,几乎不受峰值干扰 |
关键洞察:泄漏的内存永远不会被释放,所以底线会越来越高。正常业务波动是"高点涨、低点不涨",泄漏是"高点低点一起涨"。P25 精准地捕捉了这个区别。
检测逻辑
将高频 PSS 数据按 5 分钟一段计算每段的 P25,检查是否持续递增:
段1 P25=200 段2 P25=208 段3 P25=215 段4 P25=224
↑+8 ↑+7 ↑+9
4 段中 3 段递增 → 确认底线在涨 → 泄漏
允许最多 1 段回落(容许 GC 偶尔回收),至少需要 3 段数据(约 15 分钟)。
斜率和 P25 怎么配合
| 工具 | 角色 | 特点 |
|---|---|---|
| 线性回归 + t 检验 | 快速预警 | 用全部数据,反应快,但可能有短暂假阳性 |
| P25 基线递增 | 稳健确认 | 只看底线水位,排除峰值干扰,但需要更长时间 |
配合方式:斜率做初筛(NORMAL → SUSPICIOUS),P25 做确认(SUSPICIOUS → CONFIRMING)。
两条辅助检测路径
主检测路径覆盖不了两种特殊场景:
辅助一:突增检测
场景:某个 Bitmap 没释放导致一次性泄漏 300MB。这种"一步到位"不会产生持续线性增长,主路径来不及反应。
做法:每次采样时算 spike = 当前 PSS - 近 5 分钟 P25。如果 spike 超过 P25 的 50%(且至少 200MB),直接从 NORMAL 跳到 LEAKING,不走中间状态。
辅助二:GPU 泄漏检测
场景:纹理泄漏。smaps 看不到 GPU 显存,高频 PSS 数据不会有任何增长——主路径完全失灵。
做法:对比低频通道的 dumpsys TOTAL PSS(包含 GPU)和高频的 smaps PSS(不含 GPU)。如果前者在涨(t > 2.0)而后者不涨(t < 1.0),差异只能由 GPU 显存增长解释 → 判定 GPU 泄漏。
四级状态机:逐步积累证据
如果只用一次线性回归的结果直接判定泄漏,误报率会很高——内存数据天然有波动,一个 10 分钟窗口内偶然出现上升趋势很正常。
状态机的作用是逐步积累证据、层层确认。每升一级需要更强的证据,证据不足随时回退:
flowchart TD
START(("开始")) --> NORMAL
NORMAL["NORMAL<br>低开销巡逻<br>高频 30s"]
SUSPICIOUS["SUSPICIOUS<br>加密采集 高频 15s<br>最大停留 30min"]
CONFIRMING["CONFIRMING<br>多维度验证<br>额外 3 次 dumpsys<br>8 维度独立回归<br>最大停留 10min"]
LEAKING["LEAKING<br>分类诊断<br>按泄漏类型抓取文件<br>高频放宽到 60s"]
NORMAL -->|"slope, t, R2 均达标<br>连续 2 窗口满足"| SUSPICIOUS
NORMAL -->|"突增直通<br>spike 超过阈值"| LEAKING
SUSPICIOUS -->|"P25 基线 N 段中<br>至少 N-1 段递增"| CONFIRMING
SUSPICIOUS -->|"回退: 斜率 2 窗口<br>不显著或超时"| NORMAL
CONFIRMING -->|"至少 1 维度 t 显著<br>冷却已过, PSS 增长足够"| LEAKING
CONFIRMING -->|"回退: 验证不通过<br>或超时"| NORMAL
LEAKING -->|"诊断完成 + 冷却后"| NORMAL
| 阶段 | 干什么 | 高频间隔 | 升级条件 | 最大停留 |
|---|---|---|---|---|
| NORMAL | 低开销巡逻 | 30s | 连续 2 窗口 slope + t + R² 达标 | 无限 |
| SUSPICIOUS | 加密采集积累证据 | 15s | P25 基线多段递增 | 30 分钟 |
| CONFIRMING | 多维度验证 + 分类 | 15s | 至少 1 维度 t 显著 | 10 分钟 |
| LEAKING | 执行诊断 | 60s | — | — |
加密采集的效果:进入 SUSPICIOUS 后间隔从 30s 缩短到 15s,同一时间内数据点翻倍。t 检验灵敏度与数据量的关系是 t ∝ n^(3/2),数据量翻倍使 t 值提高约 2.83 倍。
超时保护:每个中间状态都有最大停留时间。如果进程内存在阈值边缘反复横跳,SUSPICIOUS 30 分钟后强制回退,不会卡死。
场景走查:一个 Java 泄漏的完整检测流程
假设主进程存在 Activity 泄漏,每次打开新页面泄漏 2MB,压测中每分钟打开 5 次,实际泄漏速率约 600 MB/h。
NORMAL(0~10 分钟):高频每 30s 采一次 PSS。前 5 分钟数据不足不做检测。第 8 分钟:slope=9.5, t=2.8, R²=0.72,三条件满足。第 9 分钟连续第 2 窗口满足 → 升级 SUSPICIOUS。
SUSPICIOUS(10~22 分钟):高频加速到 15s。计算 P25 基线——段1: 280MB → 段2: 328MB(↑48) → 段3: 379MB(↑51),3 段中 2 段递增 → 升级 CONFIRMING。
CONFIRMING(22~28 分钟):额外触发 3 次 dumpsys 采集 8 维度数据,对每个维度独立回归:Java Heap t=5.2(显著),Native Heap t=0.4,Graphics t=0.2。Java Heap 的 t 值最大且 > 2.0 → 分类为 java_leak → 升级 LEAKING。
LEAKING(28~30 分钟):根据 java_leak 类型执行诊断——GC → 等 30s → am dumpheap 抓 hprof → 拉取到主机。完成后回到 NORMAL,设 30 分钟冷却。
回退对比:如果只是打开大页面跳涨 50MB 后稳定呢?R² < 0.6(台阶不是斜坡),不升级。即使侥幸进入 SUSPICIOUS,P25 不递增,30 分钟超时自动回退。
检测灵敏度
t 检验可检测的最低泄漏速率,取决于进程自身的噪声水平和观察时间:
| 进程波动 | 10 分钟 | 15 分钟 | 30 分钟 | 60 分钟 |
|---|---|---|---|---|
| σ=5MB(稳定后台) | 33 MB/h | 18 MB/h | 6 MB/h | 2 MB/h |
| σ=20MB(中等波动) | 131 MB/h | 72 MB/h | 25 MB/h | 9 MB/h |
| σ=50MB(高波动 UI) | 329 MB/h | 179 MB/h | 63 MB/h | 22 MB/h |
核心特性:观察时间越长,可检测的泄漏速率越低——不存在"永远检测不到"的死区。
小结
检测层的核心设计思想是让数据自己说话:
| 传统方案 | 本方案 |
|---|---|
| 人工拍阈值 50MB | t 检验从数据自身推断显著性 |
| 一套阈值应对所有进程 | 自适应不同波动水平 |
| 慢泄漏检不到 | 时间越长越灵敏,理论上任何速率都能发现 |
| 一次检测直接判定 | 四级状态机逐步积累证据 |
| 误报后无法回退 | 每个阶段都有超时保护和回退机制 |
下篇预告
下篇:对症下药——5 种泄漏 5 种抓法
检测到泄漏只是起点。发现 Java Heap 在涨,抓 hprof 就对了。但如果是 Native 泄漏呢?hprof 里根本看不到 C/C++ malloc 分配的内存。GPU 泄漏呢?显存不在进程虚拟地址空间里,hprof 更看不到。
下一篇讲响应层如何根据泄漏类型精准采集诊断文件,以及在多进程场景下的冷却和并发控制。
系列目录
- 上篇:为什么传统方案不靠谱 + 采集层设计
- 本篇(中):用统计学替代"拍脑袋阈值"——检测层设计
- 下篇:对症下药,5 种泄漏 5 种抓法——响应层设计
我是测试工坊,专注自动化测试和性能工程。 如果觉得有用,点个赞让更多人看到 👍 关注我,后续更新不迷路。