ARC & MRC
“引用计数”是 iOS 系统管理堆内存的主要手段, 在早期 iOS 开发中, 引用计数是需要开发者手动管理, 称为 MRC.
MRC (Manual Reference Counting)
MRC 即手动管理内存, 系统是根据对象的引用计数来判断什么时候需要回收一个对象所占用的内存
- 引用计数是一个整数
- 从字面上, 可以理解为”对象被引用的次数”
- 也可以理解为: 它表示有多少人正在用这个对象
- 每个OC对象都有自己的引用计数
- 任何一个对象, 刚创建的时候, 初始的引用计数为1
- 当使用alloc、new或者copy创建一个对象时, 对象的引用计数默认就是1
- 当没有任何人使用这个对象时, 系统才会回收这个对象, 也就是说
- 当对象的引用计数为0时, 对象占用的内存就会被系统回收
- 如果对象的计数不为0, 那么在整个程序运行过程, 它占用的内存就不可能被回收(除非整个程序已经退出 )
引用计数相关操作如下:
- 为保证对象的存在, 每当创建引用到对象需要给对象发送一条
retain
消息, 可以使引用计数值+1 (retain
方法返回对象本身) - 当不再需要对象时, 通过给对象发送一条
release
消息, 可以使引用计数值-1 - 给对象发送
retainCount
消息, 可以获得当前的引用计数值 - 当对象的引用计数为0时, 系统就知道这个对象不再需要使用了, 通过给对象发送
dealloc
消息释放它的内存 - 需要注意的是:
release
并不代表销毁或者回收对象, 仅仅是计数减 1
1 | int main(int argc, const char * argv[]) { |
当引用计数为 0 时候, 对象将会被系统销毁
对象即将被销毁时系统会自动给对象发送一条dealloc消息(因此, 从dealloc方法有没有被调用,就可以判断出对象是否被销毁)
dealloc方法的重写:
- 一般会重写dealloc方法, 在这里释放相关资源, dealloc就是对象的遗言
- 一旦重写了dealloc方法, (MRC)就必须调用[super dealloc] (ARC 无需调用 super), 并且放在最后面调用
1 | - (void)dealloc |
使用注意
- 不能直接调用dealloc方法
- 一旦对象被回收了, 它占用的内存就不再可用, 坚持使用会导致程序崩溃(野指针错误)
野指针和空指针
- 只要一个对象被释放了, 我们就称这个对象为 “僵尸对象(不能再使用的对象)”
- 当一个指针指向一个僵尸对象(不可用内存), 我们就称这个指针为野指针
- 只要给一个野指针发送消息就会报错(EXC_BAD_ACCESS错误)
1 | int main(int argc, const char * argv[]) { |
为了避免给野指针发送消息会报错, 一般情况下, 当一个对象被释放后我们会将这个对象的指针设置为空指针, 空指针是没有指向存储空间的指针(里面存的是nil, 也就是0)
1 | int main(int argc, const char * argv[]) { |
autorelease
当我们不再使用一个对象的时候应该将其空间释放, 但是有时候我们不知道何时应该将其释放, 比如方法的返回值. 为了解决这个问题, Objective-C 提供了autorelease
方法.
使用autorelease
有什么好处呢?
- 不用再关心对象释放的时间
- 不用再关心什么时候调用
release
autorelease
的原理实质上是什么?
autorelease
实际上只是把对release
的调用延迟了, 对于每一个autorelease
, 系统只是把该对象放入了当前的autorelease pool
中,当该pool
被释放时,该pool
中的所有对象会被调用release
.
引用计数的存储
对象的引用计数一般存储在对象的isa
指针中, 引用计数过大无法存储在 isa
中, 那么超出的引用计数会存储在一个叫 SideTable
结构体的 RefCountMap
(引用计数表)散列表中.
了解 新版 isa
的实现, 需要先了解下nonpointer
概念
nonpointer
0
: 代表普通的指针, 存储着 Class、Meta-Class 对象的内存地址1
: 代表优化过, 使用位域存储更多的信息
arm64 之后,isa
指针都是nonpointer
了, 即nonpointer
: 1
arm64 之前的isa
是非nonpointer
(未优化的指针), 引用计数存储在SideTable
的RefCountMap
中
(本质是 指针从 32 位拓展到 64 位之后, 可以利用多出的 32 位存储额外数据, 加快访问速度)
isa
定义如下:
1 | // objc-private.h |
isa_t
中存储了两个引用计数相关的东西:extra_rc
和has_sidetable_rc
extra_rc
: 里面存储的值是对象本身之外的引用计数的数量, 这 19 位如果不够存储,has_sidetable_rc
的值就会变为 1;has_sidetable_rc
: 如果为 1, 代表引用计数过大无法存储在isa
中, 那么超出的引用计数会存储SideTable
的RefCountMap
中.
SideTable
1 | // NSObject.mm |
NSObject
类持有一个静态的 SideTables()
, 本质也是一个散列表
1 | static StripedMap<SideTable>& SideTables() { |
- 首先根据指针的地址做一次哈希, 知道当前指针的引用计数存在哪一个
SideTable
中(多个 SideTable 方便多线程访问)1
2
3
4static unsigned int indexForPointer(const void *p) {
uintptr_t addr = reinterpret_cast<uintptr_t>(p);
return ((addr >> 4) ^ (addr >> 9)) % StripeCount;
} - 然后从
SideTable
的refcnts
拿到引用计数
Tagged Pointer
在 iOS 引入 arm64
时, 还引入了 Tagged Pointer
, 用于直接用指针存储一些小内存对象, 比如 NSNumber, 比较短的 NSString 等. 对于 64 位程序, 引入 Tagged Pointer 后, 相关逻辑能减少一半的内存占用, 以及3 倍的访问速度提升, 100倍的创建、销毁速度提升.
Tagged Pointer
定义如下
1 | // objc-internal.h |
看下 iOS 上的 NSNumber
1 | - (void)viewDidLoad { |
第一位b的二进制为1011, 其中第一位1
是Tagged Pointer
标识位. 后面的011
是类标识位, 对应十进制为3(OBJC_TAG_NSNumber), 表示NSNumber
类.
- 小的对象可以使用
Tagged Pointer
,Tagged Pointer
在赋值时候不会产生retain
/release
, 也就避免了多线程崩溃, 所以有时候大的字符串出现野指针崩溃, 但是小的字符串没问题, 也是这个原因
retainCount 源码
上面讲了怎么存储引用计数, 其实 retainCount
的获取就一目了然了. retainCount方法的函数调用栈为:
1 | // 1. NSObject.mm |
rootRetainCount
源码
1 | inline uintptr_t |
sidetable_getExtraRC_nolock
源码
1 | size_t |
sidetable_retainCount
源码
1 | uintptr_t |
dealloc 调用
1 | ALWAYS_INLINE bool |
可以看到执行到 if (borrowed > 0)
时候,
- 如果
borrowed <= 0
, 即表示最多只有 1 个指针指向此对象, 然后会执行objc_msgSend)(this, @selector(dealloc)
触发释放逻辑 - 否则会 执行-1 然后 return 直接退出
dealloc 执行哪些操作
1 | inline void |
正常的释放逻辑 object_dispose
直接调用了 objc_destructInstance
, 源码:
1 | void *objc_destructInstance(id obj) |
clearDeallocating
还额外执行了 clearDeallocating_slow
1 | NEVER_INLINE void |
总结下 dealloc 执行了
- 执行 C++ 析构
- 移除关联对象(_object_remove_assocations)
- 移除使用 SideTable 存储的引用计数
- 移除弱引用标记, 引用到的弱引用指针置为 nil
- free 此对象
weak 实现
在我们开发过程中, 经常需要使用弱引用, 来实现对一个对象可有可无地持有.
weak 弱引用是通过 objc_initWeak
实现
1 | id |
除了弱引用的初始化 还有设置弱引用/销毁等, 只是 storeWeak
的参数会有一些不一样
1 | id |
objc_initWeak
的参数location
指向弱引用指针, newObj为需要被弱引用的对象HaveOld
代表weak指针是否指向了一个弱引用DontHaveOld
表示之前未弱引用某个对象DoHaveOld
表示之前有弱引用某个对象
HaveNew
代表weak指针是否需要只向一个新的弱引用DontHaveNew
表示不需要再弱引用一个新对象DoHaveNew
表示需要再弱引用一个新对象
CrashIfDeallocating
代表的是被弱引用的对象是否在析构, 如果在析构会error.
1 | enum CrashIfDeallocating { |
store_weak
函数的执行过程如下:
- 分别获取新旧值相关联的弱引用表;
- 如果有旧值, 就调用
weak_unregister_no_lock
函数清除旧值, 移除所有指向旧值的weak
引用, 而不是赋值为nil; - 如果有新值, 就调用
weak_register_no_lock
函数分配新值, 将所有weak指针重新指向新的对象; - 判断isa是否为
nonpointer
来设置弱引用标志位. 如果不是nonpointer
, 设置SideTable
中的弱引用标志位, 否则设置isa
的weakly_referenced
弱引用标志位.
再看看 weak_unregister_no_lock
, 如果该指针之前已经弱引用了某个对象
1 | void |
最后看看 weak_register_no_lock
真正设置弱引用的地方
1 | id |
ARC (Automatic Reference Counting)
ARC
是自动管理内存, WWDC2011 和 iOS5 引入. ARC
是新的 LLVM 3.0
编译器的一项特性, 使用ARC, 可以说一举解决了广大iOS开发者手动内存管理的麻烦.
使用ARC
后, 系统会检测出何时需要保持对象, 何时需要自动释放对象, 何时需要释放对象, 编译器会管理好对象的内存, 会自动插入retain
, release
和autorelease
, 通过生成正确的代码去自动释放或者保持对象. 我们完全不用担心编译器会出错.
使用 ARC
需要注意
- 不允许调用对象的
release
方法 - 不允许调用
autorelease
方法 - 重写父类的
dealloc
方法时, 不能再调用[super dealloc]
ARC
与 MRC
的区别只是编译器会自动转换代码,
这点在 Objective-C Automatic Reference Counting (ARC) — Clang 16.0.0git documentation 文档中有提到, 通过汇编代码也可以分析:objc_release
会减小对象的引用计数, 减小到 0 时对象就会被销毁, 假如这时有其它线程正在使用这个对象, 那么使用对象的线程就很可能发生崩溃.
崩溃场景
这一章节内容来自文章 头条稳定性治理: ARC 环境中对 Objective-C 对象赋值的 Crash 隐患
为了演示仅一行赋值代码就能造成崩溃, 以及清晰地分析崩溃的原因, 我设计了一个 Demo, 在 B 线程中释放 A 线程创建的对象使 C 线程崩溃:
复现过程:
A、B、C 三个线程同时进入 foo 函数
A 线程先创建初始值 _instance
- A 线程执行到 _instance = x0, 创建了新值并赋给 _instance;此时 _instance 引用计数为 1;
B、C 线程读取到 A 线程创建的初始值 _instance
- B、C 线程分别执行到 x1 = _instance 时, 从 _instance 中读到线程 A 创建的对象, 保存到各自的上下文中;_instance 引用计数仍为 1;
B 线程释放 _instance
- B 线程执行
objc_release(x1)
后会释放 _instance;_instance 引用计数变为 0, 被销毁;
- B 线程执行
C 线程访问 _instance
- C 线程执行到
objc_release(x1)
时访问 _instance;由于 _instance 已经被销毁, 访问时会发生崩溃.
- C 线程执行到
崩溃原因
如下图, 为什么会发生 EXC_BAD_ACCESS 崩溃?
ldr x17, [x2, #0x20]
指令认为寄存器 x2
中存放的是地址, 将该地址和 0x20
相加获得一个新地址, 再从新地址中读取 8 字节存放到 x17
中.
本例中可以分析出寄存器 x2
存放的是 Class
的地址, x2+0x20
是 Class
的成员变量 bits
的地址, 这个地址是 0x00000007374040e0
. 从这个地址中读值时操作系统发现它是非法内存地址, 从而产生 EXC_BAD_ACCESS
异常并报出这个错误地址.
附: Class 的结构体及成员变量的偏移
为什么 Class->bits 的地址会是 0x00000007374040e0
, 这个非法地址是怎么来的?
_instance 对象被销毁后, 内存被系统随机改写, 通过崩溃截图中 lldb 打印的日志可知:
- 对象的 ISA 位置存放的随机值是
0x000010d7374040c0
- Class = ISA & ISA_MASK = 0x00000007374040c0
- Class->bits = 0x00000007374040c0 + 0x20 = 0x00000007374040e0
ISA
是随机值, 那么 Class、Class->bits 也都是随机值, 很容易是一个非法的内存地址, 访问非法内存地址就会产生 EXC_BAD_ACCESS
异常.
在执行 objc_release
函数之前 _instance
就已经销毁了, 为什么执行到 ldr x17, [x2, #0x20]
这一行指令时才发生崩溃, 之前没有崩溃?
EXC_BAD_ACCESS
异常发生在访问非法内存地址时. 在 ldr x17, [x2, #0x20]
之前仅有 ldr x16, [x0]
中使用方括号 []
访问了 x0
中存储的地址. 此时 x0
中存储的是 _instance 的地址, _instance
销毁后对象的内存被系统随机改写, 而 x0
中的地址是之前就存进来的合法地址, 访问合法地址不会出现异常. (x0
存储的值指向的地址合法, 但是 x2+0x20
指向的地址不合法)
更多崩溃场景
崩溃在 objc_retain 中
崩溃原因: _instance
作为参数传递到 bar
函数, 在函数开始执行时会保留参数 objc_reatin(_instance)
, 结束执行时会释放参数objc_release(_instance)
. 若保留参数时 _instance
已被其它线程销毁, 就会导致崩溃在 objc_reatin
中.
崩溃在 objc_msgSend 中
崩溃原因: 第 7 行代码向 _instance
发送了 isEqual:
消息, 在执行到崩溃指令 ldr x11,[x16, #0x10]
时, 寄存器 x16
存放的是 _instance
的 Class
, [x16, #0x10]
指令想要读取 Class->cache
, 进而从 cache
中寻找缓存的方法. _instance
销毁后 ISA
、Class
、Class->cache
会成为随机值, 如果 Class->cache
是非法地址, 在执行 [x16, #0x10]
时就会崩溃.
崩溃在 objc_autoreleasePoolPop 中
崩溃原因: 若对象使用非 new/alloc/copy/mutableCopy 开头的接口创建, 并且不满足 Autorelease elision 策略, 会被添加到自动释放池中. 本例创建的 _instance
被添加到子线程的自动释放池中, 子线程任务执行完成后会对池中的对象 pop
, 依次调用 objc_release
进行释放, 若次此时 _instance
已在其它线程中销毁, 就会发生崩溃.
EXC_BREAKPOINT 崩溃
除了上面提到的 EXC_BAD_ACCESS 异常, 这类问题也能导致其它类型的异常, 这里举一个 EXC_BREAKPOINT 异常的例子.
崩溃原因: -[NSString stringWithFormat:@"%@",_instance]
会调用 objc_opt_respondsToSelector
函数并将 _instance
作为参数传入. 在 objc_opt_respondsToSelector
函数发生崩溃前, x16
存储的是参数 _instance
的 Class
.
ARM-指针认证 相关的指令会使 x16
寄存器与 x17
寄存器相等, 然后用 xpacd x17
对 x17
寄存器中高位清零, 再比较 x16
与 x17
, 不相等则执行 brk
指令触发 EXC_BREAKPOINT
异常. xpacd
对合法指针清零不会改变指针的值, 不会执行 brk
指令产生异常. 当参数被销毁后, x16
可能被改写为非法指针并赋给 x17
, xpacd x17
对非法指针高位清零会改变 x17
, 使 x17
不等于 x16
, 导致 EXC_BREAKPOINT
异常.
AutoReleasePool
iOS 程序开发的入口就与 Autoreleasepool 相关, 一般 main.m 文件是这样的
1 | int main(int argc, char * argv[]) { |
即 整个 iOS 的应用都是包含在一个自动释放池 block 中的.
其中 @autoreleasepool 是对 AutoReleasePool 的一个封装, 本质上是调用了 Push / Pop 方法, @autoreleasepool 帮助我们少写了这两行代码, 让代码看起来更美观
1 | int main(int argc, const char * argv[]) { |
AutoreleasePool 是什么
1 | void *objc_autoreleasePoolPush(void) { |
上面的方法看上去是对 AutoreleasePoolPage
对应静态方法 push 和 pop 的封装.
AutoreleasePoolPage
AutoreleasePoolPage 是一个类, 主要定义如下:
1 | class AutoreleasePoolPage { |
每一个自动释放池都是由一系列的 AutoreleasePoolPage
组成的, 并且每一个 AutoreleasePoolPage
的大小都是 4096 字节(16 进制 0x1000)
1 |
双向链表
自动释放池中的 AutoreleasePoolPage 是以双向链表的形式连接起来的:
parent 和 child 就是用来构造双向链表的指针.
自动释放池中的栈
如果我们的一个 AutoreleasePoolPage 被初始化在内存的 0x100816000 ~ 0x100817000 中, 它在内存中的结构如下:
- 其中有 56 bit 用于存储
AutoreleasePoolPage
的成员变量, 剩下的0x100816038
~0x100817000
都是用来存储加入到自动释放池中的对象. begin()
和end()
这两个类的实例方法帮助我们快速获取0x100816038
~0x100817000
这一范围的边界地址.next
指向了下一个为空的内存地址, 如果 next 指向的地址加入一个 object, 它就会如下图所示移动到下一个为空的内存地址中:
POOL_SENTINEL(哨兵对象)
到了这里, 你可能想要知道 POOL_SENTINEL
到底是什么, 还有它为什么在栈中.
首先回答第一个问题: POOL_SENTINEL
只是 nil 的别名.
1 |
在每个自动释放池初始化调用 objc_autoreleasePoolPush
的时候, 都会把一个 POOL_SENTINEL
push
到自动释放池的栈顶, 并且返回这个 POOL_SENTINEL
哨兵对象.
1 | int main(int argc, const char * argv[]) { |
而当方法 objc_autoreleasePoolPop
调用时, 就会向自动释放池中的对象发送 release 消息, 直到第一个 POOL_SENTINEL
:
- 双向链表中第一个 page 的
begin()
一定是POOL_SENTINEL
- 每调用一次
objc_autoreleasePoolPush
会加入一个POOL_SENTINEL
- hotPage 指向双向链表中最后一个 page
objc_autoreleasePoolPush
objc_autoreleasePoolPush
最终会调用到 autoreleaseFast
函数(关键函数), 编辑器自动插入 autorelease
本质上也是调用的 autoreleaseFast
.
1 | void *objc_autoreleasePoolPush(void) { |
最后的都会调用 page->add(obj)
将对象添加到自动释放池中.
page->add 添加对象
代码简化后:
1 | id *add(id obj) { |
这个方法其实就是一个压栈的操作, 将对象加入 AutoreleasePoolPage
然后移动栈顶的指针(next
).
autoreleaseFullPage(当前 hotPage 已满)
autoreleaseFullPage 会在当前的 hotPage 已满的时候调用:
1 | static id *autoreleaseFullPage(id obj, AutoreleasePoolPage *page) { |
autoreleaseNoPage(没有 hotPage)
如果当前内存中不存在 hotPage, 就会调用 autoreleaseNoPage 方法初始化一个 AutoreleasePoolPage:
1 | static id *autoreleaseNoPage(id obj) { |
objc_autoreleasePoolPop 方法
1 | // ctxt 表示释放到哪一个指针为止, 一般传入的都是哨兵对象, |
AutoreleasePoolPage::pop
简化版实现如下
1 | static inline void pop(void *token) { |
releaseUntil 释放对象
1 | void releaseUntil(id *stop) { |
kill() 方法
将当前页面以及子页面全部删除
1 | void kill() { |
autorelease 方法
我们已经对自动释放池生命周期有一个比较好的了解, 最后需要了解的话题就是 autorelease 方法的实现, 先来看一下方法的调用栈:
1 | - [NSObject autorelease] |
在 autorelease
方法的调用栈中, 最终都会调用上面提到的 autoreleaseFast
方法, 将当前对象加到 AutoreleasePoolPage
中. 上面已经详细解释过 autoreleaseFast
了, 再贴一下源码吧:
1 | static inline id *autoreleaseFast(id obj) |
AutoreleasePool 和 Runloop 的关系
Autorelease
对象什么时候释放?
根据Autorelease
机制, 在没有手加 AutoreleasePool
的情况下, Autorelease
对象是在当前的runloop
迭代结束时释放的, 而它能够释放的原因是 系统在每个runloop迭代中都加入了自动释放池Push和Pop.
- 使用容器的block版本的枚举器时, 内部会自动添加一个
AutoreleasePool
(但是for in
和for ( ; ; i++)
没有) - 使用
GCD
的 block 方法时, 也会自动加AutoreleasePool
根据苹果官方文档中对 Using Autorelease Pool Blocks 的描述, 我们知道在下面三种情况下是需要我们手动添加 autoreleasepool 的:
- 如果你编写的程序不是基于 UI 框架的, 比如说命令行工具;
- 如果你编写的循环中创建了大量的临时对象;
- 如果你创建了一个辅助线程.
子线程能用 Autorelease 吗?
我们知道主线程有 Runloop, 但是子线程是默认没有 Runloop 的, 那么在子线程能使用 Autorelease 吗? 能使用的话, 自动释放池是什么时候 Push 以及 Pop 的呢?
创建自动释放池
回顾一下上面的 objc_autoreleasePoolPush
会调用到 autoreleaseFast
函数, autoreleaseFast
获取了当前页 hotPage
(AutoreleasePoolPage *page = hotPage();
)
1 | static inline AutoreleasePoolPage *hotPage() |
这里的tls_get_direct
实现如下, 相当于把 key 绑定到了子线程上
1 | static inline void *tls_get_direct(tls_key_t k) |
如果内存中不存在 hotPage, 就会走 autoreleaseNoPage
函数(AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
), 创建自动释放池中的第一个 page.
销毁自动释放池
自动释放池初始化的时候, 会监听线程退出然后调用tls_dealloc
, 在tls_dealloc
调用 pop
函数清除自动释放池
1 | class AutoreleasePoolPage |
线程退出的时候, 也会调用_pthread_tsd_cleanup
函数,
1 | void |
该函数会对当前线程的TLS的资源进行清除,遍历所有pthread_key_t
,调用其析构函数。我们知道autoreleasepool在线程中有对应的pthread_key_t
, 因此,子线程中自动释放池的创建和释放都无需我们进行额外的操作。
Retain & Release 调用优化
这一章节内容来自文章 【WWDC22 110363】App 包大小优化和 Runtime 上的性能提升
Xcode 14 对于 Retain
和 Release
的包大小开销也进行了针对性的优化, 从之前的 8 个字节开销降低到了 4 个字节. Retain 和 Release 的调用和消息发送一样, 几乎也是无处不在的, 所以这项针对性的优化最高也可以带了 2% 的包大小优化收益. 但和消息发送不同, 这项优化依赖于 Runtime 的支持, 因此只有将最低支持版本迁移到 iOS 16、tvOS 16 或者是 watchOS 9 才能生效.
编译器在 ARC
时候会帮助我们插入大量的 retain
/ release
调用来确保程序中对象的内存何时释放.
每当我们通过 alloc
、new
、copy
创建了一个对象后, 该对象的引用计数就会加一, 在底层是通过 _objc_retain
指令实现的.
参考 Advanced Memory Management Programming Guide 以及 Automatic Reference Counting - Swift
而当对象离开了自己的作用域, 需要被销毁的时候, 引用计数就会减一, 在底层是通过 _objc_release
指令实现的.
基于 ARC
计数, 编译器会帮助优化掉部分 _objc_retain
和 _objc_release
指令调用. 但是如上图所示, 在方法最后结束前, 局部变量 cal
和 dateComponents
并没有作为返回值返回给方法的调用方, 因此需要被释放掉来实现内存回收.
在底层实现上, objc_retain
和 objc_release
都是普通的 C 函数, 接收唯一的参数 - 要被释放掉的对象. 而由于 ARC
的存在, 编译器会插入对这两个 C 函数的调用, 并传入对应的指针对象. 而为了遵循底层 ABI 定义的 C 函数的调用约定, 我们需要更多的代码执行这些调用来达到将对象指针传入正确的寄存器中的目的. 体现在汇编代码层面就是上图中各种 mov 指令.
自定义调用约定
Apple 基于此进行了针对性的「自定义调用约定」优化. 如上图所示, 通过自定义专门的 objc_retain
和 objc_release
调用约定, 我们可以根据对象指针的位置来使用正确的版本, 这样我们就可以避免额外的 mov
指令所带来的开销. 虽然这只是一个很细微的优化, 但正如我们前面所讲的, 对于整个 App 来说, 这项优化伴随着无处不在的 retain
和 release
调用是有着量变引起质变的效果的.
Autorelease 自动省略
Apple 今年针对 Autorelease
自动省略的优化分为两个方面.
- 受益于 Runtime 的更新,
Autorelease
自动省略更高效. - 在此基础之上, 受益于编译器的更新, 最低发布版本为 iOS 16、tvOS 16 或者是 watchOS 9 的 App 会自动获得包大小减少 2% 的优化.
什么是 Autorelease 自动省略
在充分理解 Apple 针对 Autorelease
自动省略做出的优化前, 我们先简单温习一下什么是 Autorelease
自动省略.
还是基于之前的代码, 如上图所示, getWWDCDate
方法返回了临时创建的 theDate
对象. 然后 event 对象调用了 getWWDCDate
方法, 随后声明了 theWWDCDate
对象, 让其指向了方法的返回值 theDate
对象.
对于 getWWDCDate
方法的调用方, ARC 会插入 retain
语句.
而对于被调用方即 getWWDCDate
方会插入 release
语句, 因为 theDate
对象离开了其作用域. 但我们并不能真正马上就 release
掉 theDate
对象, 因为该对象并没有其它的引用.
如果我们在此时 release
了它, 在我们完成 getWWDCDate
方法的调用前 theDate 对象就会被销毁, 这并不是我们所期望的结果.
所以一个特殊的约定就是插入 autorelease
语句, 方法的调用方就可以紧接着对方法返回的对象进行 retain
操作.
事实上 Runtime
并不会保证真正的 release
操作何时发生, 但只要确保方法返回前临时对象不被销毁, 我们就可以在随后使用方法返回的临时对象并对其执行 retain
操作.
Autorelease
操作并不是没有开销的, Autorelease
省略就是专门来进行优化这项开销的. 要了解它是如何工作的, 让我们基于上面示例代码中的 return
语句查看其汇编实现.
当我们调用 autoreleease
之后, 我们视角就需要来到 Objective-C 运行时了, 此时有意思的事情就会发生了.
Objective-C 运行时需要知道我们正在返回一个 Autorelease
的对象. 而为了实现这一点, 编译器会生成一个我们不会用到的特殊标记. 通过这个特殊标记, 运行时就知道当前是否符合 Autorelease
省略的条件, 随之而来的是我们稍后要执行的 retain
操作.
如上图所示, 我们可以看到, 在 getWWDCDate
方法 return
的地方生成了对应的汇编指令 b _objc_autoreleaseReturnValue
, 这里就相当于告诉了 Runtime 我们正在返回一个需要 Autorelease
的对象, 我们不会在方法返回前释放掉这个对象.
随后, 来到 getWWDCDate
方法的调用方, 对应的汇编指令是 bl _getWWDCDate
, 前面我们已经提到过 bl
指令是带返回的跳转指令. 所以相当于是先执行的 bl _getWWDCDate
指令, 再执行的 b _objc_autoreleaseReturnValue
指令. 这就对应上了调用一个方法, 方法内部执行完成之后, 代码控制权又回到了方法调用方的流程.
紧随其后的是 mov x29, x29
指令, x29
表示是对通用寄存器 r29
的按 64 位访问操作. 而 r29
寄存器又被成为 fp(frame pointer)
寄存器. 了解栈结构和 iOS 内存分布的读者应该都知道栈是从高地址往低地址增长的, 而 fp
就指向了高地址, 也就是栈顶的位置. 但是乍看起来这句 mov x29, x29
似乎毫无意义, 因为这相当于将 fp
上的值移动到了 fp
上面. 这里的操作的意义并不在于 fp
的值移动, 而是本身这句指令的二进制存储以十六进制表示之后就是 0xAA1D03FD
, 但是除此之外, 我们并没有得到有价值的信息.
单看这几条汇编指令可能不太清晰, 下面就让我们直接从源码入手来窥探底层的细节.
1 | // Prepare a value at +1 for return through a +0 autoreleasing convention. |
代码来自于 objc4-818.2 源码中的 NSObject.mm 源文件.
我们可以看到, objc_autoreleaseReturnValue
函数会接收一个对象, 然后根据 prepareOptimizedReturn
方法返回值结果判断是否需要进行优化, 如果需要优化, 则增加一个 +1 的标志位, 不放入自动释放池中;如果不需要优化, 则会将对象放入自动释放池中, 等待下一次 RunLoop 结束后自动释放池的 Pop 操作.
1 | // Prepare a value at +0 for return through a +0 autoreleasing convention. |
代码来自于 objc4-818.2 源码中的 NSObject.mm 源文件.
同样的, objc_retainAutoreleaseReturnValue
方法也会判断传入的对象是否可以进行优化, 如果优化成功, 就增加一个 +0 标志位, 不放入自动释放池;如果不能优化, 则放入自动释放池中并 retain
.
1 | // Try to prepare for optimized return with the given disposition (+0 or +1). |
代码来自于 objc4-818.2 源码中的 objc-object.h 源文件.
重点来了, 关键函数是 callerAcceptsOptimizedReturn
, 传入的参数值是__builtin_return_address(0)
.
__builtin_return_address
这个内建函数原型是 char *__builtin_return_address(int level)
, 作用是得到函数的返回地址, 参数表示层数, 如 __builtin_return_address(0)
表示当前函数体返回地址, 传 1 是调用这个函数的外层函数的返回值地址, 以此类推.
由于 prepareOptimizedReturn
函数是 ALWAYS_INLINE
的, 所以该方法返回的就是(objc_autoreleaseReturnValue / objc_retainAutoreleaseReturnValue)
的函数返回地址, 即变量创建的函数返回地址.
1 | // __arm__ |
代码来自于 objc4-818.2 源码中的 objc-object.h 源文件.
显然, 在 callerAcceptsOptimizedReturn
函数的实现里面, 我们可以看到 mov fp, fp
汇编指令的真实代码是什么样的, 就是比较 ra
这个指针对象的值与 0xaa1d03fd
是否相等. 到这里我们就解答了上面 mov x29, x29
汇编指令的作用了.
Thread Local Storage(TLS)线程局部存储, 目的很简单, 将一块内存作为某个线程专有的存储, 以 key-value 的形式进行读写.
在返回值身上调用 objc_autoreleaseReturnValue 方法时, runtime 将这个返回值 object 储存在 TLS 中, 然后直接返回这个 object(不调用 autorelease);同时, 在外部接收这个返回值的 objc_retainAutoreleasedReturnValue 里, 发现 TLS 中正好存了这个对象, 那么直接返回这个 object(不调用 retain).
于是乎, 调用方和被调方利用 TLS 做中转, 很有默契的免去了对返回值的内存管理.
黑幕背后的 Autorelease
在 objc4 之前, objc_autoreleaseReturnValue / objc_retainAutoreleaseReturnValue 会将传入的 obj 利用 TLS 存储, 在 objc_retainAutoreleasedReturnValue / objc_unsafeClaimAutoreleasedReturnValue 中 根据 key 拿到原来的 objc 进行比较, 如果相同就什么都不做.
objc4 做了优化, 不再存储原始 obj, objc_autoreleaseReturnValue / objc_retainAutoreleaseReturnValue 只存储了一个标志位 ReturnDisposition, objc_retainAutoreleasedReturnValue / objc_unsafeClaimAutoreleasedReturnValue 根据标志位来进行判断是否需要 retain 或 release.
iOS 内存管理思考 - autorelease
接着, 运行时会以数据的形式加载这个特殊的标记指令 0xAA1D03FD
, 然后比较是不是所期望的特殊标记指令从而达到 Autorelease
省略的效果. 关于 _objc_retainAutoreleasedReturnValue
的更多底层实现细节可以参考how-does-objc_retainautoreleasedreturnvalue-work 一文.
经过比较之后, 如果匹配成功, 则表示编译器告诉运行时我们正在返回一个随后马上会被 retain
的临时变量. 最后这就可以让我们达到省略或移除互相匹配的 autorelease
和 retain
代码. 这就是 Autorelease
省略.
Autorelease 自动省略优化方案
但是由于将代码作为数据加载并不是一个十分通用的场景, 因此 CPU 并不会对此做出特殊优化.
让我们再次回到前面的例子上, 我们还是从 autorelease
作为探索的起点. 在这个时间点上, 我们已经有了一个十分有价值的线索, 那就是方法的「返回地址」. 通过方法「返回地址」, 我们就知道在方法执行完成之后需要执行到哪个地方. 所以我们可以持续追踪这个返回地址. 值得一提的是, 获取返回地址的操作十分得轻量, 因为返回地址只是一个指针, 我们可以存在一边以备后续流程使用.
接着我们将目光离开 autorelease
, 回到方法的调用方, 当执行了 retain
操作之后, 我们重新回到了运行时中. 新的魔法开始了.
如上图绿色箭头所示, 在此时, 我们可以获取指向当前返回地址的指针.
随后, 我们只需要在运行时里面比较黄色箭头指针(之前执行 autorelease
操作时保存的函数地址)和绿色箭头的指针(执行 retain
操作时获得的函数返回地址)即可判断是否需要进行 Autorelease
省略. 因为我们这里进行的操作只是两个指针的比较, 这是十分轻量的操作. 我们不需要进行高昂的内存访问.
最重要的是, 我们不再需要通过以数据的形式加载特殊的标记指令来进行比较, 我们可以删除掉上图中 mov x29, x29
这条指令. 这让我们在代码上节省了一定的大小开销.
参考文章
- ios内存管理之weak
- AutoreleasePool详解和runloop的关系
- iOS 开发: 彻底理解 iOS 内存管理(MRC、ARC)
- 自动释放池的前世今生 —- 深入解析 autoreleasepool
- 头条稳定性治理: ARC 环境中对 Objective-C 对象赋值的 Crash 隐患
- Objective-C Automatic Reference Counting (ARC) — Clang 16.0.0git documentation
- WWDC22 - Improve app size and runtime performance
- 【WWDC22 110363】App 包大小优化和 Runtime 上的性能提升
- Advanced Memory Management Programming Guide
- Automatic Reference Counting - Swift
- 黑幕背后的 Autorelease
- how-does-objc_retainautoreleasedreturnvalue-work
- WWDC22: Improve app size and runtime performance - 掘金
- ARM-指针认证
- Using Autorelease Pool Blocks