iOS 内存管理随记
内存区域
一个 iOS app 对应的进程地址空间大概如下图所示:
- 栈区(stack):线性结构,内存连续,系统自己管理内存,程序运行记录,每个线程,也就是每个执行序列各有一个(看crash log最容易理解),都是编译的时候能确定好的,还有一个特点就是这里面的数据可以不用指针,也不会丢。
- 堆区(heap):链式结构,内存不连续,最灵活的内存区,用途多多,动态分配和释放,编译时不能提前确定,我们的Objective-C对象都是这么来的,都存在这里,通常堆中的对象都是以指针来访问的,指针从线程栈中来,但不独属于某个线程,堆也是对复杂的运行时处理的基础支持,还有就是ARC还是MRC、“谁分配谁释放”说的都是堆上对象的管理。
- 静态区(全局区)(bss):初始化数据,简单理解就是有初始值的变量、常量。
- 常量区(data):未初始化数据,只声明未给值的变量,运行前统统为0,之所以单独分出来,是出于性能的考虑,因为这些东西都是0,没必要放在程序包里,也不用copy。
- 代码区(text):最静态的,就是只读的东西,存储代码。
iOS内存管理三种方式
Tagged Pointer
假设我们要存储一个NSNumber对象,其值是一个整数。正常情况下,如果这个整数只是一个NSInteger的普通变量,那么它所占用的内存是与CPU的位数有关,在32位CPU下占4个字节,在64位CPU下是占8个字节的。而指针类型的大小通常也是与CPU位数相关,一个指针所占用的内存在32位CPU下为4个字节,在64位CPU下也是8个字节。
所以一个普通的iOS程序,如果没有Tagged Pointer对象,从32位机器迁移到64位机器中后,虽然逻辑没有任何变化,但这种NSNumber、NSDate一类的对象所占用的内存会翻倍。(由原本的 4+4 变成 8+8)。
我们再来看看效率上的问题,为了存储和访问一个NSNumber对象,我们需要在堆上为其分配内存,另外还要维护它的引用计数,管理它的生命期。这些都给程序增加了额外的逻辑,造成运行效率上的损失。
为了改进上面提到的内存占用和效率问题,苹果提出了Tagged Pointer对象。由于NSNumber、NSDate一类的变量本身的值需要占用的内存大小常常不需要8个字节,拿整数来说,4个字节所能表示的有符号整数就可以达到20多亿(注:2^31=2147483648,另外1位作为符号位),对于绝大多数情况都是可以处理的。
所以我们可以将一个对象的指针拆成两部分,一部分直接保存数据,另一部分作为特殊标记,表示这是一个特别的指针,不指向任何一个地址。
当8字节可以承载用于表示的数值时,系统就会以Tagged Pointer的方式生成指针,如果8字节承载不了时,则又用以前的方式来生成普通的指针。以上是关于Tag Pointer的存储细节。
特点
- 我们也可以在WWDC2013的《Session 404 Advanced in Objective-C》视频中,看到苹果对于Tagged Pointer特点的介绍:Tagged Pointer专门用来存储小的对象,例如NSNumber和NSDate。
- Tagged Pointer指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已,因为他没有isa指针。所以,它的内存并不存储在堆中,也不需要malloc和free。
- 在内存读取上有着3倍的效率,创建时比以前快106倍。
Non-pointer isa —— 非指针型 isa
设计思想跟 TaggetPointer 类似,isa 其实并不单单是一个指针。其中一些位仍旧编码指向对象的类。但是实际上并不会使用所有的地址空间,Objective-C 运行时会使用这些额外的位去存储每个对象数据。
1 | union isa_t { |
使查看 isa 的定义,可以看出它被定义为一个联合体,其中定义了一个位域。
关于联合体和位域的概念,可以在这篇文章查看。
1 | // __x86_64__和__arm64__下的位域定义是不一样的,不过都是占满了所有的64位 |
位域各字段意义:
- nonpointer:表示是否对isa开启指针优化 。0代表是纯isa指针,1代表除了地址外,还包含了类的一些信息、对象的引用计数等。
- has_assoc:关联对象标志位。
- has_cxx_dtor:该对象是否有C++或Objc的析构器,如果有析构函数,则需要做一些析构的逻辑处理,如果没有,则可以更快的释放对象。
- shiftcls:存在类指针的值,开启指针优化的情况下,arm64位中有33位来存储类的指针。
- magic:判断当前对象是真的对象还是一段没有初始化的空间。
- weakly_referenced:是否被指向或者曾经指向一个ARC的弱变量,没有弱引用的对象释放的更快。
- deallocating:是否正在释放。
- has_sidetable_rc:当对象引用计数大于10时,则需要进位。
- extra_rc:表示该对象的引用计数值,实际上是引用计数减一。例如:如果引用计数为10,那么extra_rc为9。如果引用计数大于10,则需要使用has_sidetable_rc。
SideTables
为了管理所有对象的引用计数和weak指针,苹果创建了一个全局的SideTables,虽然名字后面有个”s”不过他其实是一个全局的Hash表,里面的内容装的都是SideTable结构体而已。它使用对象的内存地址当它的key。管理引用计数和weak指针就靠它了。
1 | static objc::ExplicitInit<StripedMap<SideTable>> SideTablesMap; |
StripeCount:由当前系统决定大小。 iPhone 真机大小为 8,其它为 64.
- 将对象的内存地址addr右移4位得到结果1
- 将对象的内存地址addr右移9位得到结果2
- 将结果1和结果2做按位异或得到结果3
- 将结果3和StripeCount做模运算得到真正的Hash值。
因为最后模运算的结果范围是在(0-StripeCount)之间,可见SideTables一共有 StripeCount(8或64) 个单元格。
因为对象引用计数相关操作应该是原子性的。不然如果多个线程同时去写一个对象的引用计数,那就会造成数据错乱,失去了内存管理的意义。同时又因为内存中对象的数量是非常非常庞大的需要非常频繁的操作SideTables,所以不能对整个Hash表加锁。苹果采用了分离锁技术。
1 | struct SideTable { |
spinlock_t slock—锁
os_unfair_lock 类型的自旋锁。
自旋锁比较适用于锁使用者保持锁时间比较短的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的。
它的作用是在操作引用技术的时候对SideTable加锁,避免数据错误。
RefcountMap refcnts—引用计数器
对象具体的引用计数数量是记录在这里的。
这里注意RefcountMap其实是个C++的Map。为什么Hash以后还需要个Map呢?因为内存中对象的数量实在是太庞大了我们通过第一个Hash表只是过滤了第一次(SideTables最有只有 StripeCount(8或64) 个单元格),然后我们还需要再通过这个Map才能精确的定位到我们要找的对象的引用计数器。
引用计数器的数据类型是:
1 | typedef __SIZE_TYPE__ size_t; |
再进一步看它的定义其实是unsigned long,在32位和64位操作系统中,它分别占用32和64个bit。
苹果经常使用bit mask技术。这里也不例外。拿32位系统为例的话,可以理解成有32个盒子排成一排横着放在你面前。盒子里可以装0或者1两个数字。我们规定最后边的盒子是低位,左边的盒子是高位。
(1UL<<0)的意思是将一个”1”放到最右侧的盒子里,然后将这个”1”向左移动0位(就是原地不动):0b0000 0000 0000 0000 0000 0000 0000 0001
(1UL<<1)的意思是将一个”1”放到最右侧的盒子里,然后将这个”1”向左移动1位:0b0000 0000 0000 0000 0000 0000 0000 0010
下面来分析引用计数器(图中右侧)的结构,从低位到高位。
1 | // The order of these bits is important. |
WEAKLY_REFERENCED
表示是否有弱引用指向这个对象,如果有的话(值为1)在对象释放的时候需要把所有指向它的弱引用都变成nil,避免野指针错误。
SIDE_TABLE_DEALLOCATING
表示对象是否正在被释放。1正在释放,0没有。
SIDE_TABLE_RC_ONE
对象真正的引用计数存储区。
SIDE_TABLE_RC_PINNED
其中其实这一位没啥具体意义,就是随着对象的引用计数不断变大。如果这一位都变成1了,就表示引用计数已经最大了不能再增加了。
weak_table_t weak_table—弱引用管理
第一层结构体中包含两个元素:
1 | struct weak_table_t { |
weak_entry_t *weak_entries;
是一个数组,上面RefcountMap是要通过find(key)来找到精确的元素的。weak_entries则是通过循环遍历来找到对应的entry。这是因为weak的显著的特征来决定的: 当weak对象被销毁的时候,要把所有指向该对象的指针都设为nil。
size_t num_entries;
用来维护保证数组始终有一个合适的size。比如数组中元素的数量超过3/4的时候将数组的大小乘以2。
第二层weak_entry_t的结构包含3个部分:
1 | #define WEAK_INLINE_COUNT 4 |
DisguisedPtr
referent; 被指对象的地址。前面循环遍历查找的时候就是判断目标地址是否和他相等。
weak_referrer_t *referrers;
可变数组,里面保存着所有指向这个对象的弱引用的地址。当这个对象被释放的时候,referrers里的所有指针都会被设置成nil。
weak_referrer_t inline_referrers[WEAK_INLINE_COUNT];
只有4个元素的数组,默认情况下用它来存储弱引用的指针。当大于4个的时候使用referrers来存储指针。
reatin原理:
- 判断当前对象是否一个TaggedPointer,如果是则返回。
- 判断isa是否经过NONPOINTER_ISA优化,如果未经过优化,则将引用计数存储在SideTable中。64位的设备不会进入到这个分支。
- 判断当前的设备是否正在析构。
- 将isa的bits中的extra_rc进行加1操作。
- 如果在extra_rc中已经存储满了,则调用sidetable_addExtraRC_nolock方法将一半的引用计数移存到SideTable中。
常见内存泄漏场景
CoreFoundation对象 及 OC与CF对象转换
只要函数中包含了 create\new\copy\retain 等关键字, 那么这些方法产生的对象, 就必须在不再使用的时候调用1次CFRelease或者其他release函数。
__bridge
可用于Foundation对象 和 Core Foundation对象间的相互转换。—— 没有转移内存管理权
__bridge_retained
用于Foundation对象 转成 Core Foundation对象。—— 内存管理权转移,Core Foundation 需要管理其内存。
__bridge_transfer
用于Core Foundation对象 转成 Foundation对象。—— 内存管理权转移,内存管理交给 Foundation。
循环引用
delegate
问题:声明delegate属性的时候,没有使用正确的属性修饰符—weak,导致的循环引用。
解决:声明delegate属性时,使用weak修饰即可。
block
问题:self 持有 block,block内部又使用了 self,导致形成了循环引用。
解决:使用 __weak typeof(self) weakSelf = self; 在 block内部弱引用持有者打破循环。
NSTimer
不是所有的NSTimer都会造成循环引用。就像不是所有的block都会造成循环引用一样。以下两种timer不会有循环引用:
- 非repeat类型的。非repeat类型的timer不会强引用target,因此不会出现循环引用。
- block类型的,新api。iOS 10之后才支持,因此对于还要支持老版本的app来说,这个API暂时无法使用。当然,block内部的循环引用也要避免。
解决方案:
- 借助中间代理间接持有timer。
1 | // 借助中间代理间接持有timer。 |
- 继承NSProxy类对消息处理。
1 | // 继承NSProxy类对消息处理。 |
无限循环的动画
如果某个ViewController中有无限循环,也会导致即使ViewController对应的view关掉了,ViewController也不能被释放。这种问题常发生于animation处理。
解决方法:在 viewController 消失的时候,停止动画。
WKWebView 使用不当
可以在我的WKWebView使用讲解中看到具体的解决方案。
获取内存上限
Stack Overflow上已经有人测试了一波各个设备的内存上限情况。可以以这个作为参考,结合下面的内存监控,当内存达到阈值时,对内存做一些释放,以保证程序不会发生 OOM。
下面是两种可以获取设备对内存的限制。
通过 JetsamEvent 日志计算内存限制值
想要了解不同机器在不同系统版本的情况下,对 App 的内存限制是怎样的,有一种方法就是查看手机中以 JetsamEvent 开头的系统日志(我们可以从设置 -> 隐私 -> 分析中看到这些日志)。
在这些系统日志中,查找崩溃原因时我们需要关注 per-process-limit 部分的 rpages。rpages 表示的是 ,App 占用的内存页数量;per-process-limit 表示的是,App 占用的内存超过了系统对单个 App 的内存限制。
这里是我自己的手机 iPhone 11 下的数据
这部分日志的结构如下:
1 | "rpages" : 89600, |
现在,我们已经知道了内存页数量 rpages 为 89600,只要再知道内存页大小的值,就可以计算出系统对单个 App 限制的内存是多少了。
内存页大小的值,我们也可以在 JetsamEvent 开头的系统日志里找到,也就是 pageSize 的值。如下图红框部分所示:
1 | "pageSize" : 16384, |
可以看到,内存页大小 pageSize 的值是 16384。接下来,我们就可以计算出当前 App 的内存限制值:pageSize * rpages / 1024 /1024 =16384 * 89600 / 1024 / 1024 得到的值是 1400 MB,即 1.4G。
这些 JetsamEvent 日志,都是系统在杀掉 App 后留在手机里的。这些日志属于系统级的,会存在系统目录下。App 上线后开发者是没有权限获取到系统目录内容的。
通过内存警告获取内存限制值
可以利用 didReceiveMemoryWarning 这个内存压力代理事件来动态地获取内存限制值。
iOS 系统在强杀掉 App 之前还有 6 秒钟的时间,足够你去获取记录内存信息了。
利用下方内存监控的方式,在收到内存警告时,去获取内存的上限值。并将次值存储在本地,并以此上限值作为阈值,在内存紧张时,做内存释放工作。
内存监控
1 | #import <mach/mach.h> |