思考:
- 使用 CADisplayLink、NSTimer 有什么注意点?
- 介绍下内存的几大区域
- 讲一下你对 iOS 内存管理的理解
- ARC 都帮我们做了什么?(LLVM + Runtime)
- weak 指针的实现原理
- autorelease 对象在什么时机会被调用 release
- 方法里有局部对象, 出了方法后会立即释放吗?
CADisplayLink、NSTimer 定时器
CADisplayLink、NSTimer 会对 target 产生强引用,如果 target 又对它们产生强引用,那么就会引发循环引用。
循环引用问题
CADisplayLink
CADisplayLink 保证调用 -(void)timerTest
方法的频率和屏幕的刷新帧频率一致,60FPS。实际的调用频率可能不太一样,根据任务的耗时情况会有减少。
打印结果:
从打印结果可以看到,进入 TimerViewController 控制器后返回,会发现 TimerViewController 没有释放,CADisplayLink 定时器还在继续运行。
NSTimer
|
|
打印结果:
从打印结果可以看到,进入 TimerViewController 控制器后返回,会发现 TimerViewController 没有释放,NSTimer 定时器还在继续运行。
问题分析
解决方案
方案一:使用 block
|
|
打印结果:
在使用 NSTimer 的时候,使用 block 的方式传入 weakSelf 可以有效解决循环引用的问题。并且以 scheduled 开头的方法会自动将 timer 添加到当前的 runloop 中并使用 default mode。
target 传入 weakSelf 并不能解决循环引用个问题,因为 NSTimer 内部对传入的 target 也是强引用的,而且 weakSelf 只是用来解决 block 循环引用问题的方案。
CADisplayLink 没有 block 相关的 API。
方案二:使用中间对象
定义中间对象:
NSTimer
|
|
打印结果:
CADisplayLink
|
|
打印结果:
传给定时器的 target 是 YQProxy 对象,在 YQProxy 对象尝试调用 -(void)timerTest
方法时,发现没有实现后会调用 - (id)forwardingTargetForSelector:(SEL)aSelector
方法走消息转发的逻辑,在该方法内部返回已经实现了 -(void)timerTest
方法的对象,就可以正常实现 timer 定时器调用 -(void)timerTest
方法的逻辑了。
因为 YQProxy 对象没有实现 -(void)timerTest
方法,所以需要添加消息转发逻辑。如果 YQProxy 中没有添加消息转发的逻辑会出现如下报错:
YQProxy 会在其类对象以及父类的类对象里查找 -(void)timerTest
方法,查找了一圈后发现找不到,就会抛出错误。
使用代理对象(NSProxy)
定义 YQTimerProxy 继承自 NSProxy:
NSProxy 对象没有 -(instancetype)init
方法,直接 alloc 就可以了。
NSTimer
|
|
打印结果:
CADisplayLink
|
|
传给定时器的 target 是 YQTimerProxy 对象,YQTimerProxy 对象不会尝试调用 -(void)timerTest
方法,而是直接走消息转发逻辑,在对应的消息转发的方法里返回已经实现了 -(void)timerTest
方法的对象,就可以正常实现 timer 定时器调用 -(void)timerTest
方法的逻辑了。
因为 YQTimerProxy 对象不会尝试调用 -(void)timerTest
方法,而是直接调用 - (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
方法,所以需要添加消息转发逻辑。如果 YQTimerProxy 中没有添加消息转发的逻辑会出现如下报错:
可以看到 YQTimerProxy 没有去查找 -(void)timerTest
方法,而是直接查找的 - (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
方法。这一点可以通过 GNUStep 查看 NSProxy 的源码找到原因。NSProxy 内部的方法的实现都是直接调用的 - (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
方法,因此 NSProxy 作为代理对象效率更高。
NSProxy
|
|
打印结果:
从打印结果可以看到,YQProxy 的实例对象和 YQTimerProxy 的实列对象在判断是否是 ViewController 的对象类型的时候结果不同。这是因为 YQTimerProxy 继承自 NSProxy,NSProxy 的 -(BOOL) isKindOfClass:(Class)aClass
方法的实现与普通的 OC 对象同。通过 GNUStep 查看 NSProxy 的源码:
可以看到,NSProxy 的 -(BOOL)isKindOfClass:(Class)aClass
方法内部直接调用了 - (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
方法。
普通 OC 对象的 -(BOOL)isKindOfClass:(Class)aClass
方法实现 objc4-781 :
GCD 定时器
CADisplayLink 和 NSTimer 依赖于 RunLoop,如果 RunLoop 的任务过于繁重,可能会导致 CADisplayLink 和 NSTimer 不准时。GCD 的定时器会更加准时。
block 回调:
|
|
打印结果:
函数回调:
|
|
打印结果:
自定义队列
|
|
打印结果:
封装 GCD
|
|
调用 block 回调方法:
调用函数回调方法:
打印结果:
去掉警告:
"-Warc-performSelector-leaks"
可以在这里找到:
iOS程序的内存布局
- 代码段:编译之后的代码
- 数据段:字符串常量、已初始化数据和未初始化数据
- 栈:函数调用开销,比如局部变量。(分配的内存空间地址越来越小)
- 堆:通过 alloc、malloc、calloc 等动态分配的空间,分配的内存空间地址越来越大
|
|
打印结果:
打印结果解析(内地地址从小到大排序):
内存地址大小比较:
数据段:str < a < c < d < b,空间地址越来越大。
堆区:obj1 比 b 多了3位数,这3位数的空间都属于数据段。obj1 的内存地址首位数字为6,obj1 < obj2,分配的内存空间越来越大(越来越逼近栈区)。
栈区:e 的内存地址首位数字为7,f < e,分配的内存空间越来越小(越来越逼近堆区)。
Tagged Pointer
- 从64bit开始,iOS 引入了 Tagged Pointer 技术,用于优化 NSNumber、NSDate、NSString 等小对象的存储。
- 在没有使用 Tagged Pointer 之前,NSNumber 等对象需要动态分配内存、维护引用计数等,NSNumber 等对象的指针存储的是堆中 NSNumber 对象的地址值。
- 使用 Tagged Pointer 之后,NSNumber 指针里面存储的数据变成了:Tag + Data,也就是将数据直接存储在了指针中。
- 当指针不够存储数据时,才会使用动态分配内存的方式来存储数据。
objc_msgSend()
能识别 Tagged Pointer,比如 NSNumber 的 intValue 方法,直接从指针提取数据,节省了以前的调用开销。- 如何判断一个指针是否为Tagged Pointer?
iOS平台,最高有效位是1(第64bit)
Mac平台,最低有效位是1
NSNumber
|
|
打印结果:
0xb0d1e4bcdb858740
和 0xb0d1e4bcdb858750
去掉前面相同的部分后是 0x40
和 0x50
,可以看出数据直接存储在了指针中。当指针不够存储数据时,如 @(0xFFFFFFFFFFFFFFFF)
打印出来的内存地址是 0x600000b4c080
(堆空间里 NSNumber 对象的地址值),是使用了动态分配内存的方式来存储数据。
因为 objc_msgSend()
能识别 Tagged Pointer,所以在 number1 调用 intValue
方法时,objc_msgSend()
直接从指针提取数据。
可以看出 Tagged Pointer 技术不仅仅是内存空间的优化,也对使用过程进行了优化。
NSString
例一:
|
|
运行后报错(坏内存访问):
因为 name
是非原子性(nonatomic
)的,多条线程同时访问 name
的 set 方法时,如果有一条线程已经将 _name
释放了,其它线程再次对 _name
进行释放操作就会出现坏内存访问的错误:
解决方案一:
将 name
改成原子性的:
程序运行正常。
将 name
改成原子性后,name
的 set 方法就是线程安全的了,不会出现多条线程同时对 name
进行 release 操作。
解决方案二:
将 name
改成原子性后,任何地方、任何时候调用 name
的 get 方法都是有锁的。因为 name
只需要在异步线程访问时加锁,如果在主线程的话没有必要加锁,所以不使用 atomic
而是选择手动加锁,哪里需要就在哪里加锁,尽可能的提升效率节省资源:
程序运行正常。
例二:
将 @"abcdefghijk"
改成 @"abc"
:
运行正常。
同样是多线程访问、没有加锁,但是将 @"abcdefghijk"
改成 @"abc"
后,就不会出现坏内存访问的错误,难道是没有调用 name
的 set 方法吗?是的。
打印结果:
str1 是一个 __NSCFString
,其内存地址是6开头的,说明 str1 是存储在堆空间里的。
str2 是一个 NSTaggedPointerString
,@"abc"
存储在 str2 指针里,不会调用 set 方法,取值时也不会调用 get 方法而是直接从指针里取值。
判断是否是 Tagged Pointer
objc4-781 的 objc-internal.h 文件里:
_OBJC_TAG_MASK
定义:
判断是否是 tagged pointer:
如果是 mac,_OBJC_TAG_MASK
等于1。如果不是 mac, _OBJC_TAG_MASK
等于 1UL<<63。_OBJC_TAG_MASK
和指针地址进行与运算,判断结果是否是 _OBJC_TAG_MASK
,如果是的话,那这个指针就是 tagged pointer。
在 iOS 平台,如果指针转成2进制后它的最高位(64位)为1的话,那么这个指针就是 tagged pointer。
在 Mac 平台,如果指针转成2进制后它的最低位为1的话,那么这个指针就是 tagged pointer。
MRC
- 在 iOS 中,使用引用计数来管理 OC 对象的内存。
- 一个新创建的 OC 对象引用计数默认是1,当引用计数减为0,OC 对象就会销毁,释放其占用的内存空间。
- 调用 retain 会让 OC 对象的引用计数+1,调用 release 会让 OC 对象的引用计数-1。
- 内存管理的经验总结:
当调用 alloc、new、copy、mutableCopy 方法返回了一个对象,在不需要这个对象时,要调用 release 或者 autorelease 来释放它。
想拥有某个对象,就让它的引用计数+1;不想再拥有某个对象,就让它的引用计数-1。 - 可以通过以下私有函数来查看自动释放池的情况 1extern void _objc_autoreleasePoolPrint(void);
👉 使用 MRC:Build Setting -> Objective-C Automatic Referencd Counting 设置为 YES。
定义 Person 类:
release
创建 person 对象:
打印结果:
从打印结果可以看到,person 对象调用完 alloc
方法后引用计数是1,没有被释放。
添加 release:
打印结果:
从打印结果可以看到,因为 person 对象调用完 alloc
方法后引用计数是1,调用完 release
方法后引用计数减1等于0,所以 person 对象在调用 release
的那一刻就被释放了。
MRC 下 alloc
方法和 release
方法是一一对应的,每个对象使用完成后都要调用一下 release
方法,这样才能避免内存泄漏。另外,在使用 release
方法管理 person 对象时,要保证在调用 release
方法之前使用 person 对象。
autorelease
|
|
打印结果:
从打印结果可以看到,因为 person 对象调用完 alloc
方法又调用了 autorelease
后引用计数是1,直到 @autoreleasepool {}
执行完那一刻才被释放。
在 @autoreleasepool {}
执行完那一刻,会对调用了 autorelease
方法的对象进行 release
操作:
打印结果:
使用 autorelease
方法管理 person 对象的内存,只需要在调用 alloc
方法的同时调用一下 autorelease
方法,就可以放心的使用 person 对象了,当然是在 @autoreleasepool {}
内部使用。不用再关心 person 对象的释放问题,在 @autoreleasepool {}
执行完那一刻,person 对象会自动调用 release
方法。
retain
错误演示:
打印信息:
[[person dog] run]
这个时候 dog 对象因为引用计数为0已经被释放了。
[person setDog:dog]
方法表示 person 对象想要用于 dog 对象,那么 person 对象应该对 dog 的引用计数加1,只要 person 对象还在,dog 对象就不可以被释放。
上面的写法有两个地方需要优化:
1、Person 类的 - (void)setDog:(Dog *)dog
方法在获取 dog 对象时,引用计数需要加1。
2、Person 类的 - (void)dealloc
方法需要对 dog 对象进行 release
操作。
Person 类优化后:
打印结果:
创建两个 person 对象引用 dog 对象:
打印结果:
set 方法
上面 Person 类里的 set 方法还有问题,在 person 对象替换 dog 对象时会出现不释放的问题:
打印结果:
从打印结果和注释可以看到,dog1 最后的引用计数是1,没有释放。这是因为 person 对象在拥有 dog1 时,将 _dog
指向了 dog1 并对其引用计数加1,后又将 _dog
指向了 dog2 并对其引用计数加1,所以 person 对象在调用 [_dog release]
时的 _dog
是 dog2 对象,dog1 对象因此少调用了一次 release 方法,最后的引用计数最后为1无法释放。
优化 set 方法:在 person 对象引用新的 dog 对象时,需要先将之前的 dog 对象进行 release 操作。
打印结果:
从打印结果可以看到,dog1 和 dog2 对象都被释放调用。但是优化后的 set 方法还不够完善,person 对象在重复设置同一个 dog 对象的时候还是有问题:
错误信息:
问题出在 Person 类的 set 方法:
第一次赋值,dog 对象的引用计数加一,此时 dog 对象的引用计数等于2。
dog 对象调用了一次 release 方法,此时 dog 对象的引用计数等于1。
第二次赋值,会先调用 [_dog release]
, 此时 dog 对象的引用计数等于0,dog 对象被释放。再调用 _dog = [dog retain]
,此时 dog 指向的内存已经被销毁了:
因此,在 set 方法里对 _dog
处理前,需要先判断一下 _dog
是否等于传入的 dog 对象。因为一个 person 对象在拥有一个 dog 对象时只需要对其 retain 一次,所以如果 _dog == dog
就不做处理。
优化 Person 类里的 set 方法:
打印结果:
property 属性
在 .h 文件通过 property 声明的属性,编译器会自动生成成员变量和属性的 setter、getter 实现。
基本数据类型
|
|
编译器自动生成:
对象类型
|
|
编译器自动生成:
如果是在 MRC 环境下,这种情况下还需要在 delloc 方法里手动调用 _dog 的 release 方法:
常用代码解析
创建一个 iOS 项目,设置 MRC 环境。
|
|
简化:
autorelease
:
+array
:
不是通过 alloc
方法初始化的,而是通过类方法初始化的,在类方法内部已经调用过 autorelease
了。类方法 +array
大概是这样:
工厂方法
|
|
打印结果:
copy和mutableCopy
拷贝的目的:产生一个副本对象,跟原对象互不影响
修改了原对象,不会影响副本对象
修改了副本对象,不会影响原对象iOS 提供了两个拷贝方法
1、copy,不可变拷贝,产生不可变副本
2、mutableCopy,可变拷贝,产生可变副本
拷贝
NSString
|
|
打印结果:
str1、str2 和 str3 打印出来都是 test,但是 str1 和 str2 是不可变字符串,str3 是可变字符串。即:
[不可变 copy] -> 不可变
[不可变 mutableCopy] -> 可变
NSMutableString
|
|
打印结果:
不管是可变字符串还是不可变字符串,调用 copy
返回的都是不可变字符串,调用 mutableCopy
返回的都是可变字符串。
内存管理
在 MRC 环境下,调用 alloc、new、copy、mutableCopy 方法返回了一个对象,在不需要这个对象时,要调用 release 或者 autorelease 来释放它。
|
|
深拷贝和浅拷贝
- 深拷贝:内容拷贝,产生新的对象
- 浅拷贝:指针拷贝,没有产生新的对象
NSString
|
|
打印结果:
str1、str2 和 str3 打印出来都是 test,但是 str1 和 str2 指向的是同一个内存地址,str3 指向的是另一个内存地址。即 str1 通过 copy
方法拷贝出来的副本 str2 还是原对象(str1),而 str1 通过 mutableCopy
方法拷贝出来的副本 str3 是一个新的对象。
👉 拷贝的目的:产生一个副本对象,跟原对象互不影响。
str1 是一个不可变字符串对象,通过 copy
方法拷贝出来的副本也是不可变字符串对象。因为不可变字符串对象不可以被修改,不可以被修改就不会互相影响,所以 str1 和 str2 指向同一个对象满足拷贝的原则(互不影响)。而且将 str1 和 str2 指向同一个对象还节省了内存。
str1 是一个不可变字符串对象,通过 mutableCopy
方法拷贝出来的副本是可变字符串对象。因为可变字符串对象可以被修改,所以 str3 指向的是一个新的对象,保证在修改 str3 时不会影响到 str1。
(总的来看,既要保证互不影响,也要做到节省资源)
|
|
打印结果:
str1、str2、str3 和 str4 都是 @"test"
对象。
引用计数:
打印结果:
从打印结果可以看到,str1 在调用 copy
方法后,引用计数加1,相当于调用了一次 retain
方法。
打印结果:
使用 initWithFormat:
方法初始化字符串,字符串长度足够长,创建出来的是 __NSCFString
类型的字符串。__NSCFString
类型的字符串是通过引用计数管理内存的。
使用 initWithFormat:
方法初始化字符串,字符串长度不够长,创建出来的是 NSTaggedPointerString
类型的字符串。NSTaggedPointerString
类型的字符串不是通过引用计数管理内存的。
使用 initWithString:
方法初始化字符串,不管字符串长度,创建出来的是 __NSCFConstantString
类型的字符串。__NSCFConstantString
类型的字符串不是通过引用计数管理内存的。
ps:
打印结果:
通过 GNUStep 查看源码:
以 Format 结尾的方法最终调用的都是这个方法:
以 String 结尾的方法最终调用的都是这个方法:
NSMutableString
|
|
打印结果:
str1、str2 和 str3 打印出来都是 test,但是他们指向的都是不同的内存地址。即 str1 通过 copy
和 mutableCopy
方法拷贝出来的副本 str2 和 str3 都是一个新的对象。
NSArray
|
|
打印结果:
arr1 和 arr2 是不可变数组,arr3 是可变数组。即:
[不可变 copy] -> 不可变
[不可变 mutableCopy] -> 可变
arr1、arr2 和 arr3 打印出来都是同样的内容,但是 arr1 和 arr2 指向的是同一个内存地址,arr3 指向的是另一个内存地址。即 arr1 通过 copy
方法拷贝出来的副本 arr2 还是原对象(arr1),而 arr1 通过 mutableCopy
方法拷贝出来的副本 arr3 是一个新的对象。
|
|
打印结果:
NSArray 和 NSString 不同,@[@"1", @"2"]
、arr1 和 arr2 虽然内容相同,但是它们是不同的对象(arr1 和 arr2 是同一个对象)。
NSMutableArray
|
|
打印结果:
arr1 和 arr3 是可变数组,arr2 是不可变数组。即:
[可变 copy] -> 不可变
[可变 mutableCopy] -> 可变
arr1、arr2 和 arr3 打印出来都是同样的内容,但是他们指向的都是不同的内存地址。即 arr1 通过 copy
和 mutableCopy
方法拷贝出来的副本 arr2 和 arr3 都是一个新的对象。
NSDictionary
|
|
打印结果:
dict1 和 dict2 是不可变字典,dict3 是可变字典。即:
[不可变 copy] -> 不可变
[不可变 mutableCopy] -> 可变
dict1、dict2 和 dict3 打印出来都是同样的内容,但是 dict1 和 dict2 指向的是同一个内存地址,dict3 指向的是另一个内存地址。即 dict1 通过 copy
方法拷贝出来的副本 dict2 还是原对象(dict1),而 dict1 通过 mutableCopy
方法拷贝出来的副本 dict3 是一个新的对象。
NSMutableDictionary
|
|
打印结果:
dict1 和 dict3 是可变字典,dict2 是不可变字典。即:
[可变 copy] -> 不可变
[可变 mutableCopy] -> 可变
dict1、dict2 和 dict3 打印出来都是同样的内容,但是他们指向的都是不同的内存地址。即 dict1 通过 copy
和 mutableCopy
方法拷贝出来的副本 dict2 和 dict3 都是一个新的对象。
小结
copy | mutableCopy | |
---|---|---|
NSString | NSString 浅拷贝 |
NSMutableString 深拷贝 |
NSMutableString | NSString 深拷贝 |
NSMutableString 深拷贝 |
NSArray | NSArray 浅拷贝 |
NSMutableArray 深拷贝 |
NSMutableArray | NSArray 深拷贝 |
NSMutableArray 深拷贝 |
NSDictionary | NSDictionary 浅拷贝 |
NSMutableDictionary 深拷贝 |
NSMutableDictionary | NSDictionary 深拷贝 |
NSDictionary 深拷贝 |
copy策略的property
定义 Person
报错:
data
是 Person 用 copy
策略定义的 NSMutableArray
,data
的 set 方法是:
在 person.data = [NSMutableArray array]
赋值的这一刻,虽然传入的是一个 NSMutableArray
,但是因为是 copy
策略,所以在赋值时需要进行 copy
操作 _data = [data copy]
,所以 _data
是一个 NSArray
类型的不可变数组。
对于可变类型的变量,在声明时使用 strong
策略。
对于不可变类型的变量,在声明时使用 copy
策略。
比如 UITextField
里的 text、attributedText 和 placeholder 等,都是使用的 copy 策略,保证不管传入的是可变类型还是不可变类型,UITextField
内部使用的都是不可变类型:
copyWithZone:
对象类型进行 copy
操作,首先要遵守 <NSCopying>
协议,然后实现 - (id)copyWithZone:(struct _NSZone *)zone
方法:
打印结果:
引用计数的存储
在 64bit 中,引用计数可以直接存储在优化过的 isa 指针中,也可能存储在 SideTable 类中。
在 objc-weak.h 文件查看 weak_table_t
定义:
retainCount
在 objc4-781 的 NSObject.mm 文件中查看 retainCount
:
在 objc-object.h 文件中查看 rootRetainCount()
方法:
在 NSObject.mm 文件中查看 sidetable_getExtraRC_nolock()
方法:
has_sidetable_rc
:引用计数器是否过大无法存储在 isa 中,如果为1,那么引用计数会存储在一个叫 SideTable 的类的属性中。
release
|
|
在 objc-object.h 文件中查看 rootRelease()
方法:
在 NSObject.mm 文件中查看 sidetable_release()
方法:
retain()
在 objc-object.h 文件中查看 retain()
方法:
在 NSObject.mm 文件中查看 sidetable_retain()
方法:
weak指针的原理
局部变量的内存管理
创建局部变量 person:
打印结果:
__strong
使用 __strong
修饰的局部变量 person:
打印结果:
__weak
使用 __weak
修饰的局部变量 person:
打印结果:
weakPerson
指针指向的内存地址被销毁后,weakPerson
指针会自动置为 nil。
__unsafe_unretained
使用 __unsafe_unretained
修饰的局部变量 person:
报错:
报错的原因是指针 unsafePerson
还在,但是它指向的内存地址已经不存在了。这也是跟 weakPerson
指针相比不同也是不够安全的地方。
dealloc
objc4-781 查看源码:
NSObject.mm 文件:
在 objc-object.h 文件查看 rootDealloc()
方法:
- nonpointer
0,代表普通的指针,isa 只存储着 Class、Meta-Class 对象的内存地址
1,代表优化过,isa 使用位域存储更多的信息 - has_assoc
是否有设置过关联对象,如果没有,释放时会更快 - has_cxx_dtor
是否有 C++ 的析构函数(.cxx_destruct),如果没有,释放时会更快 - weakly_referenced
是否有被弱引用指向过,如果没有,释放时会更快 - has_sidetable_rc
引用计数器是否过大无法存储在 isa 中,如果为1,那么引用计数会存储在一个叫 SideTable 的类的属性中
如果条件不成立会走到 objc-runtime-new.mm 文件的 object_dispose()
方法:
在 objc-object.h 文件查看 clearDeallocating()
方法:
在 NSObject.mm 文件 查看 clearDeallocating_slow()
方法:
在 objc-weak.mm 文件查看 weak_clear_no_lock()
方法:
自动释放池
自动释放池的主要底层数据结构是:__AtAutoreleasePool
、AutoreleasePoolPage
,调用了 autorelease
的对象最终都是通过 AutoreleasePoolPage
对象来管理的。
__AtAutoreleasePool
堆代码:
找到 main.m 所在文件,在终端输入:
上面的代码转成 C++ 代码后就是这个样子:
简化一下:
在生成的 main.mm 文件中搜索 __AtAutoreleasePool
,__AtAutoreleasePool
是一个 C++ 结构体:
认识了 __AtAutoreleasePool
结构体后,通过 clang 生成的 C++ 代码可以看做:
通过上面的摸索,@autoreleasepool {}
的第一个大括号其实是调用了 objc_autoreleasePoolPush()
方法,第二个大括号是调用 objc_autoreleasePoolPop()
方法,objc_autoreleasePoolPop()
方法的参数是 objc_autoreleasePoolPush()
方法生成的 atautoreleasepoolobj
。
如果有多个 @autoreleasepool {}
嵌套的话就是这样:
查看 objc_autoreleasePoolPush()
源码:
查看 objc_autoreleasePoolPop()
源码:
从源码可以看到,objc_autoreleasePoolPush()
和 objc_autoreleasePoolPop()
方法内部都是通过 AutoreleasePoolPage
实现的。
AutoreleasePoolPage
定义
在 objc4-781 的 NSObject-internal.h 文件可以看到 AutoreleasePoolPage 的定义:
AutoreleasePoolPage
结构体的 thread 存储的是其对应的线程,也表示一个 AutoreleasePoolPage
对应一个线程。
结构
每个 AutoreleasePoolPage
对象占用4096字节内存,除了用来存放它内部的成员变量,剩下的空间用来存放 autorelease
对象的地址,所有的 AutoreleasePoolPage
对象通过双向链表的形式连接在一起。
假设 AutoreleasePoolPage
对象的内存地址从 0x1000
开始:
从 0x1000
到 0x2000
共4096个字节(0x1000
)。
从 0x1000
到 0x1038
共56个字节(0x38
),即 AutoreleasePoolPage
结构体内部的成员变量大小之和。
从 0x1038
到 0x2000
共4040个字节,分别是 begain()
和 end()
方法调用的位置,这段内存用来保存调用了 autorelease
方法的对象的地址值。
查看 begain()
方法源码:
可以看到 begain()
方法内部直接返回的是 AutoreleasePoolPage
内存地址开始位置加上其自身占用内存大小(0x1000
+ 0x38
= 0x1038
)。
查看 end()
方法源码:
可以看到 end()
方法内部直接返回的是 AutoreleasePoolPage
内存地址开始位置加上 SIZE
:
一个 AutoreleasePoolPage
结构体能够存放的 autorelease
对象的地址是有限的,如果超出存储最大值,会新创建一个 AutoreleasePoolPage
结构体用来存储剩下的部分。多个 AutoreleasePoolPage
结构体通过机构体中的 child 指向下一个 AutoreleasePoolPage
结构体,通过 parent 指向上一个 AutoreleasePoolPage
结构体,构成双向链表结构:
- 👉 双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。
- 👉 循环链表是另一种形式的链式存贮结构。它的特点是表中最后一个结点的指针域指向头结点,整个链表形成一个环。
原理
调用 push
方法会将一个 POOL_BOUNDARY
入栈,并且返回其存放的内存地址。
调用 pop
方法时传入一个 POOL_BOUNDARY
的内存地址,会从最后一个入栈的对象开始发送 release
消息,直到遇到这个 POOL_BOUNDARY
。id *next
指向了下一个能存放 autorelease
对象地址的区域。
比如在 @autoreleasepool {}
内创建1000个 person 对象调用 autorelease
。
一个 person 对象的指针占8个字节,1000个 person 对象就是8000个字节。一个 AutoreleasePoolPage
对象可以保存4040个字节,所以会再创建一个 AutoreleasePoolPage
对象用来保存剩下的 person 对象的地址值。
将 @autoreleasepool {}
转成 __AtAutoreleasePool
:
objc_autoreleasePoolPush()
会调用 AutoreleasePoolPage
对象的 push()
方法,将一个 POOL_BOUNDARY
入栈,并且返回其存放的内存地址(0x1038
):
每个调用 autorelease
方法的 person 对象都会添加到 AutoreleasePoolPage
对象中:
正如上面提到的一样,一个 AutoreleasePoolPage
对象可以保存4040个字节,所以会创建一个新的 AutoreleasePoolPage
对象用来保存剩下的 person 对象的地址值。
objc_autoreleasePoolPop(atautoreleasepoolobj)
方法传入的 atautoreleasepoolobj
是 objc_autoreleasePoolPush()
方法返回的 POOL_BOUNDARY
的地址值(0x1038
),即 objc_autoreleasePoolPop(POOL_BOUNDARY)
。拿到 POOL_BOUNDARY
后,objc_autoreleasePoolPop()
方法内部会从最后一个加入到 AutoreleasePoolPage
里的对象开始,依次调用 release
方法,直到 POOL_BOUNDARY
完成释放工作。
在整个过程中,next
指向 page 中下一个将要存放的对象的地址, 通过 *next++ = obj
来实现对象的存入并 next
指针的累加, 用 id obj = *--page->next
来取出要 release
的对象并实现 next
的递减。
验证
可以通过以下私有函数来查看自动释放池的情况:
_objc_autoreleasePoolPrint()
方法是定义在 Runtime 里的,所以是不开源的。但是可以通过 extern
关键字在 main.m 文件中声明,编译器就会自动去找到这个方法,从而实现调用。
位置0,打印结果:
位置1,打印结果:
位置2,打印结果:
位置3,打印结果:
位置4,打印结果:
位置5,打印结果:
位置6,打印结果:
打印结果中的 POOL
代表的就是 POOL_BOUNDARY
,person 代表的就是调用 autorelease
方法的对象。releases pending
表示当前自动释放池里的对象个数。
多个 AutoreleasePoolPage
对象的情况:
打印结果:
从打印结果中可以看到,第一个 page 里有两个 POOL_BOUNDARY
,由于对象太多超出了第一个 page 的存储范围,所以创建出了第二个 page。第二个 page 中存储了多出来的 person 对象,还有一个 POOL_BOUNDARY
。PAGE (hot)
表示该 page 为当前页,PAGE (full) (cold)
中的 full 表示这一页已经满了,cold 表示该 page 不是当前页。
源码分析
objc_autoreleasePoolPush()
方法的实现原理是调用 AutoreleasePoolPage
对象的 push()
方法。objc_autoreleasePoolPop()
方法的实现原理是调用 AutoreleasePoolPage
对象的 pop()
方法。autorelease
方法的实现原理则是调用了 AutoreleasePoolPage
对象的 autorelease()
方法。
在 objc4-781 中的 NSObject.mm 文件查看。
objc_autoreleasePoolPush(void)
|
|
push()
方法:
如果 page 不存在就调用 autoreleaseNewPage()
方法传入 POOL_BOUNDARY
创建:
如果 AutoreleasePoolPage
对象已经存在就直接调用 autoreleaseFast()
方法添加 POOL_BOUNDARY
:
- (id)autorelease
|
|
autorelease((id)this)
方法:
调用 autoreleaseFast()
方法添加 obj:
autorelease
方法最终是通过调用 autoreleaseFast()
方法,将调用了 autorelease
方法的对象保存到了 page 中。
objc_autoreleasePoolPop(void *ctxt)
|
|
pop()
方法:
popPageDebug()
方法:
popPage()
方法:
releaseUntil()
方法:
pop()
方法最终是通过调用 releaseUntil()
方法,将 page 里的对象依次调用 objc_release(obj)
方法释放掉了。
autorelease 释放时机
创建一个 iOS 项目,选择 MRC 环境,修改 main.m 文件(当前XCode版本11.6)。
定义 Person 类,添加打印:
打印结果:
从打印结果可以看到,person 对象是在 viewWillAppear
方法执行完成后被释放的。
Runloop和Autorelease
iOS 在主线程的 Runloop 中注册了两个 Observer:
第一个 Observer 监听了 kCFRunLoopEntry
事件,会调用 objc_autoreleasePoolPush()
。
第二个 Observer 监听了 kCFRunLoopBeforeWaiting
事件,会调用 objc_autoreleasePoolPop()
、objc_autoreleasePoolPush()
,监听了 kCFRunLoopBeforeExit
事件,会调用 objc_autoreleasePoolPop()
。
打印当前的 RunLoop,查看 Observers:
打印结果里有很多内容,这里是摘出来的跟 Autorelease 相关的两个 Observe:
_wrapRunLoopWithAutoreleasePoolHandler
是回调方法,在收到消息时用来处理 Autorelease 相关操作。
第一个 observe 的 activities
是 0x1
,第二个 observe 的 activities
是 0xa0
。
activities
对应的枚举:
因为:0x1
(十六进制)-> 1(十进制)0xa0
(十六进制)-> 160(十进制)
所以:
所以第一个 observe 监听的状态就是 kCFRunLoopEntry
,第二个 observe 监听的状态就是 kCFRunLoopAfterWaiting
和 kCFRunLoopExit
。
- 第一次循环:
01 状态是kCFRunLoopEntry
,RunLoop 会调用objc_autoreleasePoolPush()
方法;
07 状态是kCFRunLoopAfterWaiting
,RunLoop 会先调用objc_autoreleasePoolPop()
方法,然后调用objc_autoreleasePoolPush()
方法; - 非第一次循环:
10-1 回到 02 继续循环至 07;
07 状态是kCFRunLoopAfterWaiting
,RunLoop 会先调用objc_autoreleasePoolPop()
方法,然后调用objc_autoreleasePoolPush()
方法;
10-1 回到 02 继续循环至 07; - 结束循环:
10-2 退出 RunLoop;
11 状态是kCFRunLoopExit
,RunLoop 会调用objc_autoreleasePoolPop()
方法。
因为 02 会先调用 objc_autoreleasePoolPop()
方法,然后调用 objc_autoreleasePoolPush()
方法,所以 push()
和 pop()
实现了一一对应的关系。
在看打印结果:
在 viewWillAppear
执行完成后,RunLoop 到了 02 的位置。此时 RunLoop 监听到了 kCFRunLoopAfterWaiting
状态,会先调用 objc_autoreleasePoolPop()
方法,然后调用 objc_autoreleasePoolPush()
方法。在调用 objc_autoreleasePoolPop()
方法时,Autorelease 中的 person 对象就被释放了。
另外,打印结果也说明了 viewDidLoad
和 viewWillAppear
方法是在同一个循环中执行的。
ARC环境下的release
|
|
打印结果:
从打印结果可以看到,person 对象在 viewDidLoad
方法执行完就释放了。所以在 ARC 环境下,在方法最后结束前,对方法内部的局部变量调用了 release 方法:
总结
使用 CADisplayLink、NSTimer 有什么注意点?
CADisplayLink 和 NSTimer 依赖于 RunLoop,如果 RunLoop 的任务过于繁重,可能会导致 CADisplayLink 和 NSTimer 不准时。
CADisplayLink 和 NSTimer 有可能会造成循环引用问题,CADisplayLink 可以通过使用代理对象(NSProxy)解决,NSTimer 除了可以通过使用代理对象(NSProxy)解决外,还可以使用带有 block 回调的初始化方法。介绍下内存的几大区域
代码段:编译之后的代码
数据段:字符串常量、已初始化数据和未初始化数据
栈:函数调用开销,比如局部变量。(分配的内存空间地址越来越小)
堆:通过 alloc、malloc、calloc 等动态分配的空间,分配的内存空间地址越来越大讲一下你对 iOS 内存管理的理解
在 iOS 中,使用引用计数来管理 OC 对象的内存。
一个新创建的 OC 对象引用计数默认是1,当引用计数减为0,OC 对象就会销毁,释放其占用的内存空间。
调用 retain 会让 OC 对象的引用计数+1,调用 release 会让 OC 对象的引用计数-1。
内存管理的经验总结:
当调用 alloc、new、copy、mutableCopy 方法返回了一个对象,在不需要这个对象时,要调用 release 或者 autorelease 来释放它。
想拥有某个对象,就让它的引用计数+1;不想再拥有某个对象,就让它的引用计数-1。ARC 都帮我们做了什么?(LLVM + Runtime)
ARC 是 LLVM 编译器和 Runtime 协作协作实现的。ARC 利用 LLVM 编译器自动生成 release、retain 和 autorelease 等内存管理相关的代码,利用 Runtime 实现在运行时对弱引用的管理(添加和清除)。weak 指针的实现原理
将弱引用存储在一个弱引用表(哈希表)里面,在对象要销毁时,就以对象的地址值 & mask 得到一个索引取出对应的弱引用表,并把弱引用表里存储的弱引用都清除掉。autorelease 对象在什么时机会被调用 release?
autorelease 对象在什么时候调用 release 是由 RunLoop 控制的,在 RunLoop 监听到kCFRunLoopAfterWaiting
或kCFRunLoopExit
状态时会调用objc_autoreleasePoolPop()
方法,此时 autorelease 对象会调用 release。即在 RunLoop 进入休眠或者退出时调用 release。方法里有局部对象, 出了方法后会立即释放吗?
会。在方法结束前,runtime 会自动为方法内部的局部对象调用 release 方法进行释放。
相关阅读:
深入理解 Tagged Pointer