Analyze Heap Memory
摘要:新手级的 iOS 内存分析入门文章,通过学习 MemoryGraph 和 Allocations 的使用,帮助你在开发中更好的解决内存问题。
简介
在开发过程中大家肯定多多少少遇到过 OOM (内存溢出) 的问题,解决起来并不简单。本文将分析内存异常的两类常见模式,并针对性的利用官方提供的 Memory Graph 和 Instruments 工具对问题进行分析和优化,帮助大家学会排查和解决常见的内存问题。而在最后的扩展部分,将为大家讲解 Leaks 检测的具体原理,以及 weak 和 unowned 之间有什么性能差异。
作者:
大家好我是 iPhreetom,目前就职于字节跳动,负责稳定性方向的问题排查和工具建设。
审核:
皮拉夫大王,目前就职于抖音基础技术团队,负责 iOS 稳定性相关工作。
黄骋志,老司机技术成员,周报轮值主编。目前就职于字节跳动。
基础知识
认识虚拟内存
现代 App 可以交互的内存都是虚拟内存,下图是 App 进程中虚拟内存的大致抽象分类:
但如果我们想进一步探究这类抽象的类型下面是什么,可以使用 Xcode 导出 Memory Graph 后,使用 vmmap {filepath} 命令进行输出。可以从下图看到,我们得到了 App 所有虚拟内存地址的详细归属信息!
刚才说的“不同类型”的内存,实质是关注这块内存的 REGION TYPE 是什么。
正如 REGION TYPE 里的 REGION 所表述的,某一种类型的内存是以 REGION 作为一个单位进行管理的,而每个 REGION 中会管理个数不一的 PAGE,PAGE 是内存管理的最小单位。因而 REGION 大小、某中类型内存的大小都是 PAGE 的倍数,或者说是 16 KB 对齐的(64 bit 系统的 PAGE 大小通常是 16 KB)。
而每种 pages 有三个状态:clean、compressed、dirty,递进的分类关系如下图所示。
被申请了但没有被修改过的是 clean pages。dirty pages 则是被修改过的内存页。compressed pages 是 dirty pages 被系统策略进行了压缩的 pages,它们实际的物理内存占用更小,但是访问时需要解压缩。
但在进行 App 优化时,我们究竟要关注什么呢?
虚拟内存占用 == 所有 page 的数量 * page 的大小(16 KB) 常规意义的内存占用 == dirty + compressed page 数量 * page 大小
什么叫做常规的内存占用?可以理解为开发者在 Xcode 的 memory report 中看到的内存大小口径、Jetsam 系统服务在判断是否要因为 app 内存占用过多而发送 memory warning、或者强杀 app 最终造成 app OOM 时的内存统计口径。
它有一个专有的名词叫做 app 的 footprint,这个概念很重要。因为 app 的 footprint 既不是 app 的虚拟内存占用,也不是 app 的物理内存占用,而是一种根据 page 数量和 page 大小算得的统计口径。
它区别于 app 物理内存占用的点在于:compressed pages 通过压缩节省出来的物理内存实际上是留给系统用的。因而压缩后有多大 app 不需要关心,app 只需要关心压缩前有多大。我们在治理内存问题时,可以将 compressed pages 近似看做 dirty pages。
理解了这些概念后,我们举个例子来理解这三种 page 状态的转变:比如你 malloc 了 1 MB 内存,但没有对它进行后续操作,那么这部分的占用的内存都是 clean 的状态,而如果你通过 memset 对它们赋值则将导致这些内存从 clean 变成 dirty,被纳入 app footprint 的计算。而在后续的过程中,系统可能会将 dirty 的 page 压缩成 compressed 状态,但 app 并不需要关心这类转换操作。
最后我们来简单了解一下各类 iPhone 机型 app 可以使用的虚拟、物理内存大小,这里引用一下掘金一篇文章的数据 iOS APP 虚拟内存用量初探[1]:
iPhone 13 Pro 可用虚拟内存 = 11.30 G 可用 footprint = 3.00 G
而使用 apple 提供的虚拟内存扩充[2]、物理扩充能力[3],我们可以将 app 可使用的虚拟、物理内存扩展到更大的值。
扩充后:
iPhone 13 Pro 可用虚拟内存 = 59 G 可用 footprint = 4.00 G
需要注意不同的机型可用的内存值会有些差异,大家通过自己测试,得到更加详尽的数据列表。
Heap memory 是什么
在编写代码时,我们开发着最常交互的内存区域就是堆和栈,今天我们关注的是堆内存的分析。我们在使用 malloc、alloc 或者使用 new 等方法时,实际上都会在 Heap 中申请内存。也是上文中 REGION TYPE 为 Malloc xxxx 类型的内存。如果我们使用 mmap 或者 vm_allocation 申请内存,则会归属到 mapped file 或者 VM_ALLCATION 中,不属于 Heap Memory。
因此,本文的主题 Analyze Heap Memory 也可以简单理解为涵盖了除 mmap 和 vm_allocation 之外大部内存操作。
分析工具简介
Xcode memory report
利用 Xcode memory report 看内存水位上涨的趋势,如下图。通常我们可以发现 App “存在”内存问题,但基于这个宏观判断,并不能发现为什么内存有异常上涨。
这时候就需要使用官方提供的两个工具 Memory Graph 和 Instrument 帮助我们进一步分析。
Memory Graph
在 Xcode 中可以随时暂停 App 的运行,使用 Memory Graph 工具创建当前内存的快照,通过 Memory Graph 工具可以分析内存节点之间的引用关系,帮助我们定位为什么某一块内存没有被释放。
并且,如果在 Xcode 的 scheme -> diagnostics 中勾选了 MallocStackLogging,Memory Graph 还能显示处每个内存节点创建堆栈。本文接下来利用 Memory Graph 的内容默认都开启了 MallocStackLogging 选项(位置如下图),而更细节的配置选项中,一般选择 Live Allocations Only 即可。
利用 Memory Graph 虽然可以探明具体节点的引用关系,但针对分析全局内存占用的需求而言,它不能直观的排序展示出哪块内存占用较多,这时需要引处第三个工具 Allocations。
Instruments - Allocations
在 Instruments 中选择 Allocations 模板,或者将 Memory Graph 分享保存的 .graph 文件(File -> Export Memory Graph)利用 Instruments 打开,我们可以看到如下的数据:
Allocations 可以将 heap 内存按不同方案进行分类排序,常用按内存节点类型排序和按分配堆栈排序。通过排序,我们可以迅速定位到占用内存最大的内存块,然后针对性的进行排查。(要注意的是,如果是 Memory Graph 导出的文件,则需要配对使用对应包的 dsym 文件才能符号化)
经典问题排查
Autoreleasepool 对象堆积
让我们结合一个 Demo 来探究 Autoreleasepool 对象堆积这一经典问题。Demo 的逻辑很简单,在 viewDidLoad 中增加一个 UIButton,每次点击后执行 doSomeThings 函数,for 循环创建一百万个 NSString 对象,具体代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
UIButton *btn = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, 400, 400)];
[btn setTitle:@"触发Autoreleasepool对象堆积" forState:UIControlStateNormal];
[btn addTarget:self action:@selector(doSomeThings) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:btn];
} - (void)doSomeThings {
for (int i = 0; i < 1000000; i++) {
NSString *tempString = [NSString stringWithFormat:@"Temporary String %d", i];
}
}
运行 Demo 后我们发现每次点击这个按钮都会出现一个内存占用的尖刺,如下图所示。对于不了解 Autoreleasepool 机制的同学,这个现象不太符合直觉,因为 for 循环中的对象离开作用域后理论上应该会马上释放,其中的逻辑不应该导致内存出现激增。
为了进一步探寻原因,我们使用 Allocations 对 Demo 进行采集,在时间线上选取一个尖刺的低点和高点,对下面的统计数据按 Persistent Bytes 进行排序:
我们会发现,CFString 这类节点占用的内存最多,堆积了 993992 个对象,占用了 45.5 MiB 内存,和代码里创建 NSString 的逻辑可以对上。(同时这里能看到 Autoreleasepool content 对象个数很多有 1971 个,根据排查经验,这个时候可以怀疑 Autoreleasepool 存在堆积的问题了)
为了进一步确认 CFString 节点是和 Demo 的代码逻辑相关,我们使用 Allocations 的另一个聚类方式 Call Tree,将内存占用按申请时的堆栈进行聚类。在下图可以看到 60 MB 的内存占用中有 54 MiB 来自 demo 中的 doSomeThings 函数。
并且我们可以通过双击右侧的函数名称,跳转到源码显示,NSString 这一行总共创建了 281.73 MB 的内存。
嗯?不是上面说 54 MB 么?为什么这边这变成了 280 MB?因为此处显示的是累积创建的内存大小,包含了已经释放和没有释放的部分,而前面的 54 MB 的口径是创建了但未释放的部分。重新回到 statistic 类型的统计,按 Total Bytes 进行排序,我们可以看到有四类相关的申请加起来刚好是 280 MB。
了解使用 Allocations 定位具体代码后,还需要了解一下 Autoreleasepool 的原理才能理解应该如何优化。
Autoreleasepool 通常用于延长函数返回变量的生命周期,被加入 Autoreleasepool 的对象会被引用计数加一,而 Autoreleasepool 销毁时会将这些对象的引用计数减一。大多数情况下线程会有一个 Top level 的 Autoreleasepool,但它仅在某些特定的生命周期才会清空 pool 内的对象,比如在线程销毁的时候,而这通常会导致一些内存堆积问题。
而 GCD 的 API 也有和 Autoreleasepool 相关的参数,比如创建 serial queue 的时候可以选择 DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL,这将使得每个被提交的 block 都会被包裹在一个独立的 Autoreleasepool 中。而默认情况则是 Serail Queue 的任务执行完,队列空闲时才释放。
结合我们 Demo 中的例子,for 循环内创建的 CFString 对象会被加入 Autoreleasepool 中,延迟到线程销毁时才被释放,而不是在每轮 for 循环结束时被释放。
// ...
for (int i = 0; i < 1000000; i++) {
NSString *tempString = [NSString stringWithFormat:@"Temporary String %d", i];
}
// release all the obj in the pool
要想优化这种堆积的现象,最简单的方法就是在合适位置新增一个自动释放池:
for (int i = 0; i < 1000000; i++) {
@Autoreleasepool {
NSString *tempString = [NSString stringWithFormat:@"Temporary String %d", i];
}
}
再次运行,点击按钮,我们的 Demo 不再出现内存的尖刺,至此我们简单了解了如何通过 Allocations 分析 Autoreleasepool 对象堆积的问题,并通过在合适位置新增 Autoreleasepool 标识,使得对象能在合适的生命周期内被释放。
Leaks 内存泄露
解决了 Autoreleasepool 堆积问题后,我们再来看看另一个经典问题内存泄露 Leaks。首先,要定义什么是 leaks,我们将 App 申请的内存分成 3 种类型,一类是正常申请后使用的内存,第二类是无用内存,申请了但没有使用过(比如错误的缓存策略,一个单例保存了过多的资源),第三类是我们的代码不再可达的内存(比如存在循环引用,导致实例已经被释放的情况下,实例的某个属性一直无法被释放)。三种类型如下图所示:
我们将第三类问题称为内存泄露 leaks。
对于大部分 leaks 问题,我们首要的目标都是找到对应的循环引用关系,使用 weak 或者 unowned 方法破除循环引用,使得对象能正常释放:
一般而言,我们会使用 Memory Graph 来帮助我们寻找和解决这类循环引用导致的 leaks 问题。接下来结合 WWDC 的 demo,我们来看看如何使用 Memory Graph 解决这类问题。
在 Xcode 中点击下图右侧箭头对应的图标即可触发 Memory Graph 分析,而左侧箭头对应的图标可以选择仅查看 Leaks 问题和仅看 App Image 导致的 Leaks 问题。
在进行过滤了后,我们点击一个具体的 Leaks 问题,就可以在下图的中间部分看到对象的引用关系,这里ThumbnailRenderer 通过 cacheProvider 强引用了一个 ThreadnailLoader 对象,而ThreadnailLoader 对象强引用了一个 Swift closure context 对象,最后 Swift closure context 对象强引用了ThumbnailRenderer 造成了一个循环引用。
我们点击循环中某一个节点,可以在右侧看到其申请的堆栈,通过最右侧的小箭头可以跳转到源码进行进一步分析。
在下图的源码中,我们可以看到两条引用链 loader 强引用 block ,而 block 强引用 render ,通过查看 render 的源码应该也能发现最后一条强引用的链条。
一般而言,这类 block 强引用变量导致的循环引用问题,都是将 block 中对变量的引用改成 weak 来解决,具体的修改如下:
loader.completionHandler = { [weak renderer] in
guard let renderer else { return }
self.thumbnails = renderer.images
}
我们将 render 变成弱引用,同时在使用 render 时判断是否已经被释放,来保障逻辑的正确。再次运行,就可以发现 Memory Graph 中不再有 Leaks 问题。
拓展知识
Leaks 检测的原理
刚刚我们利用 Memory Graph 的 Leaks 检测解决了一例内存泄露问题,但 Memory Graph 究竟是如何寻找到 Leaks 的呢?让我们来看看 man leaks 中简介:
NAME
leaks – Search a process's memory for unreferenced malloc buffers
leaks 工具实质上是检测 malloc 出的内存块中(OC、Swift 对象创建最终也会调用到 malloc 系列的方法),哪些不再被引用但仍旧存在。对这个表述,还可以这样理解:malloc 后未被释放的内存块地址是全集,而 leaks 工具通过扫描全局内存判断出哪些内存块没有被“引用过”是泄露的。
具体而言,leaks 通过一个个字节地扫描进程内的全局内存块(比如 __DATA 类型的内存区域)、寄存器或者栈区域。如果有数据看上去刚好匹配上某个 malloc 内存块的地址,则认为这块内存引用了某个 malloc 内存块。
这种检测方案在一些特定情况会导致 leaks 无法被检测到,比如设计一块泄露的 malloc 内存块地址是 0x1000abcd,而我们刚好有一个全局的 uint64_t 变量的值为 0x1000abcd,leaks 工具也会认为那个 malloc 内存块被“引用过”因而不算泄露。
但一般而言,泄露的对象个数都是较多的,遗漏一两个并不会影响整体大盘的判断,通过修复工具检测出的问题,就能大幅降低 App 的内存泄露数量。
“弱引用”之间的性能差异
了解了 leaks 检测的基本原理后,我们再来探索循环引用中常用的“弱引用” weak 和 unowned 之间的区别。
简单而言,使用 weak 比较安全,但消耗更多内存、访问速度更慢。而使用 unowned 速度更快、不消耗额外内存,但需要注意的是当指向的对象被销毁后,再次访问指正会导致 crash 问题。
可以将 unowned 比作强制解 optional 的 weak,如果你能够保障 unowned 对象的生命周期小于等于目标对象,则可以使用 unowned 来提高性能。
顺带一提,在一些场景,因为 ARC 机制引入的 release、retain 方法可能会成为性能开销的大头,这时候我们可以利用一些机制来优化这种性能损耗。
在 swift 中,可以通过开启 -whole-module-optimization 使得编译器将一些函数调用内联,减少性能开销。针对频繁需要 copy 的 struct,应该保持其结构简洁,以提高整体性能。
针对 OC,将方法标记为 objc_direct 可以让编译器更准确的分析方法内部的行为,从而减少不必要的 retain 和 release 操作,并运行编译器尝试将方法变成内联,进一步消除方法调用的开销。而将方法标记为 objc_externally_retained 则会告诉编译器某些参数的生命周期是有保障的,因此不需要在这些参数上执行 retain 和 release 操作。
总结
本文从虚拟内存的基础知识开始介绍,带大家了解了应用开发中解决 OOM 问题需要关注哪类型的内存,并简单介绍了三类分析工具的使用场景。分析了对象堆积和内存泄露这两例经典内存问题,探究了 leaks 的检测原理、“弱引用”之间的区别。
如果读者对内存治理感兴趣,还可以探究内存监控相关的开源工具:比如在线下利用 MLeaksFinder[4] 在运行时自动检测 ViewController、view 以及它们属性的泄露情况,在线上利用 Matrix 的 WCMemoryStatPlugin[5] 在线上发生 OOM 后获得 Allocations 的分析日志。
关注我们
我们是「老司机技术」,一个持续追求精品 iOS 内容的技术公众号。欢迎关注。
关注有礼,关注【老司机技术】公众号,回复「2024」,领取 WWDC24 及以前的内参
iOS APP 虚拟内存用量初探: https://juejin.cn/post/7196931784328626234
[2]虚拟内存扩充: https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_kernel_extended-virtual-addressing
[3]物理扩充能力: https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_kernel_increased-memory-limit
[4]MLeaksFinder: https://github.com/Tencent/MLeaksFinder
[5]WCMemoryStatPlugin: https://github.com/Tencent/matrix