APM_OOMDetector

前言

APM 中,有一个模块属于 OOM 检测,也就是内存溢出(Out of Memory),当 iOS 应用因为内存原因而系统终止时,进程会被系统直接杀死。如果是被系统直接杀死,是没有机会再去执行代码的,所以这篇文章就来讨论怎么解决这个问题。

既然无法在死亡瞬间记录,那么可以采用类似飞机的 "黑匣子" 的方式,也就是飞行途中不断记录飞行状态,如果飞机真的出现什么问题,那么可以通过黑匣子来推断当时可能发生了什么。

OOMDetector 就是来做这个事情的。

OOMDetector

它的思路就是在运行的时候不断记录状态,然后如果进程突然被杀死了,那么下次启动的时候,根据之前记录的状态,来推断是不是因为 OOM 被系统杀死的。

OOMDetector 的 OOM 检测主链上有 3 个重要的文件:

  • <uuid>.oom
  • <uuid>.mmap
  • app.images

目录结构大概是:

c 复制代码
  Library/
  ├─ Foom/
  │  └─ <uuid>.oom
  │
  └─ OOMDetector_New/
     └─ <uuid>/
        ├─ <uuid>.mmap
        └─ app.images
文件 内容
<uuid>.com 生命周期、正常退出、崩溃、卡死、最后内存等状态
<uuid>.mmap 超过阈值的 malloc 分配堆栈及聚合大小
app.images 当时加载的二进制镜像及地址范围,用于解释堆栈地址

每次运行,都会生成一个 uuid,用来关联这些文件。

另外,这里面有一些概念,比如 mmapimages,后面都会提到。

mmap

OOMDetector 中写入磁盘是系统提供的 mmap() 方法来实现的 ,它是 Darwin/POSIX 提供的 C API,APM 中监控崩溃模块的落盘,也是用 mmap() 来实现的。

它的作用是把一个磁盘文件映射到进程的一段虚拟内存,你修改了这段内存,就等于修改到了它关联的磁盘文件。

一个文件可以看作连续排列的字节:

c 复制代码
文件偏移量: 0 1 2 3 4 5 ...  
文件内容: 00 00 00 00 00 00 ...

文件偏移量就是一个字节距离文件开头有多远。

进程的内存也有自己的地址:

c 复制代码
内存地址: P P+1 P+2 P+3 P+4 P+5 ...  
内存内容: 00 00 00 00 00 00 ...

这里的 P 代表系统选择的一段虚拟内存的起始地址。

"虚拟内存地址" 是进程看到的地址。

mmap 所做的事情,就是建立一套映射:

c 复制代码
内存地址 P ↔ 文件偏移量 0  
内存地址 P+1 ↔ 文件偏移量 1  
内存地址 P+2 ↔ 文件偏移量 2  
...

这样,当程序修改比如 P + 2 对应的字节时,操作系统就知道:

这个内存位置属于文件映射,它对应文件偏移量为 2 的位置。

<uuid.oom>

是用 mmap 的方式写入的,是一堆的二进制,启动的时候,OOMDetector 会把一个 160 KB 的文件映射到内存,它里面记录的内容大概是这些:

json 复制代码
{  
    "lastMemory": "430",  
    "memWarning": 1,  
    "uuid": "8D53-27A1-ABCD",  
    "systemVersion": "Version 18.x",  
    "appVersion": "1.2.3",  
    "appState": 1,  
    "isCrashed": false,  
    "isDeadLock": false,  
    "deadlockStack": "",  
    "isExit": false,  
    "ocurTime": 1756864804.2,  
    "startTime": 1756864700.1,  
    "isOOMDetectorOpen": false,  
    "crash_stage": "GalleryViewController"  
}

appState

swift 复制代码
APPENTERBACKGROUND = 0 // 最后进入后台  
APPENTERFORGROUND = 1 // 最后进入前台  
APPDIDTERMINATE = 2 // 收到了正常终止通知

isCrashed

是否有崩溃,但是 OOMDetecotr 并不捕获崩溃,只是提供一个值,如果你有自己的检测崩溃的模块,那么在检测到崩溃的时候,把这个值设置成 true,也就是它提供给外部使用的。

isExit

这个是判断进程是不是正常退出。

监听了:

c 复制代码
exit()
和 
_exit()

如果是主动调用这两个方法,那么就算是正常退出。

isDeadLock

是否卡死,这个也 isCrashed 一样,如果你有什么检测卡死的模块检测到系统卡死了,把这个设置成 true

OOMDetector 的源码注释里面是要求:

请在卡死检测组件检测到 5 秒以上卡顿时调用。

不过这个具体还是看你的检测卡死模块怎么做的。

systemVersion

本次运行的系统版本:

swift 复制代码
[NSProcessInfo processInfo].operatingSystemVersionString

appVersion

这是宿主传入的 App 版本,在启动前传入:

swift 复制代码
[[FOOMMonitor getInstance] setAppVersion:@"OOMDetector_demo"];  
[[FOOMMonitor getInstance] start];

lastMemory

最后一次在前台采集到的 resident_size,单位是 MB。

这个数据来自 Apple Mach 层的 task_info()

swift 复制代码
task_info(  
    mach_task_self(),  
    MACH_TASK_BASIC_INFO,  
    ...  
)

memWarning

本次运行,收到了多少次内存警告:

swift 复制代码
UIApplicationDidReceiveMemoryWarningNotification

deadlockStack

这个和 isDeadLock 是挂钩的,也是外部传入,卡死时候的堆栈。

crash_stage

表示最后记录到的业务页面。

OOMDetector swizzle 了:

swift 复制代码
UIViewController.viewDidAppear:

startTime

调用 FOOMMonitor.start 的时间。

ocurTime

(应该是 occurTime。。)

最后一次更新状态的时间。

所以 ocurTime - startTime 大概等于黑匣子覆盖的运行时长。

uuid

本次运行的唯一标识。

swift 复制代码
<uuid>.oom  

<uuid>.mmap  

app.images 和 <uuid>.mmap 在同一个目录。

isOOMDetectorOpen

这个字段不可信,不用管。

真正用于判断的

在最终的判断中,真正使用的是:

swift 复制代码
appState  
isCrashed  
isExit  
isDeadLock  
systemVersion  
appVersion

它们组成了这样的排除链:

swift 复制代码
是否最后在前台?  
    否 → 不判断 FOOM  
    是  
    ↓  
是否记录到已知崩溃?  
    是 → normal_crash  
    否  
    ↓  
是否记录到正常 exit?  
    是 → no_crash  
    否  
    ↓  
版本是否一致?  
    否 → no_crash  
    是  
    ↓  
是否记录到卡死?  
    是 → deadlock_crash  
    否 → foom_crash

其余字段负责补充上下文、关联堆栈或者描述配置,不直接决定 FOOM

<uuid>.mmap

这是 OOMDetector 的核心所在。

它分了两个部分来记录,可以理解为两个账本。

一个是地址账本,一个是堆栈账本,地址账本不会写进 .mmap,堆栈账本才会写入。

地址账本和堆栈账本是一个类似明细表和汇总表的关系,然后通过 digest 连接。

c 复制代码
  地址账本(明细)
  内存地址 → 分配大小 + digest
                      │
                      ▼
  堆栈账本(汇总)
  digest → 未释放总数量 + 未释放总大小 + 调用栈

比如,同一条调用栈分配了两块内存:

c 复制代码
  地址账本
  0x1000 → 10 MB,digest=123
  0x2000 → 20 MB,digest=123

  堆栈账本
  digest=123 → count=2,size=30 MB,stack=A→B→C

当执行:

c 复制代码
free(0x1000)

处理过程是:

  1. 0x1000 查地址账本
  2. 得到它占用 10 MB,属于 digest=123
  3. 删除地址记录
  4. 去堆栈账本扣减对应汇总

结果变成:

c 复制代码
  地址账本
  0x2000 → 20 MB,digest=123

  堆栈账本
  digest=123 → count=1,size=20 MB

两者缺一不可。

这里有个概念是 digest,可以理解为一条调用栈的 "指纹"。

比如,调用栈地址:

c 复制代码
  A → B → C
       ↓ CRC64 计算
  digest:123456

OOMDetector 用自己实现的 CRC64,根据调用栈的原始地址计算出一个 uint64_t 数字。

它的作用是快速判断和归组:

digest 相同 -> 认为来自同一条调用栈 -> 合并 countsize

地址账本

它是一个 C++ 的哈希表:CPtrsHashmap

解决的核心问题是:

分配时知道 "地址、大小、调用栈",但释放时通常只拿到 "地址"。怎样知道应该从哪条调用栈中扣掉多少内存?

因此它保存了一条反向关系:

分配地址 -> 原始分配大小 -> 调用栈 digest

真正存入哈希表的是 ptr_log_t

c 复制代码
  typedef struct ptr_log_t {
      uint64_t digest; // 产生这次分配的调用栈摘要
      uint32_t size; // 这块内存的原始分配大小
      vm_address_t address; // 这次分配返回的起始地址
      ptr_log_t *next; // 哈希冲突时连接同一桶的下一条记录
  } ptr_log_t;

在启动 malloc 监控时,OOMDetector 创建:

c 复制代码
  oom_ptrs_hashmap =
      new CPtrsHashmap(500000, global_memory_zone);

这里的 500000 是哈希桶数量,不是最多只能保存 50 万次分配。每个桶最初只保存一个链表头,真正的地址记录会按需动态分配。

底层的桶结构很简单:

c 复制代码
  typedef struct base_entry_t {
      void *root;
  } base_entry_t;

初始化时创建整个桶数组,并将每个 root 设置 NULL

当一次 malloc 事件到达后,源码调用:

c 复制代码
recordMallocStack(result, (uint32_t)arg2, 2);

其中:

c 复制代码
  result = 新分配的地址
  arg2   = 分配大小

进入 recordMallocStack() 后,先获取当前调用栈并计算 digest,然后准备两份数据:

c 复制代码
  base_ptr.digest = digest;
  base_ptr.size = size;

接着在同一把锁内执行:

c 复制代码
  if (oom_ptrs_hashmap->insertPtr(address, &base_ptr)) {
      oom_stacks_hashmap
          ->insertStackAndIncreaseCountIfExist(digest, &base_stack);
  }

也就是:

  1. 先把单次分配放入地址账本
  2. 地址插入成功后,才增加堆栈账本的 countsize
  3. 如果该地址已经存在,insertPtr() 返回 NO,堆栈账本也不会重复累计

同一把锁是要保证地址账本和堆栈账本的记录是一个原子操作。

free 的时候,把释放的地址传给:

c 复制代码
  removeMallocStack(address);

内部逻辑是:

c 复制代码
  uint32_t size = 0;
  uint64_t digest = 0;

  if (oom_ptrs_hashmap->removePtr(address, &size, &digest)) {
      oom_stacks_hashmap
          ->removeIfCountIsZero(digest, size, count);
  }

removePtr() 会同时完成两件事:

  1. 从地址账本删除这个地址
  2. 在删除前返回它的 sizedigest

然后就可以知道要在堆栈账本那边的哪个 digest 对应的堆栈分组去,减少多少的 countsize

这里有个问题,就是怎么解决哈希冲突,因为不同的地址可能会计算出相同的桶下标。

比如:

c 复制代码
地址 A → bucket[42]  
地址 B → bucket[42]  
地址 C → bucket[42]

OOMDetector 是用单向链表的方式来解决的:

c 复制代码
bucket[42]  
    ↓  
[A, 20 MB, S1]  
    ↓  
[B, 8 MB, S2]  
    ↓  
[C, 12 MB, S1]  
    ↓  
    NULL

其中 next 指向下一条记录。

插入 B 时:

  1. 检查根节点是不是 B
  2. 不是就沿着 next 向后遍历
  3. 确认链表里没有 B
  4. 把 B 加到链表末尾

也就是如果两个地址都返回同一个桶,那就按单向链表往下走。

堆栈账本

CStackHashmap,负责把已经采集到的分配记录,按照 digest 汇总成:

digest -> 当前未释放数量 + 当前未释放总大小

例如:

c 复制代码
  地址账本
  0x1000 → 10 MB,digest=123
  0x2000 → 20 MB,digest=123
  0x3000 → 5 MB, digest=456

  堆栈账本
  digest=123 → count=2,size=30 MB
  digest=456 → count=1,size=5 MB

源码中其实有三层数据。

  1. 当前分配的临时数据:base_stack_t
c 复制代码
  typedef struct base_stack_t {
      uint32_t     depth; // 深度
      address     *stack; // 调用栈地址
      uint32_t     size; // 分配大小
      uint32_t     type; // malloc 或 VM 类型
      uint32_t     count; // 分配数量
  } base_stack_t;

它只代表刚刚发生的这一次分配,处理完这次分配后,这个临时结构就没用了。

  1. 内存中的聚合账本:merge_stack_t
c 复制代码
  typedef struct merge_stack_t {
      uint64_t       digest;
      uint32_t       depth;
      uint32_t       count;
      uint32_t       cache_flag; // 是否已经写入 <uuid>.mmap
      uint32_t       size;
      merge_stack_t *next; // 哈希冲突列表
  } merge_stack_t;

内存中的 merge_stack_t 实际没有保存调用栈地址数组,它主要保存 digest 和汇总值,其中 depth 创建时被设为 0,后面也没有参与主流程。

  1. 文件中的持久化记录:cache_stack_t

只有超过设置的阈值后,才会产生这一层:

c 复制代码
  typedef struct cache_stack_t {
      uint32_t count;
      uint32_t size;
      uint32_t type;
      uint32_t stack_depth;
      uint64_t digest;
      address  stacks[64];
  } cache_stack_t;

这一层才真正保存原始调用栈地址,并被写入 <uuid>.mmap

这三层的关系是:

c 复制代码
  base_stack_t
  刚发生的一次分配,带完整调用栈
          ↓ 按 digest 合并
  merge_stack_t
  内存中的数量和大小汇总
          ↓ 超过阈值
  cache_stack_t
  带原始调用栈的持久化记录

然后看看是怎么创建的,在启动 malloc 监控时,源码创建:

c 复制代码
  oom_stacks_hashmap =
      new CStacksHashmap(50000, global_memory_zone, log_path, log_mmap_size);

初始化的时候先创建 500000 个哈希桶。

地址账本插入成功后,调用:

c 复制代码
  oom_stacks_hashmap
      ->insertStackAndIncreaseCountIfExist(digest, &base_stack);

堆栈账本使用:

c 复制代码
offset = digest % (entry_num - 1);

找到对应的桶,然后分两种情况。

如果是第一个看到这个 digest,那么就创建新的 merge_stack_t

c 复制代码
  digest = 当前 digest
  count  = 本次分配数量
  size   = 本次分配大小
  cache_flag = 0

如果这个 digest 已经存在,直接累加:

c 复制代码
  current->count += stack->count;
  current->size  += stack->size;

例如:

c 复制代码
  第一次分配 10 MB:
  digest=123 → count=1,size=10 MB

  第二次分配 20 MB:
  digest=123 → count=2,size=30 MB

哈希冲突就用 next 链表,然后只有 digest 相同才会合并。

每次增加 size 的时候都会判断:

c 复制代码
  if (current->size > oom_threshold) {
      current->cache_flag = 1;
      logger->updateStack(current, stack);
  }

假设设定的阈值是 30 MB:

c 复制代码
  10 MB → 不持久化
  25 MB → 不持久化
  35 MB → 写入 <uuid>.mmap

写入时:

  • countsize 来自聚合后的 merge_stack_t
  • stack_depthstacks 来自本次分配的 base_stack_t
  • digest 用于找到或更新 .mmap 中对应的槽位

释放的时候,地址账本会告诉堆栈账本:

c 复制代码
  被释放大小 = 10 MB
  所属 digest = 123

然后堆栈账本找到 digest 后执行:

c 复制代码
  size  -= 10 MB
  count -= 1

然后根据结果判断,如果仍然没有低于阈值,那么更新 .mmap 中的 countsize

c 复制代码
  原来:count=4,size=40 MB
  释放:10 MB
  现在:count=3,size=30 MB

如果已经低于阈值,那么将 .mmap 对应槽位的 countsize 清零,同时:

c 复制代码
cache_flag = 0

但只要还有为释放分配,内存中的 merge_stack_t 仍然保留,以后重新超过阈值还能再次写入。

如果数量降到了 0,那么就从内存堆栈账本中彻底删除这一条 digest 记录。

来看一个完整的例子:

假设阈值是 30 MB,没有开启抽样(抽样待会说):

c 复制代码
  调用栈 A → B → C
  digest = 123

连续分配:

c 复制代码
  地址0x1000:12 MB
  地址0x2000:10 MB
  地址0x3000:12 MB

堆栈账本变化:

c 复制代码
  第一次:count=1,size=12 MB,不写文件
  第二次:count=2,size=22 MB,不写文件
  第三次:count=3,size=34 MB,写入 .mmap

此时,.mmap 保存:

c 复制代码
  digest=123
  count=3
  size=34 MB
  stack=A→B→C 的原始地址

然后释放 0x2000

c 复制代码
34 MB - 10 MB = 24 MB

结果:

c 复制代码
  内存堆栈账本:
  digest=123 → count=2,size=24 MB

  <uuid>.mmap:
  对应槽位被清零,因为已经低于30 MB

如果进程此时死亡,报告里不会看到这条记录,如果死亡前它再次增长到超过 30 MB,就会重新进入 .mmap

app.images

images 就是当前进程加载的可执行文件,一个应用中可能有很多个这种可执行文件,比如:

  • App 主程序
  • 各种动态 Framework
  • 系统动态库

可以把它理解成 "本次运行的地址地图":

c 复制代码
某个二进制文件 -> 它在这次运行中占用了哪段地址

它负责解释 <uuid>.mmap 中那些原始调用地址属于哪个二进制文件。

它大概长这样:

json 复制代码
  [
    {
      "name": "OOMDetectorDemo",
      "beginAddr": 4362076160,
      "endAddr": 4370464768
    },
    {
      "name": "UIKitCore",
      "beginAddr": 6510000000,
      "endAddr": 6550000000
    }
  ]

啥意思呢,.mmap 中记录了一堆地址,比如:

c 复制代码
0x104012340

假设 app.images 中有:

c 复制代码
  OOMDetectorDemo
  beginAddr = 0x104000000
  endAddr   = 0x105000000

因为:

c 复制代码
0x104000000 < 0x104012340 < 0x105000000

那么就可以判断出这个调用地址属于 OOMDetectorDemo,然后生成:

c 复制代码
0 OOMDetectorDemo 0x104000000 0x104012340

其中依次是:

c 复制代码
栈帧序号、镜像名称、镜像加载地址、原始调用地址

也就是它能知道这个地址是属于哪个可执行文件的。

然后再通过 dSYM,就能拿到这个地址的调用栈。

抽样

一次 malloc 记录并不只是保存一个数字,还需要:

c 复制代码
捕获调用栈
-> 计算 digest
-> 加锁
-> 写入地址账本
-> 更新堆栈账本
-> 必要时更新 mmap

在应用运行的时候,小额 malloc 可能会非常频繁。如果每次都完整处理的话,会带来明显的 CPU 和内存开销,甚至让内存监控本身加重内存压力。

所以 OOMDetector 选择:

  • 如果是单次的大分配,会全部记录,避免漏掉重要的分配
  • 数量庞大的小分配只记录一部分,用样本来估算整体
  • 在抓取调用栈之前判断是否要抽样,因此被跳过的分配不会继续执行昂贵的后续流程

抽样由两个参数控制:

c 复制代码
  sampleFactor:抽样倍数
  sampleThreshold:单次分配大小界线

例如:

c 复制代码
  sampleFactor = 10
  sampleThreshold = 30 KB

每次分配时:

c 复制代码
  大小 >= 30 KB
      → 直接记录,不抽样

  大小 < 30 KB
      → 生成随机数
      → 大约每 10 次选中 1 次

源码相当于:

c 复制代码
  if (sampleFactor > 1 && size < sampleThreshold) {
      if (rand() % sampleFactor != 0) {
          return;
      }
  }

如果一次 5 KB 分配被选中:

c 复制代码
  地址账本:
  记录真实地址和真实大小 5 KB

  堆栈账本:
  count += 10
  size  += 5 KB × 10 = 50 KB

也就是让一个样本代表大约 10 次类似的分配。

这块被抽中的内存释放时,也按相同倍数扣除:

c 复制代码
  count -= 10
  size  -= 50 KB

没有被抽中的分配不会进入地址账本,释放时也不会影响堆栈账本。

所以如果开启抽样,小分配的 countsize 其实是估算值,sampleFactor=1 表示关闭抽样,全部记录。

源码默认:

c 复制代码
sampleFactor = 1
sampleThreshold = 3KB

也就是默认关闭抽样,如果开启的话,抽样的限制默认是 3KB。

关于 digest

调用栈是一直在变化的,那么为什么 digest 可以标识出这是不是同一条调用栈。

OOMDetector 记录的是 malloc 发生那一瞬间的调用栈快照。

例如程序执行到:

首页 -> 加载图片 -> 创建缓存 -> malloc

malloc 发生时,OOMDetector 得到一组有顺序的调用地址:

c 复制代码
[地址A, 地址B, 地址C, 地址D]

这组有顺序的地址,就是这里所说的 "一条调用栈"。然后把整组地址计算成:

c 复制代码
CRC64([A, B, C, D]) = digest 123

函数返回以后,线程当前的调用栈会变,但已经记录下来的 digest 123 不会再变化。

如果下一次又通过完全相同的路径分配:

首页 -> 加载图片 -> 创建缓存 -> malloc

通常还能得到:

c 复制代码
[A, B, C, D] → digest 123

于是 OOMDetector 把两次分配归到同一组。

如果换了一条路径:

详情页 -> 下载图片 -> 创建缓存 -> malloc

地址序列可能变成:

c 复制代码
[A, B, X, Y] → digest 456

于是被认为是另一条调用栈。

OOMDetector 认为两条调用栈相同的标准就是算出来的 digest 相等。

关于 VM(虚拟内存)

OOMDetector 还监控了 VM。

VM,Virtual Memory,虚拟内存。

它并不是一块额外的内存,而是操作系统给每个进程提供的一套 独立地址空间和地址映射机制

程序里的指针,比如 0x104012340,保存的是虚拟地址,不是内存芯片上的物理地址,系统按 "页" 维护一张映 射表:

c 复制代码
  App 看到的虚拟地址
  0x1000 ──────────────→ 某一页物理 RAM
  0x2000 ──────────────→ 文件中的某一页
  0x3000 ──────────────→ 暂时还没有物理页

CPU 访问虚拟地址时,系统负责把它翻译到实际物理内存。

虚拟内存主要解决三个问题:

  • 进程隔离:每个 App 有自己的地址空间,不能直接访问其他进程的内存
  • 简化内存使用:程序只操作连续的虚拟地址,不必关心物理 RAM 实际放在哪里
  • 支持多种映射:同一套地址机制可以承载匿名内存、文件映射、共享内存、程序代码等

"虚拟" 不代表不消耗资源。页面真正被使用后,通常需要物理内存或文件作为支撑。

申请了 100 MB 虚拟地址范围,不代表立刻占用 100 MB 物理 RAM

它和 malloc 可以理解成上下两层:

c 复制代码
  业务对象、malloc()
          ↓
  malloc 分配器
  管理许多大小不同的内存块
          ↓
  虚拟内存系统
  管理更大的地址区域和内存页
          ↓
  物理 RAM / 文件

malloc(100) 面向的是 "小块内存":返回一块至少 100 字节的空间。

malloc 分配器自己也需要向更底层的 VM 系统取得大块区域,再切分给许多 malloc 请求。但有些内存区域可能由系统库或底层代码直接创建,不一定表现为普通 malloc 事件。

OOMDetector 通过 __syscall_logger 这个私有的系统 API 来监控 VM 的创建,后续的流程和 malloc 差不多:

c 复制代码
  malloc 事件 ─┐
               ├→ 地址账本 → 堆栈账本 → 超过阈值 → mmap 文件
  VM 事件 ─────┘

这两个监听是并列的:

c 复制代码
  malloc 事件
  → malloc 地址账本
  → malloc 堆栈账本
  → <uuid>.mmap

  VM 区域分配事件
  → VM 地址账本
  → VM 堆栈账本
  → vm_<uuid>.mmap

但是它没法监听到 VM 的释放。

另外,malloc 底层也可能产生 VM 事件,所以 VM 监控还会过滤 malloc 类型,避免重复统计。

为什么要监控它呢?

如果只监控 malloc,可以知道:

某条调用栈通过 malloc 还有 100 MB 没释放。

但看不到哪些没有直接走普通 malloc 记录路径的虚拟内存区域。

所以 VM 监控的目的就是:

补充 malloc 账本看不到的虚拟内存分配来源。

怎么理解 VM 报告

假设报告显示:

c 复制代码
  某条 VM 调用栈
  count = 2
  size = 100 MB

它只能说明:

这条调用路径创建的两个虚拟内存区域,总长度约为 100 MB,而且 OOMDetector 没有观察到它们被释放。

VM 的数据只是定位线索,还有需要注意的是,VM 这个分支默认是关着的,并且依赖私有的系统方法 __syscall_logger

合并报告

前面介绍的几个文件:

c 复制代码
<uuid>.oom -> 上次运行状态
<uuid>.mmap -> malloc 聚合堆栈
vm_<uuid>.mmap -> 可选的 VM 聚合堆栈
app.images -> 上次运行的镜像地址范围

并不是最终的报告,真正的报告是 App 在下次启动的时候,由 FOOMMonitor 将这些文件重新读取并合并得到的。

1. 读取上一轮 .oom

本次启动创建自己的 .oom 文件后,FOOMMonitor 会扫描 Library/FOOM 目录,跳过本次运行的文件,只处理之前留下的 .oom

.oom 的开头 4 字节保存归档数据长度,后面是通过 NSKeyedArchiver 保存的状态字典:

c 复制代码
  前 4 字节:归档数据长度
  后续字节:上次运行状态

读取时先校验长度,再通过 NSKeyedUnarchiver 恢复出字典。

2. 判断上次属于哪种退出

恢复状态后,OOMDetector 根据以下字段进行排除:

c 复制代码
  appState
  isCrashed
  isExit
  isDeadLock
  systemVersion
  appVersion

主要判断流程是:

源码中对应的数值是:

c 复制代码
  0 = no_crash
  1 = normal_crash
  2 = deadlock_crash
  3 = foom_crash

只有被判断为 foom_crash 时,才会继续寻找这次运行对应的内存堆栈。

3. 根据 UUID 找到内存堆栈

.oom 字典中保存了上次运行的 UUID,OOMDetector 使用它查找:

c 复制代码
  Library/OOMDetector_New/<uuid>/
  ├── <uuid>.mmap
  ├── vm_<uuid>.mmap
  └── app.images

读取顺序是:

  1. 先读取 app.images
  2. 读取 <uuid>.mmap 中的 maclloc 堆栈
  3. 如果有 VM,再读取 vm_<uuid>.mmap 中的 VM 堆栈
  4. mallocVM 记录合并成一个数组

如果 app.images 不存在,就无法判断原始地址属于哪个镜像,源码也不会继续解析这次堆栈。

4. 将 .mmap 转成报告记录

遍历 .mmap

c 复制代码
count == 0 -> 空槽位,跳过
count > 0  -> 转换成一条堆栈记录

转换后的每条记录大概这样:

json 复制代码
  {
    "stack_type": "malloc",
    "malloc_size": 35651584,
    "malloc_count": 3,
    "frame": {
      "calls": [
        "0 OOMDetectorDemo 0x104000000 0x104012340",
        "1 OOMDetectorDemo 0x104000000 0x104015678",
        "2 UIKitCore 0x1a2000000 0x1a2034560"
      ]
    }
  }

其中:

  • stack_type:来自 malloc 还是 VM
  • malloc_size:这条调用栈当前未释放的聚合大小
  • malloc_count:未释放数量,开启抽样后可能是估算值
  • calls:堆栈序号、镜像名称、加载地址和原始调用地址

这里还没有使用 dSYM,只完成了 "原始地址属于哪个镜像" 的转换,之后才能使用匹配的 dSYM 进行符号化。

5. 组装最终报告

以下是最终对象的 JSON 化展示,源码实际组装和传递的是 NSDictionary,不是直接生成一个 JSON 文件:

json 复制代码
  {
    "parts": [
      {
        "category": "sigkill",
        "s": 1756864700.1,
        "e": 1756864804.2,
        "mem_used": "430",
        "mem_warning_cnt": 1,
        "crash_stage": "GalleryViewController",
        "enable_oom": true,
        "crash_type": 3,
        "stack_oom": {
          "time_slices": [
            {
              "threads": [
                {
                  "stack_type": "malloc",
                  "malloc_size": 35651584,
                  "malloc_count": 3,
                  "frame": {
                    "calls": [
                      "0 OOMDetectorDemo 0x104000000 0x104012340",
                      "1 OOMDetectorDemo 0x104000000 0x104015678"
                    ]
                  }
                }
              ]
            }
          ]
        }
      }
    ]
  }

这里的 threads 里面的每个元素是一条分配调用栈的聚合记录,并不是线程快照。

报告还会另外携带一条关联信息:

json 复制代码
  {
    "uin": "10000",
    "client_identify": "<uuid>",
    "occur_time": 1756864804.2
  }

client_identify 就是上次运行的 UUID。

要定位到具体方法、还是需要结合 dSYM 做符号化才行。

c 复制代码
  符号化前:MyApp 中 0x104012340 对应的分配路径,仍占用 34 MB

  符号化后:ImageCache.createBuffer(),ImageCache.swift 第 128 行,
            对应的分配路径仍占用 34 MB

6. 交给业务并清理文件

组装完成后,QQLeakFileUploadCenter 会把:

  • 报告对象
  • 关联信息
  • 报告类型 QQStackReportTypeOOMLog

传给外部设置的 delegate

处理完成后,源码会删除上次运行的相关文件。

总结

OOMDetector 的整体思路,就像文章开头提到的黑匣子:在应用运行期间持续保存状态和内存分配信息,等到下次启动时,再利用这些记录分析上次发生了什么。

主要是留下两条线索。

第一条线索是 <uuid>.oom 中的运行状态,用来判断上次退出是否疑似 FOOM

第二条线索是 <uuid>.mmap中的大额未释放分配堆栈,用来追查内存来自哪些调用路径。

内存记录的核心是两个相互配合的账本。

地址账本记住每块被跟踪内存的大小和 digest,让释放事件能够找到对应的分配。

堆栈账本按照 digest 汇总未释放数量和总大小,再将超过阈值的结果持久化。

抽样用于降低记录开销,可选的 VM 监控则补充 malloc 之外的虚拟分配来源。

下次启动时,同一次运行的文件通过 UUID 重新关联,状态记录与聚合堆栈被合并成报告,交给业务处理。app.images 提供原始调用地址所属的镜像信息,再配合对应构建的 dSYM 完成符号化,就能把 "某条调用路径仍占用大量内存" 进一步关联到具体方法和代码位置。

这套机制提供的是异常退出时的推断,还有内存分配来源的线索,不能直接证明系统就是因为 OOM 杀死了进程,也不能因为说有尚未释放的内存就认为发生了内存泄露。

这个库上一次更新已经是 7 年前了。。。

相关推荐
秋雨梧桐叶落莳1 小时前
【iOS】从源码深入理解RunLoop机制
macos·ios·objective-c·cocoa·cocoapods·uikit
烂蜻蜓2 小时前
Flask入门教程(二十七):Session Interface API——自定义Session存储后端
网络·ios·flask
用户38034165882973 小时前
VideoToolbox 硬编解码的十个坑:为什么它几乎从不报错
ios
bcbnb3 小时前
Flutter-Notebook代码混淆:Android与iOS平台安全配置
后端·ios
2501_915106324 小时前
第一次开发 iPhone App,可能碰到的问题,解决办法
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
2501_915106324 小时前
iOS数据采集技术详解:从性能监控到崩溃分析的全链路实践
android·ios·小程序·https·uni-app·iphone·webview
末代iOS程序员华仔20 小时前
iOS 5.6 条例解决方法
flutter·ios·swift
末代iOS程序员华仔1 天前
Guideline 5.6 - Developer Code of Conduct
flutter·ios·swift
2501_915918411 天前
Flutter项目配置iOS混淆的详细步骤与工具推荐
android·flutter·ios·小程序·uni-app·cocoa·iphone