0x1 서문
이 문서에서는 Xcode를 사용하여 메모리 문제를 감지하고 진단하는 방법을 설명합니다. 우선 메모리 구성, 앱의 메모리 사용량 영향 및 몇 가지 일반적인 메모리 문제를 이해해야 합니다. 마지막으로 메모리 문제를 분석하고 찾기 위해 leaks, vmmap 및 malloc_history와 같은 도구가 도입됩니다.
시스템의 메모리는 제한되어 있으며 메모리를 더 낮고 합리적으로 사용하면 앱이 더 나은 경험을 얻을 수 있습니다.
- 더 빠른 애플리케이션 활성화(핫 스타트 가능성 증가, 백그라운드 진입 후 대용량 메모리 점유로 인해 시스템에서 프로세스가 재활용되는 것을 방지)
- 더 빠른 응답(더듬임 감소)
- 보다 복잡한 기능 처리(동영상, 애니메이션 로드)
- 더 많은 장치에서 실행하기에 충분함(메모리가 적은 장치)
0x2 메모리 구조
메모리는 시스템에서 관리하며 일반적으로 페이지 단위로 나뉩니다. iOS에서 각 페이지에는 16KB의 공간이 있습니다. 데이터 조각은 여러 페이지의 메모리를 점유할 수 있으며 점유된 총 페이지 수에 각 페이지의 공간을 곱하면 이 데이터 조각이 사용하는 총 메모리가 됩니다. 앱의 메모리 사용량은 다음 세 가지 범주로 나눌 수 있습니다.
- 더러운 기억
- 압축 메모리
- 깨끗한 메모리
1. 더러운 기억
더티 메모리는 다음과 같이 앱에서 작성한 메모리입니다.
- 힙의 모든 메모리
- 화상 디코딩용 버퍼
- 프레임워크의 __DATA 및 __DATA_DIRTY 섹션
2. 메모리 압축
메모리가 부족하면 시스템은 특정 전략에 따라 더 많은 공간을 확보하여 사용할 수 있습니다.일반적인 방법은 우선 순위가 낮은 일부 데이터를 디스크로 옮기는 것입니다.이를 다시 메모리 공간으로 옮기는 역할을 합니다. 그러나 모바일 장치의 경우 디스크에서 빈번한 IO 작업으로 인해 저장 장치의 수명이 단축됩니다. 따라서 iOS7부터 시스템은 메모리 공간을 확보하기 위해 압축 메모리를 사용하기 시작했습니다.
iOS에서는 메모리가 부족할 때 최근에 사용하지 않은 더티 메모리를 원래 크기의 절반 이하로 압축할 수 있으며 필요할 때 압축을 풀고 재사용할 수 있습니다. 다음 기능을 사용하여 메모리를 절약하면서 시스템의 응답 속도를 향상시킵니다.
- 비활성 메모리 공간 감소
- 압축을 통한 전력 효율 향상 및 디스크 IO 손실 감소
- 압축/압축 해제가 매우 빠르므로 CPU 시간 오버헤드를 최대한 줄일 수 있습니다.
- 멀티 코어 작업 지원
iOS는 메모리가 부족할 때 메모리 압축 기술을 사용하는 반면, MacOS는 메모리가 부족할 때 메모리 압축 및 디스크 스와핑 기술을 사용합니다.
3. 메모리 정리
还没有被写入的内存或可以被系统清除且在需要时能重新加载的内存(内存是按页分配的,只有整页的数据被清除才可以被系统重新分配,只被清除部分数据,导致系统无法重新分配该页)
- 内存映射文件
- 可以被整页释放的内存
- Frameworks中的__DATA_CONST部分
- 应用的二进制可执行文件
4.小结:
- 应用内存占用大小 = 脏内存大小 + 压缩内存大小
- 减少应用的内存占用 = 减少脏内存大小 = 减少堆上内存占用 + 图片解码缓冲区大小
0x2 内存泄露
应用程序申请内存后,无法释放已申请的内存空间,一次内存泄露的危害可以忽略,但内存泄露堆积的后果很严重,无论多少内存,迟早会被占光。 根据内存泄露原因,可以分为以下两类:
- 无主内存(没有指针指向的内存,已经无法被释放)
- 循环引用
0x3 堆大小问题
堆是进程地址空间的一部分,用来存储动态生成的对象。堆上容易出现以下问题:
- 堆分配回归
- 碎片化
堆分配回归的治理策略:
- 移除无用内存分配
- 减少过大内存的分配
- 不再使用的内存需要释放
- 需要时才去分配内存
什么是碎片化? page(内存页)是系统授予进程的固定大小、不可分割的最小内存块。因为page是不可分割的,当进程写入page的任意部分,整个page都会被认为是dirty(脏内存)并且进程将会管理它,即使page的大部分没有被使用到。
当进程的dirty page没有被100%占用时,就会产生碎片化。 举个例子: 假如有有一个脏内存页,该页被使用了一半的内存(8KB),此时创建了一个需要16KB大小的对象,则该页无法放下,所以需要使用一个新的内存页进行放入该对象。假如有n个类似的脏内存页(未被100%使用),即使它们未被使用的总内存大于新对象所需要的内存,系统也无法进行分配至这些页中,导致内存利用率低。这种现象就是内存碎片化
降低内存碎片化的方法就是创建内存相邻,生命周期相似的对象。这能确保这些对象会被一起释放,这样进程就会得到一大块连续的空闲内存进行对象分配。
0x4 定位内存问题
下面以MemoryGraphDemo为例,分别介绍Xcode 内存图与命令行工具的使用方式,来讲述如何定位循环引用、无主内存和内存追溯。
循环引用场景:
两个对象相互强引用的场景
1.打开项目(demo已设置)按步骤设置 Edit Scheme -> Run -> Diagnostics -> Malloc Stack Logging -> Live Allocations Only 打开该配置后,内存图会记录malloc的分配堆栈日志,发现内存问题后,可以通过记录的堆栈回溯找到存在问题的代码。但是会给app增加额外内存占用,所以仅在调试时使用该配置。
2.运行demo,点击循环引用场景,制造一个泄露点,然后打开内存图,点击步骤如图所示
3.然后过滤出泄露对象.内存图左边栏可以查看总的泄露对象个数、类型,中间的图表明该泄露是一个循环引用导致的,右边Object栏可以查看对象的具体信息,包含类型、大小、地址信息。Bactrace则是产生泄露点的堆栈,该堆栈只有打开了Malloc Stack Logging后才会有。通过点击堆栈后面的小箭头,可以直接跳转到代码位置。
4.leaks可以使用进程名来运行,以demo为例:
leaks MemoryGraphDemo
控制台输出对应信息,下图为部分关键信息:
- 头部:展示内存泄露的概览,产生了2个泄露对象,浪费了共96KB
- STACK:展示了产生泄露的相关堆栈
- ROOT CYCLE:代表是循环引用导致泄露
leaks也可以通过模糊匹配进程名的方式使用,如leaks Memory
也是有效的, 想了解更多的使用方式,可以使用man leaks
命令查看leaks的使用手册。
无主内存场景:
内存无法被释放或未调用相关的释放函数的场景
1.重新运行项目(避免循环引用场景干扰),点击No Active References
场景
2.同样的步骤打开Xcode内存图
从图中可以看到,没有任何对象引用这个数组,因此它也就不可能被调用释放函数,释放这块内存。
3.使用相同的命令leaks MemoryGraphDemo
,输出结果如下: ROOT LEAK:代表该泄露问题是由于没有任何指针指向该对象导致的泄露
间接持有场景
假设有一个对象A,A持有一个可变集合B,集合B里存放都是C对象,C对象强持有A。
A -> Set, Set add C, C -> A
1.再次重启demo,点击Indirect Retain Cycles
,打开Xcode内存图
从图中可以看到四个对象之间产生了相互引用的关系,导致无法释放内存。
2.使用leaks工具查看
从输出结果红框里可以分析出,循环引用导致的泄露(ROOT CYCLE), 一个SomeItem对象强持有(__strong)_helper,_helper对象强持有(__strong)_items, _items内持有了一个SomeItem对象。
隐式间接持有场景
该场景基本和间接持有场景基本一致,区别在于集合的持有方式。本场景使用分类的方式为helper添加一个集合对象(分类添加属性的方式objc_setAssociatedObject)。
A -> Dynamic Set, Dynamic Set add C, C -> A
这种场景内存图和leaks工具都不能直接过滤出来,需要结合代码上下文和内存图进行分析。
1.再次重启demo,点击Dynamic Indirect Retain Cycles
, 然后打开Xcode内存图
2.同样,使用leaks命令(leaks MemoryGraphDemo
)的结果如下
从输出结果来看,本场景没有发生循环引用和无主内存,但是在过滤框中搜索someItem,会发现该对象和helper对象依然存在内存中。
3.过滤出app创建的对象,可以看到对象仍然在内存中,并且可以看出helper通过objc_setAssociatedObject方式添加的数组对象,并不会被helper直接持有。而是被objc_setAssociatedObject函数创建的一块内存持有着该数组。从Xcode右边栏Object区获取helper的Address,使用leaks MemoryGraphDemo --traceTree=Address
命令可以更清晰的看出其引用关系 从图中可以看出helper被someItem持有,且someItem被NSMutableArray对象持有,NSMutableArray对象由objc_setAssociatedObject创建的对象持有,最终存储在objc::AssociationManager::_mapStorage中。可以通过objc的源码分析为什么这种引用方式造成对象不会被释放。
参照objc4-866.9对于objc_setAssociatedObject的实现 首先objc::AssociationManager::_mapStorage中是个静态变量,初始化后一直存在,所以关联的数组对象不会被释放,因为被_mapStorage这个静态变量所持有。
从代码中可以看出,调用_object_set_associative_reference时,获取静态变量_mapStorage,然后根据对象指针创建一个object-key,根据该key获取/创建一个hashMap,该hashMap以外部传入的key为键,以包含value的一个对象为值进行存储关联。
简单的说,就是helper对象通过objc_setAssociatedObject记录的数组,最终是被_mapStorage存储,helper通过key的方式进行访问数组,操作数组。由于helper被数组元素对象强持有了,所以最终也是被_mapStorage引用, 当helper对象没有被其他对象引用时,_mapStorage是否移除关联对象决定了helper是否能被释放。
那按照这个逻辑看的话,岂不是所有对象分类添加的属性都不会被释放?从理论上来说,这是不可能发生的,因为如果随便写一个分类,并为其添加属性的话,都会导致该分类对象无法释放,最终必然会导致大量内存泄露问题。那么,_mapStorage什么时候释放掉关联的对象? 全局搜索_object_remove_associations
函数,有两处调用,一处是外部调用的接口,一处是在对象进行dealloc调用的时候。
通过objc_destructInstance
的实现逻辑可以知道,当对象调用dealloc时,如果对象有绑定关联对象,则会进行调用_object_remove_associations
方法释放_mapStorage对该对象的关联记录。
所以这种场景下,除非手动调用objc_removeAssociatedObjects
函数进行释放helper的关联对象,否则只能等helper对象的dealloc执行进行自动释放关联对象。但是helper被someItem强持有,someItem被数组持有,数组最终被_mapStorage持有。所以helper并不会调用dealloc方法,而_mapStorage释放数组依赖于helper的dealloc调用,这样就造成了一个隐式的间接持有关系。
小结
所以定位这种问题,需要从业务场景,代码上下文中进行分析,从而推断该对象未释放是否是正常情况。比如:
- 销毁了某个ViewController,但是该vc中的某些对象依然存在内存中
- GCD延迟block持有的对象
内存追溯场景
当项目随着迭代越发庞大时,对于某些场景的内存增长的原因难以通过查看代码的方式了解。本场景就是讲述如何通过使用工具的方式在庞大的源码中定位到内存增长的代码。
假设某个迭代的版本发现内存突然增加,但是不知道是哪块代码引发的问题。比如SDWebImage加载高清图片
1.重新运行demo,点击Large Buffers
2.可以看到模拟器内存由30M+激增到300M+,真机由13M+增长到70M+ (iOS 15以上)
3.使用vmmap -summary MemoryGraphDemo
命令,查看demo进程的内存分布情况。 在iOS中SWAPPED SIZE就是压缩内存大小,从输出的结果来看,CG Image和CoreAnimation这两块区域占据大量内存(共330M左右)。所以排查的目标放在这两个区域。
4.使用vmmap -v MemoryGraphDemo | grep "CG image\|CoreAnimation"
查看这两块内存区的详细信息。其中会包含相应的占用内存地址范围和大小。 可以对比图中脏内存和压缩内存的大小来锁定大内存块的起始地址和结束地址。
5.使用malloc_history MemoryGraphDemo -fullStacks 0x288000000
命令通过传入内存块的起始地址,可以输出该内存块被创建时的一个调用堆栈。
从输出的结果中可以发现堆栈包含一个SDImageCoderHelper类的调用,找到该类,并定位到31行。 从代码中可以看出这里只针对iOS 15以上版本调用了系统函数imageByPreparingForDisplay
,从malloc_history命令的输出结果和断点的方式(该函数前后断点)测试,可以确定是该函数导致应用的内存激增。
6.那么如何解决这个问题?
针对可能出现大图的场景设置options
[imageView sd_setImageWithURL:url placeholderImage:nil options:SDWebImageAvoidDecodeImage];
复制代码
小结
当需要查看内存的分布是否合理时,尽量覆盖业务场景(该方法的缺陷),然后通过以下步骤定位内存占用
vmmap -summary process
:查看内存的一个整体分布vmmap -v process | grep "xxx"
:查看怀疑区的详细信息,获得地址malloc_history process -fullStacks 地址
:查看该地址内存的创建堆栈- 找到对应业务代码分析
0x5 总结
本篇文章主要介绍了Xcode内存图和leaks工具的使用,以及排查内存问题的流程与思路:
- 运行项目,测试覆盖场景
- 使用内存图/leaks查看内存泄露情况
- 针对场景检查是否有隐式间接持有场景
- 根据情况修复问题
- 回归
这套流程足够一般中小项目进行排查内存问题,但是对于大型的、复杂的项目,该流程有明显的缺点,就是手动操作成本比较高,使用起来并不是非常方便,且测试场景的覆盖率直接影响排查问题的准确率。
这套流程的最佳实践应该是利用UITest测试将内存图文件导出来,并结合leaks、vmmap、malloc_history工具对内存图文件进行分析,实现自动化输出可视化结果的一套流程。