在上篇我们介绍了小对象,copy,strong的内存管理,介绍了release和dealloc的底层实现,这篇文章继续研究内存管理中的AutoReleasePool,研究AutoReleasePool也会研究下跟AutoReleasePool关联紧密的NSRunLoop。
AutoReleasePool 自动释放池
自动释放池是OC的一种内存自动回收机制,在MRC中可以用AutoReleasePool来延迟内存的释放,在ARC中可以用AutoReleasePool将对象添加到最近的自动释放池,不会立即释放,会等到runloop休眠或者超出autoreleasepool作用域{}之后才会被释放。可以通过下图来表示
- 1.从程序
启动到加载完成,主线程对应的runloop会处于休眠状态,等待用户交互来唤醒runloop - 2.用户的
每一次交互都会启动一次runloop,用于处理用户的所有点击、触摸事件等 - 3.
runloop在监听到交互事件后,就会创建自动释放池,并将所有延迟释放的对象添加到自动释放池中 - 4.在一次
完整的runloop结束之前,会向自动释放池中所有对象发送release消息,然后销毁自动释放池
C++分析
我们在main.m写如下代码
转成C++代码:
通过上图我们知道
@autoreleasepool被转化成__AtAutoreleasePool __autoreleasepool,这是个结构体。__AtAutoreleasePool结构体定义如下:通过上图可以知道这个结构体提供了两个方法(这两个方法很重要):1.
objc_autoreleasePoolPush2.objc_autoreleasePoolPop。
通过上图我们可以知道一下几点:
- 1.这个结构体有
构造函数+析构函数,结构体定义的对象在作用域结束后,会自动调用析构函数 - 2.其中
{}是作用域,优点就是结构清晰,可读性强,可以及时创建销毁关于上面说的构造和析构的调用时机和表现,我们可以写下面的代码来看看:
通过上面我们可以得出,在
LjTest创建时,会自动调用析构函数,再出了{}作用域后,会自动调用析构函数
汇编分析
我们在main.m中加断点
运行程序,来到断点,开启汇编调试
通过调试也得出clang分析的结果。
底层源码分析
在objc源码中,对AutoreleasePool做如下说明:
通过描述:
- 1.
自动释放池是一个关于指针的栈结构 - 2.其中的
指针是指向释放的对象或者pool_boundary哨兵(现在经常被称为边界) - 3.
自动释放池是一个页的结构(虚拟内存中提及过),而且这个页是一个双向链表(表示有父节点和子节点,在类中提及过,即类的继承链) - 4.
自动释放池和线程有关系通过上面对自动释放池的说明,我们知道我们研究的几个方向: - 1.
自动释放池什么时候创建? - 2.
对象是如何加入自动释放池的? - 3.
哪些对象才会加入自动释放池?带着这些问题,我们出发来探索自动释放池的底层原理
AutoreleasePoolPage分析
从最初的clang或者汇编分析我们了解了自动释放池其底层是调用的objc_autoreleasePoolPush和objc_autoreleasePoolPop,它们源码如下:
从源码中我们可以发现,都是
调用的AutoreleasePoolPage的push和pop实现,以下是其定义
从上面可以做出以下判断:
- 1.
自动释放池是一个页,同时也是一个对象,这个页的大小是4096字节 - 2.从其定义中发现,
AutoreleasePoolPage是继承自AutoreleasePoolPageData,且该类的属性也是来自父类,以下是AutoreleasePoolPageData的定义
发现其中有
AutoreleasePoolPage对象,所以有以下一个关系链AutoreleasePoolPage -> AutoreleasePoolPageData -> AutoreleasePoolPage,从这里可以说明自动释放池除了是一个页,还是一个双向链表结构
- 1.其中
AutoreleasePoolPageData结构体的内存大小为56字节属性magic的类型是magic_t结构体,所占内存大小为m[4];所占内存(即4*4=16字节)属性next(指针)、thread(对象)、parent(对象)、child(对象)均占8字节(即4*8=32字节)- 属性
depth、hiwat类型为uint32_t,实际类型是unsigned int类型,均占4字节(即2*4=8字节) 通过上面可以知道一个空的AutoreleasePoolPage的结构如下:
objc_autoreleasePoolPush 源码分析
进入push的源码实现:
有以下逻辑:
- 1.首先进行判断
是否存在pool - 2.如果没有,则通过
autoreleaseNewPage方法创建 - 3.如果有,则通过
autoreleaseFast压栈哨兵对象
autoreleaseNewPage创建页
先看下autoreleaseNewPage创建页的实现过程
通过上面的代码实现(autoreleaseFullPage后面会重点分析),我们知道一下结论
- 1.判断
当前页是否存在 - 2.如果
存在通过autoreleaseFullPage方法进行压栈对象 - 3.如果
不存在,则通过autoreleaseNoPage方法创建页autoreleaseNoPage方法中可知当前线程的自动释放池是通过AutoreleasePoolPage创建的(973行)AutoreleasePoolPage的构造方法是通过实现父类AutoreleasePoolPageData的初始化方法实现的
AutoreleasePoolPage
上面说了当前线程的自动释放池是通过AutoreleasePoolPage创建,看下AutoreleasePoolPage构造方法:
其中
AutoreleasePoolPageData方法传入的参数含义为:
begin()表示压栈的位置(即下一个要释放对象的压栈地址)。可以通过源码调试begin,发现其具体实现等于页首地址+56,其中的56就是结构体AutoreleasePoolPageData的内存大小我们再看下AutoreleasePoolPage初始化
- 1.
objc_thread_self()是表示当前线程,而当前线程是通过tls获取 - 2.
newParent表示父节点 - 3.后续两个参数是
通过父节点的深度、最大入栈个数计算depth以及hiwat
查看自动释放池内存结构
由于在ARC模式下,是无法手动调用autorelease,所以将Demo切换至MRC模式(Build Settings -> Objectice-C Automatic Reference Counting设置为NO)
写如下代码:
运行结果:
通过运行结果,我们发现
release是6个,但是我们压栈对象是5个,其中的POOL表示哨兵,即边界,其目的是为了防止越界。我们再看下打印地址,发现页的首地址和哨兵对象相差0x38,转成10进制正好是56,这也是AutoreleasePoolPage自身的内存大小。我们将循环次数改成505,再来运行一次
通过上图我们发现
第一页满了,存储了504个要释放的对象,第二页只存储了一个。我们再改下循环次数,改为1015次,再看看是不是一页只能存504个对象
通过运行发现,第一页存储
504,第二页存储505,第三页存储6个
通过上面我们可以得出以下结论:
- 1.第一页可以存放
504个对象,且只有第一页有哨兵,当一页压栈满了,就会开辟新的一页 - 2.第二页开始,
最多可以存放505个对象 - 3.一页的大小等于
505 * 8 = 4040上面的结论我们之前讲AutoreleasePoolPage的SIZE是就说了,一页的大小为4096字节,而在其构造函数中对象的压栈位置,是从首地址+56开始的,所以可以一页中实际可以存储4096-56 = 4040字节,转换成对象是4040 / 8 = 505个,即一页最多可以存储505个对象,其中第一页有哨兵对象只能存储504个。其结构图如下:通过上面可以知道:
- 1.
一个自动释放池只有一个哨兵对象,且哨兵在第一页 - 2.
第一页最多可以存504个对象,第二页开始最多存 505个
小结
- 1.
autoreleasepool其本质是一个结构体对象,一个自动释放池对象就是页,是栈结构存储,符合先进后出的原则即可 - 2.
页的栈底是一个56字节大小的空占位符,一页总大小为4096字节 - 3.只有
第一页有哨兵对象,最多存储504个对象,从第二页开始最多存储505个对象 - 4.
autoreleasepool在加入要释放的对象时,底层调用的是objc_autoreleasePoolPush方法 - 5.
autoreleasepool在调用析构函数释放时,内部的实现是调用objc_autoreleasePoolPop方法
压栈对象 autoreleaseFast
进入autoreleaseFast源码:
主要分一下几步:
- 1.获取当前操作页,并判断页是否存在以及是否满了
- 2.如果
页存在,且未满,则通过add方法压栈对象 - 3.如果页
存在,且满了,则通过autoreleaseFullPage方法安排新的页面 - 4.如果
页不存在,则通过autoreleaseNoPage方法创建新页
autoreleaseFullPage 方法
查看源码:
这个方法主要是用于判断当前页是否已经存储满了,如果当前页已经满了,通过
do-while循环查找子节点对应的页,如果不存在,则新建页,并压栈对象从上面AutoreleasePoolPage初始化方法中可以看出,主要是通过操作child对象,将当前页的child指向新建页面,由此可以得出页是通过双向链表连接。
add 方法
查看源码:
这个方法主要是
添加释放对象,其底层是实现是通过next指针存储释放对象,并将next指针递增,表示下一个释放对象存储的位置。从这里可以看出页是通过栈结构存储
objc_autoreleasePoolPop 源码分析
在objc_autoreleasePoolPop方法中有个参数,在clang分析时,发现传入的参数是push压栈后返回的哨兵对象,即ctxt,其目的是避免出栈混乱,防止将别的对象出栈,其内部是调用AutoreleasePoolPage的pop方法,我们看下pop源码
pop源码实现,主要由以下几步:
- 1.空页面的处理,并
根据token获取page - 2.容错处理
- 3.通过
popPage出栈页 查看popPage源码
进入
popPage源码,其中传入的allowDebug为false,则通过releaseUntil出栈当前页stop位置之前的所有对象,即向栈中的对象发送release消息,直到遇到传入的哨兵对象。
releaseUntil源码
看源码我们可以知道:
- 1.
releaseUntil实现,主要是通过循环遍历,判断对象是否等于stop,其目的是释放stop之前的所有的对象 - 2.首先通过获取
page的next释放对象(即page的最后一个对象),并对next进行递减,获取上一个对象 - 3.判断
是否是哨兵对象,如果不是则自动调用objc_release释放
kill源码实现
通过kill实现我们知道,主要是销毁当前页,将当前页赋值为父节点页,并将父节点页的child对象指针置为nil
总结
通过上面的分析,针对自动释放池的push和pop,总结如下
- 在自动释放池的
压栈(即push)操作中- 当没有pool,即只有空占位符(存储在tls中)时,则创建页,
压栈哨兵对象 - 在页中
压栈普通对象主要是通过next指针递增进行的 - 当
页满了时,需要设置页的child对象为新建页所以,综上所述,objc_autoreleasePush的整体底层的流程如下图所示
- 当没有pool,即只有空占位符(存储在tls中)时,则创建页,
- 在自动释放池的
出栈(即pop)操作中- 在页中
出栈普通对象主要是通过next指针递减进行的, - 当
页空了时,需要赋值页的parent对象为当前页 综上所述,objc_autoreleasePoolPop出栈的流程如下所示
- 在页中
Runloop
Runloop源码下载地址传送门
RunLoop和线程的关系
Runloop不支持创建,只能获取,目前有两种方式获取线程:
CFRunLoopGetMain源码
_CFRunLoopGet0源码
通过上面可以知道,
Runloop只有两种,一种是主线程的,一个是其它线程的。即Runloop和线程是一一对应的
RunLoop的创建
通过上面的_CFRunLoopGet0可以知道Runloop是通过__CFRunLoopCreate创建(系统创建,开发者自己试无法创建的)。我们查看下__CFRunLoopCreate源码:
我们发现
__CFRunLoopCreate主要是对runloop属性的赋值操作。我们看下1321行的CFRunLoopRef![]()
- 1.根据定义得知,其实
RunLoop也是一个对象。是__CFRunLoop结构体的指针类型 - 2.
一个RunLoop依赖于多个Mode,意味着一个RunLoop需要处理多个事务,即一个Mode对应多个Item,而一个item中,包含了timer、source、observer,可以用下图说明
Mode类型
其中mode在苹果文档中提及的有五个,而在iOS中公开暴露出来的只有 NSDefaultRunLoopMode和NSRunLoopCommonModes。 NSRunLoopCommonModes实际上是一个Mode的集合,默认包括 NSDefaultRunLoopMode和NSEventTrackingRunLoopMode。
NSDefaultRunLoopMode:默认的mode,正常情况下都是在这个model下运行(包括主线程)NSEventTrackingRunLoopMode(cocoa):追踪mode,使用这个mode去跟踪来自用户交互的事件(比如UITableView上下滑动流畅,为了不受其他mode影响。)。UITrackingRunLoopMode(iOS)NSModalPanelRunLoopMode:处理modal panels事件。NSConnectionReplyMode:处理NSConnection对象相关事件,系统内部使用,用户基本不会使用NSRunLoopCommonModes:这是一个伪模式,其为一组run loop mode的集合,将输入源加入此模式意味着在Common Modes中包含的所有模式下都可以处理。在Cocoa应用程序中,默认情况下Common Modes包含default modes,modal modes,event Tracking modes。可使用CFRunLoopAddCommonMode方法想Common Modes中添加自定义modes。
Source & Timer & Observer
Source表示可以唤醒RunLoop的一些事件,例如用户点击了屏幕,就会创建一个RunLoop,主要分为Source0和Source1Source0表示非系统事件,即用户自定义的事件Source1表示系统事件,主要负责底层的通讯,具备唤醒能力
Timer就是常用NSTimer定时器这一类Observer主要用于监听RunLoop的状态变化,并作出一定响应,主要有以下一些状态
验证
RunLoop和mode是一对多
上面我们说过RunLoop和mode是一对多的关系,下面我们通过运行代码来实操证明。
我们先通过lldb命令获取mainRunloop、currentRunloop的currentMode
runloop在运行时的mode只有一个
下面我们获取mainRunLoop所有的模型
从上面的打印结果可以验证
runloop和CFRunloopMode具有一对多的关系
mode和Item也是一对多
我们继续在断点处,通过bt查看堆栈信息,从这里看出timer的item类型如下所示(截取部分)
在RunLoop源码中查看Item类型,有以下几种:
- block应用:
__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__ - 调用timer:
__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ - 响应source0:
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ - 响应source1:
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ - GCD主队列:
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ - observer源:
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__我们下面以
Timer为例,一般初始化timer时,都会将timer通过addTimer:forMode:方法添加到Runloop中,于是在源码中查找addTimer的相关方法,即CFRunLoopAddTimer方法,其源码实现如下 - 1.其实现主要判断
是否是kCFRunLoopCommonModes,然后查找runloop的mode进行匹配处理 - 2.其中
kCFRunLoopCommonModes不是一种模式,是一种抽象的伪模式,比defaultMode更加灵活 - 3.通过
CFSetAddValue(rl->_commonModeItems, rlt);可以得知,runloop与mode是一对多的,同时可以得出mode与item也是一对多的
RunLoop执行
我们知道,RunLoop的执行依赖于run方法,从下面的堆栈信息中可以看出,其底层执行的是__CFRunLoopRun方法
进入__CFRunLoopRun源码:
通过__CFRunLoopRun源码可知,针对不同的对象,有不同的处理
- 如果有
observer,则调用__CFRunLoopDoObservers - 如果有
block,则调用__CFRunLoopDoBlocks - 如果有
timer,则调用__CFRunLoopDoTimers - 如果是
source0,则调用__CFRunLoopDoSources0 - 如果是
source1,则调用__CFRunLoopDoSource1
_ _CFRunLoopDoTimers
查看下__CFRunLoopDoTimers源码
主要是
通过for循环,对单个timer进行处理,下面进入__CFRunLoopDoTimer源码![]()
通过源码可知:主要逻辑就是
timer执行完毕后,会主动调用__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__函数,正好与timer堆栈调用中的一致
Timer总结
- 1.为
自定义的timer,设置Mode,并将其加入RunLoop中 - 2.在RunLoop的
run方法执行时,会调用__CFRunLoopDoTimers执行所有timer - 3.在
__CFRunLoopDoTimers方法中,会通过for循环执行单个timer的操作 - 4.在
__CFRunLoopDoTimer方法中,timer执行完毕后,会执行对应的timer回调函数 以上,是针对timer的执行分析,对于observer、block、source0、source1,其执行原理与timer是类似的,这里就不再重复说明以下是苹果官方文档针对RunLoop处理不同源的图示
RunLoop底层原理
从上述的堆栈信息中可以看出,run在底层的实现路径为CFRunLoopRun -> CFRunLoopRun -> __CFRunLoopRun
进入CFRunLoopRun源码,其中传入的参数1.0e10(科学计数)等于1* e^10,用于表示超时时间
进入CFRunLoopRunSpecific源码:
- 首先根据
modeName找到对应的mode,然后主要分为三种情况:- 如果是
entry,则通知observer,即将进入runloop - 如果是
exit,则通过observer,即将退出runloop - 如果是
其他中间状态,主要是通过runloop处理各种源上面说到会调用__CFRunLoopRun,上面讲了这里面会根据不同的事件源进行不同的处理,当RunLoop休眠时,可以通过相应的事件唤醒RunLoop。
- 如果是
总结
综上所述,RunLoop的执行流程
AutoreleasePool相关面试题
有关面试图
临时变量什么时候释放?
- 1.如果在
正常情况下,一般是超出其作用域就会立即释放 - 2.如果将临时变量加入了
自动释放池,会延迟释放,即在runloop休眠或者autoreleasepool作用域之后释放
AutoreleasePool原理
- 1.
自动释放池的本质是一个AutoreleasePoolPage结构体对象,是一个栈结构存储的页,每一个AutoreleasePoolPage都是以双向链表的形式连接 - 2.
自动释放池的压栈和出栈主要是通过结构体的构造函数和析构函数调用底层的objc_autoreleasePoolPudh和objc_autoreleasePoolPop,实际上是调用AutoreleasePoolPage的push和pop两个方法 - 3.每次
调用push操作其实就是创建一个新的AutoreleasePoolPage,而AutoreleasePoolPage的具体操作就是插入一个POOL_BOUNDARY,并返回插入POOL_BOUNDARY的内存地址。而push内部调用autoreleaseFast方法处理,主要有以下三种情况- 当
page存在,且不满时,调用add方法将对象添加至page的next指针处,并next递增 - 当
page存在,且已满时,调用autoreleaseFullPage初始化一个新的page,然后调用add方法将对象添加至page栈中 - 当
page不存在时,调用autoreleaseNoPage创建一个hotPage,然后调用add方法将对象添加至page栈中
- 当
- 4.当
执行pop操作时,会传入一个值,这个值就是push操作的返回值,即POOL_BOUNDARY的内存地址token。所以pop内部的实现就是根据token找到哨兵对象所处的page中,然后使用 objc_release释放token之前的对象,并把next指针到正确位置
AutoreleasePool能否嵌套使用?
- 1.可以嵌套使用,其目的是可以
控制应用程序的内存峰值,使其不要太高 - 2.可以嵌套的原因是因为
自动释放池是以栈为节点,通过双向链表的形式连接的,且是和线程一一对应的 - 3.自动释放池的
多层嵌套其实就是不停的pushs哨兵对象,在pop时,会先释放里面的,在释放外面的
哪些对象可以加入AutoreleasePool?alloc创建可以吗?
- 1.在
MRC下使用new、alloc、copy关键字生成的对象和retain了的对象需要手动释放,不会被添加到自动释放池中 - 2.在
MRC下设置为autorelease的对象不需要手动释放,会直接进入自动释放池 - 3.所有
autorelease的对象,在出了作用域之后,会被自动添加到最近创建的自动释放池中 - 4.在
ARC下只需要关注引用计数,因为创建都是在主线程进行的,系统会自动为主线程创建AutoreleasePool,所以创建会自动放入自动释放池
AutoreleasePool的释放时机是什么时候?
- 1.App启动后,苹果在主线程
RunLoop里注册了两个Observer,其回调都是_wrapRunLoopWithAutoreleasePoolHandler()。 - 2.第一个Observer监视的事件是
Entry(即将进入 Loop),其回调内会调用_objc_autoreleasePoolPush()创建自动释放池。其order是-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。 - 3.第二个Observer监视了两个事件:
BeforeWaiting(准备进入休眠) 时调用 _objc_autoreleasePoolPop()和_objc_autoreleasePoolPush()释放旧的池并创建新池;Exit(即 将退出Loop)时调用_objc_autoreleasePoolPop()来释放自动释放池。这个Observer的order是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。
thread和AutoreleasePool的关系
在官方文档中,找到如下说明
理解如下:
- 1.
每个线程,包括主线程在内都维护了自己的自动释放池堆栈结构 - 2.新的自动释放池在被创建时,会被添加到
栈顶;当自动释放池销毁时,会从栈中移除 - 3.对于
当前线程来说,会将自动释放的对象放入自动释放池的栈顶;在线程停止时,会自动释放掉与该线程关联的所有自动释放池总结:每个线程都有与之关联的自动释放池堆栈结构,新的pool在创建时会被压栈到栈顶,pool销毁时,会被出栈,对于当前线程来说,释放对象会被压栈到栈顶,线程停止时,会自动释放与之关联的自动释放池
RunLoop和AutoreleasePool的关系
在官方文档中,找到如下说明
理解如下:
- 1.主程序的
RunLoop在每次事件循环之前,会自动创建一个autoreleasePool - 2.并且会在
事件循环结束时,执行drain操作,释放其中的对象
RunLoop相关面试题
面试题一
当前有个子线程,子线程中有个timer。timer是否能够执行,并进行持续的打印?
【答案】:不可以,因为
子线程的runloop默认不启动, 需要runloop run启动,需要将上述代码改成下面这样:
RunLoop和线程的关系
- 1.
每个线程都有一个与之对应的RunLoop,所以RunLoop与线程是一一对应的,其绑定关系通过一个全局的DIctionary存储,线程为key,runloop为value。 - 2.
线程中的RunLoop主要是用来管理线程的,当线程的RunLoop开启后,会在执行完任务后进行休眠状态,当有事件触发唤醒时,又开始工作,即有活时干活,没活就休息 - 3.
主线程的RunLoop是默认开启的,在程序启动之后,会一直运行,不会退出 - 4.
其他线程的RunLoop默认是不开启的,如果需要,则手动开启
NSRunLoop和CFRunLoopRef区别
- 1.
NSRunLoop是基于CFRunLoopRef面向对象的API,是不安全的 - 2.
CFRunLoopRef是基于C语言,是线程安全的
Runloop的mode作用是什么?
mode主要是用于指定RunLoop中事件优先级的
面试题五
以+scheduledTimerWithTimeInterval:的方式触发的timer,在滑动页面上的列表时,timer会暂停回调,为什么?如何解决?
- 1.timer停止的原因是因为
滑动scrollView时,主线程的RunLoop会从NSDefaultRunLoopMode切换到UITrackingRunLoopMode,而timer是添加在NSDefaultRunLoopMode。所以timer不会执行 - 2.将
timer放入NSRunLoopCommonModes中执行
写到最后
写的内容比较多,由于本人能力有限,有些地方可能解释的有问题,请各位能够指出,同时对内存管理有关的疑问,欢迎大家留言,也希望大家点赞多多支持。希望大家能够相互交流、探索,一起进步!