iOS 内存优化之工具介绍

0x1 前言

本文将介绍如何使用Xcode检测和诊断内存问题。首先需要了解内存构成,内存占用对app的影响、以及一些常见的内存问题。最后将介绍leaks、vmmap、malloc_history等工具来分析定位内存问题。

系统的内存是有限,更低的、合理的使用内存能使app获得更好的体验:

  1. 更快的应用程序激活(提高热启动概率,避免进入后台后,因占据较大内存被系统回收进程)
  2. 更快速的响应(减少卡顿)
  3. 处理更复杂的功能(加载视频、动画)
  4. 更够在更多的设备上运行(低内存设备)

0x2 内存构成

内存是由系统管理,一般以页为单位来划分。在iOS上,每一页包含16KB的空间。一段数据可能会占用多页内存,所占用页总数乘以每页空间得到的就是这段数据使用的总内存。 app的内存占用可分为以下三类:

  1. 脏内存
  2. 压缩内存
  3. 干净的内存

1.脏内存

脏内存是已经被app写入的内存,如下:

  • 所有堆上的内存
  • 图片解码的缓冲区
  • Frameworks中的__DATA和__DATA_DIRTY部分

2.压缩内存

当内存不足的时候,系统会按照一定策略来腾出更多空间供使用,比较常见的做法是将一部分低优先级的数据挪到磁盘上,之后当再次访问到这块数据的时候,系统会负责将它重新搬回内存空间中。然后对于移动设备而言,频繁对磁盘进行IO操作会降低存储设备的寿命。所以从iOS7开始,系统开始采用压缩内存的方式来释放内存空间。

在iOS中当内存紧张时能够将最近未使用过的脏内存占用压缩至原有大小的一半以下,并且能够在需要时解压复用。在节省内存的同时提高系统的响应速度,有以下特点:

  • 减少了不活跃的内存占用
  • 改善电源效率,通过压缩减少磁盘IO带来的损耗
  • 压缩/解压十分迅速,能够尽可能减少CPU的时间开销
  • 支持多核操作

iOS在内存紧张时使用的是内存压缩技术,而MacOS在内存紧张时使用内存压缩和磁盘交换技术

3.干净的内存

还没有被写入的内存或可以被系统清除且在需要时能重新加载的内存(内存是按页分配的,只有整页的数据被清除才可以被系统重新分配,只被清除部分数据,导致系统无法重新分配该页)

  • 内存映射文件
  • 可以被整页释放的内存
  • Frameworks中的__DATA_CONST部分
  • 应用的二进制可执行文件

4.小结:

  1. 应用内存占用大小 = 脏内存大小 + 压缩内存大小
  2. 减少应用的内存占用 = 减少脏内存大小 = 减少堆上内存占用 + 图片解码缓冲区大小

0x2 内存泄露

应用程序申请内存后,无法释放已申请的内存空间,一次内存泄露的危害可以忽略,但内存泄露堆积的后果很严重,无论多少内存,迟早会被占光。 根据内存泄露原因,可以分为以下两类:

  • 无主内存(没有指针指向的内存,已经无法被释放)
  • 循环引用

0x3 堆大小问题

堆是进程地址空间的一部分,用来存储动态生成的对象。堆上容易出现以下问题:

  1. 堆分配回归
  2. 碎片化

堆分配回归的治理策略:

  • 移除无用内存分配
  • 减少过大内存的分配
  • 不再使用的内存需要释放
  • 需要时才去分配内存

什么是碎片化? 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 settings.png 打开该配置后,内存图会记录malloc的分配堆栈日志,发现内存问题后,可以通过记录的堆栈回溯找到存在问题的代码。但是会给app增加额外内存占用,所以仅在调试时使用该配置。

2.运行demo,点击循环引用场景,制造一个泄露点,然后打开内存图,点击步骤如图所示 test.png

3.然后过滤出泄露对象.内存图左边栏可以查看总的泄露对象个数、类型,中间的图表明该泄露是一个循环引用导致的,右边Object栏可以查看对象的具体信息,包含类型、大小、地址信息。Bactrace则是产生泄露点的堆栈,该堆栈只有打开了Malloc Stack Logging后才会有。通过点击堆栈后面的小箭头,可以直接跳转到代码位置。 how.png

4.leaks可以使用进程名来运行,以demo为例:

leaks MemoryGraphDemo

控制台输出对应信息,下图为部分关键信息: leaks-rc.png

  • 头部:展示内存泄露的概览,产生了2个泄露对象,浪费了共96KB
  • STACK:展示了产生泄露的相关堆栈
  • ROOT CYCLE:代表是循环引用导致泄露

leaks也可以通过模糊匹配进程名的方式使用,如leaks Memory也是有效的, 想了解更多的使用方式,可以使用man leaks命令查看leaks的使用手册。

无主内存场景:

内存无法被释放或未调用相关的释放函数的场景

1.重新运行项目(避免循环引用场景干扰),点击No Active References场景

2.同样的步骤打开Xcode内存图

scene-no-ref.png 从图中可以看到,没有任何对象引用这个数组,因此它也就不可能被调用释放函数,释放这块内存。

3.使用相同的命令leaks MemoryGraphDemo,输出结果如下: leaks-no-ref.png ROOT LEAK:代表该泄露问题是由于没有任何指针指向该对象导致的泄露

间接持有场景

假设有一个对象A,A持有一个可变集合B,集合B里存放都是C对象,C对象强持有A。

A -> Set, Set add C, C -> A

1.再次重启demo,点击Indirect Retain Cycles,打开Xcode内存图 scene-irc.png

从图中可以看到四个对象之间产生了相互引用的关系,导致无法释放内存。

2.使用leaks工具查看 leaks-irc.png

从输出结果红框里可以分析出,循环引用导致的泄露(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内存图 scene-dirc.png

2.同样,使用leaks命令(leaks MemoryGraphDemo)的结果如下 leaks-dirc.png

从输出结果来看,本场景没有发生循环引用和无主内存,但是在过滤框中搜索someItem,会发现该对象和helper对象依然存在内存中。 scene-dirc-leak.png

3.过滤出app创建的对象,可以看到对象仍然在内存中,并且可以看出helper通过objc_setAssociatedObject方式添加的数组对象,并不会被helper直接持有。而是被objc_setAssociatedObject函数创建的一块内存持有着该数组。从Xcode右边栏Object区获取helper的Address,使用leaks MemoryGraphDemo --traceTree=Address命令可以更清晰的看出其引用关系 leaks-dirc-leak.png 从图中可以看出helper被someItem持有,且someItem被NSMutableArray对象持有,NSMutableArray对象由objc_setAssociatedObject创建的对象持有,最终存储在objc::AssociationManager::_mapStorage中。可以通过objc的源码分析为什么这种引用方式造成对象不会被释放。

参照objc4-866.9对于objc_setAssociatedObject的实现 首先objc::AssociationManager::_mapStorage中是个静态变量,初始化后一直存在,所以关联的数组对象不会被释放,因为被_mapStorage这个静态变量所持有。

objc_set_static.png objc_set_imp.png 从代码中可以看出,调用_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_dealloc.png

通过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进程的内存分布情况。 leaks-la.png 在iOS中SWAPPED SIZE就是压缩内存大小,从输出的结果来看,CG Image和CoreAnimation这两块区域占据大量内存(共330M左右)。所以排查的目标放在这两个区域。

4.使用vmmap -v MemoryGraphDemo | grep "CG image\|CoreAnimation" 查看这两块内存区的详细信息。其中会包含相应的占用内存地址范围和大小。 vmmap-v.png 可以对比图中脏内存和压缩内存的大小来锁定大内存块的起始地址和结束地址。

5.使用malloc_history MemoryGraphDemo -fullStacks 0x288000000命令通过传入内存块的起始地址,可以输出该内存块被创建时的一个调用堆栈。

vmmap-core.png vmmap-cg.png

从输出的结果中可以发现堆栈包含一个SDImageCoderHelper类的调用,找到该类,并定位到31行。 sdwebimage.png 从代码中可以看出这里只针对iOS 15以上版本调用了系统函数imageByPreparingForDisplay,从malloc_history命令的输出结果和断点的方式(该函数前后断点)测试,可以确定是该函数导致应用的内存激增。

6.那么如何解决这个问题?

针对可能出现大图的场景设置options

[imageView sd_setImageWithURL:url placeholderImage:nil options:SDWebImageAvoidDecodeImage];
复制代码

小结

当需要查看内存的分布是否合理时,尽量覆盖业务场景(该方法的缺陷),然后通过以下步骤定位内存占用

  1. vmmap -summary process:查看内存的一个整体分布
  2. vmmap -v process | grep "xxx":查看怀疑区的详细信息,获得地址
  3. malloc_history process -fullStacks 地址:查看该地址内存的创建堆栈
  4. 找到对应业务代码分析

0x5 总结

本篇文章主要介绍了Xcode内存图和leaks工具的使用,以及排查内存问题的流程与思路:

  1. 运行项目,测试覆盖场景
  2. 使用内存图/leaks查看内存泄露情况
  3. 针对场景检查是否有隐式间接持有场景
  4. 根据情况修复问题
  5. 回归

这套流程足够一般中小项目进行排查内存问题,但是对于大型的、复杂的项目,该流程有明显的缺点,就是手动操作成本比较高,使用起来并不是非常方便,且测试场景的覆盖率直接影响排查问题的准确率。

这套流程的最佳实践应该是利用UITest测试将内存图文件导出来,并结合leaks、vmmap、malloc_history工具对内存图文件进行分析,实现自动化输出可视化结果的一套流程。

猜你喜欢

转载自juejin.im/post/7190296873373007931