Android 端自动化内存泄漏检测

15 阅读32分钟

1. 背景与挑战

在 Android 自动化压测场景中,内存泄漏是影响应用稳定性的关键问题。传统的内存泄漏排查依赖人工在测试结束后对比首末内存快照,存在以下痛点:

痛点说明
发现滞后测试结束才能看到泄漏,无法在压测过程中实时感知
诊断困难发现泄漏后缺少过程数据,需要重新复现后手动抓取 hprof
类型不分Java 堆泄漏、Native 泄漏、GPU 泄漏、线程泄漏的诊断手段完全不同,统一抓 hprof 对非 Java 泄漏无效
采集干扰高频使用 dumpsys meminfo 采集会显著占用 system_server,采集行为本身干扰被测应用的性能表现
多进程受限进程数增多时串行采集耗时剧增,易超时

目标场景:在自动化压测(Monkey、UI 自动化、稳定性测试等)执行过程中,自动完成"检测 → 分类 → 抓文件"全链路,测试结束后直接产出可分析的诊断文件。


2. 方案目标

2.1 总目标

在压测过程中自动检测内存泄漏,判定泄漏类型,采集对应的诊断文件,供开发人员直接分析定位。

2.2 目标拆解

graph TD
    G["总目标:自动检测 + 分类 + 抓文件"]

    G --> G1["目标1:低干扰采集"]
    G --> G2["目标2:准确检测"]
    G --> G3["目标3:泄漏分类"]
    G --> G4["目标4:精准抓文件"]
    G --> G5["目标5:多进程适配"]

    G1 --> G1a["高频采集耗时低于 100ms"]
    G1 --> G1b["system_server 占用率低于 25%"]

    G2 --> G2a["零人工阈值配置"]
    G2 --> G2b["误报率低于 1%"]
    G2 --> G2c["20 MB/h 级别可检测"]

    G3 --> G3a["5 种类型:Java / Native / GPU / Thread / Unknown"]

    G4 --> G4a["Java 泄漏抓 hprof"]
    G4 --> G4b["Native 泄漏抓 showmap"]
    G4 --> G4c["GPU 泄漏抓 gfxinfo"]
    G4 --> G4d["线程泄漏抓 /proc/task"]

    G5 --> G5a["任意进程数不超时"]
    G5 --> G5b["负载均匀分布"]

2.3 设计原则

原则说明
按需升级平时用最轻的方式巡逻,只在需要确认时才用重量级命令
统计驱动用 t 检验判断趋势显著性,阈值由数据自身决定,不需要人工调参
多级确认预警 → 确认 → 定性,任何阶段证据不足可回退,避免误报
对症下药不同泄漏类型采集不同的诊断文件,不做无效操作

3. 整体架构

方案分为三层:采集层检测层响应层,形成"采数据 → 判泄漏 → 抓文件"的完整链路。

采集层:负责以极低开销持续获取内存数据。它维护两条独立通道——高频通道每 1530 秒采一次 PSS 总量(只需读内核文件,耗时 50ms3s,不经过 system_server),低频通道每 30 秒轮转一个进程执行 dumpsys meminfo 拿完整的 8 维度内存明细。高频数据用于趋势检测(数据点越多、统计检验越灵敏),低频数据用于泄漏分类(需要知道具体是哪个维度在涨)。

检测层:接收采集层的数据,通过统计算法判断内存是否在泄漏。核心逻辑是"线性回归 + t 检验"判断 PSS 是否有统计显著的增长趋势,辅以"P25 基线递增"确认底线水位在持续上涨。整个过程由一个四级状态机驱动(NORMAL → SUSPICIOUS → CONFIRMING → LEAKING),每升一级需要更强的证据,证据不足随时回退,确保不误报。进入 CONFIRMING 后,还会对 8 个维度分别做回归,找出"谁在涨",从而将泄漏分为 Java / Native / GPU / Thread / Unknown 五种类型。

响应层:收到检测层的泄漏类型后,执行对应的诊断文件采集。Java 泄漏抓 hprof,Native 泄漏抓 showmap,GPU 泄漏抓 gfxinfo,线程泄漏抓 /proc/task——不同类型用不同的手段,不做无效操作。同时通过进程级冷却(30 分钟内不重复 dump)和全局锁(同一时刻只有 1 个进程在 dump)控制诊断开销。

三层之间的数据流:高频通道产出的 PSS 时间序列进入检测层的线性回归模块;低频通道产出的 8 维度数据进入多维度交叉验证模块;两个模块的结果汇入状态机;状态机输出泄漏类型给响应层的分类诊断路由。

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

4. 采集层设计

4.1 设计思路

内存泄漏检测需要高频数据看趋势——数据点越多,统计检验(t 检验)灵敏度越高,越能发现慢速泄漏。但如果每次都通过 dumpsys meminfo 获取完整的 8 维度内存明细,单次耗时 5~15 秒,会严重占用 system_server,干扰被测应用的性能表现(即"观测者效应")。

核心矛盾是:频率越高检测越灵敏,但传统采集方式(dumpsys)的开销又不允许高频

解决思路是拆通道:把"看趋势"和"看明细"分开。

  • 高频通道:只采 PSS 总量(一个数字),直接读内核文件 /proc/{pid}/smaps_rollup,完全不经过 system_server,耗时仅 50ms。一个数字足以做线性回归和 t 检验,判断"内存是否在持续增长"。
  • 低频通道:采完整 8 维度明细(Java Heap / Native Heap / Code / Stack / Graphics / Private Other / System / TOTAL),通过 dumpsys meminfo 获取,耗时 5~15 秒。这些数据用于泄漏分类——确认泄漏后需要知道"哪个维度在涨"才能决定抓什么文件。

这样高频通道以极低开销提供密集的数据点用于趋势检测,低频通道以较高开销但不频繁的节奏补充分类所需的维度数据。两条通道独立运行,互不干扰。

4.2 设备能力探测

高频通道的轻量采集依赖 Linux 内核的 /proc 文件系统,但不同 Android 版本对该文件系统的支持程度不同:

  • Android 10+ 内核提供了 smaps_rollup 文件,它是内核预先聚合好的 PSS 汇总值,读一次文件就能拿到整个进程的 PSS 总量,耗时仅约 50ms。
  • Android 5~9 没有 smaps_rollup,但有 smaps 文件(逐 VMA 列出 PSS),需要用 awk 逐行累加,耗时 1~3 秒,但仍然不经过 system_server,开销可控。
  • 极老设备smaps 也无法正常读取,只能回退到 dumpsys meminfo 作为唯一采集方式。

因此在设备连接时做一次自动探测,确定该设备属于哪个 Level,然后缓存结果,后续所有采集直接按对应方式执行,无需重复探测:

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适用版本高频方式高频耗时原理
1Android 10+cat smaps_rollup~50ms内核预聚合的 PSS 汇总文件,绕过 system_server
2Android 5-9awk '/^Pss/' smaps1-3s从 VMA 列表手动聚合 PSS,同样不走 system_server
3极端老设备无高频,仅低频回退到 dumpsys,牺牲检测速度保证兼容性

4.3 高频通道

高频通道的职责是为检测层提供密集的 PSS 时间序列。它对所有监控进程同时采集 PSS 总量(Level 1/2 设备通过读内核文件,Level 3 设备跳过高频只走低频)。采集间隔不是固定的,而是根据状态机当前阶段自适应调整——正常巡逻时采得慢一些节省资源,发现可疑时自动加快频率提高灵敏度:

状态机阶段采集间隔说明
NORMAL(正常巡逻)30 秒低开销,足以发现快/中速泄漏
SUSPICIOUS(可疑)15 秒加密采集,提高 t 检验灵敏度约 2.8 倍
LEAKING(已确认)60 秒减少干扰,此时正在执行诊断操作

每次采到的数据以 (timestamp, pss) 元组形式存入固定大小的滑动窗口 deque(maxlen=240)。选择 240 的原因:在 NORMAL 阶段(30s 间隔),240 个数据点覆盖 2 小时;在 SUSPICIOUS 阶段(15s 间隔),覆盖 1 小时。窗口满了之后自动淘汰最老的数据,内存占用恒定。因为存了真实时间戳,即使采集间隔从 30s 变为 15s(加密采集),回归算法以时间为 x 轴,依然能正确计算斜率,不受变频影响。

4.4 低频通道(轮转制)

低频通道的职责是为检测层提供完整的 8 维度内存明细,用于泄漏分类。它通过 dumpsys meminfo {进程名} 采集,这个命令会通过 Binder 向 system_server 发起 IPC 调用,让 system_server 读取目标进程的内存信息并格式化返回,单次耗时 5~15 秒。

关键问题:如果同时对 3 个进程串行执行 dumpsys,总耗时 1545 秒;5 个进程则 2575 秒——很容易超过采集周期导致超时。而且 system_server 是全系统共享的服务进程,连续高负载会影响其他系统功能。

解决方案——轮转制:每 30 秒只对一个进程执行 dumpsys,下次换下一个进程。这样无论监控多少个进程,每 30 秒 system_server 的负载固定只有一次 5~15 秒的 dumpsys 调用,永远不会超时。代价是每个进程的采样间隔变为 30s × 进程数(3 个进程时每个进程 90 秒采一次详细数据),但低频数据只用于分类(不用于趋势检测),这个频率足够了:

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 峰值负载连续 30-45s 高负载均匀分布,每次仅 5-15s
每进程采样间隔固定30s × 进程数(自动适配)

4.5 GPU 校准

问题背景:高频通道读取的 smaps / smaps_rollup 是内核级别的数据,它统计的是进程虚拟地址空间中的内存(Java Heap、Native Heap、mmap 文件等)。但 GPU 显存(纹理、帧缓冲区等)由 GPU 驱动通过 DMA-BUF 或 ION 机制分配,不在进程的虚拟地址空间内,因此 smaps 看不到这部分内存。这意味着如果只看高频 PSS,纯 GPU 泄漏是完全检测不到的。

校准方法dumpsys meminfo 的 App Summary 部分包含一个 Graphics 字段,它统计了该进程的 GPU 显存占用。每次低频采集时,从同一次 dumpsys 输出中提取 Graphics 值,作为 GPU 偏移量:

corrected_pss = smaps_pss + Graphics 字段值

为什么用"同一次":Graphics 值和 TOTAL PSS 来自同一次 dumpsys 调用,二者在时间上严格对齐,不存在时间差。高频通道拿到新的 smaps_pss 后,用最近一次低频采集获得的 GPU 偏移量做校准即可。

校准对检测的影响:泄漏检测看的是增长趋势(斜率),不是绝对值。即使 GPU 偏移量在两次低频采集之间有微小变化,也不影响高频数据的趋势判断。校准的主要目的是让上报的内存值更接近 dumpsys 看到的 TOTAL PSS 真实值,方便开发人员理解。


5. 检测层设计

5.1 设计思路

检测层要回答三个递进的问题。之所以是递进而不是并行,是因为每个问题的计算复杂度不同——前面的问题用来快速筛掉大量"没有泄漏"的情况,只有通过初筛的才进入更精细的确认环节,避免无谓的计算开销:

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 多段递增

5.2 核心算法:线性回归 + t 检验

详细数学推导见 附录 A

基本原理:把高频 PSS 时间序列看作一组 (时间, 内存) 数据点,用最小二乘法拟合一条直线 y = slope × x + intercept。如果内存在持续增长,数据点会大致沿一条向上的直线分布,slope(斜率)就是内存增长的速率(MB/秒)。

拟合直线的同时计算三个指标:

  • slope(斜率):内存变化速率的直接量化。slope > 0 说明内存在涨,slope 的绝对值代表涨的快慢。
  • (决定系数,也叫拟合优度):衡量数据点和拟合直线的吻合程度,取值范围 0~1。R² = 1.0 表示所有数据点完美落在一条直线上;R² = 0.6 表示有 60% 的变化可以用线性趋势解释。R² 的关键作用是过滤"阶梯跳变":比如打开一个大页面后内存一次性跳升 100MB 然后稳定不动,这时虽然 slope > 0,但数据分布像"台阶"而不是"斜坡",R² 会很低(< 0.6),从而被正确排除。
  • t(t 统计量):斜率的"信噪比"。计算方式是 t = slope / se_slope,即斜率除以斜率自身的标准误差。通俗地说,t 回答的是"这个斜率到底是真的趋势,还是数据噪声碰巧凑出来的"

判定条件(三条同时满足才认为存在泄漏趋势):

① slope > 0       → 内存在涨(方向判断)
② t > 2.0         → 涨幅在统计上显著(95% 置信度,非噪声)
③ R² > 0.6        → 数据是线性增长(非阶梯跳变)

为什么是零配置——用数值举例说明 t 检验的自适应性

假设两个进程在相同时间窗口内的 PSS 都以 50 MB/h 的速率增长(slope 相同):

  • 稳定后台进程(σ=5MB):数据点紧密围绕直线,噪声小 → 标准误差 se_slope 很小 → t = slope / se_slope 得到很大的 t(比如 t=8.5) → 轻松判定显著,这种慢速泄漏也能检测到。
  • 高波动 UI 进程(σ=50MB):数据点散乱地围绕直线,噪声大 → 标准误差 se_slope 很大 → 同样的 slope 得到很小的 t(比如 t=0.85) → 判定不显著,因为在高波动环境下 50 MB/h 可能只是正常业务抖动。

也就是说,t 检验会根据进程自身的波动水平自动调整灵敏度:对稳定进程更灵敏(因为小增长也很突出),对波动进程更保守(因为需要更强的趋势才能和噪声区分开)。这就是"零配置"的含义——不需要为不同进程设置不同的阈值,t 检验从数据自身推断出合适的判定标准

另一个自适应特性:数据量越多,检测越灵敏。随着采样时间增加,标准误差按 1/n^(3/2) 的速度下降,同样的泄漏速率会产生越来越大的 t 值。因此不存在"永远检测不到"的死区——只要泄漏是真实存在的,观察足够长的时间总能检测到。

t > 2.0 和 R² > 0.6 均为统计学标准值(t > 2.0 对应约 95% 置信度,R² > 0.6 对应中等线性度),不是业务参数,无需针对不同设备或进程调整。

5.3 P25 基线确认

什么是 P25:P25 是第 25 百分位数(也叫下四分位数)。把一段时间内的所有 PSS 值从小到大排列,排在 25% 位置的那个值就是 P25。直观理解:P25 代表这段时间内内存的"底线水位"——即进程空闲或低负载时的内存占用

为什么选 P25 而不是均值或中位数

  • 均值容易被业务高峰拉高。比如进程在做批量操作时内存飙到 500MB、空闲时回到 200MB,均值可能是 350MB,完全不能反映底线。如果业务峰值刚好在某段时间更频繁,均值会升高,导致误判为泄漏。
  • 中位数(P50) 比均值好,但仍受业务负载分布影响。
  • P25 只看最低 25% 的数据,几乎不受业务峰值干扰。只有当底线真正抬升(即使在空闲时内存也回不去了),P25 才会上涨。这恰恰是内存泄漏的本质特征:泄漏的内存永远不会被释放,所以底线会越来越高

检测逻辑:将高频 PSS 数据按 5 分钟一段计算每段的 P25,然后检查 P25 是否在多段间持续递增:

段1 P25=2002 P25=2083 P25=2154 P25=224
               ↑+8          ↑+7          ↑+9
4 段中 3 段递增 ≥ N-1 → 确认底线在涨 → 泄漏

允许 N 段中最多 1 段回落(容许 GC 或 LMK 回收导致的偶尔回降),最少需要 3 段数据(≈15 分钟)。

斜率与 P25 的分工

  • 线性回归 + t 检验:快速预警。它用全部数据做判断,反应快,但可能因为业务峰值导致短暂的假阳性(比如持续几分钟的高负载期间 slope 显著)。
  • P25 基线递增:稳健确认。它只看底线水位,排除了峰值干扰。如果线性回归说"在涨",同时 P25 也在多段间递增,就基本确定是真泄漏,而不是业务波动。

两者配合使用:斜率做初筛(NORMAL → SUSPICIOUS),P25 做确认(SUSPICIOUS → CONFIRMING)。

5.4 辅助检测

线性回归 + P25 是主检测路径,但有两种场景它覆盖不了,需要辅助路径补充:

辅助路径一:突增检测

场景:某个 Bitmap 没释放导致一次性泄漏 300MB,或者一个循环分配在几秒内吃掉大量内存。这种"一步到位"的泄漏不会产生持续的线性增长趋势(因为涨完就停了),线性回归可能来不及检测。

检测方式:每次高频采样时计算当前 PSS 与近 5 分钟 P25(底线)的差值:

spike = 当前 PSS − 近 5 分钟 P25
spike > max(P25 × 50%, 200MB) → 直接确认泄漏(跳过中间状态)

也就是说,如果内存突然比底线高出 50% 以上(且至少 200MB),直接判定为突发泄漏,从 NORMAL 状态直通 LEAKING,触发诊断文件采集。200MB 的绝对下限是为了排除小内存进程的测量误差。

辅助路径二:GPU 泄漏检测

场景:纹理泄漏、帧缓冲区泄漏等纯 GPU 侧的内存增长。如前文 4.5 节所述,smaps 的 PSS 看不到 GPU 显存,因此即使 GPU 在持续泄漏,高频 PSS 数据也不会有任何增长趋势——主路径完全失灵。

检测方式:利用低频通道 dumpsys meminfo 输出的 TOTAL PSS(包含 GPU 显存)。如果 dumpsys TOTAL PSS 在涨(对其做线性回归 + t 检验,t > 2.0),但 smaps PSS 不涨(t < 1.0),两者之间的差异只能由 GPU 显存增长来解释。满足这个条件时直接判定为 GPU 泄漏(gpu_leak 类型),触发 dumpsys gfxinfo 等 GPU 相关诊断文件采集。

这条辅助路径使用的是低频数据(每个进程 30s × 进程数 的间隔),数据点较少,因此检出时间比主路径慢一些(20~30 分钟),但它是检测纯 GPU 泄漏的唯一手段。

5.5 四级状态机

为什么需要状态机:如果只用一次线性回归的结果直接判定泄漏,误报率会很高。内存数据天然有波动,一个 10 分钟窗口内偶然出现上升趋势是正常的。因此需要一个逐步积累证据、层层确认的机制——这就是状态机的作用。

状态机有四个状态,每升一级需要更强的证据。每个状态都有超时保护,证据不足时会自动回退到 NORMAL,不会卡在某个中间状态白白消耗资源:

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线性回归 + t + R²无限
SUSPICIOUS加密采集积累证据15s斜率 + P25 基线30min
CONFIRMING多维度验证+分类15s8 维度交叉验证10min
LEAKING执行诊断动作60s

加密采集的效果:SUSPICIOUS 阶段间隔从 30s 缩短到 15s,同一时间内数据点翻倍。由于 t 检验的灵敏度和数据量的关系是 t ∝ n^(3/2),数据量翻倍使 t 值提高约 2.83 倍——原本 t=1.5(不显著)变为 t=4.2(显著)。这是状态机的核心设计:用低开销初筛,可疑时自动加大投入。

场景走查:一个真实的 Java 泄漏从发现到诊断的完整流程

以下用一个具体例子说明状态机如何运转。假设主进程存在一个 Activity 泄漏,每次打开新页面泄漏 2MB 对象,压测过程中每分钟打开约 5 次页面,实际泄漏速率约 600 MB/h。

阶段 1:NORMAL(0~10 分钟)

系统启动后进入 NORMAL 状态,高频通道每 30 秒采一次 PSS。前 5 分钟(10 个数据点)数据量不足,不做检测。5 分钟后开始做线性回归:

  • 第 5 分钟:slope=8.2 MB/min,t=1.3,R²=0.45。t < 2.0,不显著——可能只是启动阶段的正常内存增长。状态保持 NORMAL。
  • 第 8 分钟:slope=9.5 MB/min,t=2.8,R²=0.72。三个条件全部满足!但规则要求"连续 2 个窗口"都满足。标记 consecutive_sig_count=1
  • 第 9 分钟:slope=9.1 MB/min,t=3.1,R²=0.68。连续第 2 个窗口满足。consecutive_sig_count=2

状态升级为 SUSPICIOUS

阶段 2:SUSPICIOUS(10~22 分钟)

高频间隔自动从 30s 缩短到 15s(加密采集),数据点密度翻倍。同时开始计算 P25 基线。

  • 第 10~15 分钟(段 1):P25=280MB
  • 第 15~20 分钟(段 2):P25=328MB(↑48)
  • 第 20~25 分钟(段 3):P25=379MB(↑51)

3 段中 2 段递增(满足 N-1=2 的要求)。

状态升级为 CONFIRMING

阶段 3:CONFIRMING(22~28 分钟)

额外触发 3 次 dumpsys meminfo 对该进程采集 8 维度数据。然后对每个维度做独立的线性回归 + t 统计:

  • Java Heap:slope=+7.8 MB/min,t=5.2(显著
  • Native Heap:slope=+0.3 MB/min,t=0.4(不显著)
  • Graphics:slope=+0.1 MB/min,t=0.2(不显著)
  • 其他维度:t 均 < 1.0

Java Heap 的 t 值最大且 > 2.0 → 分类为 java_leak

同时检查:PSS 总增长 = 当前 PSS - 进入 SUSPICIOUS 时的 PSS = 379 - 260 ≈ 119MB > 20MB(下限)。冷却检查通过(首次诊断)。

状态升级为 LEAKING

阶段 4:LEAKING(28~30 分钟)

根据分类 java_leak,执行 Java 泄漏诊断流程:

  1. kill -10 {pid} 触发 GC
  2. 等待 30 秒让 GC 充分执行
  3. am dumpheap {进程名} {路径} 生成 hprof 文件
  4. adb pull 拉取到主机

高频间隔放宽到 60s,减少对 dump 过程的干扰。

诊断完成后,状态回到 NORMAL,设置 30 分钟冷却期。在冷却期内如果再次检测到泄漏趋势,只上报事件(类型 + 速率),不重复执行 dump。

回退场景对比:假如同一个进程只是因为用户打开了一个大页面导致内存跳升 50MB 然后稳定,情况会完全不同:

  • NORMAL 阶段:R² < 0.6(阶梯跳变而非线性),不会升级到 SUSPICIOUS。
  • 即使 R² 恰好 > 0.6,进入 SUSPICIOUS 后 P25 不递增(因为内存稳定了),30 分钟超时后回退到 NORMAL。

5.6 多维度交叉验证与泄漏分类

为什么要分类:不同类型的泄漏需要不同的诊断文件。如果不分类就统一抓 hprof(Java 堆快照),对 Native 泄漏来说 hprof 里根本看不到 C/C++ malloc 分配的内存;对 GPU 泄漏来说 hprof 里也没有 GPU 显存信息。白白浪费 60~90 秒的 dump 时间,还可能因为 dumpheap 触发 GC 而干扰应用。

8 个维度是什么dumpsys meminfo 的 App Summary 部分会列出进程内存的分类统计,本方案使用以下 8 个维度:

维度含义典型泄漏场景
Java HeapJava/Kotlin 对象占用Activity/Fragment 未释放、静态引用持有大对象
Native HeapC/C++ malloc 分配JNI 代码中 malloc 未 free、第三方 so 库泄漏
Code加载的 dex/oat/so 代码段动态加载过多插件
Stack线程栈空间(每线程约 1MB)线程创建后不销毁
GraphicsGPU 纹理/帧缓冲区大量 Bitmap 绑定到 GPU 纹理未释放
Private Other匿名共享内存等Ashmem 泄漏、大量未关闭的 Cursor
System系统级分配(通常不可控)一般不泄漏
TOTAL以上所有之和用于 GPU 辅助检测中和 smaps PSS 对比

分类算法:进入 CONFIRMING 阶段后,对这 8 个维度的低频时间序列各自独立做线性回归 + t 统计量计算。然后比较各维度的 t 值,找出增长最显著的那个维度:

flowchart LR
    DIM["8 维度各自<br>线性回归"] --> CMP["比较 t 值"]
    CMP --> |"Java Heap t 最大且 > 2.0"| J[java_leak]
    CMP --> |"Native Heap t 最大且 > 2.0"| N[native_leak]
    CMP --> |"Stack t 最大且 > 2.0"| T[thread_leak]
    CMP --> |"Graphics t 最大且 > 2.0"| G[gpu_leak]
    CMP --> |"无维度显著 / 多维度同时"| U[unknown]

6. 响应层设计

响应层的职责是:收到检测层传来的泄漏类型后,精准地采集对应的诊断文件。这些文件是开发人员定位泄漏根因的直接依据——hprof 可以看到 Java 对象引用链、showmap 可以看到每个 .so 的内存占用、gfxinfo 可以看到 GPU 纹理缓存。

6.1 分类诊断路由

确认泄漏后,根据类型执行不同的诊断动作。核心设计理念:对症下药

传统方案不区分泄漏类型,统一抓 hprof(Java 堆快照),存在两个问题:一是 hprof dump 本身非常重(需要 GC + 暂停进程 + 写大文件,耗时 60~90 秒),对被测应用干扰大;二是对非 Java 泄漏来说 hprof 完全无效——Native 泄漏在 C/C++ 堆上,GPU 泄漏在显存里,hprof 都看不到。

本方案根据 5.6 节的分类结果,为不同类型走不同的诊断路径。其中只有 Java 泄漏需要最重的 hprof dump,其他类型只需几秒钟的轻量级命令即可采集到有效信息。

在执行诊断前,还需要通过两道控制门:

flowchart TD
    LEAK["泄漏确认"] --> COOL{"冷却检查<br>距上次 30min 以上?"}
    COOL -->|"否"| LOG["仅上报事件<br>类型 + 速率"]
    COOL -->|"是"| LOCK{"全局锁<br>其他进程在 dump?"}
    LOCK -->|"是"| WAIT["下轮重试"]
    LOCK -->|"否"| TYPE{"泄漏类型"}

    TYPE --> JAVA["java_leak<br>耗时 60-90s"]
    TYPE --> NATIVE["native_leak<br>耗时 3-5s"]
    TYPE --> GPU["gpu_leak<br>耗时 10-20s"]
    TYPE --> THREAD["thread_leak<br>耗时 1-2s"]
    TYPE --> UNK["unknown<br>耗时 70-100s"]

6.2 各类型诊断文件

以下是 5 种泄漏类型各自需要采集的文件、采集方式和产出物的详细说明。每种类型的设计原则是:用最短的时间、最小的干扰,采集到足以定位根因的最少文件

java_leak — Java 堆对象泄漏

步骤命令说明
1kill -10 {pid}触发 GC,清除可回收对象
2sleep 30等待 GC 充分执行
3am dumpheap {pro} {path}生成 hprof 堆快照
4adb pull拉取到主机
5leakAnalyzer 分析输出泄漏对象 Top N

产出:hprof 文件 + 泄漏分析 CSV。hprof 记录所有 Java 对象的引用关系,可定位 GC Root → 泄漏对象的引用链。

native_leak — C/C++ 内存泄漏

步骤命令说明
1showmap -v {pid}进程内存映射详情
2cat /proc/{pid}/smaps完整 VMA 列表
3cat /proc/{pid}/maps内存映射简表

产出:showmap + smaps + maps 三个文件。showmap 列出每个 .so 和匿名映射的占用,可定位哪个库的堆区在增长。不抓 hprof:hprof 只含 Java 堆对象,Native malloc 不在其中。

gpu_leak — GPU 纹理/缓冲区泄漏

步骤命令说明
1dumpsys meminfo {pro}完整 meminfo(含 Graphics 明细)
2dumpsys gfxinfo {pro}图形渲染信息、纹理缓存
3dumpsys SurfaceFlingerSurface/图层状态

产出:meminfo + gfxinfo + SurfaceFlinger。GPU 显存由驱动管理,不在进程虚拟地址空间内,gfxinfo 可查看纹理缓存占用。

thread_leak — 线程泄漏

步骤命令说明
1cat /proc/{pid}/status线程数(Threads 字段)
2ls /proc/{pid}/task/线程 ID 列表
3dumpsys meminfo {pro}完整 meminfo 快照

产出:线程数 + 线程列表 + meminfo。Stack 每涨约 1MB ≈ 多了 1 个线程,task 列表可查看哪些线程没关闭。

unknown — 不确定类型

触发条件:多维度交叉验证中无单一维度显著(所有 t < 2.0)、或多个维度同时显著(无法确定主要泄漏源)、或由突增检测直通(没有经过 CONFIRMING 分类)。

兜底策略:同时抓 Java + Native 两套诊断文件(hprof + showmap + smaps + maps),覆盖最常见的两种泄漏类型。虽然耗时较长(70~100 秒),但 unknown 出现的频率低(大多数泄漏都能被明确分类),且宁可多抓不漏。

6.3 响应耗时对比

泄漏类型响应耗时说明
java_leak60-90s需要 GC + dumpheap
native_leak3-5s仅文件读取命令
thread_leak1-2s仅文件读取命令
gpu_leak10-20sdumpsys 级命令
unknown70-100sJava + Native 全量

非 Java 泄漏只需几秒即可完成诊断文件采集,不会对被测应用造成 GC + dumpheap 的额外干扰。

6.4 冷却与并发控制

为什么需要冷却:如果一个进程持续泄漏,检测层会不断报告"泄漏"。如果每次报告都触发 dump,一个 1 小时的压测可能产出十几个 hprof 文件(每个几百 MB),浪费磁盘空间且干扰测试。实际上,对同一个进程来说,第一份诊断文件通常就包含了足够的定位信息——泄漏对象在那里,引用链在那里。因此设置冷却期,避免重复劳动。

为什么需要全局锁:在多进程监控场景下(如主进程 + 小程序进程 + 推送进程),可能多个进程同时被判定为泄漏。如果同时对多个进程执行 dump,system_server 需要同时处理多个 am dumpheap 请求,可能导致 ANR 或系统卡顿。全局锁确保同一时刻只有一个进程在做诊断操作。

机制说明
进程级冷却同一进程两次诊断间隔 ≥ 30 分钟。冷却期内检测层仍正常运行,如果再次检测到泄漏趋势会上报事件(含泄漏类型和速率),但不执行 dump 操作——零开销
全局锁同一时刻最多 1 个进程在执行诊断动作。如果进程 A 正在 dump,此时进程 B 也到了 LEAKING 状态,B 会被标记为"下轮重试",等 A 完成后再执行
状态重置诊断完成后状态机回到 NORMAL,数据窗口不清空。冷却结束后如果泄漏仍在持续,需要重新积累 2 窗口的证据才能再次触发诊断,确保每次 dump 前都有新的统计证据

7. 覆盖能力

7.1 泄漏场景覆盖矩阵

泄漏场景检测手段预计检出时间
快速泄漏 >600 MB/ht 检验~5 min
中速泄漏 100-600 MB/ht 检验 + P25 确认10-20 min
慢泄漏 20-100 MB/ht 检验(长窗口) + P2520-30 min
极慢泄漏 <20 MB/hP25 长期积累60 min+
突发泄漏(瞬间 >200MB)突增检测30s
阶梯跳变(一次性资源加载)R² < 0.6 过滤 + P25 不递增不触发(正确排除)
纯 GPU 泄漏GPU 辅助路径(TOTAL vs smaps)20-30 min
高波动进程 + 中速泄漏t 检验自适应 + 加密采集15-20 min
周期性业务 + 泄漏连续窗口确认 + P2520-30 min

7.2 检测灵敏度(t 检验理论值)

t 检验可检测的最低泄漏速率取决于数据噪声(σ)和观察时间(n 个数据点):

进程波动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

公式:最低可检测速率 ≈ 6.93 × σ / n^(3/2) × (3600 / 采样间隔)

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

7.3 已知边界

场景说明建议补充方案
极慢泄漏 <20MB/h需运行 >1 小时才能积累足够统计证据测试首末内存对比
GC 压住的对象泄漏对象已泄漏但 GC 能回收软引用,内存量不变GC 日志监控
文件描述符/连接泄漏不是内存泄漏,不在本方案范围内fd 监控

8. 健壮性设计

自动化检测工具本身必须足够稳健,不能因为边界情况导致误报、卡死或资源泄漏。以下逐一分析可能遇到的异常场景及应对策略:

8.1 误报防护

场景风险应对策略原理
启动阶段数据不足进程刚启动几分钟,数据点很少,线性回归结果不可靠各检测算法有最低数据量门槛——线性回归至少需要 10 个数据点(约 5 分钟),P25 至少需要 3 段(约 15 分钟)。数据不足时不做任何判定统计检验的可靠性依赖样本量,样本太少时任何结论都不可信
启动阶段内存正常增长应用刚启动时需要加载资源、初始化缓存,内存会正常上涨一段时间。这段上涨可能触发线性回归的 t > 2.0即使进入 SUSPICIOUS,P25 基线在应用稳定后不再递增(因为底线已经到了正常水位),30 分钟超时后自动回退到 NORMALP25 看的是底线而非均值,启动完成后底线稳定 = P25 不递增 = 不会误升级
业务峰值误报用户高频操作导致内存临时飙高(如连续打开多个大页面),均值上升,看起来像泄漏P25 只反映底线水位,业务峰值只拉高 P75/P90,不影响 P25;R² 过滤非线性模式正常业务波动是"高点涨低点不涨",泄漏是"高点低点一起涨"
阶梯跳变一次性加载大资源(如打开地图插件 +80MB),之后稳定不动。线性回归 slope > 0R² < 0.6(数据分布像台阶而不是斜坡,线性度差);P25 在跳变后稳定不递增阶梯是"一步到位",泄漏是"持续增长",R² 能区分这两种模式
周期性业务定时任务每 5 分钟触发一次内存抖动(上涨后回落),看起来有短暂的增长趋势单个窗口内 slope 可能短暂显著,但连续 2 个窗口不易同时显著(因为下一个窗口包含了回落的数据);P25 也不递增(每次回落后底线恢复)"连续 2 窗口"的要求过滤了偶发波动,P25 忽略了周期性峰值

8.2 状态卡死防护

场景风险应对策略
边缘波动进程内存在泄漏阈值边缘反复横跳——时而 t > 2.0 时而 t < 2.0,进入 SUSPICIOUS 后一直得不到确认也回退不了SUSPICIOUS 阶段最大停留 30 分钟,CONFIRMING 最大停留 10 分钟。超时后无论当前状态如何强制回退到 NORMAL。最多浪费的是 30 分钟的加密采集开销(高频 15s 而不是 30s),不影响系统稳定性
CONFIRMING 维度数据不足进入 CONFIRMING 阶段时,某些进程的低频数据可能只有很少几个点(比如 5 进程轮转时每个进程 2.5 分钟才采一次),不够做维度回归进入 CONFIRMING 后立即触发对该进程的 3 次额外 dumpsys(间隔 30 秒),确保有 3+ 个新鲜的维度数据点用于回归分析

8.3 运行时异常防护

场景风险应对策略
进程重启 PID 变化被监控进程被系统杀死后重启,PID 变了,旧数据(对应旧 PID 的内存数据)对新进程毫无意义每次采集前验证当前 PID 是否仍然有效(kill -0 {pid}),如果失效则按进程名重新查询 PID,同时清空该进程的所有数据窗口并将状态机重置为 NORMAL
多进程同时泄漏主进程和子进程同时进入 LEAKING 状态,如果同时执行 dump 会压垮 system_server全局锁确保同一时刻最多 1 个进程在做诊断。锁被占用时,其他进程仅上报泄漏事件(类型 + 速率 + 当前 PSS),不执行 dump 操作,等锁释放后下轮重试
dumpsys 解析失败dumpsys meminfo 的输出格式在不同 Android 版本和 ROM 上可能有差异,某些字段可能缺失或格式异常字段级异常处理:每个维度的解析独立 try-except,单个字段解析失败只标记该字段为 None,不影响其他维度的解析和检测
采样间隔不均匀状态机切换导致高频间隔从 30s 变为 15s 再变为 60s,或者采集命令偶尔耗时异常导致实际间隔偏差所有数据存为 (timestamp, value) 元组,线性回归以真实时间戳为 x 轴。无论采样间隔如何变化,斜率计算始终基于实际时间,不会因为间隔不均匀而产生偏差
工具自身内存长时间运行时数据持续积累,如果用 list 存储会导致工具自身内存不断增长使用 deque(maxlen) 固定窗口大小。高频窗口 maxlen=240,低频窗口 maxlen=60。满了之后自动丢弃最老的数据。每个进程的全部数据结构内存占用约 15KB,10 个进程也只有 150KB