阅读 292

iOS底层学习——内存管理(内存五大区、TiggedPointer、引用计数)

1.内存布局

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

ee0f152a7b524e6f8207f864b51a7589~tplv-k3u1fbpfcp-watermark.image.png

1.内存五大区

  1. 栈区(stack)

    • 特点

      • 栈是系统数据结构,其对应的进程或者线程是唯一的
      • 栈是向低地址扩展的数据结构
      • 栈是一块连续的内存区域,遵循先进后出(FILO)原则
      • 栈的地址空间在iOS中是以0X7开头
      • 栈区一般在运行时分配
    • 存储内容

      • 栈区是由编译器自动分配并释放的,主要用来存储局部变量
      • 函数的参数,例如函数的隐藏参数(id selfSEL _cmd
    • 优缺点

      • 优点:因为栈是由编译器自动分配并释放的,不会产生内存碎片,所以快速高效
      • 缺点:栈的内存大小有限制,数据不灵活
      • iOS主线程栈大小是1MB,其他主线程是512KBMAC只有8M

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

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

    image.png

  2. 堆区(heap)

    • 特点

      • 堆是向高地址扩展的数据结构
      • 堆是不连续的内存区域,类似于链表结构(便于增删,不便于查询),遵循先进先出(FIFO)原则
      • 堆的地址空间在iOS中是以0x6开头,其空间的分配总是动态的
      • 堆区的分配一般是在运行时分配
    • 存储内容

      • 堆区是由程序员动态分配和释放的,如果程序员不释放,程序结束后,可能由操作系统回收
      • OC中使用alloc或者使用new开辟空间创建对象
      • C语言中使用malloccallocrealloc分配的空间,需要free释放
    • 优缺点

      • 优点:灵活方便,数据适应面广泛
      • 缺点:需手动管理,速度慢、容易产生内存碎片

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

  3. 全局区(静态区)(BSS段)

    • BSS段bss segment)通常是指用来存放程序中未初始化的或者初始值为0的全局变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配

    • 数据段:数据段(data segment)通常是指用来存放程序中已初始化的全局变量的一块内存区域,数据段属于静态内存分配。

    • 全局区是编译时分配的内存空间,在iOS中一般以0x1开头,在程序运行过程中,此内存中的数据一直存在,程序结束后由系统释放,主要存放

      • 未初始化的全局变量和静态变量,即BSS区(.bss
      • 已初始化的全局变量和静态变量,即数据区.data
    • static修饰的变量会成为静态变量,该变量的内存由全局/静态区在编译阶段完成分配,且仅分配一次。

    • static可以修饰局部变量也可以修饰全局变量。

  4. 常量区(数据段)

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

    • 代码区是编译时分配主要用于存放程序运行时的代码,代码会被编译成二进制存进内存的
    • 代码区需要防止在运行时被非法修改,所以只准许读取操作,而不允许写入(修改)操作——它是不可写的。
  • 补充 除了以上内存区域外,系统还会保留一些内存区域。

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));
    }
    复制代码

    上面代码中,abobject都是局部变量,这些变量都存储在栈区。运行结果:

    image.png

  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);
        // 访问---通过对象->堆区地址->存在栈区的指针
    }
    复制代码

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

    image.png

  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);
    }
    复制代码

    在上面案例中,通过打印全局区的变量的地址与栈区变量进行对比,运行结果见下图:

    image.png

2.TiggedPointer小对象

1.何为小对象

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

image.png

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

image.png

2.案例分析

我们通过案例继续分析其区别。

  • 案例1 image.png

  • 案例2 image.png

  • 运行结果

    分别运行上面两个案例,会有怎么样的结果呢?

    • 案例1会报错
    • 案例2正常运行

    调试打开查看汇编,案例1运行报错信息,见下图: image.png 分析运行报错日志,坏内存访问,为什么呢?

  • 原因分析

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

  • 如何解决呢?

    修饰改为atomic或者加

  • 为什么案例2可以正常运行呢?

    案例1中,设置断点,发现此时nameStr数据类型为__NSCFString,见下图: image.png

    而在案例2中,nameStr数据类型为TiggedPointer,见下图: image.png

    正常对象都是指针指向堆内存中的地址,所以案例1会因为多线程访问而造成坏内存访问,而TaggedPointer存储在常量区,不会创建内存。在进行对象释放时,针对TiggedPointer类型进行了过滤处理,也就说TiggedPointer类型不会对引用计数进行处理。见下面源码:

    image.png

3.TiggedPointer原理分析

我们在进行类的加载_read_images方法中已经探索到了TiggedPointer方面的内容。见下图:

image.png

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

image.png

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

全局搜索objc_debug_taggedpointer_obfuscator,我们可以找到针对TaggedPointer对象的指针编码和解码算法:

image.png

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

uintptr_t value = (objc_debug_taggedpointer_obfuscator ^ ptr);
复制代码

解码过程为:

value ^ objc_debug_taggedpointer_obfuscator;
复制代码

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

image.png

其中**0xa000000000000621**就是解码后得到的结果。那么这个地址代表什么意义呢?这是我们需要探索的!!!

  • 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,则视为小对象。

    下面引入案例进行分析:

    image.png

    通过上面的案例的输出结构,基本可以确定,高位的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

    是不是这样呢?查看下面的源码:

    image.png

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

    #if __has_feature(objc_fixed_enum)  ||  __cplusplus >= 201103L
    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

    和我们的猜想完全一致!小对象类型地址包含了类型。那么值存储在哪里呢?

  • TaggedPointer值分析

    同样我们引入一个案例:

    image.png

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

    image.png

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

  • 总结

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

3.引用计数

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

我们首先回顾一下,nonpointer isa,使用了结构体位域,针对arm64架构x86架构提供了不同的位域设置规则。其中包括了两个重要的字段:has_sidetable_rc引用计数表extra_rc对象引用计数

如何去分析他们之间的关系呢,allocretain方法!我们在前面的章节中,已经分析了alloc的处理流程,完成isa的创建,并初始化引用计数为1。见下图:

image.png

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

1.retain方法

找到retain方法的实现源码:

image.png

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

image.png

通过初步解读,发现红色框区域即为核心代码,下面深入分析该部分的内容。

在方法的一开始就进行了判断,当前对象是否为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,再找到自己引用计数表的存储空间,对自己的引用计数进行加操作。

散列表的相关内容,在分析弱引用的时候已经说明:weak实现原理和销毁过程

如果正在释放isDeallocating,也就是此时isaextra_rchas_sidetable_rc都为0,则不需要对引用计数进行处理,源码如下:

    bool isDeallocating() {
        return extra_rc == 0 && has_sidetable_rc == 0;
    }
复制代码

如果以上内容都不满足,则会进行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 (slowpath(transcribeToSideTable)) {

        // Copy the other half of the retain counts to the side  table.
        sidetable_addExtraRC_nolock(RC_HALF);
    }
    
    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),所以满状态的一半已经有相当大的存储空间了!

2.release方法

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

image.png

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

image.png

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

    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中,见下面代码:

    // 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方法将该SideTableSideTables中抹除:

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 (performDealloc) {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
    }
复制代码

3.dealloc

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

image.png

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

image.png image.png

如果存在关联对象,通过_object_remove_assocations方法对关联对象进行释放。此部分内容在分类的类扩展和分类(类别)中已经做了分析。通过调用clearDeallocating方法,完成散列表和弱引用表的释放,此部分内容在weak实现原理和销毁过程中也做了说明。

image.png

4.retainCount

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

image.png

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

文章分类
iOS
文章标签