探索底层原理,积累从点滴做起
往期回顾
前言
内存管理在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,即引用计数,然后返回。
今天对于内存管理的分析就到这里,我会在后续的文章中继续为大家分析有关内存管理的知识。