面试题1:讲一下你对 iOS 内存管理的理解(MRC和ARC)。
面试题2:ARC 都帮我们做了什么?(ARC是LLVM编译器(自动添加retain和release)和Runtime系统(自动清空弱引用表)相互协作的一个结果)。
面试题3:weak
指针的实现原理(弱引用存放在哈希表中,当调用对象的dealloc方法时,会清空当前对象的弱引用表)。
面试题4:autorelease
对象在什么时机会被调用release
?(Runloop某次循环中或休眠前)
面试题5:方法里有局部对象, 出了方法后会立即释放吗?(如果添加了autorelease不会立即释放,出方法后会在Runloop的某次循环中或休眠前释放;如果是ARC,出方法后会立即释放)
一、引用计数
在iOS中,使用引用计数来管理OC对象的内存。
一个新创建的OC对象引用计数默认是1,当引用计数减为0,OC对象就会销毁,释放其占用的内存空间。
调用retain
方法会让OC对象的引用计数+1,调用release
方法会让OC对象的引用计数-1。
内存管理的经验总结:
- 调用
alloc
、new
、copy
、mutableCopy
方法会返回一个对象,在不需要这个对象时,要调用release
或者autorelease
来释放它。 - 想拥有某个对象,就让它的引用计数+1。
- 不想再拥有某个对象,就让它的引用计数-1。
在Xcode中,默认需要手动关闭ARC:
1 | @implementation DBPerson |
对象在调用release
后,如果retainCount
为0就会调用dealloc
方法,最后被释放。
手动调用release
比较繁琐,而且很容易产生内存泄漏(该释放的对象没有释放),因此也可以使用autorelease
,让对象自动在恰当的时机自动释放(参考本章节的第四部分)。
1 | DBPerson *person = [[[DBPerson alloc] init] autorelease]; |
基本数据类型不需要内存管理。更多的MRC操作请查阅相关资料。
引用计数是怎么存储的?
在64bit中,引用计数可以直接存储在优化过的isa指针中(参考前面介绍isa结构体的章节),也可能存储在SideTable
类中:
1 | struct SideTable { |
refcnts
是一个存放着对象引用计数的散列表(key是当前对象,value是引用计数的值)。
二、深浅拷贝
copy的目的:产生一个副本对象,跟源对象互不影响(修改了源对象,不影响副本对象;修改了副本对象,不影响源对象)。
iOS提供了2个copy方法:
copy
:不可变拷贝,产生不可变副本。mutableCopy
:可变拷贝,产生可变副本。
深拷贝:内容拷贝,产生新的对象。
浅拷贝:指针拷贝,没有产生新的对象。
示例代码:
1 | NSString *str1 = [[NSString alloc] initWithFormat:@"abc"]; |
平时开发中,我们习惯把声明字符串属性时使用copy
修饰,为什么呢?
1 | @property (nonatomic, copy) NSString *name; |
上面的属性name使用copy
修饰后,setter方法内部其实是这样的:
1 | - (void)setName:(NSString *)name { |
不可变或可变对象进行copy
返回的是不可变对象,所以字符串使用copy
修饰后,外面传入的对象和成员变量指向的是同一个内存地址,这样也能保证当前属性存储的数据可维护性更高。
面试题:下面代码能否正常运行?为什么?
1 | @interface DBPerson : NSObject |
上面的代码编译时期没有问题,但是运行时会崩溃报错,因为使用copy
修饰的属性,在setter方法内部会让成员变量指向copy
返回的对象,由于返回的是不可变对象(copy
后返回的是NSArray
类型),所以调用NSMutableArray
的方法会报错。
Foundation框架中类似NSString的还有NSDictionary、NSMutableDictionary、NSArray、NSMutableArray、NSData、NSMutableData、NSSet、NSMutableSet等等。建议声明属性时,如果是字符串就使用
copy
,其他对象类型就使用strong
。
自定义对象不能直接调用copy
或mutableCopy
把源对象的属性都拷贝一份呢?
不可以,需要遵守NSCopying
协议,并实现copyWithZone:
协议,或遵守NSMutableCopying
并实现mutableCopyWithZone:
协议。
1 | @interface DBPerson : NSObject <NSCopying> |
三、weak指针
1 | @interface DBPerson : NSObject |
使用__strong
修饰的强指针person1
对person
进行了强引用,所以person
对象在离开viewDidLoad
方法前不会被释放。
如果使用__weak
修饰的弱指针person2
对person
进行弱引用,在person
离开作用域前会被释放,person2
也会被置为nil
。最终的输出结果是:
1 | NSLog(@"111"); |
如果使用__unsafe_unretained
修饰的不安全弱指针person3
对person
进行弱引用,在person
离开作用域前会被释放,person3
不会被置为nil
,因此释放后访问person3
会报野指针错误(person3
指向的对象已经被释放,但还是指向那块内存地址)。
1 | NSLog(@"111"); |
_weak
比__unsafe_unretained
安全,所以建议使用__weak
。
__weak
修饰的指针是怎么做到当对象销毁时__weak
指针被置为nil
的?
当一个对象要释放时,会自动调用dealloc
(__weak
指针是在对象执行dealloc
后被置为nil
的),接下来的调用轨迹是:
1 | dealloc |
查看objc的源码:
1 | void *objc_destructInstance(id obj) { |
clearDeallocating()
内部会根据当前对象的内存地址,通过哈希查找方式到SideTable
中清除refcnts
。
四、自动释放池
示例代码(MRC):
1 | int main(int argc, const char * argv[]) { |
上面代码中,person对象加了autorelease后,会在离开最近的大括号前自动释放,在大括号内可以安全使用person。那么person对象究竟是在什么时候释放呢?
把上面的代码转为C++:
1 | struct __AtAutoreleasePool { |
创建person对象的代码被自动释放池的构造函数和析构函数所包围(以下为简化的伪代码):
1 | { |
因此我们只需要研究autoreleasepool
内部的逻辑就可以知道对象时如何被释放的。
自动释放池的主要底层数据结构是:__AtAutoreleasePool
、AutoreleasePoolPage
。
调用了autorelease
的对象最终都是通过AutoreleasePoolPage
对象来管理的。
源码分析(objc源码中NSObject.mm
):clang重写@autoreleasepool
。
1 | class AutoreleasePoolPage { |
AutoreleasePoolPage
的结构:
每个AutoreleasePoolPage
对象占用4096字节内存,除了用来存放它内部的成员变量,剩下的空间用来存放autorelease
对象的地址,所有的AutoreleasePoolPage
对象通过双向链表的形式连接在一起。
begin()
:从AutoreleasePoolPage的哪个位置开始存储autorelease
对象的地址。
1 | id * begin() { |
end()
:返回AutoreleasePoolPage的结束地址。
1 | id * end() { |
child
:指向下一个AutoreleasePoolPage节点,如果是最后一个节点,child指向nil。
parent
:指向上一个AutoreleasePoolPage节点,如果是第一个节点,parent指向nil。
id *next
:指向下一个能存放autorelease
对象地址的区域。
调用objc_autoreleasePoolPush
方法会将一个POOL_BOUNDARY
入栈(放到AutoreleasePoolPage中),并且返回其存放的内存地址(这一步其实就是设置本次autorelease的栈底边界)。
1 |
只要对象调用了autorelease
,就会自动把这个对象内存地址入栈(放到AutoreleasePoolPage中)。
调用objc_autoreleasePoolPop
方法时传入一个POOL_BOUNDARY
的内存地址(objc_autoreleasePoolPush
返回的地址),会从最后一个入栈的对象开始发送release
消息,直到遇到这个POOL_BOUNDARY
,本次autorelease
释放结束。
可以通过以下私有函数来查看自动释放池的情况(这个是系统自带的私有函数,内部实现未暴露,我们只需要声明该函数就可以直接使用):
1 | extern void _objc_autoreleasePoolPrint(void); |
Runloop和Autorelease:
示例代码:
1 | // main.m |
上面代码中,person对象调用了autorelease,那么person对象什么时候被释放呢?
在main函数的自动释放池结束后?不可能,只要程序不退出@autoreleasepool
就不会退出,更不可能释放person对象。
其实iOS的main函数内部维护了一个主线程的Runloop,这个Runloop中默认注册了2个Observer(_wrapRunLoopWithAutoreleasePoolHandler
):
第1个Observer:
- 监听了
kCFRunLoopEntry
事件,会调用objc_autoreleasePoolPush()
。
- 监听了
第2个Observer:
- 监听了
kCFRunLoopBeforeWaiting
事件,会调用objc_autoreleasePoolPop()
、objc_autoreleasePoolPush()
。 - 监听了
kCFRunLoopBeforeExit
事件,会调用objc_autoreleasePoolPop()
。
- 监听了
也就是说person对象什么时候释放是由Runloop控制的(在Runloop某次循环中或休眠前释放)。