iOS 内存管理--内存五大区、TiggedPointer、引用计数

前言

作为一名iOS开发者,内存的的只是储备是必不可少的,这篇文章会带领我们探索iOS的内存管理,继续往下走吧。

准备工作

  • Objc-818.2
  • Threading Programming Guide

1. 内存布局

内存五大分区: 栈区、堆区、全局区、常量区、代码区

内存五大区

1.1 内存五大区

  • 栈区--stack
    • 特点
      • 栈是系统数据结构,其对应的进程或者线程是唯一
      • 栈是向低地址扩展的数据结构
      • 栈是一块连续的内存区域,遵循先进后出FILO)原则
      • 栈的地址空间在iOS中是以0X7开头
      • 栈区一般在运行时分配
    • 存储内容
      • 栈区是由编译器自动分配并释放的,主要用来存储局部变量
      • 函数的参数,例如函数的隐藏参数(id self,SEL _cmd
    • 优缺点
      • 优点:因为栈是由编译器自动分配并释放的,不会产生内存碎片,所以快速高效
      • 缺点:栈的内存大小有限制数据不灵活
      • iOS主线程栈大小是1MB,其他主线程是512KBMAC只有8M

注意:传入函数的参数值、函数体内声明的局部变量等,由编译器自动分配释放,通常在函数执行结束后就释放了(不包括static修饰的变量,static意味该变量存放在全局/静态区)。

在Threading Programming Guide中有相关内存大小的说明,如下:

内存大小相关说明

  • 堆区--heep
    • 特点
      • 堆是向高地址扩展的数据结构
      • 堆是不连续的内存区域,类似于链表结构便于增删,不便于查询),
      • 遵循先进先出FIFO)原则
      • 堆的地址空间在iOS中是以0x6开头,其空间的分配总是动态
      • 堆区的分配一般是在运行时分配
    • 存储内容
      • 堆区是由程序员动态分配释放的,如果程序员不释放,程序结束后,可能由操作系统回收
      • OC中使用alloc或者使用new开辟空间创建对象
      • C语言中使用malloccallocrealloc分配的空间,需要free释放
    • 优缺点
      • 优点:灵活方便,数据适应面广泛
      • 缺点:需手动管理,速度慢容易产生内存碎片

注意:当需要访问堆中内存时,一般需要先通过对象读取到栈区的指针地址,然后通过指针地址访问堆区。因为现在iOS基本都使用ARC来管理对象,所以也不需要手动释放

  • 全局区(静态区)(BSS段)

    • BSS段bss segment)通常是指用来存放程序中未初始化的或者初始值为0全局变量的一块内存区域BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配
    • 数据段:数据段(data segment)通常是指用来存放程序中已初始化的全局变量的一块内存区域,数据段属于静态内存分配
    • 全局区是编译时分配的内存空间,在iOS中一般以0x1开头,在程序运行过程中,此内存中的数据一直存在,程序结束后由系统释放,主要存放
      • 未初始化的全局变量和静态变量,即BSS区.bss
      • 已初始化的全局变量和静态变量,即数据区.data
    • static修饰的变量会成为静态变量,该变量的内存由全局/静态区编译阶段完成分配,且仅分配一次
    • static可以修饰局部变量也可以修饰全局变量
  • 常量区(数据段)

    • 常量区是编译时分配的内存空间,在iOS中一般以0x1开头,在程序结束后由系统释放
    • 通常是指用来存放程序中已经初始化的全局变量和静态变量的一块内存区域。数据段属于静态内存分配,可以分为只读数据段读写数据段。字符串常量等,是放在只读数据段中,结束程序时才会被收回。
  • 代码区(代码段)

    • 代码区是编译时分配主要用于存放程序运行时的代码,代码会被编译成二进制存进内存
    • 代码区需要防止在运行时被非法修改,所以只准许读取操作,而不允许写入(修改)操作——它是不可写的

补充:除了以上内存区域外,系统还会保留一些内存区域

1.2 内存分区验证

1.2.1 栈区

验证代码如下:

- (void)testStack{
    NSLog(@"************栈区************");

    // 栈区
    int a = 10;
    int b = 20;
    NSObject *object = [NSObject new];

    NSLog(@"a == \t%p",&a);
    NSLog(@"b == \t%p",&b);
    NSLog(@"object == \t%p",&object);

    NSLog(@"%lu",sizeof(&object));
    NSLog(@"%lu",sizeof(a));
}

上面代码中,a、b、object都是局部变量,这些变量都存储在栈区。运行结果如下:

运行结果

1.2.2 堆区

验证代码如下:

- (void)testHeap{
    NSLog(@"************堆区************");
    // 堆区
    NSObject *object1 = [NSObject new];
    NSObject *object2 = [NSObject new];
    NSObject *object3 = [NSObject new];
    NSLog(@"object1 = %@",object1);
    NSLog(@"object2 = %@",object2);
    NSLog(@"object3 = %@",object3);
    // 访问---通过对象->堆区地址->存在栈区的指针
}

代码创建了三个变量,这三个变量都存储在栈区,这些变量存储的指针都指向堆区的对象。运行结果如下:

运行结果

1.2.3 全局区、常量区

验证代码如下:

int clA;
int clB = 10;

static int bssA;
static NSString *bssStr1;
static int bssB = 10;
static NSString *bssStr2 = @"hello";
static NSString *name = @"name";

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"************栈区************");
    int sa = 10;
    NSLog(@"bssA == \t%p",&sa);
    NSLog(@"************全局区************");
    NSLog(@"clA == \t%p",&clA);
    NSLog(@"bssA == \t%p",&bssA);
    NSLog(@"bssStr1 == \t%p",&bssStr1);
    NSLog(@"clB == \t%p",&clB);
    NSLog(@"bssB == \t%p",&bssB);
    NSLog(@"bssStr2 == \t%p",&bssStr2);
    NSLog(@"bssStr2 == \t%p",&name);
}

通过打印全局区的变量的地址与栈区变量进行对比,运行结果如下:


运行结果

2. TiggedPointer小对象

2.1 什么是小对象

iOS开发中,一个对象至少要8个字节,但是对于一些数据来说是有些浪费的,比如NSNumberNSDateNSString(小字符串)。所以64位环境下,引入了Tagged Pointer技术,用一个小对象来存储这些数据。以字符串为例,见下图:

字符串小对象

通过以上案例发现,str1str4的区别,str1的类型是NSTaggedPointerString,而str4__NSCFString类型。同时通过控制台输出地址发现,其余堆区的地址也有很大的区别,如下:
运行输出

2.2 案例分析

通过以下的案例,我们继续分析他们之间的区别。

  • 案例1
    案例1
  • 案例2
    案例2
  • 运行结果
    分别运行上面两个案例,得出的结果分别是
    • 案例1出现报错
    • 案例2能够正常运行

打开汇编,查看案例1报错的信息,如下:

查看案例1报错信息

分析其报错的原因是坏内存访问,这是什么回事呢?

  • 原因分析
    set方法实际就是新值的retain旧值的release。由于nameStr修饰为nonatomic所以是线程不安全的。当多条线程同时访问,造成多次release,所以会出现坏内存访问

  • 解决方案
    修饰改为atomic或者加锁

  • 为什么案例2可以正常运行呢?
    案例1中,设置断点,发现此时nameStr数据类型为__NSCFString,如下:

    查看案例1字符串类型

    而在案例2中,nameStr数据类型为TiggedPointer,如下:
    查看案例1字符串类型

  • 正常对象都是指针指向堆内存中的地址,所以案例1会因为多线程访问而造成坏内存访问,而TaggedPointer存储在常量区不会创建内存

  • 在进行对象释放时,针对TiggedPointer类型进行了过滤处理,也就说TiggedPointer类型不会对引用计数进行处理

  • release方法源码

    release源码

2.3 TiggedPointer原理分析

在之前类加载原理分析中的_read_images方法中已经探索到了TiggedPointer方面的内容,如下:

_read_images

通过initializeTaggedPointerObfuscator方法,实现TaggedPointer指针混淆器的初始化,实现源码如下:
initializeTaggedPointerObfuscator

也就是说,上面案例中,我们通过%p打印TaggedPointer对象地址时得到的内容,是指针经过混淆器换算后得到的结果

接着根据objc_debug_taggedpointer_obfuscator方法,针对TaggedPointer对象的指针编码解码算法:,如下:


通过上面的算法可以发现,编码过程为

uintptr_t value = (objc_debug_taggedpointer_obfuscator ^ ptr);

解码过程为

value ^ objc_debug_taggedpointer_obfuscator;

找到了编码解码算法,我们可以将小对象输出的地址进行解码,得到他原来的指针内容。见下面处理流程:

小对象解码

0xa000000000000621就是解码得到的结果,这又有什么意义呢?请继续往下探索。

2.3.1 TaggedPointer指针类型分析

TaggedPointer相关的源码中,找到了下面这个代码:

static inline bool 
_objc_isTaggedPointer(const void * _Nullable ptr)
{
    return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}

#if OBJC_SPLIT_TAGGED_POINTERS
# define _OBJC_TAG_MASK (1UL<<63)

判断一个对象是否为TaggedPointer类型,通过对象指针&_OBJC_TAG_MASK之后并等于_OBJC_TAG_MASK自己;而这个mask高位为1,其余都为064位数值。也就是说如果一个对象的高位地址是1,则视为小对象

引入案例进行分析:

案例分析

通过上面的案例的输出结构,基本可以确定,高位的0xa代表NSString0xb代表NSNumber0xe代表NSDate。我们来还原一下:

  • 0xa -> 1010
  • 0xb -> 1011
  • 0xe -> 1110
    可以发现高位都是1,所以这些都是TaggedPointer类型,也就是小对象。那么如果移除高位的1,剩下的位就应该是代表tag,即:
  • 0xa -> 1010 -> 010 表示NSString
  • 0xb -> 1011 -> 011 表示NSNumber
  • 0xe -> 1110 -> 110 表示NSDate

那么类型是不是这样子标记的呢?那么我们根据源码继续探索,如下:

_objc_makeTaggedPointer

在小对象类型进行标记时,传入了objc_tag_index_t类型的tag,查看objc_tag_index_t的定义,如下:

enum objc_tag_index_t : uint16_t
#else
typedef uint16_t objc_tag_index_t;
enum
#endif
{
    // 60-bit payloads
    OBJC_TAG_NSAtom            = 0, 
    OBJC_TAG_1                 = 1, 
    OBJC_TAG_NSString          = 2, 
    OBJC_TAG_NSNumber          = 3, 
    OBJC_TAG_NSIndexPath       = 4, 
    OBJC_TAG_NSManagedObjectID = 5, 
    OBJC_TAG_NSDate            = 6,

    // 60-bit reserved
    OBJC_TAG_RESERVED_7        = 7, 
    ......
}
  • NSString = 2 -> 010
  • NSNumber = 3 -> 011
  • NSDate = 6 -> 110

结论:好明显就是跟我们猜想的完全一致!!!!小对象类型地址包含了类型!

2.3.2 TaggedPointer值分析

在上面分析中只是找出了小对象的标记为以及小对象类型的tag,那么值存储在哪里呢?请继续往下走哦!
同样我们引入一个案例:

案例分析

在上面的案例中,我们可以发现指针的末尾位表示小对象的长度。那么数值存储在哪呢?WWDC的相关说明中提到,如需要获取其内部的数值,需要查看二进制按位获取对应的数值。分析过程如下:
获取对应的二进制位

通过上面可以看出,小对象的指针包含了对象类型对象的值对象的长度信息。

2.4 总结

通过解读源码和案例的分析,我们发现小对象在进行释放操作时会被过滤不会执行相关的释放流程,其是存储在常量区,并不会进行内存的申请和释放效率高更高了。

3. 引用计数

我们知道内存管理方案分为MRCARC,但是不管是哪种方案,都是对引用计数的处理,这些方法涉及:allocdeallocrealeaseretainretainCountautorealease等。
MRC环境下,需要我们手动调用这些方法,ARC环境,系统会自动帮我们调用。那么这些方法的实现原理是怎样的呢?请继续往下走。

(isa详解)[https://www.jianshu.com/p/6a295edebf69]文章中详细说明了nonpointer isa,使用了结构体位域,针对arm64架构和x86架构提供了不同的位域设置规则。其中包括了两个重要的字段:has_sidetable_rc引用计数表和extra_rc对象引用计数。

那么它们之间的关系是怎么样子的呢?alloc与retain方法肯定是绕不开的探索方向了,我们在前面的章节中,已经分析了alloc的处理流程,完成isa的创建,并初始化引用计数为1。见下图:

alloc流程

retain也会对对象的引用计数进行操作,下面从retain方法开始分析。

3.1 retain方法

找到retain方法的实现源码,如下:

retain

其调用了rootRetain方法,查找rootRetain的实现源码,如下:
rootRetain

处理好rootRetain方法内部的判断逻辑关系,发现do while是这个方法的核心部分,我们就重点分析这一部分的内容。

在方法的一开始就进行了判断,当前对象是否为TaggedPointer类型,也就是小对象,如果是小对象直接返回不处理,所以小对象不进行引用计数方面的处理,也不需要进行内存的开辟和释放,由系统自动完成

do...while循环中进行isa中引用计数相关的操作,在while判断语句中,调用StoreExclusive方法完成新老isa的比对替换操作,成功后跳出循环。

在循环中,首先判断如果不是nonpointer isa,则处理对象对应的散列表SideTable的引用计数。见下面代码:

    if (slowpath(!newisa.nonpointer)) {
            ClearExclusive(&isa.bits);
            if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
            else return sidetable_retain(sideTableLocked);
    }
    
    id
    objc_object::sidetable_retain(bool locked)
    {
    #if SUPPORT_NONPOINTER_ISA
        ASSERT(!isa.nonpointer);
    #endif
        SideTable& table = SideTables()[this];
        if (!locked) table.lock();
        size_t& refcntStorage = table.refcnts[this];
        if (! (refcntStorage & SIDE_TABLE_RC_PINNED)) {
            refcntStorage += SIDE_TABLE_RC_ONE;
        }
        table.unlock();
        
        return (id)this;
    }

流程是:从系统维护的SideTables中找到自己所在的散列表SideTable,再找到自己引用计数表的存储空间,对自己的引用计数进行加操作

散列表的相关内容,在分析弱引用的时候已经说明:(iOS weak实现原理和销毁过程)[https://www.jianshu.com/p/1b566137b3fe]

如果以上内容都不满足,则会进行isaextra_rc属性的操作,也就是对引用计数加1,不同框架下extra_rc所在isa的位置不同,所以RC_ONE位域值也不同。见下面代码:

    uintptr_t carry;
    newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  //extra_rc++

carry用于判断extra_rc字段是否已经存储满了,如果已满,则执行下面的这段代码:

 if (slowpath(carry)) {
            // newisa.extra_rc++ overflowed
            if (variant != RRVariant::Full) {
                ClearExclusive(&isa.bits);
                return rootRetain_overflow(tryRetain);
            }
            // Leave half of the retain counts inline and 
            // prepare to copy the other half to the side table.
            if (!tryRetain && !sideTableLocked) sidetable_lock();
            sideTableLocked = true;
            transcribeToSideTable = true;
            newisa.extra_rc = RC_HALF;
            newisa.has_sidetable_rc = true;
        }

如果extra_rc已满,会将extra_rc所能存储的容量的一半放到,对象对应的散列表中。见下面这段代码:

  if (variant == RRVariant::Full) {
        if (slowpath(transcribeToSideTable)) {
            // Copy the other half of the retain counts to the side table.
            sidetable_addExtraRC_nolock(RC_HALF);
        }
....
}

sidetable_addExtraRC_nolock方法的源码实现如下:

bool 
objc_object::sidetable_addExtraRC_nolock(size_t delta_rc)
{
    ASSERT(isa.nonpointer);
    SideTable& table = SideTables()[this];

    size_t& refcntStorage = table.refcnts[this];
    size_t oldRefcnt = refcntStorage;
    // isa-side bits should not be set here
    ASSERT((oldRefcnt & SIDE_TABLE_DEALLOCATING) == 0);
    ASSERT((oldRefcnt & SIDE_TABLE_WEAKLY_REFERENCED) == 0);

    if (oldRefcnt & SIDE_TABLE_RC_PINNED) return true;

    uintptr_t carry;
    size_t newRefcnt = 
        addc(oldRefcnt, delta_rc << SIDE_TABLE_RC_SHIFT, 0, &carry);
    if (carry) {
        refcntStorage =
            SIDE_TABLE_RC_PINNED | (oldRefcnt & SIDE_TABLE_FLAG_MASK);
        return true;
    }
    else {
        refcntStorage = newRefcnt;
        return false;
    }
}

在进行散列表操作时进行了的操作,这样会影响性能,所以在extra_rc满状态下,会将其满状态的一半放到散列表中,避免频繁操作散列表。同时extra_rc满状态也不是频繁的出现slowpath(carry),所以满状态的一半已经有相当大的存储空间了!

3.2 release方法

release的处理流程也就很容易理解了,对引用计数的反向操作。找到release的实现源码,如下:

release

释放时也会判断当前的对象是否为小对象TaggedPointer,如果是小对象就不需要对引用计数进行处理。如果不是小对象则调用release方法。继续跟踪代码,最终会调用到rootRelease方法,如下:
rootRelease

同样其依然会进行判断是否为nopointerisa、是否正在释放,如果不是,则进行extra_rc减1操作,见下面代码:

 uintptr_t carry;
        newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc--
        if (slowpath(carry)) {
            // don't ClearExclusive()
            goto underflow;
        }

同时也会设置一个标记位carry,用于判断extra_rc是否已经被清空。如果此时extra_rc的引用计数值为0,则会走到underflow流程中。在underflow中,首先判断该对象是否存在散列表,如果存在,则从散列表中移除一些引用计数到extra_rc中,见下面代码:

underflow

其中的核心操作代码如下:

    // Try to remove some retain counts from the side table.        
    auto borrow = sidetable_subExtraRC_nolock(RC_HALF);
    
    bool emptySideTable = borrow.remaining == 0; // we'll clear the side table if no refcounts remain there
    
    // 设置extra_rc,并对散列表进行设置,是否清空散列表
    newisa.extra_rc = borrow.borrowed - 1;  // redo the original decrement too
    newisa.has_sidetable_rc = !emptySideTable;

在此过程中,会将散列表中的一部分引用计数赋值到extra_rc中,同时,根据剩余引用数,来设置散列表是否需要清空。如果此时散列表被设置为emptySideTable,空,则会调用sidetable_clearExtraRC_nolock方法将该SideTable从SideTables中抹除:

 if (emptySideTable)
                sidetable_clearExtraRC_nolock();

void
objc_object::sidetable_clearExtraRC_nolock()
{
    ASSERT(isa.nonpointer);
    SideTable& table = SideTables()[this];
    RefcountMap::iterator it = table.refcnts.find(this);
    table.refcnts.erase(it);
}

extra_rc数值为空,散列表也被清除,则此时处于isDeallocating状态,会进入deallocate流程中,发送dealloc消息,完成对象的释放。

if (slowpath(newisa.isDeallocating()))
        goto deallocate;

deallocate:
    // Really deallocate.

    ASSERT(newisa.isDeallocating());
    ASSERT(isa.isDeallocating());

    if (slowpath(sideTableLocked)) sidetable_unlock();

    __c11_atomic_thread_fence(__ATOMIC_ACQUIRE);
   // 发送dealloc消息,完成对象的释放。
    if (performDealloc) {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
    }
    return true;
}

3.3 dealloc

dealloc最终会调用rootDealloc方法,代码如下:

rootDealloc

首先判断其是否为小对象,小对象不需要处理,因为系统会自动帮我们释放掉。同时通过对象的isa判断是否为nonpointer isa,如果是继续判断其是否能有弱引用、是否存在关联对象、是否存在析构、是否存在散列表。如果不存在上面的内容则会调用free方法,将对象释放。如果存在,则调用object_dispose方法。见下面代码:
object_dispose

object_dispose会调用objc_destructInstance方法,其实现如下:
objc_destructInstance

如果存在关联对象,通过_object_remove_assocations方法对关联对象进行释放。此部分内容在分类的(iOS 类扩展&关联对象)[https://www.jianshu.com/p/4315c2440c56]中已经做了分析,通过调用clearDeallocating方法,完成散列表弱引用表的释放,此部分内容在(iOS weak实现原理和销毁过程)[https://www.jianshu.com/p/1b566137b3fe]中也做了说明,如下所示:
clearDeallocating

3.4 retainCount

获取引用计数最终调用的是rootretainCount方法,源码实现见下图:

rootretainCount

首先判断是否为否为小对象,小对象不做引用计数处理。如果是nonpointer isa,首先从isa指针中获取extra_rc数值,同时判断是否存在散列表,如果存在,则再加上散列表中的数值。如果不是nonpointer isa,直接获取对象对应SideTable中的引用计数。

总结

这篇文章针对iOS内存管理先探索到这里了,内存管理还有很多的内容,我们继续往后探索。继续努力!

你可能感兴趣的:(iOS 内存管理--内存五大区、TiggedPointer、引用计数)