内存泄漏自动检测(中):用统计学替代"拍脑袋阈值"

16 阅读11分钟

这是「Android 内存泄漏自动检测」系列的第 2 篇,共 3 篇。 上篇讲了采集层如何以 50ms 的开销拿到高频数据。本篇讲检测层——拿到数据后,怎么判断是泄漏还是正常波动。


传统方案怎么判泄漏?拍脑袋

大多数团队做内存泄漏检测,用的都是这个逻辑:

  1. 把数据分成前半段和后半段
  2. 分别算均值
  3. 后半段均值 - 前半段均值 > 50MB → 判定泄漏

这个 50MB 是怎么来的?拍脑袋拍的

这个方案有 5 个根本缺陷:

缺陷后果
硬编码阈值50MB 是个"万金油"数字,高波动进程天天误报,低波动进程漏报
慢泄漏盲区20MB/h 的泄漏跑 30 分钟只涨 10MB,永远过不了 50MB
阶梯误判打开一个大页面内存跳涨 150MB 后稳定不动——均值差 > 50MB,误判为泄漏
丢失时间信息只比较两个均值,丢掉了每个数据点的时间位置
不区分状态不知道是"正在涨"还是"已经稳在高位"

本质问题:它只比较了两个数字的差值,没有利用数据中丰富的时间序列信息。

我想要的是:不需要人工调阈值,不同进程自动适配,慢泄漏也能抓到。


检测层的三个递进问题

检测层不是一步到位做判定的。它回答三个逐步深入的问题,像过滤漏斗一样逐层筛选:

graph LR
    Q1["1. 内存是否在涨?"] -->|"线性回归斜率"| Q2["2. 涨是真的还是噪声?"]
    Q2 -->|"t 检验"| Q3["3. 是持续泄漏还是临时波动?"]
    Q3 -->|"P25 基线递增"| CONFIRM["确认泄漏, 进入分类"]
问题工具判据
内存是否在涨?线性回归斜率 slopeslope > 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=2002 P25=2083 P25=2154 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加密采集积累证据15sP25 基线多段递增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/h18 MB/h6 MB/h2 MB/h
σ=20MB(中等波动)131 MB/h72 MB/h25 MB/h9 MB/h
σ=50MB(高波动 UI)329 MB/h179 MB/h63 MB/h22 MB/h

核心特性:观察时间越长,可检测的泄漏速率越低——不存在"永远检测不到"的死区。


小结

检测层的核心设计思想是让数据自己说话

传统方案本方案
人工拍阈值 50MBt 检验从数据自身推断显著性
一套阈值应对所有进程自适应不同波动水平
慢泄漏检不到时间越长越灵敏,理论上任何速率都能发现
一次检测直接判定四级状态机逐步积累证据
误报后无法回退每个阶段都有超时保护和回退机制

下篇预告

下篇:对症下药——5 种泄漏 5 种抓法

检测到泄漏只是起点。发现 Java Heap 在涨,抓 hprof 就对了。但如果是 Native 泄漏呢?hprof 里根本看不到 C/C++ malloc 分配的内存。GPU 泄漏呢?显存不在进程虚拟地址空间里,hprof 更看不到。

下一篇讲响应层如何根据泄漏类型精准采集诊断文件,以及在多进程场景下的冷却和并发控制。


系列目录

  • 上篇:为什么传统方案不靠谱 + 采集层设计
  • 本篇(中):用统计学替代"拍脑袋阈值"——检测层设计
  • 下篇:对症下药,5 种泄漏 5 种抓法——响应层设计

我是测试工坊,专注自动化测试和性能工程。 如果觉得有用,点个赞让更多人看到 👍 关注我,后续更新不迷路。