搞懂Objective-C中的ARC

作者: 野码道人 | 来源:发表于2021-06-07 01:52 被阅读0次

    写这篇文章的背景

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

    相关文章

      网友评论

        本文标题:搞懂Objective-C中的ARC

        本文链接:https://www.haomeiwen.com/subject/qdznsltx.html