写这篇文章的背景
前段时间招人,面试了一个多月,有关内存的基础问题,能完全答出来的竟无一人,回答出百分之80的人也寥寥无几,于是决定写这篇文章,简单业务流水线道友们一般都能写出符合需求,可以正常工作的代码,稍微复杂点的也许也不再话下,一旦涉及到性能、鲁棒性等要求很高的项目,不能真正理解内存的程序员将给整个项目带来灾难和隐藏的坑,所以本文旨在让道友们真正理解内存,这是再基础不过的东西,然而又是必须知道的东西,让我们一起,重温下基础吧,本文不打算大量罗列源码,而是从显而易见的东西开始
先从一个小问题开始
面试官:alloc的对象都存储在堆上是吗?
候选人:是的
面试官:好的,静态变量存储在数据段是吗?
候选人:是的,未初始化的存储在bss段,初始化的存储在data段
面试官:很好,不错,看一段代码,这两行代码可以写成一行吗
static NSObject *obj = nil;
obj = [[NSObject alloc] init];
像这样:
static NSObject *obj = [[NSObject alloc] init];
候选人:应该可以吧(get不到问题的点)
面试官:那内存是如何分布的呢?
候选人:不可能同时存储在数据段和堆区吧(小声嘀咕)
面试官:顺着这个思路再思考下
候选人:。。。(过去三分钟)
面试官:好的,换种问法,单例很常见吧,那么它可以手动释放吗?
候选人:既然用单例来实现,说明整个程序生命周期需要共享一个实例,不会存在需要释放的场景
面试官:比如一个app里面有很多业务线,业务线退出的时候,需要清理业务线所占内存,如若有单例存在,这个时候可以手动释放吗?
候选人:把指针置为nil?,应该可以吧(试图得到面试官的提示)
面试官:那这以后呢,单例就不占用内存了吗?
候选人:。。。(彻底卡住)
Objective-C中的指针
可曾听过一句话,一切OC对象皆指针,嗯~这句话很对,我想开讲之前有必要说下究竟OC中的指针是什么,对象又是什么,它们不是一个东西嘛?iOS操作系统是基于unix的一个分支开发的,自然继承了unix内核的部分功能,内存分区为:栈、堆、数据段、常量区、代码段,本文不打算枯燥的讲理论,我们设计几个demo直接从表象出发,去探寻理论,会不会记忆更深刻呢?!
还是从上面的代码出发
static NSObject *obj = [[NSObject alloc] init];
这样是无法通过编译的,编译器提示Initializer element is not a compile-time constant
,意思是初始化的元素不是编译期分配的常量,obj指针即是分配在数据段的变量,在编译时就需要分配内存,alloc的对象内存开辟在堆上并且是运行时分配的,用运行时的对象去初始化编译期的指针是没有办法做到的,所以编译器提示我们这样做是不对的
以上得出个结论:
- 栈、堆内存是运行时分配的
- 数据段内存是编译时分配的(这么说并不完全准确,往下看)
(注:app的可执行文件二进制里面包括静态库和源码,动态库和资源文件会单独存储,系统的动态库整个操作系统共享一份)
注意我们讲的是内存,我们的可执行程序是以二进制的形式存在于手机上的,这里说的代码段并非用于存储二进制文件,而是存储程序启动时候被载入内存中的可执行代码,紧随其后,操作系统会为程序中的全局变量和静态变量在数据段开辟内存(起初会存储在bss段,初始化后会清空bss段,存储在data段),常量的内存空间开辟和初始化是一起执行的,初始化后不再有机会改变,所以准确的说:
- 代码段内存是装载时分配的,数据段和常量区紧随其后,这些都发生在动态链接之前
看一个demo:环境是x86模拟器,嗯~64位架构
@interface Person : NSObject
@property (nonatomic, assign) int a;
@property (nonatomic, assign) int b;
@property (nonatomic, assign) int c;
@end
Person *obj = nil;
NSLog(@"%lu", sizeof(obj));
NSLog(@"%lu", malloc_size((__bridge const void *)obj));
obj = [[Person alloc] init];
NSLog(@"%lu", malloc_size((__bridge const void *)obj));
NSLog(@"%lu", class_getInstanceSize(Person.class));
2021-06-05 16:41:36.982538+0800 test[69294:38540223] 8
2021-06-05 16:41:36.982640+0800 test[69294:38540223] 0
2021-06-05 16:41:36.982712+0800 test[69294:38540223] 32
2021-06-05 16:41:36.982772+0800 test[69294:38540223] 24
首先我们看Person的实例对象有哪些成员需要在堆上开辟内存空间,一个isa指针8个字节,三个int类型变量12个字节,总共20个字节
控制台输出sizeof是8,证明指针本身在64位系统占用8个字节,紧接着malloc_size输出0,证明只是一个指向nil的指针,还没有在堆区分配内存,malloc_size然后输出32证明在堆区开辟了32字节的内存,16字节为一个开辟单元是iOS系统的规范,所以要想存储20个字节就需要开辟两个单元的大小,就是32字节,最后class_getInstanceSize输出24证明对象实际占用24字节,是因为iOS系统内存存储是按照8字节对齐的,所以20个字节之后需要补齐4个字节的0用于内存对齐,无论是开辟空间对齐,还是存储对齐都是操作系统设计之初的效率考虑
好的~我们回到上面说的单例释放问题,是否可手动释放呢?答案是部分可以,部分不能,原因是,堆栈的内存动态分配,动态释放,而数据段、常量区、代码段内存直到app进程退出才会释放,所以单例指针置为nil的时候,堆区对象的引用计数为0会自动释放,而还有一个指针存储在数据段,占用8个字节
什么是ARC,引用计数存储在哪里,哪些对象是通过引用计数来管理内存的
面试官:如下代码在MRC环境会有内存泄漏,为什么?
- (void)viewDidLoad {
[super viewDidLoad];
NSObject *obj = [[NSObject alloc] init];
}
候选人:因为obj没有调用release或者autorelease
面试官:嗯,那还是MRC环境,下面的代码会泄露吗?
- (void)viewDidLoad {
[super viewDidLoad];
static NSObject *obj = nil;
obj = [[NSObject alloc] init];
}
候选人:嗯~~~会吧
面试官:你怎样理解内存泄漏,什么叫内存泄漏
候选人:就是一个对象,没有释放掉,就泄露了
面试官:啊~~~,那么能在ARC环境举个内存泄漏的例子吗
候选人:比如block是self的属性,然后里面引用了self,没有加__weak
面试官:这个是循环引用吧,所以循环引用会内存泄漏是吗
候选人:是的(over)
只有堆区对象才有引用计数,引用计数存储在对象本身的结构里,嗯~可以通过isa指针辗转访问到(结构稍微复杂,有兴趣可以看我的这篇文章:https://www.jianshu.com/p/8279c444e536),ARC是自动引用计数,即编译器在编译期在合适的位置自动插入release或是autorelease
回到上面问题
- (void)viewDidLoad {
[super viewDidLoad];
NSObject *obj = [[NSObject alloc] init];
}
MRC下如上代码泄漏的根本原因是,obj是声明在栈上的指针,作用域之外自动释放,即大括号之外指针已经不存在了,但是alloc的对象引用计数是1,但是已经没有指针引用它了,所以这块堆内存将没有机会释放了,这就是内存泄漏
那么下面的代码在MRC下为什么就没有泄漏呢
- (void)viewDidLoad {
[super viewDidLoad];
static NSObject *obj = nil;
obj = [[NSObject alloc] init];
}
原因是这个obj指针声明在数据段,生命周期和app进程生命周期一致,虽然alloc的对象引用计数也始终为1,但是有个static指针一直引用它,所以这块堆内存没有泄漏
以上明确几个常见内存问题概念:
- 野指针:堆内存已经释放,但是还有指针指向这块内存,就是野指针,访问野指针crash
- 内存泄漏:堆区内存引用计数不为0,但是没有指针指向这块内存,内存碎片
- 循环引用:堆内存之间存在相互强引用,并且没有第三种力量打破这个环,内存碎片
- OOM:堆内存开辟大小不固定,超过系统的限制,crash
- 栈溢出:栈内存大小是固定的,超过系统限制,crash
ARC下哪些对象是autorelease对象
面试官:ARC下除了__autoreleasing显式创建autorelease对象的方式,还有哪些情况会生成autorelease对象
候选人:alloc和new出来的对象都会加入默认的autoreleasePool中,所以都是autorelease对象
面试官:哦?那ARC下release关键字是被弃用了吗?
候选人:是的(斩钉截铁)
这么回答的人占3成,有些恐怖~,我们还是通过一个demo来探索下,__autoreleasing显式的创建autorelease对象比较明显,我们来聊下隐式的情况
__weak NSString *weak_String;
__weak NSString *weak_StringRelease;
__weak NSString *weak_StringAutorelease;
- (void)testArc {
[self createString];
NSLog(@"------%s------", __func__);
NSLog(@"%@", weak_String);
NSLog(@"%@\n\n", weak_StringRelease);
NSLog(@"%@\n\n", weak_StringAutorelease);
}
- (void)createString {
NSString *constAreaString = @"字面量string";
NSString *heapAreastring = [[NSString alloc] initWithFormat:@"堆区string-release"];
NSString *stringAutorelease = [NSString stringWithFormat:@"堆区string-autorelease"];
NSLog(@"%lu", malloc_size((__bridge const void *)constAreaString));
NSLog(@"%lu", malloc_size((__bridge const void *)heapAreastring));
NSLog(@"%lu", malloc_size((__bridge const void *)stringAutorelease));
weak_String = constAreaString;
weak_StringRelease = heapAreastring;
weak_StringAutorelease = stringAutorelease;
NSLog(@"------%s------", __func__);
NSLog(@"%@", weak_String);
NSLog(@"%@\n\n", weak_StringRelease);
NSLog(@"%@\n\n", weak_StringAutorelease);
}
- (void)viewDidLoad {
[super viewDidLoad];
[self testArc];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
NSLog(@"------%s------", __func__);
NSLog(@"%@", weak_String);
NSLog(@"%@\n\n", weak_StringRelease);
NSLog(@"%@\n\n", weak_StringAutorelease);
}
结果如下:
2021-06-06 00:25:44.981838+0800 test[81234:39339960] 0
2021-06-06 00:25:44.981936+0800 test[81234:39339960] 64
2021-06-06 00:25:44.982022+0800 test[81234:39339960] 64
2021-06-06 00:25:44.982116+0800 test[81234:39339960] -------[ViewController createString]------
2021-06-06 00:25:44.982213+0800 test[81234:39339960] 字面量string
2021-06-06 00:25:44.982278+0800 test[81234:39339960] 堆区string-release
2021-06-06 00:25:44.982357+0800 test[81234:39339960] 堆区string-autorelease
2021-06-06 00:25:44.982428+0800 test[81234:39339960] -------[ViewController testArc]------
2021-06-06 00:25:44.982508+0800 test[81234:39339960] 字面量string
2021-06-06 00:25:44.982578+0800 test[81234:39339960] (null)
2021-06-06 00:25:44.982656+0800 test[81234:39339960] 堆区string-autorelease
2021-06-06 00:25:44.992637+0800 test[81234:39339960] -------[ViewController viewDidAppear:]------
2021-06-06 00:25:44.992753+0800 test[81234:39339960] 字面量string
2021-06-06 00:25:44.992830+0800 test[81234:39339960] (null)
2021-06-06 00:25:44.992899+0800 test[81234:39339960] (null)
首先看字面量的方式创建的字符串malloc_size为0,说明它不在堆上,嗯~在常量区,另外两个malloc_size都是正数,证明是堆区对象,createString函数三个对象都有值,而当testArc的时候weak_StringRelease的值已经为空,即离开了createString函数的作用域就释放了,此时weak_StringAutorelease还有值,直到viewDidAppear的时候只有字面量创建的对象才能够打印出来,这个结果说明了什么呢,说明编译器做了如下优化:
- (void)createString {
//这行类型变成了__NSCFConstantString
__NSCFConstantString *constAreaString = @"字面量string";
NSString *heapAreastring = [[NSString alloc] initWithFormat:@"堆区string-release"];
//这行在末尾插入了autorelease
NSString *stringAutorelease = [[NSString stringWithFormat:@"堆区string-autorelease"] autorelease];
NSLog(@"%lu", malloc_size((__bridge const void *)constAreaString));
NSLog(@"%lu", malloc_size((__bridge const void *)heapAreastring));
NSLog(@"%lu", malloc_size((__bridge const void *)stringAutorelease));
weak_String = constAreaString;
weak_StringRelease = heapAreastring;
weak_StringAutorelease = stringAutorelease;
NSLog(@"------%s------", __func__);
NSLog(@"%@", weak_String);
NSLog(@"%@\n\n", weak_StringRelease);
NSLog(@"%@\n\n", weak_StringAutorelease);
//在作用域末尾插入了release
[heapAreastring release];
}
注意关键点,有三处变化__NSCFConstantString *constAreaString、[[NSString stringWithFormat:@"堆区string-autorelease"] autorelease]、[heapAreastring release];
- 字面量创建的直接存储在常量区
- alloc出来的存储在堆区并且作用域结束前直接插入release
- 通过stringWithFormat工厂方法创建的对象则在其后插入autorelease,这是因为工厂方法里面通过alloc分配堆内存,到返回出来以后其作用域已经结束,所以只能延迟释放了,否则没有办法返回非空对象
同样是这个demo把字符串的长度缩短,结果会很不一样
NSString *constAreaString = @"字面量";
NSString *heapAreastring = [[NSString alloc] initWithFormat:@"release"];
NSString *stringAutorelease = [NSString stringWithFormat:@"autorelease"];
2021-06-06 00:51:27.827647+0800 test[82761:39451059] 0
2021-06-06 00:51:27.827750+0800 test[82761:39451059] 0
2021-06-06 00:51:27.827843+0800 test[82761:39451059] 0
2021-06-06 00:51:27.827941+0800 test[82761:39451059] -------[ViewController createString]------
2021-06-06 00:51:27.828040+0800 test[82761:39451059] 字面量
2021-06-06 00:51:27.828139+0800 test[82761:39451059] release
2021-06-06 00:51:27.828224+0800 test[82761:39451059] autorelease
2021-06-06 00:51:27.828292+0800 test[82761:39451059] -------[ViewController testArc]------
2021-06-06 00:51:27.828369+0800 test[82761:39451059] 字面量
2021-06-06 00:51:27.828444+0800 test[82761:39451059] release
2021-06-06 00:51:27.828535+0800 test[82761:39451059] autorelease
2021-06-06 00:51:27.838554+0800 test[82761:39451059] -------[ViewController viewDidAppear:]------
2021-06-06 00:51:27.838684+0800 test[82761:39451059] 字面量
2021-06-06 00:51:27.838765+0800 test[82761:39451059] release
2021-06-06 00:51:27.838853+0800 test[82761:39451059] autorelease
我们发现他们已经都不在堆区了,而是存储在常量区,这是一项优化叫做NSTagged Pointer,即指针和对象存储在一起,这项技术是苹果公司对小对象做的优化NSString、NSNumber、NSDate。所以上述代码经过编译器的优化,就变成了下面这样
__NSCFConstantString *constAreaString = @"字面量";
NSTaggedPointerString *heapAreastring = [[NSString alloc] initWithFormat:@"release"];
NSTaggedPointerString *stringAutorelease = [[NSString stringWithFormat:@"autorelease"];
以上结论:
- 字面量创建的直接存储在常量区
- alloc出来的存储在堆区并且作用域结束前直接插入release(符合NSTagged Pointer的会直接分配在常量区,类型是NSTaggedPointer_接类型名,标识指针和对象存储在一起)
- 通过stringWithFormat工厂方法创建的对象则在其后插入autorelease,这是因为工厂方法里面通过alloc分配堆内存,到返回出来以后其作用域已经结束,所以只能延迟释放了,否则没有办法返回非空对象(符合NSTagged Pointer的会直接分配在常量区,类型是NSTaggedPointer_接类型名,标识指针和对象存储在一起)
objc_autoreleaseReturnValue 和 objc_retainAutoreleasedReturnValue的纠正
值得一提的是,即便编译器插入autorelease关键字,也不一定会将这个对象放入autoreleasePool,为了减轻autoreleasePool的负担,苹果做了一项优化,objc_autoreleaseReturnValue 和 objc_retainAutoreleasedReturnValue,这里不分析源码,直接给出上层的解释,如下,对象在加入autoreleasePool之前会调用objc_autoreleaseReturnValue,这个方法会检测后面串行的代码是否调用了objc_retainAutoreleasedReturnValue(就是一次引用计数+1的操作),如果有则不加入autoreleasePool,直接返回对象,❌引用计数不会+1
并且在当前线程存储区域做个标记,待到执行到objc_retainAutoreleasedReturnValue的时候检测标志位,如在优化流程中则直接返回对象并且重置标志位,否则加入autoreleasePool,❌引用计数+1
注意:上面一段话❌部分都是错误的理解,实际上引用计数在对象初始化后就已经存在,是对象相关联的东西,+1与否和自动释放池没有半点关系,-1与否才有关系
错误的理解如下:
优化前
id obj = objc_msgSend(objc_msgSend(NSMutableString, @selector(string)));
objc_autorelease(obj);
objc_retain(obj);
// 这里引用计数为2
objc_release(obj);
优化后
id obj = objc_msgSend(objc_msgSend(NSMutableString, @selector(string)));
// 这里引用计数为1
objc_release(obj);
而实际上呢:
NSMutableString *str = [NSMutableString string];
NSMutableString *strRetain = str;
NSLog(@"%li", CFGetRetainCount((__bridge CFTypeRef)str));
优化后retainCount的结果是2
021-06-07 00:53:55.610395+0800 test[92513:40083306] 2
所以结论是:
编译器优化后,会在执行到objc_retainAutoreleasedReturnValue的时候,不会将对象加入autoreleasePool,而是在这次引用计数+1操作之后作用域结束之前再加入一个release操作,当然错误的理解对编写代码来讲并不会产生什么影响,所以非重点,想深入的可以去看下源码:https://opensource.apple.com/source/objc4/
最后
本来打算继续探讨下autorelease对象的释放时机、为什么需要手动添加autoreleasePool、autoreleasePool的源码实现、autoreleasePool的设计哲学,不过篇幅已经很长了,下篇再继续讨论吧~
回复下评论区的提问,对象是何时被加入autoreleasepool的
有关autorelease的解析我写了一篇文章,可以看下:https://www.jianshu.com/p/91097e9d7335
这里回答下修_远的问题,第二个问题本文有相关描述,这里回答下第一个问题
简单回答如下:运行时,对象在调用autorelease的时候就开始检测后续串行代码是否有引用计数加1操作,没有的话就会直接调用AutoreleasePoolPage的add函数添加到双向链表
源码级别的回答:
id *add(id obj)
{
ASSERT(!full());
unprotect();
id *ret = next; // faster than `return next-1` because of aliasing
*next++ = obj;
protect();
return ret;
}
在NSObject.mm可以查到源码,AutoreleasePoolPage定义了一系列工具函数,其中添加到autoreleasePool中的操作是add函数,这个函数的调用栈如下:
- add
- autoreleaseFast
- autorelease
- objc_autorelease
- objc_autoreleaseReturnValue
要回答这个问题涉及到的知识有点多,我在另一篇文章中有讲:https://www.jianshu.com/p/1b15240d8d34可以详细看看
程序的最小执行流是线程,iOS系统为每个线程定义了一系列的数据结构,在线程初始化的时候就初始化相关结构,一个栈、一个autoreleasepool、一个runloop还有一个线程局部存储区域(很小),运行时执行到autorelease语句的时候,会优先检测对象是否符合NSTaggedPointer,如果符合就抛出异常,证明程序不应该进入autorelease环节,如果不符合就往下走流程,调用objc_autoreleaseReturnValue函数,优先进行检测后续串行代码是否调用了objc_retainAutoreleasedReturnValue函数,如果没有调用,就会直接调用add函数添加到双向链表,如果有引用计数+1操作,则会把一个标记存储在TLS(线程局部存储),待到执行到objc_retainAutoreleasedReturnValue的时候检测标志位,如在优化流程中则直接返回对象并且重置标志位