搞懂Objective-C中的ARC

写这篇文章的背景

前段时间招人,面试了一个多月,有关内存的基础问题,能完全答出来的竟无一人,回答出百分之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的时候检测标志位,如在优化流程中则直接返回对象并且重置标志位

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容