美文网首页iOS进阶内存管理iOS Kit
iOS底层原理探索—内存管理(一)

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

作者: 劳模007_Mars | 来源:发表于2019-08-19 21:21 被阅读10次

探索底层原理,积累从点滴做起。大家好,我是Mars。

往期回顾

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的概念,用于优化NSNumberNSDateNSString等小对象的存储。

在没有使用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 (int i = 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 (int i = 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个字节已经存储不下这个字符串了,那么就会动态分配内存的方式来存储,就是调用nameset方法。

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

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

那么第2段代码为什么能执行成功呢?原因很简单,由于Tagged Pointer技术,name的指针的8个字节足以存放字符串abc,就不涉及调用nameset方法。所以能够成功打印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) int age;

- (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等事情。
ARCLLVM编译器RunTime协作的结果。其中LLVM编译器自动生成releasereatinautorelease的代码,像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进阶


iOS进阶.jpg

相关文章

网友评论

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

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