写在前面
一个优秀的App
必然是对内存"精打细算"的,本文就来探索一下内存管理中的一些门道与RunLoop
的相关知识.
一、内存布局
①. 五大区
接下来我从内存中的低地址往高地址
依次介绍五大区:
- 代码段(.text)
- 存放着程序代码,直接加载到内存中
- 初始化区域(.data)
- 存放着初始化的全局变量、静态变量
- 内存地址:一般以
0x1
开头
- 未初始化区域(.bss)
-
bss
段存放着未初始化的全局变量、静态变量 - 内存地址:一般以
0x1
开头
-
- 堆区(heap)
- 堆区存放着通过
alloc
分配的对象、block copy
后的对象 - 堆区
速度比较慢
- 内存地址:一般以
0x6
开头
- 堆区存放着通过
- 栈区(stack)
- 栈区存储着
函数
、方法
以及局部变量
- 栈区
比较小
,但是速度比较快
- 内存地址:一般以
0x7
开头
- 栈区存储着
在这里提一句关于函数在内存中的分布:
函数指针
存在栈区
,函数实现
存在堆区
除了五大区之外,内存中还有保留字段和内核区
- 内核区:以4GB手机为例,系统将其中的3GB给了
五大区+保留区
,剩余的1GB给内核区使用,它主要是系统用来进行内核处理操作的区域 - 保留字段:保留一定的区域给保留字段,进行一些存储或预留给系统处理
nil
等
这里有个疑问,为什么五大区的最后内存地址是从0x00400000
开始的.其主要原因是0x00000000
表示nil
,不能直接用nil
表示一个段,所以单独给了一段内存用于处理nil
等情况.
以下的两张图,便于我们更好的理解内存分布.
平时在使用App过程中,栈区就会向下增长,堆区就会向上增长.
接下来看看堆区和栈区中的一些内容- 对于
alloc
创建的对象obj
,分别打印了obj
的对象地址 和obj
对象的指针地址
(可以参考前文的汇总图)-
obj
的对象地址
是以0x6
开头,说明是存放在堆区
-
obj
对象的指针地址
是以0x7
开头,说明是存放在栈区
-
那么在堆区和栈区访问对象的顺序是怎样的呢?
- 堆区访问对象的顺序是先拿到栈区的指针,再拿到指针指向的对象,才能获取到对象的
isa
、属性方法等
- 栈区访问对象的顺序是
直接通过寄存器访问到对象的内存空间
,因此访问速度快
②. 内存布局相关面试题
面试题1:全局变量和局部变量在内存中是否有区别?如果有,是什么区别?
- 有区别
- 全局变量保存在内存的全局存储区(即bss+data段),占用静态的存储单元
- 局部变量保存在栈区,只有在所在函数被调用时才动态的为变量分配存储单元
- 两者访问的权限不一样
面试题2:Block中可以修改全局变量,全局静态变量,局部静态变量,局部变量吗?
- 可以修改全局变量,全局静态变量,因为全局变量 和 静态全局变量是全局的,作用域很广,
block
可以访问到 - 可以修改局部静态变量,不可以修改局部变量
- 局部静态变量(
static
修饰的) 和 局部变量,被block
从外面捕获,成为__main_block_impl_0
这个结构体的成员变量 - 局部变量是
以值
方式传递到block
的构造函数中的,只会捕获block
中会用到的变量,由于只捕获了变量的值,并非内存地址,所以在block
内部不能改变局部变量的值 - 局部静态变量是以
指针
形式,被block
捕获的,由于捕获的是指针,所以可以修改局部静态变量的值
- 局部静态变量(
-
ARC
环境下,一旦使用__block
修饰并在block
中修改,就会触发copy
操作,block
就会从栈区copy
到堆区,此时的block
是堆区block
-
ARC
模式下,Block
中引用id类型
的数据,无论有没有__block
修饰,都会retain
,对于基础数据类型,没有__block
修饰就无法修改变量值;如果有__block
修饰,也是在底层修改__Block_byref_a_0
结构体,将其内部的forwarding
指针指向copy
后的地址,来达到值的修改
面试题3:关于全局静态变量的误区
- 全局静态变量是可变的
- 全局静态变量的值
只针对文件而言
,不同文件的全局静态变量的内存地址是不一样的,也就是无论别的文件怎么修改,本文件使用时都拿原有值/本文件修改后的值
二、内存管理方案
①. taggedPointer
①.1 taggedPointer初探
分别调用下面两种方法,哪个会崩溃?为什么?
#import "ViewController.h"
@interface ViewController ()
@property (nonatomic, strong) dispatch_queue_t queue;
@property (nonatomic, strong) NSString *nameStr;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.queue = dispatch_queue_create("com.tcj.cn", DISPATCH_QUEUE_CONCURRENT);
[self taggedPointerDemo];
[self testNormal];
}
- (void)taggedPointerDemo {
for (int i = 0; i<10000; i++) {
dispatch_async(self.queue, ^{
self.nameStr = [NSString stringWithFormat:@"tcj"]; // alloc 堆 iOS优化 - taggedpointer
NSLog(@"%@",self.nameStr);
});
}
}
- (void)testNormal {
for (int i = 0; i<10000; i++) {
dispatch_async(self.queue, ^{
self.nameStr = [NSString stringWithFormat:@"又一黑马诞生12345"];
NSLog(@"%@",self.nameStr);
});
}
}
@end
经过运行测试之后,会发现testNormal
会崩溃,而taggedPointerDemo
方法正常运行
首先来分析下为什么会崩溃的原因?其实是多线程
和setter、getter
操作造成的
- 调用
setter
方法会objc_retain(newValue)
+objc_release(oldValue)
- 但是加上多线程就不一样了——在某个时刻线程1对旧值进行
relese
(没有relese完毕
),同时线程2也对旧值进行relese
操作,即同一时刻对同一片内存空间释放多次,会造成野指针问题
(访问坏的地址)
但是为什么testNormal
会崩溃,而taggedPointerDemo
方法正常运行?
-
testNormal
中的对象为__NSCFString
类型,存储在堆上
-
taggedPointerDemo
中的对象为NSTaggedPointerString
类型,存储在常量区
.因为nameStr
在alloc``分配时在堆区,由于较小
,所以经过Xcode
中iOS的优化
,成了NSTaggedPointerString
类型,存储在常量区
其实之前在objc
源码的方法中有看到过类似的身影——objc_retain
和objc_release
的对象如果是isTaggedPointer
类型就直接返回(不操作)
小对象的地址分析
以NSString
为例,对于NSString
来说
- 一般的
NSString
对象指针,都是string值
+指针地址
,两者是分开的 - 对于
Tagged Pointer
指针,其 指针 + 值,都能在小对象中体现.所以Tagged Pointer
既包含指针,也包含值
在之前的文章讲类的加载时,其中的_read_images
源码有一个方法对小对象进行了处理,即initializeTaggedPointerObfuscator
方法,我们下面介绍
①.2 taggedPointer深入
在推出iPhone 5s(iPhone首个采用64位架构)的时候,为了节省内存和提高执行效率,同时也提出了taggedPointer
底层也做了对objc_debug_taggedpointer_obfuscator
进行异或的操作(两次异或同一个数相当于编码解码 -- iOS10.14之后做的混淆操作)
我们可以在objc
源码(818.2版本)中通过objc_debug_taggedpointer_obfuscator
查找taggedPointer
的编码和解码,来查看底层是如何混淆处理的
通过实现,我们可以得知,在编码和解码部分,经过了两层异或,其目的是得到小对象自己,例如以 1010 0001
为例,假设mask为 0101 1000
1010 0001
^0101 1000 mask(编码)
1111 1001
^0101 1000 mask(解码)
1010 0001
所以在外界,为了获取小对象的真实地址,我们也可以通过类似的方法对taggedPointer
进行解码.我们可以将解码的源码拷贝到外面,将NSString
混淆部分进行解码,如下所示
观察解码后的小对象地址,其中的 62
表示 b
的 ASCII
码,再以 NSNumber
为例,同样可以看出,1
就是我们实际的值
到这里,我们验证了小对象指针地址中确实存储了值,那么小对象地址高位其中的0xa
、0xb
又是什么含义呢?
//NSString
0xa000000000000621
//NSNumber
0xb000000000000012
0xb000000000000025
需要去源码中查看_objc_isTaggedPointer
源码,主要是通过保留最高位的值(即64位的值),判断是否等于_OBJC_TAG_MASK
(即2 ^ 63), 来判断是否是小对象
所以0xa
、0xb
主要是用于判断是否是小对象taggedpointer
,即判断条件,判断第64位
上是否为1
(taggedpointer
指针地址即表示指针地址,也表示值)
-
0xa
转换成二进制为 1 010
(64位为1
,63~61后三位
表示tagType类型
-2
),表示NSString
类型 -
0xb
转换为二进制为1 011
(64位为1
,63~61后三位
表示tagType类型
-3
),表示NSNumber
类型,这里需要注意一点,如果NSNumber
的值是-1
,其地址中的值是用补码
表示的
这里可以通过_objc_makeTaggedPointer
方法的参数tag
类型objc_tag_index_t
进入其枚举,其中 2
表示NSString
,3
表示NSNumber
同理,我们可以定义一个NSDate
对象,来验证其tagType
是否为 6
.通过打印结果,其地址高位是0xe
,转换为二进制为1 110
,排除64位的1
,剩余的3位
正好转换为十进制是6
,符合上面的枚举值
我们在来看看NSString的内存管理
我们可以通过NSString
初始化的两种方式,来测试NSString
的内存管理
- 通过
WithString
+@""
方式初始化 - 通过
WithFormat
方式初始化
从上面可以总结出,NSString
的内存管理主要分为3种
-
__NSCFConstantString
:字符串常量
,是一种编译时
常量,retainCount值很大
,对其操作,不会引起引用计数变化
,存储在字符串常量区
-
__NSCFString
:是在运行时
创建的NSString子类
,创建后引用计数会加1
,存储在堆上
-
NSTaggedPointerString
:标签指针,是苹果在64位
环境下对NSString
、NSNumber
等对象做的优化
.对于NSString
对象来说- 当
字符串是由数字、英文字母组合且长度小于等于9
时,会自动成为NSTaggedPointerString
类型,存储在常量区
- 当有
中文或者其他特殊符号
时,会直接成为__NSCFString
类型,存储在堆区
- 当
①.3 taggedPointer总结
-
Tagged Pointer
小对象类型(用于存储NSNumber
、NSDate
、小NSString
),小对象指针不再是简单的地址,而是地址 + 值
,即真正的值
,所以,实际上它不再是一个对象
了,它只是一个披着对象皮的普通变量
而已.所以可以直接进行读取.优点是占用空间小,节省内存
-
Tagged Pointer
小对象,不会进入 retain 和 release
,而是直接返回了,意味着不需要ARC进行管理
,所以可以直接被系统自主的释放和回收
-
Tagged Pointer
的内存并不存储在堆
中,而是在常量区
中,也不需要malloc
和free
,所以可以直接读取
,相比存储在堆区的数据读取,效率
上快了3倍
左右.创建的效率
相比堆区快了近100倍
左右 -
taggedPointer
的内存管理方案,比常规的内存管理,要快很多
-
Tagged Pointer
的64位
地址中,前4位代表类型
,后4位主要适用于系统做一些处理
,中间56位用于存储值
- 优化内存建议:对于
NSString
来说,当字符串较小
时,建议直接通过@""
初始化,因为存储在常量区
,可以直接进行读取
.会比WithFormat
初始化方式更加快速
②. nonpointer_isa
nonpointer_isa
在前面章节已经有提到过了,这是苹果优化内存的一种方案: isa
是个8字节(64位)
的指针,仅用来isa指向
比较浪费,所以isa
中就掺杂了一些其他数据来节省内存
③. SideTable
当引用计数存储到一定值时,并不会再存储到Nonpointer_isa
的位域的extra_rc
中,而是会存储到 SideTables
散列表中
③.1 散列表为什么在内存中有多张?最多能够多少张?
- 如果散列表只有一张表,意味着全局所有的对象都会存储在一张表中,操作任意一个对象,都会进行开锁解锁(锁是锁整个表的读写).当开锁时,由于所有数据都在一张表,这意味着数据不安全
- 如果每个对象都开一个表,会耗费性能,所以也不能有无数个表
-
散列表的类型是SideTable,有如下定义
- 通过查看
sidetable_unlock
方法定位SideTables
,其内部是通过SideTablesMap
的get
方法获取. 而SideTablesMap
是通过StripedMap<SideTable>
定义的
从而进入StripedMap
的定义,从这里可以看出,同一时间,真机中散列表最多只能有8张
③.2 为什么在用散列表,而不用数组、链表?
-
数组
:特点在于查询方便(即通过下标访问
),增删比较麻烦
,所以数组的特性是读取快,存储不方便
-
链表
:特点在于增删方便,查询慢
(需要从头节点开始遍历查询
),所以链表的特性是存储快,读取慢
-
散列表的本质
就是一张哈希表,哈希表集合了数组和链表的长处
,增删改查都比较方便
,例如拉链哈希表(在之前锁的文章中,讲过的tls
的存储结构就是拉链形式
的),是最常用的,如下所示
三、ARC&MRC
面试中常常会问到ARC
和MRC
,其实这两者在内存管理中才是核心所在
① MRC(手动内存管理)
- 在
MRC
时代,系统是通过对对象的引用计数来判断是否销毁,有以下规则- 对象被
创建时
引用计数都为1
- 当对象
被其他指针引用
时,需要手动调用[objc retain]
,使对象的引用计数+1
- 当指针变量不再使用对象时,需要手动调用
[objc release]
来释放对象
,使对象的引用计数-1
- 当一个对象的
引用计数为0
时,系统就会销毁
这个对象
- 对象被
- 所以,在
MRC
模式下,必须遵守:谁创建
,谁释放
,谁引用
,谁管理
② ARC(自动内存管理)
-
ARC
模式是在WWDC2011
和iOS5
引入的自动管理机制
,即自动引用计数.是编译器的一种特性.其规则与MRC一致,区别在于-
ARC
中禁止手动
调用retain/release/retainCount/dealloc
-
编译器
会在适当的位置插入release
和autorelease
-
ARC
新加了weak
、strong
关键字
-
-
ARC
是LLVM
和Runtime
配合的结果
③ alloc
之前已经对alloc流程有了一个详细的介绍
④ retain
retain
会在底层调用 objc_retain
-
objc_retain
先判断是否为isTaggedPointer
,是就直接返回不需要处理,不是在调用obj->retain()
-
objc_object::retain
通过fastpath
大概率调用rootRetain()
,小概率通过消息发送调用对外提供的SEL_retain
-
rootRetain
调用rootRetain(false, false)
-
rootRetain
内部实现其实是个do-while
循环:- 先判断是否为
nonpointer_isa
(小概率事件)不是的话,则直接操作SideTables
散列表中的引用计数表,此时的散列表并不是只有一张,而是有很多张
- 找到对应的散列表进行
+=SIDE_TABLE_RC_ONE
,其中SIDE_TABLE_RC_ONE
是左移两位找到引用计数表
- 找到对应的散列表进行
- 判断是否正在释放,如果正在释放,则执行
dealloc
流程 - 调用
addc
函数执行extra_rc+1
,即引用计数+1
操作,并给一个引用计数的状态标识carry
,用于表示extra_rc是否满了
- 对
isa
中的第45位(RC_ONE在arm64中为45)extra_rc
进行操作处理
- 对
- 如果
carray
的状态表示extra_rc
的引用计数满了,此时需要操作散列表,即将满状态的一半拿出来存到extra_rc
,另一半存在 散列表的rc_half
.这么做的原因是因为如果都存储在散列表,每次对散列表操作都需要开解锁,操作耗时,消耗性能大,这么对半分操作的目的在于提高性能- 这里为什么优先考虑使用
isa
进行引用计数存储是因为引用计数存储在isa的bits中
- 这里为什么优先考虑使用
- 先判断是否为
retain 总结:
-
retain
在底层首先会判断是否是Nonpointer isa
,如果不是,则直接操作散列表 进行+1操作
- 如果
是Nonpointer isa
,还需要判断是否正在释放
,如果正在释放,则执行dealloc流程
,释放弱引用表和引用计数表,最后free
释放对象内存 - 如果
不是正在释放
,则对Nonpointer isa进行常规的引用计数+1
.这里需要注意一点的是,extra_rc在真机上只有8位用于存储引用计数的值
,当存储满了
时,需要借助散列表用于存储
.需要将满了的extra_rc对半分
,一半(即2^7)存储在散列表中
.另一半还是存储在extra_rc中
,用于常规的引用计数的+1或者-1操作
,然后再返回
⑤ release
release
与retain
相似,会在底层调用objc_release
-
objc_release
先判断是否为isTaggedPointer
,是就直接返回不需要处理,不是在调用obj->release()
-
objc_object::release
通过fastpath
大概率调用rootRelease()
,小概率通过消息发送调用对外提供的SEL_release
-
rootRelease
调用rootRelease(true, false)
-
rootRelease
内部实现也有个do-while
循环- 先判断是否为
nonpointer_isa
(小概率事件)不是的
话则直接对散列表中的引用计数进行-1操作
- 如果是
Nonpointer isa
,则对extra_rc中的引用计数值进行-1操作
,并存储此时的extra_rc状态到carry中
- 如果此时的状态
carray为0
,则走到underflow
流程- 判断
散列表中是否存储了一半的引用计数
- 如果
是
,则从散列表中取出存储的一半引用计数
,进行-1操作
,然后存储到extra_rc中
- 如果此时
extra_rc没有值
,散列表中也是空的
,则直接进行析构
,即dealloc
操作,属于自动触发
- 判断
- 先判断是否为
⑥ retainCount
前面说了这么多引用计数,那么我们来看看retainCount
和引用计数有什么关系呢?来看一个问题:
alloc创建的对象的引用计数为多少?
上述代码打印输出1
,然而在alloc
流程中并没有看到任何与retainCount
相关的内容,这又是怎么一回事呢?接下来就来看看retainCount
的底层实现
- 进入
retainCount -> _objc_rootRetainCount -> rootRetainCount
源码,其实现如下
在这里我们可以通过源码断点调试,来查看此时的extra_rc
的值,结果如下:
当来到953行断点时,此时的extra_rc为0
,而过到954行代码,我们在来看extra_rc
的值为多少.
此时的值却为1
了.
isa_t bits = __c11_atomic_load((_Atomic uintptr_t *)&isa.bits, __ATOMIC_RELAXED)
以上代码将bits
里面的extra_rc进行了+1
操作.
答案:alloc
创建的对象实际的引用计数为0
,其引用计数打印结果为1
,是因为在底层rootRetainCount
方法中,引用计数默认+1
了,但是这里只有对引用计数的读取操作,是没有写入操作的,简单来说就是:为了防止alloc创建的对象被释放(引用计数为0会被释放),所以在编译阶段,程序底层默认进行了+1操作.(新版objc源码)
-
alloc
创建的对象没有retain和release
-
alloc
创建对象的引用计数为0
,会在编译时期
,程序默认加1
,所以读取引用计数时为1
⑦ autorealese
将在自动释放池章节讲到.
⑧ dealloc
在retain
和release
的底层实现中,都提及了dealloc
析构函数,下面来分析dealloc
的底层的实现
-
dealloc
在底层会调用_objc_rootDealloc
-
_objc_rootDealloc
调用rootDealloc
- 在
rootDealloc
方法中- 判断是否为
isTaggedPointer
,是的话直接返回,不是的话继续往下走 - 判断
isa标识位
中是否有弱引用
、关联对象
、c++析构函数
、额外的散列表
,有的话调用object_dispose
,否则直接free
- 判断是否为
-
object_dispose
中- 先判空处理
- 接着调用
objc_destructInstance
(核心部分) - 最后再
free释放对象
-
objc_destructInstance
- 判断
是否有c++析构函数和关联对象
,有的话分别调用object_cxxDestruct
、_object_remove_assocations
进行处理 - 然后再调用
clearDeallocating
- 判断
-
clearDeallocating
中- 判断是否是
nonpointer
,是的话调用sidetable_clearDeallocating
清空散列表 - 判断
是否
有弱引用和额外的引用计数表has_sidetable_rc
,是的话调用clearDeallocating_slow进行弱引用表和引用计数表的处理
- 判断是否是
所以综上所述,dealloc
的流程可以总结为:
- 1:根据当前对象的状态是否直接调⽤free()释放
- 2:是否存在C++的析构函数、移除这个对象的关联属性
- 3:将指向该对象的弱引⽤指针置为nil
- 4:从弱引⽤表中擦除对该对象的引⽤计数
最后附上一张dealloc
流程图
因此到目前为止,从最开始的alloc -> retain -> release -> dealloc
就全部串联起来了.
四、弱引用
①. weak原理
笔者在之前的[iOS之武功秘籍⑩: OC底层题目分析]中已经讲过了.
②. NSTimer中的循环引用
众所周知使用NSTimer
容易出现循环引用,那么我们就来分析并解决一下
假设此时有A、B两个
界面,在B界面
中有如下定时器代码.
代码运行起来所发生的问题就是 B界面
pop
到 A界面
时不会触发 B 界面
的 dealloc
函数.主要原因是B界面没有释放
,即没有执行dealloc
方法,导致timer
也无法停止和释放
前面我们已经看到了release
在引用计数为0
时会调用dealloc
消息发送,此时没有触发dealloc
函数必然是出现了循环引用
,那么循环引用出现在哪个环节?其实是NSTimer
的API是被强持有的
,直到Timer invalidated.
即此时timer
持有self
,self
也持有timer
,构成了循环引用
那么能不能像block
一样使用弱引用来解决循环引用呢?答案是不能的!
此时他们之间的持有关系如下:
之前在Block
篇章说的是使用弱引用__weak typeof(self) weakSelf = self
可以解决循环引用; 不处理引用计数,使用弱引用表管理,怎么在这里就不好使了呢?
到这我又有两个问题?
-
weakSelf
会对引用计数进行+1操作
吗? -
weakSelf
和self
的指针地址相同吗,是指向同一片内存吗?
带着疑问,我们在weakSelf
前后打印self
的引用计数
运行后发现前后self
的引用计数都是8
.也就是 weakSelf没有对内存进行+1操作
继续打印weakSelf
和 self
对象,以及他们的指针地址:
从打印结果可以看出 weakSelf
和 self
指向的都是 TCJTimerViewController对象
,但是weakSelf
和self
的指针并不相同
——两者并不是一个东东,只是指向同一个TCJTimerViewController对象
.
通过block
底层原理的方法 _Block_object_assign
可知,block
捕获的是 对象的指针地址
即 block
持有的是weakSelf
的指针地址;timer
持有的是weakSelf的指针指向的对象
,这里间接持有了self
,所以仍然存在循环引用导致释放不掉.
③. 解决NSTimer的循环引用
解决思路 : 我们需要打破这一层强持有 - self
③.1 思路一:pop时在其他方法中销毁timer
- 既然
dealloc
不能来,就在dealloc
函数调用前解决掉这层强引用 - 可以在
viewWillDisappear
、viewDidDisappear
中处理NSTimer
,但这样处理效果并不好,因为跳转到下一页定时器也会停止工作,与业务不符 - 使用
didMoveToParentViewController
可以很好地解决这层强引用.这个方法是用于当一个视图控制器中添加或者移除viewController后
,必须调用的方法.目的是为了告诉iOS
,已经完成添加/删除子控制器的操作. - 在
B界面
中重写didMoveToParentViewController
方法
③.2 思路二:中介者模式,即不使用self,依赖于其他对象
- 使用其他全局变量,此时
timer
持有全局变量,self
也持有全局变量,只要页面pop
,self
因为没有被持有就能正常走dealloc
,在dealloc
中再去处理timer
- 此时的持有链分别是
runloop->timer->target->timer
、self->target
、self->timer
#ifdef DEBUG
#define CJNSLog(format, ...) printf("%s\n", [[NSString stringWithFormat:format, ## __VA_ARGS__] UTF8String]);
#else
#define CJNSLog(format, ...);
#endif
#import "TCJTimerViewController.h"
#import <objc/runtime.h>
static int num = 0;
@interface TCJTimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) id target;
@end
@implementation TCJTimerViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.target = [[NSObject alloc] init];
class_addMethod([NSObject class], @selector(fireHome), (IMP)fireHomeObjc, "v@:");
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self.target selector:@selector(fireHome) userInfo:nil repeats:YES];
}
void fireHomeObjc(id obj){
CJNSLog(@"%s -- %@",__func__,obj);
}
- (void)fireHome{
num++;
CJNSLog(@"hello word - %d",num);
}
- (void)dealloc{
[self.timer invalidate];
self.timer = nil;
CJNSLog(@"%s",__func__);
}
③.3 思路三:自定义封装timer(使用包装者)
- 类似于方案二,但是使用更便捷
- 如果传入的响应者
target
能响应传入的响应事件selector
,就使用runtime
动态添加方法并开启计时器 -
fireWapper
中如果有wrapper.target
,就让wrapper.target
(外界响应者)调用wrapper.aSelector
(外界响应事件) -
fireWapper
中没有了wrapper.target
,意味着响应者释放了(无法响应了),此时定时器也就可以休息了(停止并释放) - 持有链分别是
runloop->timer->TCJTimerWrapper
、vc->TCJTimerWrapper-->vc
//*********** .h文件 ***********
@interface TCJTimerWapper : NSObject
- (instancetype)cj_initWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
- (void)cj_invalidate;
@end
//*********** .m文件 ***********
#import "TCJTimerWapper.h"
#import <objc/message.h>
@interface TCJTimerWapper ()
@property(nonatomic, weak) id target;
@property(nonatomic, assign) SEL aSelector;
@property(nonatomic, strong) NSTimer *timer;
@end
@implementation TCJTimerWapper
- (instancetype)cj_initWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo{
if (self == [super init]) {
//传入vc
self.target = aTarget;
//传入的定时器方法
self.aSelector = aSelector;
if ([self.target respondsToSelector:self.aSelector]) {
Method method = class_getInstanceMethod([self.target class], aSelector);
const char *type = method_getTypeEncoding(method);
//给timerWapper添加方法
class_addMethod([self class], aSelector, (IMP)fireHomeWapper, type);
//启动一个timer,target是self,即监听自己
self.timer = [NSTimer scheduledTimerWithTimeInterval:ti target:self selector:aSelector userInfo:userInfo repeats:yesOrNo];
}
}
return self;
}
//一直跑runloop
void fireHomeWapper(TCJTimerWapper *wapper){
//判断target是否存在
if (wapper.target) {
//如果存在则需要让vc知道,即向传入的target发送selector消息,并将此时的timer参数也一并传入,所以vc就可以得知`fireHome`方法,就这事这种方式定时器方法能够执行的原因
//objc_msgSend发送消息,执行定时器方法
void (*cj_msgSend)(void *,SEL, id) = (void *)objc_msgSend;
cj_msgSend((__bridge void *)(wapper.target), wapper.aSelector,wapper.timer);
}else{
//如果target不存在,已经释放了,则释放当前的timerWrapper
[wapper.timer invalidate];
wapper.timer = nil;
}
}
//在vc的dealloc方法中调用,通过vc释放,从而让timer释放
- (void)cj_invalidate{
[self.timer invalidate];
self.timer = nil;
}
- (void)dealloc
{
NSLog(@"%s",__func__);
}
@end
#ifdef DEBUG
#define CJNSLog(format, ...) printf("%s\n", [[NSString stringWithFormat:format, ## __VA_ARGS__] UTF8String]);
#else
#define CJNSLog(format, ...);
#endif
#import "TCJTimerViewController.h"
#import "TCJTimerWapper.h"
static int num = 0;
@interface TCJTimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) TCJTimerWapper *timerWapper;
@end
@implementation TCJTimerViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.timerWapper = [[TCJTimerWapper alloc] cj_initWithTimeInterval:1 target:self selector:@selector(fireHome) userInfo:nil repeats:YES];
}
- (void)fireHome{
num++;
CJNSLog(@"hello word - %d",num);
}
- (void)dealloc{
[self.timerWapper cj_invalidate];
CJNSLog(@"%s",__func__);
}
这种方式看起来比较繁琐,步骤很多,而且针对timerWapper
,需要不断的添加method
,需要进行一系列的处理.
③.4 思路四:利用NSProxy虚基类的子类——NSProxy有着NSObject同等的地位,多用于消息转发
- 使用
NSProxy
打破NSTimer
的对vc
的强持有,但是强持有依然存在,需要手动关闭定时器 - 持有链分别是
runloop->timer->TCJProxy->timer
、vc->TCJProxy-->vc
//************TCJProxy.h文件************
@interface TCJProxy : NSProxy
+ (instancetype)proxyWithTransformObject:(id)object;
@end
//************TCJProxy.m文件************
@interface TCJProxy()
@property (nonatomic, weak) id object;
@end
@implementation TCJProxy
+ (instancetype)proxyWithTransformObject:(id)object{
TCJProxy *proxy = [TCJProxy alloc];
proxy.object = object;
return proxy;
}
-(id)forwardingTargetForSelector:(SEL)aSelector {
return self.object;
}
//************TCJTimerViewController.m文件************
#ifdef DEBUG
#define CJNSLog(format, ...) printf("%s\n", [[NSString stringWithFormat:format, ## __VA_ARGS__] UTF8String]);
#else
#define CJNSLog(format, ...);
#endif
#import "TCJTimerViewController.h"
#import "TCJProxy.h"
static int num = 0;
@interface TCJTimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) TCJProxy *proxy;
@end
@implementation TCJTimerViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.proxy = [TCJProxy proxyWithTransformObject:self];
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self.proxy selector:@selector(fireHome) userInfo:nil repeats:YES];
}
- (void)fireHome{
num++;
CJNSLog(@"hello word - %d",num);
}
- (void)dealloc{
[self.timer invalidate];
self.timer = nil;
CJNSLog(@"%s",__func__);
}
思路一较为简便,思路二合理使用中介者但是很拉胯,思路三适合装逼,思路四更适合大型项目(定时器用的较多) 详细代码
五、AutoReleasePool 自动释放池
自动释放池
是OC
中的一种内存自动回收机制
,在MRC
中可以用AutoReleasePool来延迟内存的释放
,在ARC
中可以用AutoReleasePool将对象添加到最近的自动释放池
,不会立即释放
,会等到runloop休眠
或者超出autoreleasepool作用域{}
之后才会被释放
.其机制可以通过下图来表示
- 从程序
启动到加载完成
,主线程
对应的runloop
会处于休眠状态
,等待用户交互
来唤醒runloop
- 用户的
每一次交互
都会启动一次runloop
,用于处理
用户的所有点击
、触摸事件
等 -
runloop
在监听
到交互事件
后,就会创建自动释放池
,并将所有延迟释放
的对象添加到自动释放池中
- 在一次
完整的runloop结束之前
,会向自动释放池
中的所有对象发送release消息
,然后销毁自动释放池
① Clang分析 autoreleasepool结构
通过clang
命令对空白的main.m
文件输出一份main.cpp
文件来查看@autoreleasepool
的底层结构
clang
命令为:xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp
-
转成
C++
代码如下
-
通过上图我们知道
@autoreleasepool
被转化成__AtAutoreleasePool __autoreleasepool
,这是个结构体
.__AtAutoreleasePool
结构体定义如下:
通过上图我们可以知道以下几点:
-
__AtAutoreleasePool
是一个结构体
,有构造函数 + 析构函数
,结构体定义的对象
在作用域结束后
,会自动调用析构函数
- 其中
{}
是作用域
,优点是结构清晰
,可读性强
,可以及时创建销毁
关于涉及的构造函数和析构函数的调用时机,可以通过下面一个案例来验证
从运行结果可以得出,在TCJTest创建对象时
,会自动调用构造函数
,在出了{}作用域后
,会自动调用析构函数
.
② 汇编分析 autoreleasepool结构
在main
代码部分加断点,运行程序,并开启汇编调试:
通过调试结果发现,和我们clang分析的结果是一样的.
③ objc源码分析 autoreleasepool
在objc源码
中有一段对AutoreleasePool
的注释.
从中可以得出几点:
- 1.
自动释放池
是一个关于指针的栈结构
- 2.其中的
指针
是指向释放的对象
或者pool_boundary哨兵
(现在经常被称为边界
) - 3.
自动释放池
是一个页的结构
(虚拟内存中提及过),而且这个页是一个双向链表
(表示有父节点
和子节点
,在类中提及过,即类的继承链) - 4.
自动释放池
和线程是有关系
通过上面对自动释放池
的说明,我们知道我们研究的几个方向:
- 1.
自动释放池什么时候创建?
- 2.
对象是如何加入自动释放池的?
- 3.
哪些对象才会加入自动释放池?
带着这些问题,我们出发来探索自动释放池的底层原理
③.1 AutoreleasePoolPage分析
从最初的clang
或者汇编分析
我们了解了自动释放池其底层调用的
是objc_autoreleasePoolPush
和objc_autoreleasePoolPop
这两个方法,其源码实现如下[图片上传失败...(image-d024be-1615298790653)]
从源码中我们可以发现,都是调用AutoreleasePoolPage
的push
和pop
实现,以下是其定义,从定义中可以看出,自动释放池是一个页,同时也是一个对象,并且AutoreleasePoolPage
是继承于AutoreleasePoolPageData
的.
从上面可以做出以下判断:
- 1.
自动释放池
是一个页
,同时也是一个对象
,这个页的大小是4096字节
- 2.从其定义中发现,
AutoreleasePoolPage
是继承自AutoreleasePoolPageData
,且该类的属性也是来自父类,以下是AutoreleasePoolPageData
的定义
可以发现:
- 其中有
AutoreleasePoolPage
对象,所以有以下一个关系链AutoreleasePoolPage -> AutoreleasePoolPageData -> AutoreleasePoolPage
,从这里可以说明自动释放池除了是一个页
,还是一个双向链表结构
-
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的源码实现:有以下逻辑:
- 首先进行判断
是否
存在pool
- 如果没有,则通过
autoreleaseNewPage
方法创建 - 如果有,则通过
autoreleaseFast
压栈哨兵对象
autoreleaseNewPage创建新页
先来看下autoreleaseNewPage
创建新页的实现过程
通过上面的代码实现(autoreleaseFullPage
后面会重点分析),我们可得到以下结论
- 1.获取当前操作页,
- 2.如果当前操作页存在,则通过
autoreleaseFullPage
方法进行压栈对象 - 3.如果当前操作页不存在,则通过
autoreleaseNoPage
方法创建页- 在
autoreleaseNoPage
方法中可知当前线程的自动释放池是通过AutoreleasePoolPage
创建 -
AutoreleasePoolPage
的构造方法
是通过实现父类AutoreleasePoolPageData的初始化方法实现的
.
- 在
AutoreleasePoolPage构造方法
上面说了当前线程的自动释放池
是通过AutoreleasePoolPage创建
,看下AutoreleasePoolPage
构造方法:
其中AutoreleasePoolPageData
方法传入的参数含义为:
-
begin()
表示压栈的位置
(即下一个要释放对象的压栈地址
).可以通过源码调试begin
,发现其具体实现等于页首地址+56
,其中的56
就是结构体AutoreleasePoolPageData的内存大小
.- 由于在
ARC
模式下,是无法手动调用autorelease
,所以将Demo
切换至MRC
模式(Build Settings -> Objectice-C Automatic Reference Counting设置为NO
)
- 由于在
分析:AutoreleasePoolPageData
中的指针和对象
都占8字节
,uint
占4字节
,只有magic_t
未知(因为不是个指针,所以需要看具体类型);magic_t是个指针
,由于静态变量的存储区域在全局段,所以magic_t
占用4*4=16
字节,即AutoreleasePoolPageData
结构体的内存大小为56字节
.
-
objc_thread_self()
是表示当前线程
,而当前线程是通过tls获取
newParent
表示父节点后续两个参数是
通过父节点的深度
、最大入栈个数
计算的depth
以及hiwat
查看自动释放池内存结构
接着我们使用_objc_autoreleasePoolPrint
函数来打印一下自动释放池的相关信息(记得切换为MRC
模式调试,这里前面我们已经切换了)
通过运行结果如下,我们发现release
是6个,但是我们压栈的对象其实只有5个
,其中的POOL表示哨兵对象
,即边界
,其目的是为了防止越界
,我们再看下打印地址,发现页的首地址
(PAGE)和哨兵对象
(POOL)相差0x38
,转成10进制
正好是56
.也就是AutoreleasePoolPage自己本身的内存大小
.
那么是否可以无限往AutoreleasePool
中添加对象呢?答案是不能!
将循环次数i
的上限改为505
,其内存结构如下,发现第一页满了
,存储了504个
要释放的对象
,第二页只存储了一个
在将循环次数i
据改为505+506,来验证第二页是否也是存储504个对象?
通过运行发现,第一页存储504
,第二页存储505
,第三页存储2个
.
通过上述测试,我们可以得出以下结论:
- 第一页可以存放
504个对象
,且只有第一页有哨兵对象
,当一页压栈满了,就会开辟新的一页 - 第二页开始,
最多可以存放505个对象
- 一页的大小等于
505 * 8 = 4040
这个结论我们之前讲AutoreleasePoolPage
中的SIZE
的时候就说了,一页的大小是4096字节
,而在其构造函数中对象的压栈位置
,是从首地址+56字节开始
的,所以可以一页中实际可以存储4096-56 = 4040字节
,转换成对象是 4040 / 8 = 505
个,即一页最多可以存储505个对象
,其中第一页有哨兵对象
(由于自动释放池在初始化时会POOL_BOUNDARY哨兵对象push到栈顶,所以第一页只能存放504个对象,接下来每一页都能存放505个对象)只能存储504个.其结构图示如下
通过上面的结论,我有一个疑问:哨兵对象在一个自动释放池有几个?
- 在
一个自动释放池
中只有一个哨兵对象
,且哨兵在第一页
- 第一页最多可以存
504
个对象,第二页开始最多存505
个
③.2 哨兵对象 -- POOL_BOUNDARY
哨兵对象
本质上是个nil
,它的作用主要在调用objc_autoreleasePoolPop
时体现:
- 根据传入的哨兵对象地址找到哨兵对象所在的
page
- 在当前
page
中,将晚于哨兵对象插入的所有autorelese对象
都发送一次release
消息,并移动next指针
到正确位置 - 从最新加入的对象一直
向前
清理,可以向前跨越若干个page
,直到哨兵对象所在的page
③.3 压栈对象autoreleaseFast
进入autoreleaseFast源码:主要有以下几步:
- 1.获取当前操作页,并判断页是否存在以及是否满了
- 2.如果页
存在
,且未满
,则通过add
方法压栈对象
- 3.如果页
存在
,且满了
,则通过autoreleaseFullPage
方法安排新的页面
- 4.如果页
不存在
,则通过autoreleaseNoPage
方法创建新页
autoreleaseFullPage方法
其源码为:这个方法主要是用于判断当前页是否已经存储满了,如果当前页已经满了,通过do-while
循环查找子节点对应的页,如果不存在
就开辟新的AutoreleasePoolPage并设为HotPage
,然后压栈对象
.从上面AutoreleasePoolPage
初始化方法中可以看出,主要是通过操作child对象
,将当前页的child指向新建页面
,由此可以得出页
是通过双向链表连接
.
add方法
查看源码:这个方法主要是添加释放对象
,其底层是实现是通过next指针
存储释放对象,并将next指针递增
,表示下一个释放对象存储的位置
.从这里可以看出页
是通过栈结构存储
③.4 autorelease 底层分析
在demo
中,我们通过autorelease
方法,在MRC
模式下,将对象压栈到自动释放池,下面来分析其底层实现:
-
查看autorelease方法源码
-
进入对象的autorelease实现
从这里看出,无论是压栈哨兵对象
,还是普通对象
,都会来到autoreleaseFast
方法,只是区别标识不同
而以.
③.5 objc_autoreleasePoolPop 源码分析&出栈
objc_autoreleasePoolPop 源码分析
在objc_autoreleasePoolPop
方法中有个参数,在clang
分析时,发现传入的参数是push压栈后返回的哨兵对象
,即ctxt
,其目的是避免出栈混乱
,防止将别的对象出栈
,其内部是调用AutoreleasePoolPage
的pop
方法,我们看下pop源码:
pop源码实现,主要由以下几步:
- 1.空页面的处理,并根据token获取page
- 2.容错处理
- 3.通过
popPage
出栈页
出栈 -- popPage
查看popPage源码:进入popPage源码,其中传入的allowDebug
为false
,则通过releaseUntil
出栈当前页stop位置之前的所有对象
,即向栈中的对象发送release消息
,直到遇到传入的哨兵对象
.
releaseUntil方法
看源码我们可以知道:
-
releaseUntil
的实现,主要是通过循环遍历
,判断对象是否等于stop
,其目的是释放stop之前的所有的对象
- 首先通过获取
page的next释放对象
(即page的最后一个对象),并对next
进行递减
,获取上一个对象
- 判断
是否是哨兵对象
,如果不是则自动调用objc_release
释放
kill方法
通过kill实现我们知道,主要是销毁当前页
,将当前页赋值为父节点页
,并将父节点页的child对象指针置为nil
③.6 总结
- 1.
autoreleasepool
其本质是一个结构体对象
,一个自动释放池对象
就是页
,是栈结构存储
,符合先进后出
的原则 - 2.
页的栈底
是一个56
字节大小的空占位符
,一页总大小为4096字节
- 3.只有
第一页有哨兵对象
,最多存储504个对象
,从第二页开始
最多存储505个对象
- 4.
autoreleasepool
在加入要释放的对象
时,底层调用的是objc_autoreleasePoolPush
方法(push
操作)- 当没有
pool
,即只有空占位符
(存储在tls中)时,则创建页,压栈哨兵对象
- 在页中
压栈普通对象
主要是通过next指针递增
进行的 - 当
页满了
时,需要设置页的child
对象为新建页
-
objc_autoreleasePush
的整体底层的流程图如下
- 当没有
- 5.
autoreleasepool
在调用析构函数释放
时,内部的实现是调用objc_autoreleasePoolPop
方法(pop操作)- 在页中
出栈普通对象
主要是通过next指针递减
进行的 - 当
页空了
时,需要赋值页的parent
对象为当前页 -
objc_autoreleasePoolPop出栈的流程图如下
- 在页中
④ 提出疑问
④.1 临时变量什么时候释放?
- 1.如果在
正常情况
下,一般是超出其作用域就会立即释放
- 2.如果将临时变量加入了
自动释放池
,会延迟释放
,即在runloop休眠或者autoreleasepool作用域之后释放
④.2 自动释放池原理 即AutoreleasePool原理
- 1.
自动释放池
的本质
是一个AutoreleasePoolPage结构体对象
,是一个栈结构存储的页
,每一个AutoreleasePoolPage
都是以双向链表的形式连接
- 2.
自动释放池
的压栈
和出栈
主要是通过结构体的构造函数
和析构函数
调用底层的objc_autoreleasePoolPush
和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指针到正确位置
④.3 自动释放池能否嵌套使用?
- 1.可以嵌套使用,其目的是可以
控制应用程序的内存峰值
,使其不要太高 - 2.可以嵌套的原因是因为
自动释放池是以栈为节点
,通过双向链表的形式连接
的,且是和线程一一对应的
- 3.自动释放池的
多层嵌套
其实就是不停的push哨兵对象
,在pop
时,会先释放里面的,在释放外面的
④.4 哪些对象可以加入AutoreleasePool?alloc创建可以吗?
- 1.在
MRC
下使用new、alloc、copy
关键字生成的对象和retain
了的对象需要手动释放
,不会被添加到自动释放池中 - 2.在
MRC
下设置为autorelease
的对象不需要手动释放
,会直接进入自动释放池
- 3.所有
autorelease
的对象,在出了作用域之后
,会被自动添加到最近创建的自动释放池
中 - 4.在
ARC
下只需要关注引用计数
,因为创建都是在主线程
进行的,系统会自动为主线程创建AutoreleasePool
,所以创建的对象会自动放入自动释放池
④.5 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,优先级最低
,保证其释放池子发生在其他所有回调之后
④.6 thread和AutoreleasePool的关系
每个线程都有与之关联的自动释放池堆栈结构,新的pool
在创建时
会被压栈到栈顶
,pool
销毁时,会被出栈
,对于当前线程
来说,释放对象会被压栈到栈顶
,线程停止
时,会自动释放
与之关联的自动释放池.
④.7 RunLoop和AutoreleasePool的关系
- 1.主程序的RunLoop在每次事件循环之前,会自动创建一个autoreleasePool
- 2.并且会在事件循环结束时,执行drain操作,释放其中的对象
六、NSRunLoop
① RunLoop介绍
RunLoop
是事件接收
和分发机制
的一个实现,是线程相关的基础框架的一部分,一个RunLoop
就是一个事件处理的循环,用来不停的调度工作以及处理输入事件.
RunLoop
本质是一个do-while
循环,没事做就休息,来活了就干活.与普通的while
循环是有区别的,普通的while
循环会导致CPU进入忙等待状态
,即一直消耗cpu,而RunLoop
则不会,RunLoop是一种闲等待
,即RunLoop具备休眠功能
.
RunLoop的作用
- 保持程序的
持续运行
- 处理
App
中的各种事件(触摸
、定时器
、performSelector
) - 节省
cpu
资源,提供程序的性能,该做事就做事,该休息就休息
RunLoop源码的下载地址,在其中找到最新版下载即可
② RunLoop和线程的关系
②.1 获取RunLoop
一般在日常开发中,对于RunLoop
的获取主要有以下两种方式
②.2 CFRunLoopGetMain源码
②.3 _CFRunLoopGet0源码
通过上面可以知道,Runloop
只有两种,一种是主线程
的,一个是其它线程
的.即Runloop和线程
是一一对应
的.
③ RunLoop的创建
通过上面的_CFRunLoopGet0
可以知道Runloop
是通过__CFRunLoopCreate
创建(系统创建,开发者自己是无法创建的
).我们查看下__CFRunLoopCreate
源码:
我们发现__CFRunLoopCreate
主要是对runloop
属性的赋值操作.我们继续看CFRunLoopRef
的源码
可以得出以下结论:
- 1.根据定义得知,其实
RunLoop
也是一个对象.是__CFRunLoop
结构体的指针
类型 - 2.
一个RunLoop依赖于多个Mode
,意味着一个RunLoop需要处理多个事务
,即一个Mode对应多个Item
,而一个item
中,包含了timer
、source
、observer
,可以用下图说明
③.1 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
:这是一个伪模式
,其为一组runloop mode的集合
,将输入源加入此模式意味着在Common Modes
中包含的所有模式下都可以处理.在Cocoa
应用程序中,默认情况下Common Modes
包含default modes,modal modes,event Tracking modes
.可使用CFRunLoopAddCommonMode
方法将Common Modes
中添加自定义modes
.
③.2 Source & Timer & Observer
-
Source
表示可以唤醒RunLoop
的一些事件,例如用户点击了屏幕,就会创建一个RunLoop
,主要分为Source0
和Source1
-
Source0
表示非系统事件
,即用户自定义的事件 -
Source1
表示系统事件
,主要负责底层的通讯
,具备唤醒能力
-
-
Timer
就是常用NSTimer
定时器这一类 -
Observer
主要用于监听RunLoop的状态变化
,并作出一定响应
,主要有以下一些状态
④ 测试验证
④.1 验证:RunLoop和mode是一对多
上面我们说过RunLoop
和mode
是一对多
的关系,下面我们通过运行代码来实操证明. 我们先通过lldb命令
获取mainRunloop
、currentRunloop
的currentMode
运行结果表明runloop
在运行时的mode
只有一个.
下面我们获取mainRunLoop
所有的模型
从上面的打印结果可以验证runloop
和CFRunloopMode
具有一对多
的关系.
④.2 验证: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__
- 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
,用于表示超时时间
- 首先根据
modeName
找到对应的mode
,然后主要分为三种情况:- 如果是
entry
,则通知observer
,即将进入runloop
- 如果是
exit
,则通过observer
,即将退出runloop
- 如果是其他
中间状态
,主要是通过runloop处理各种源
- 如果是
上面说到会调用__CFRunLoopRun
,上面讲了在这一步里面会根据不同的事件源进行不同的处理
,当RunLoop休眠时
,可以通过相应的事件唤醒RunLoop
.
所以,综上所述,RunLoop
的执行流程,如下所示
⑦ 提出疑问
⑦.1 当前有个子线程,子线程中有个timer。timer是否能够执行,并进行持续的打印?
不可以,因为子线程的runloop默认不启动
, 需要runloop run
手动启动.
⑦.2 RunLoop和线程的关系
1.每个线程
都有一个与之对应的RunLoop
,所以RunLoop
与线程
是一一
对应的,其绑定关系通过一个全局的Dictionary存储
,线程为key
,runloop为value
.
2.线程中的RunLoop
主要是用来管理线程
的,当线程的RunLoop开启
后,会在执行完任务后
进行休眠状态
,当有事件触发唤醒
时,又开始工作
,即有活时干活,没活就休息
3.主线程
的RunLoop
是默认开启
的,在程序启动之后,会一直运行,不会退出
4.其他线程
的RunLoop
默认是不开启
的,如果需要,则手动开启
⑦.3 NSRunLoop和CFRunLoopRef区别
- 1.
NSRunLoop
是基于CFRunLoopRef
面向对象的API
,是不安全
的 - 2.
CFRunLoopRef
是基于C
语言,是线程安全的
⑦.4 Runloop的mode作用是什么?
mode
主要是用于指定RunLoop
中事件优先级
的
⑦.5 以+scheduledTimerWithTimeInterval:的方式触发的timer,在滑动页面上的列表时,timer会暂停回调,为什么?如何解决?
- 1.
timer
停止的原因是因为滑动scrollView
时,主线程的RunLoop
会从NSDefaultRunLoopMode
切换到UITrackingRunLoopMode
,而timer
是添加在NSDefaultRunLoopMode
。所以timer
不会执行 - 2.将
timer
放入NSRunLoopCommonModes
中执行.
写在后面
和谐学习,不急不躁.我还是我,颜色不一样的烟火.