iOS底层原理探索—内存管理(一)

作者: iOS弗森科 | 来源:发表于2019-10-08 16:38 被阅读0次

    探索底层原理,积累从点滴做起

    往期回顾

    iOS底层原理探索 — OC对象的本质

    iOS底层原理探索 — class的本质

    iOS底层原理探索 — KVO的本质

    iOS底层原理探索 — KVC的本质

    iOS底层原理探索 — Category的本质(一)

    iOS底层原理探索 — Category的本质(二)

    iOS底层原理探索 — 关联对象的本质

    iOS底层原理探索 — block的本质(一)

    iOS底层原理探索 — block的本质(二)

    iOS底层原理探索 — Runtime之isa的本质

    iOS底层原理探索 — Runtime之class的本质

    iOS底层原理探索 — Runtime之消息机制

    iOS底层原理探索 — RunLoop的本质

    iOS底层原理探索 — RunLoop的应用

    iOS底层原理探索 — 多线程的本质

    iOS底层原理探索 — 多线程的经典面试题

    iOS底层原理探索 — 多线程的“锁”

    前言

    内存管理在APP开发过程中占据着一个很重要的地位,在iOS中,系统为我们提供了ARC的开发环境,帮助我们做了很多内存管理的内容,其实在MRC时代,内存管理对于开发者是个很头疼的问题。我们会通过几篇文章的分析,来帮助我们了解iOS中内存管理的原理,以及在ARC的开发环境下系统帮助我们做了哪些内存管理的操作。

    iOS程序的内存布局

    我们通过一张图展示iOS程序的内存布局:

    内存布局.png

    在iOS程序的内存中,从底地址开始,到高地址一次分为:程序区域、数据区域、堆区、栈区。其中程序区域主要是代码段,数据区域包括数据段和BSS段。我们具体分析一下各个区域所代表的含义:

    代码段: 存放编译后的代码,内存区域较小。程序结束时系统会自动回收存储在代码段中的数据。

    数据段: 也叫常量区,保存已初始化的全局变量、静态变量等。直到程序结束的时候才会被回收。

    BSS段: 也叫静态区,保存未被初试化的全局变量、静态变量。一旦初始化就会被回收,并且将数据转存到数据段中。

    堆区(heap): 保存由alloc创建出来的对象,动态分配内存。需要程序员来进行内存管理。从底地址到高地址分配内存空间

    栈区(stack): 保存局部变量,自动分配内存,系统管理。当局部变量的作用域执行完毕后就会被系统立即回收。从高地址到底地址分配内存空间

    Tagged Pointer技术

    在 2013 年 9 月,苹果推出了 iPhone5s 。iPhone5s 配备了首个采用 64 位架构的 A7 双核处理器。为了节省内存和提高执行效率,苹果提出了Tagged Pointer的概念,用于优化NSNumber、NSDate、NSString等小对象的存储。

    在没有使用Tagged Pointer之前, NSNumber等对象需要动态分配内存、维护引用计数等,NSNumber指针存储的是堆中NSNumber对象的地址值。

    例如下面这句代码:

    NSNumber*number=@10;

    在没有使用Tagged Pointer之前,内存中包括一个占8字节的指针变量number,和一个占16字节的NSNumber对象,指针变量number指向NSNumber对象的地址。这样需要耗费24个字节内存空间。

    未使用TaggedPointer.png

    使用Tagged Pointer之后,NSNumber指针里面存储的数据变成了:Tag + Data,也就是将数据直接存储在了指针中。

    直接将数据10保存在指针变量number中,这样仅占用8个字节。

    使用了TaggedPointer.png

    当然,当指针不够存储数据时,就会使用动态分配内存的方式来存储数据。

    我们用代码来验证一下:

    测试.png

    在测试代码中创建7个NSNumber类型的对象,分别赋值后打印地址,可以看出使用Tagged Pointer之后,NSNumber指针里面存储着对象的值。其中number7由于赋了一个很大的值,指针不够存储,就使用了动态分配内存的方式来存储number7的值。

    当然,以上测试代码要运行在64位环境下。

    接下来我们通过一道面试题来帮助我们理解:

    以下两段代码的执行结果是什么?

    //第1段代码dispatch_queue_t queue=dispatch_get_global_queue(0,0);for(inti=0;i<1000;i++){dispatch_async(queue,^{self.name=[NSString stringWithFormat:@"asdasdefafdfa"];});}NSLog(@"end");

    //第2段代码dispatch_queue_t queue=dispatch_get_global_queue(0,0);for(inti=0;i<1000;i++){dispatch_async(queue,^{self.name=[NSString stringWithFormat:@"abc"];});}NSLog(@"end");

    答案是第1段代码会崩溃,报出坏内存访问的错误;第2段代码正常打印end

    这是为什么呢?

    这就涉及到我们上文讲到的Tagged Pointer技术。我们先来看第1段代码中self.name = [NSString stringWithFormat:@"asdasdefafdfa"];这句代码,这句代码的意思将后面的值赋给self.name。注意,此时要赋的值是一长串字符串,name的指针的8个字节已经存储不下这个字符串了,那么就会动态分配内存的方式来存储,就是调用name的set方法。

    我们知道,在set方法内部,会首先调用[_name release]释放旧值,再赋新值。但是我们赋值的代码是在子线程中异步执行的,那么就存在同时会有多条线程同时调用[_name release],这就出现问题了。

    问题的解决方法很简单,可以把name的nonatomic修饰符改成atomic,这一点我们在iOS底层原理探索 —多线程的读写安全中讲到过atomic的作用,这里不再赘述。或者最直接有效的解决方案就是在异步复制时进行加锁和解锁即可。以保证线程安全。

    那么第2段代码为什么能执行成功呢?原因很简单,由于Tagged Pointer技术,name的指针的8个字节足以存放字符串abc,就不涉及调用name的set方法。所以能够成功打印end。

    MRC中的内存管理

    在iOS中,使用引用计数的技术来管理OC对象的内存:

    一个新创建的OC对象引用计数默认是1,当引用计数减为0,OC对象就会销毁,释放其占用的内存空间。调用retain会让OC对象的引用计数+1,调用release或者autorelease会让OC对象的引用计数-1

    我们在上文中提到了在set方法内部,会首先调用[_name release]释放旧值,再赋新值。

    在MRC时代,程序员需要手动的去管理内存,创建一个对象时,需要在set方法和get方法内部添加释放对象的代码。并且在对象的dealloc里面添加释放的代码。

    我们用几个简单的例子来看一下:

    使用assign关键字修饰的数据常量,set方法和get方法内部直接赋值和取值

    @property(nonatomic,assign)intage;-(void)setAge:(int)age{_age=age;}-(int)age{return_age;}

    使用strong关键字修饰的对象,set方法内部需要先释放旧值,再retain新值

    @property(nonatomic,strong)Person*person;-(void)setPerson:(Person*)person{if(_person!=person){[_person release];_person=[person retain];}}-(Person*)person{return_person;}

    使用copy关键字修饰的对象,set方法内部需要先释放旧值,再copy新值

    @property(nonatomic,copy)NSArray*data;-(void)setData:(NSArray*)data{if(_data!=data){[_data release];_data=[data copy];}}

    ARC的内存管理

    在ARC环境中,我们不再像以前一样自己手动管理内存,系统帮助我们做了release或者autorelease等事情。

    ARC是LLVM编译器和RunTime协作的结果。其中LLVM编译器自动生成release、reatin、autorelease的代码,像weak弱引用这些则靠RunTime在运行时释放。

    引用计数

    上文我们讲到在iOS中,使用引用计数的技术来管理OC对象的内存,那么引用计数是如何存储的呢?我们之前在iOS底层原理探索 — Runtime之isa的本质一文中讲过在__arm64__架构之后,isa指针不单单只存储了类对象和元类对象的内存地址,而是使用共用体的方式存储了更多信息。其中就包括引用计数。

    我们再来回顾一下isa指针内部存储的内容:

    struct {    // 0代表普通的指针,存储着类对象、元类对象的内存地址。    // 1代表优化后的使用位域存储更多的信息。    uintptr_t nonpointer        : 1;    // 是否有设置过关联对象,如果没有,释放时会更快    uintptr_t has_assoc        : 1;    // 是否有C++析构函数,如果没有,释放时会更快    uintptr_t has_cxx_dtor      : 1;    // 存储着类对象、元类对象对象的内存地址信息    uintptr_t shiftcls          : 33;    // 用于在调试时分辨对象是否未完成初始化    uintptr_t magic            : 6;    // 是否有被弱引用指向过。    uintptr_t weakly_referenced : 1;    // 对象是否正在释放    uintptr_t deallocating      : 1;    // 引用计数器是否过大无法存储在isa中    // 如果为1,那么引用计数会存储在一个叫SideTable的类的属性中    uintptr_t has_sidetable_rc  : 1;    // 里面存储的值是引用计数器减1    uintptr_t extra_rc          : 19;};

    我们可以看到,在extra_rc里面存储的值是引用计数器减1,但是当extra_rc的19位内存不够存储引用计数时,has_sidetable_rc的值就会变为1,那么此时引用计数会存储在一个叫SideTable的类的属性中。

    SideTable.png

    SideTable类中有一个RefcountMap类型的散列表,这个散列表中就存放着引用计数。

    我们来到源码文件NSObject.mm文件看一下源码:

    在源码中,retainCount方法内部会调用rootRetainCount方法,在rootRetainCount方法,内部会做一系列的引用计数操作:

    rootRetainCount源码.png

    经过一系列判断,如果has_sidetable_rc的值就会为1时,说明此时引用计数会存储在SideTable的类RefcountMap散列表中。然后通过sidetable_getExtraRC_nolock()函数去获取引用计数。

    sidetable_getExtraRC_nolock.png

    sidetable_getExtraRC_nolock函数内部,也是先通过key找到对应的SideTable,在SideTable中通过key找到RefcountMap散列表,在散列表中拿到refcnts,即引用计数,然后返回。

    今天对于内存管理的分析就到这里,我会在后续的文章中继续为大家分析有关内存管理的知识。

    相关文章

      网友评论

        本文标题:iOS底层原理探索—内存管理(一)

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