iOS底层-内存管理的方案(TaggedPointer& NONPOINTER_ISA&散列表)

1,721 阅读7分钟

前言

在上篇文章中讲述了内存的五大区与布局方面的知识,说到内存管理的方案我们第一个想到的是ARC和MRC,但在ARC和MRC中常见是TaggedPointerNONPOINTER_ISA以及散列表,本文将对这三个方案进行详细的分析

TaggedPointer

简介

  • WWDC2020中对TaggedPointer进行了讲解。

    1. TaggedPointer是字面意思就是标记的指针,我们知道正常的指针指向的是内存,但对于有些类型例如NSNumber,它不需要一般对象的cache_tmethod_list等,它只是一个值完全可以存在指针中,这样也大大的优化访问速度与内存,于是就产生了TaggedPointer。至于优化如下图:

    截屏2021-10-24 10.04.07.png

    • 在官方给出的解释中:
      1. TaggedPointer主要针对一些小对象类型,例如NSNUmberNSDateNSIndexPath等,
      2. 小对象将值存在本身的指针中,没有另外调用Malloc开辟或者free释放内存,
      3. TaggedPointer3倍内存空间的优化
      4. 相比于allocate/destoryTaggedPointer的速度要快106倍

案例分析

下面通过NSString案例来分析:

  • 案例如下 截屏2021-10-25 14.21.33.png
    案例中对三个长度不同的字符串进行了直接打印,copy后打印,[NSString stringWithFormat:]后打印以及[[NSString alloc] initWithFormat:]后打印,结果如下:

    截屏2021-10-25 14.29.23.png
    在打印结果中可以观察处理出现三种stringtype__NSCFConstantString__NSTaggedPointerString__NSCFString。下面来着重分析下NSTaggedPointerString

相关特性

模拟器

  • NSString类型

    使用p/t查看ws的二进制地址:

    截屏2021-10-25 17.13.40.png
    WWDC2020中,我们知道在模拟器下,高位第一位是判断是否为TaggedPointer,其他位分析如下:

    截屏2021-10-26 09.26.14.png

    至于类型的判断,可以对照objc4-818.2源码objc-internal文件中的tag枚举来定位 截屏2021-10-25 17.32.12.png

注意事项:
1. 对于[[NSString alloc] initWithFormat:][NSString stringWithFormat:]类型创建的字符串,当长度不大于9位时是TaggedPointerString类型。当长度大于9时是NSCFString类型
2. 当TaggedPointerString类型字符串长度不大于7时,可以直接在地址中读取;当长度大于7时读取内容的规则就变了

  • TaggedPointerString长度为8或者9时,保存的是6位编码后的字符,且编码的字母表为eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX

  • 当字符串长度大于10时,保存的是5位编码后的字符,且编码的字母表为eilotrm.apdnsIc ufkMShjTRxgC4013

  • NSNumberNSIndexPathNSDate的格式也类似,但是在低4位表示的就不是长度

真机

  • 真机情况下大于ws

    截屏2021-10-25 18.25.51.png
    此时表示类型和位数的位置发生了变化

    截屏2021-10-26 09.26.29.png

  • 由于我们调试时将源码中的混淆代码抽出来,然后包装成自己的kc_objc_decodeTaggedPointer再调用,这样比较麻烦,我们可以直接设置环境变量OBJC_DISABLE_TAG_OBFUSCATION,将值置为YES,这样就无需就直接打印出来需要的地址了

    截屏2021-10-25 18.53.13.png

面试题

@property (nonatomic, strong) dispatch_queue_t  queue;
@property (nonatomic, strong) NSString *nameStr;
// viewDidLoad
self.queue = dispatch_queue_create("com.wushuang.cn", DISPATCH_QUEUE_CONCURRENT);

- (void)taggedPointerDemo {
    for (int i = 0; i<10000; i++) {
        dispatch_async(self.queue, ^{
            self.nameStr = [NSString stringWithFormat:@"wushuang"];
             NSLog(@"%@",self.nameStr);
        });
    }
}

- (void)cfStringDemo {
    for (int i = 0; i<10000; i++) {
        dispatch_async(self.queue, ^{
            self.nameStr = [NSString stringWithFormat:@"wushuangWelcome~"];
            NSLog(@"%@",self.nameStr);
        });
    }
}

2个方法分别在touchBegin中连续点击调用,结果cfStringDemo中的案例会崩溃,主要原因是nameStr赋值时会进行旧值的release和新值的retain,由于是多线程访问,所以在某个瞬间会出现多次release,进而就产生了野指针。但是taggedPointerDemo的案例就不会崩溃,唯一的区别就是一个是__NSCFString,一个是__NSTaggedPointerString,此时就需要去objc4-818.2源码中查看retainrelease原理:

  • retain

    • 在源码中根据查找顺序:retain->_objc_rootRetain->objc_object::rootRetain()->objc_object::rootRetain(bool tryRetain, objc_object::RRVariant variant)
    • 核心代码如下:

    截屏2021-10-25 23.45.09.png

    • 如果是taggedPointer类型,就直接返回,没有做引用计数及其它相关处理
  • release

    • release查找顺序如下:release->_objc_rootRelease->objc_object::rootRelease()->objc_object::rootRelease(bool performDealloc, objc_object::RRVariant variant)
    • 核心代码如下:

    截屏2021-10-25 23.52.25.png

    • 如果是taggedPointer类型,也是直接返回,没有做其它处理

所以taggedPointer不会引起引用计数的变化,不会产生野指针导致崩溃

总结
1. Tagged Pointer专⻔⽤来存储⼩的对象,例如NSNumberNSDate
2. Tagged Pointer指针的值不再是地址了,⽽是真正的值。实际上它不再是⼀个对象了,只是⼀个披着对象⽪的普通变量⽽已。所以它的内存并不存储在堆中,也不需要malloc和free
3. 在内存读取上有着3倍的效率,创建时⽐以前快106倍。

NONPOINTER_ISA

  • 说到taggedPointer往往会联想到NONPOINTER_ISA,在前面的文章 iOS底层-对象的本质中我们讲解了isa的种类和结构。
    • NONPOINTER_ISA表示是否对isa指针开启指针优化:
      • 0:纯isa指针
      • 1:不⽌是类对象地址,isa 中包含了类信息、对象的引⽤计数等
    • NONPOINTER_ISA也是对存储进行了优化,使得isa中的64位得到充分的使用,其中的shiftclstaggedPointer中的Payload类似,用来存储有效数据
  • NONPOINTER_ISA中的extra_rc存储的是引用计数相关数据。

我们知道引用计数与retainrelease等有关,retainCount又是怎样的,下面将对他们进行分析

Retain、Release、retainCount、散列表

objc_object::rootRetain

// tryRetain: false, variant: Fast
ALWAYS_INLINE id
objc_object::rootRetain(bool tryRetain, objc_object::RRVariant variant)
{
    if (slowpath(isTaggedPointer())) return (id)this;
    
    bool sideTableLocked = false;
    bool transcribeToSideTable = false;
    isa_t oldisa;
    isa_t newisa;

    oldisa = LoadExclusive(&isa.bits);

    if (variant == RRVariant::FastOrMsgSend) { ... }  //FastOrMsgSend类型的操作
    if (slowpath(!oldisa.nonpointer)) { ... } // 非nonpointer操作

    do {
        transcribeToSideTable = false;
        newisa = oldisa;
        if (slowpath(!newisa.nonpointer)) {
            ClearExclusive(&isa.bits);
            if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
            else return sidetable_retain(sideTableLocked);
        }
        // don't check newisa.fast_rr; we already called any RR overrides
        if (slowpath(newisa.isDeallocating())) { ... } //正在析构
        uintptr_t carry;
        newisa.bits = addc(newisa.bits, RC_ONE, 0, &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;
        }
    } while (slowpath(!StoreExclusive(&isa.bits, &oldisa.bits, newisa.bits)));

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

        if (slowpath(!tryRetain && sideTableLocked)) sidetable_unlock();
    } else {
        ASSERT(!transcribeToSideTable);
        ASSERT(!sideTableLocked);
    }

    return (id)this;
}

从源码中可以分析出,核心代码在do-while循环,和variant == RRVariant::Full处,先来分析do-while循环代码

  • do-while循环代码先将oldisa赋值给newisa,如果newisa非nonpointer类型,会根据判断走sidetable_tryRetain或者sidetable_retain方法,如果extra_rc存满会走carry判断进行extra_rc存一半,循环外散列表存一半操作。先来看看sidetable_tryRetain方法:

  • sidetable_tryRetain:

    bool
    objc_object::sidetable_tryRetain()
    {
    #if SUPPORT_NONPOINTER_ISA
        ASSERT(!isa.nonpointer);
    #endif
        SideTable& table = SideTables()[this]; // 拿到当前对象的散列表
    
        bool result = true;
        auto it = table.refcnts.try_emplace(this, SIDE_TABLE_RC_ONE); // 拿到SideTable中的引用计数表`RefcountMap`
        auto &refcnt = it.first->second; // 引用计数表中的size_t
        if (it.second) {
            // there was no entry
        } else if (refcnt & SIDE_TABLE_DEALLOCATING) { // 正在析构
            result = false;
        } else if (! (refcnt & SIDE_TABLE_RC_PINNED)) { // 没有存满
            refcnt += SIDE_TABLE_RC_ONE; // 引用计数增加
        }
      
        return result;
    }
    
    • 该方法的主要作用拿到当前对象的散列表中的引用计数表SideTable,然后判断是否在析构,判断没有存满的话就引用计数增加

    • 疑问:引用计数增加的值为什么是(1UL<<2)

      • 先看提到的几个宏定义的
      #define SIDE_TABLE_WEAKLY_REFERENCED (1UL<<0) //是否有过 weak 对象
      #define SIDE_TABLE_DEALLOCATING      (1UL<<1) //表示该对象是否正在析构
      #define SIDE_TABLE_RC_ONE            (1UL<<2)  //从第三个 bit 开始才是存储引用计数数值的地方
      #define SIDE_TABLE_RC_PINNED         (1UL<<(WORD_BITS-1)) // 最高位
      

      从字面意思得知:

      • 第一个宏:是否有过 weak 对象
      • 第二个宏:表示该对象是否正在析构
      • 第三个宏:第三个 bit 开始才是存储引用计数数值的地方
      • 第四个宏:表示在最高位占满,用来判断是否存满
    • 所以引用计数需要加上SIDE_TABLE_RC_ONE

  • sidetable_retain:

    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;
    }
    

    该流程也是获取对象的散列表,然后进行引用计数增加。

后面再执行newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry)extra_rc进行赋值,RC_ONE是开始存储extra_rc的位置。addc函数的作用是将newisa.bitsRC_ONE相加并存入newisa.bits当存满了carry值就为1。如果存满会将引用计数在extra_rc中存储一半然后做相关的标记,然后调用sidetable_addExtraRC_nolock方法

  • 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); // delta_rc << SIDE_TABLE_RC_SHIFT 是从第二个位置开始存
        if (carry) {
            refcntStorage =
                SIDE_TABLE_RC_PINNED | (oldRefcnt & SIDE_TABLE_FLAG_MASK); // 溢出的话就refcntStorage为可存储的最大值
            return true;
        }
        else {
            refcntStorage = newRefcnt;
            return false;
        }
    }
    
    主要是从引用计数表获取引用计数后,将另一半引用计数存入相应的位置

retain流程图

  • retain后整个流程如下:

    截屏2021-10-27 16.46.56.png

objc_object::rootRelease

  • 通过上面Retain流程的分析,release的分析要容易许多,主要源码如下:
ALWAYS_INLINE bool
objc_object::rootRelease(bool performDealloc, objc_object::RRVariant variant)
{
    if (slowpath(isTaggedPointer())) return false;

    bool sideTableLocked = false;
    isa_t newisa, oldisa;
    oldisa = LoadExclusive(&isa.bits);

    if (variant == RRVariant::FastOrMsgSend) { ... }
    if (slowpath(!oldisa.nonpointer)) { ... }

retry:
    do {
        newisa = oldisa;
        if (slowpath(!newisa.nonpointer)) {
            ClearExclusive(&isa.bits);
            return sidetable_release(sideTableLocked, performDealloc);
        }
        if (slowpath(newisa.isDeallocating())) { ... }

        // don't check newisa.fast_rr; we already called any RR overrides
        uintptr_t carry;
        newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc--
        if (slowpath(carry)) {
            // don't ClearExclusive()
            goto underflow;
        }
    } while (slowpath(!StoreReleaseExclusive(&isa.bits, &oldisa.bits, newisa.bits)));

    if (slowpath(newisa.isDeallocating()))
        goto deallocate;
    ...
    return false;
 underflow:
    newisa = oldisa;
    if (slowpath(newisa.has_sidetable_rc)) {
        if (variant != RRVariant::Full) {
            ClearExclusive(&isa.bits);
            return rootRelease_underflow(performDealloc);
        }
        if (!sideTableLocked) { // 未加锁相关赋值后再进行 retry
            ClearExclusive(&isa.bits);
            sidetable_lock();
            sideTableLocked = true;
            // Need to start over to avoid a race against 
            // the nonpointer -> raw pointer transition.
            oldisa = LoadExclusive(&isa.bits);
            goto retry;
        }
        auto borrow = sidetable_subExtraRC_nolock(RC_HALF); // 从散列表借一半
        bool emptySideTable = borrow.remaining == 0; // we'll clear the side table if no refcounts remain there

        if (borrow.borrowed > 0) {
            bool didTransitionToDeallocating = false;
            newisa.extra_rc = borrow.borrowed - 1;  // redo the original decrement too
            newisa.has_sidetable_rc = !emptySideTable;

            bool stored = StoreReleaseExclusive(&isa.bits, &oldisa.bits, newisa.bits);
            ...
            if (!stored) {
                // Inline update failed.
                // Put the retains back in the side table.
                ClearExclusive(&isa.bits);
                sidetable_addExtraRC_nolock(borrow.borrowed);
                oldisa = LoadExclusive(&isa.bits);
                goto retry;
            }

            // Decrement successful after borrowing from side table.
            if (emptySideTable)
                sidetable_clearExtraRC_nolock();

            if (!didTransitionToDeallocating) {
                if (slowpath(sideTableLocked)) sidetable_unlock();
                return false;
            }
        }
        else {
            // Side table is empty after all. Fall-through to the dealloc path.
        }
    }

deallocate:
    ...
    if (performDealloc) {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
    }
    return true;
}
  • 主要是先判断是不是小对象,如果不是再判断是不是nonpointer

    • 如果不是nonpointer就调用sidetable_release方法进行引用计数减少操作,代码如下
    objc_object::sidetable_release(bool locked, bool performDealloc)
    {
    #if SUPPORT_NONPOINTER_ISA
        ASSERT(!isa.nonpointer);
    #endif
        SideTable& table = SideTables()[this];
    
        bool do_dealloc = false;
    
        if (!locked) table.lock();
        auto it = table.refcnts.try_emplace(this, SIDE_TABLE_DEALLOCATING);
        auto &refcnt = it.first->second;
        if (it.second) {
            do_dealloc = true;
        } else if (refcnt < SIDE_TABLE_DEALLOCATING) { // 如果是week对象
            // SIDE_TABLE_WEAKLY_REFERENCED may be set. Don't change it.
            do_dealloc = true;
            refcnt |= SIDE_TABLE_DEALLOCATING;
        } else if (! (refcnt & SIDE_TABLE_RC_PINNED)) { // 判断是否存满
            refcnt -= SIDE_TABLE_RC_ONE; // 引用计数减1
        }
        table.unlock();
        if (do_dealloc  &&  performDealloc) {
            ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc)); // 调用dealloc
        }
        return do_dealloc;
    }
    

    do_dealloctrueperformDealloctrue则发送dealloc消息.

  • 再调用subc进行extra_rc--,如果减多了调用sidetable_subExtraRC_nolock向散列表借一半再对extra_rc进行赋值,代码如下:

    uintptr_t
    objc_object::sidetable_subExtraRC_nolock(size_t delta_rc)
    {
        ASSERT(isa.nonpointer);
        SideTable& table = SideTables()[this]; // 获取散列表
    
        RefcountMap::iterator it = table.refcnts.find(this);
        if (it == table.refcnts.end()  ||  it->second == 0) { // 散列表没有
            // Side table retain count is zero. Can't borrow.
            return { 0, 0 };
        }
        size_t oldRefcnt = it->second;
    
        // isa-side bits should not be set here
        ASSERT((oldRefcnt & SIDE_TABLE_DEALLOCATING) == 0);
        ASSERT((oldRefcnt & SIDE_TABLE_WEAKLY_REFERENCED) == 0);
    
        size_t newRefcnt = oldRefcnt - (delta_rc << SIDE_TABLE_RC_SHIFT); // 借一半,剩余一半再存入
        ASSERT(oldRefcnt > newRefcnt);  // shouldn't underflow
        it->second = newRefcnt;
        return { delta_rc, newRefcnt >> SIDE_TABLE_RC_SHIFT };
    }
    

    如果散列表没有就返回进行发送dealloc消息,如果有就借一半

release流程图

截屏2021-10-27 18.22.22.png

retainCount

  • retainCount主要调用流程如下:retainCount -> _objc_rootRetainCount -> rootRetainCount,核心代码如下:
inline uintptr_t 
objc_object::rootRetainCount()
{
    if (isTaggedPointer()) return (uintptr_t)this;

    sidetable_lock();
    isa_t bits = __c11_atomic_load((_Atomic uintptr_t *)&isa.bits, __ATOMIC_RELAXED);
    if (bits.nonpointer) { 
        uintptr_t rc = bits.extra_rc;
        if (bits.has_sidetable_rc) { // 如果有散列表,retainCount值为 extra_rc 和引用计数表里的值相加
            rc += sidetable_getExtraRC_nolock();
        }
        sidetable_unlock();
        return rc;
    }

    sidetable_unlock();
    return sidetable_retainCount(); // 如果是非`nonpointer`,则直接取散列表
}
  • 如果是isTaggedPointer直接返回,如果是nonpointer类型,则取extra_rc,如果在散列表中存有,则取散列表中的值和extra_rc的和
  • 无论是sidetable_getExtraRC_nolock还是sidetable_retainCount方法里面都有引用计数值 >> SIDE_TABLE_RC_SHIFT的操作,主要是因为从存的时候从第二位开始存,所以计算retainCount时需要右移SIDE_TABLE_RC_SHIFT

散列表

散列表SideTable是一个结构体,主要由互斥锁RefcountMap引用计数表,和weak_table_t弱引用表三个成员变量组成,还有相关的析构函数和加锁方法:

截屏2021-10-27 18.44.01.png

RetainRelease中,我们已经分析了RefcountMap引用计数表的相关操作,至于weak_table_t弱引用表,将在下篇文章继续分析