前言
APM 中,有一个模块属于 OOM 检测,也就是内存溢出(Out of Memory),当 iOS 应用因为内存原因而系统终止时,进程会被系统直接杀死。如果是被系统直接杀死,是没有机会再去执行代码的,所以这篇文章就来讨论怎么解决这个问题。
既然无法在死亡瞬间记录,那么可以采用类似飞机的 "黑匣子" 的方式,也就是飞行途中不断记录飞行状态,如果飞机真的出现什么问题,那么可以通过黑匣子来推断当时可能发生了什么。
而 OOMDetector 就是来做这个事情的。
OOMDetector
它的思路就是在运行的时候不断记录状态,然后如果进程突然被杀死了,那么下次启动的时候,根据之前记录的状态,来推断是不是因为 OOM 被系统杀死的。
OOMDetector 的 OOM 检测主链上有 3 个重要的文件:
<uuid>.oom<uuid>.mmapapp.images
目录结构大概是:
c
Library/
├─ Foom/
│ └─ <uuid>.oom
│
└─ OOMDetector_New/
└─ <uuid>/
├─ <uuid>.mmap
└─ app.images
| 文件 | 内容 |
|---|---|
<uuid>.com |
生命周期、正常退出、崩溃、卡死、最后内存等状态 |
<uuid>.mmap |
超过阈值的 malloc 分配堆栈及聚合大小 |
app.images |
当时加载的二进制镜像及地址范围,用于解释堆栈地址 |
每次运行,都会生成一个 uuid,用来关联这些文件。
另外,这里面有一些概念,比如 mmap、images,后面都会提到。
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)
处理过程是:
- 用
0x1000查地址账本 - 得到它占用 10 MB,属于
digest=123 - 删除地址记录
- 去堆栈账本扣减对应汇总
结果变成:
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 相同 -> 认为来自同一条调用栈 -> 合并 count 和 size
地址账本
它是一个 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);
}
也就是:
- 先把单次分配放入地址账本
- 地址插入成功后,才增加堆栈账本的
count和size - 如果该地址已经存在,
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() 会同时完成两件事:
- 从地址账本删除这个地址
- 在删除前返回它的
size和digest
然后就可以知道要在堆栈账本那边的哪个 digest 对应的堆栈分组去,减少多少的 count 和 size。
这里有个问题,就是怎么解决哈希冲突,因为不同的地址可能会计算出相同的桶下标。
比如:
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 时:
- 检查根节点是不是 B
- 不是就沿着
next向后遍历 - 确认链表里没有 B
- 把 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
源码中其实有三层数据。
- 当前分配的临时数据:
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;
它只代表刚刚发生的这一次分配,处理完这次分配后,这个临时结构就没用了。
- 内存中的聚合账本:
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,后面也没有参与主流程。
- 文件中的持久化记录:
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
写入时:
count、size来自聚合后的merge_stack_tstack_depth、stacks来自本次分配的base_stack_tdigest用于找到或更新.mmap中对应的槽位
释放的时候,地址账本会告诉堆栈账本:
c
被释放大小 = 10 MB
所属 digest = 123
然后堆栈账本找到 digest 后执行:
c
size -= 10 MB
count -= 1
然后根据结果判断,如果仍然没有低于阈值,那么更新 .mmap 中的 count 和 size:
c
原来:count=4,size=40 MB
释放:10 MB
现在:count=3,size=30 MB
如果已经低于阈值,那么将 .mmap 对应槽位的 count 和 size 清零,同时:
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
没有被抽中的分配不会进入地址账本,释放时也不会影响堆栈账本。
所以如果开启抽样,小分配的 count 和 size 其实是估算值,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
读取顺序是:
- 先读取
app.images - 读取
<uuid>.mmap中的maclloc堆栈 - 如果有
VM,再读取vm_<uuid>.mmap中的 VM 堆栈 - 将
malloc和VM记录合并成一个数组
如果 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还是VMmalloc_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 年前了。。。