美文网首页iOS 底层探索之路
iOS 底层探索:内存管理 (上)

iOS 底层探索:内存管理 (上)

作者: 欧德尔丶胡 | 来源:发表于2020-11-25 13:38 被阅读0次

    iOS 底层探索: 学习大纲 OC篇

    前言

    • 由于ARC的出现,内存管理在我们开发中其实经常被熟练的开发者忽视,但是经过对底层的深入研究,我们会发现苹果对内存的管理的处理会让我们学习到很多东西,其次移动设备的内存资源是有限的,当 App 运行时占用的内存大小超过了限制后,就会被强杀掉,从而导致用户体验被降低。所以,为了提升 App 质量,开发者要非常重视应用的内存管理问题。

    • 这篇开始由浅入深分析总结整理内存管理的内容

    内容

    一、内存布局

    具体参考: iOS 底层探索:内存五大区

    针对4GB虚拟内存

    二、内存管理方案

    不同场景下的内存方案有以下三种:

    • TaggedPointer:⼩对象-NSNumber,NSDate
    • NONPOINTER_ISA:⾮指针型isa
    • 散列表:引⽤计数表,弱引⽤表
    1.TaggedPointer

    先分析一个面试题

    //MARK: - taggedPointer 面试题
    - (void)taggedPointerDemo {
      
        self.queue = dispatch_queue_create("com.HJ.cn", DISPATCH_QUEUE_CONCURRENT);
        
        for (int i = 0; i<10000; i++) {
            dispatch_async(self.queue, ^{
                self.nameStr = [NSString stringWithFormat:@"HJ"];  // alloc 堆 iOS优化 - taggedpointer
                 NSLog(@"%@ --- %d",self.nameStr , i);
            });
        }
    }
    //------完整的打印. 运行不报错
    2020-11-24 14:22:04.326685+0800 002---taggedPointer[28847:474034] HJ --- 9995
    2020-11-24 14:22:04.327784+0800 002---taggedPointer[28847:474074] HJ --- 9996
    2020-11-24 14:22:04.332117+0800 002---taggedPointer[28847:474077] HJ --- 9997
    2020-11-24 14:22:04.333909+0800 002---taggedPointer[28847:474068] HJ --- 9998
    2020-11-24 14:22:04.334401+0800 002---taggedPointer[28847:474031] HJ --- 9999
    
    //------打印. 运行报错
    for (int i = 0; i<10000; i++) {
            dispatch_async(self.queue, ^{
                self.nameStr = [NSString stringWithFormat:@"HJ_内存管理的探索"];
                NSLog(@"%@",self.nameStr);
            });
        }
    

    运行报错如下图


    我们知道这段就崩溃了,在多线程的时候讨论过,这段崩溃是由于多线程同时setter /getter 导致的过度释放。

    我们可以看到两段代码唯有唯有字符串长短不一样。为什么会这样呢?我们通过断点调试分析如下:

    不崩溃 崩溃

    我们可以看到 一个是NSTaggedPointerString ,一个是__NSCFString,这里就可以看出@"HJ" 是经过iOS优化过的,它变成了一个字符串常量NSTaggedPointerString

    打印其地址如下:很明显他们存储在不同的区域。

        NSString * str1 = [NSString stringWithFormat:@"HJ"];
        NSString * str2 = [NSString stringWithFormat:@"HJ_内存管理的探索"];
        NSLog(@"%p-%@",str1,str1);
        NSLog(@"%p-%@",str2,str2);
       //真机下运行的
       // 0x8a9c70c41689beb6   -   HJ
       // 0x000000028234fcf0   -   HJ_内存管理的探索
    

    我们进入源码查看retain和release方法如下:

    id 
    objc_retain(id obj)
    {
        if (!obj) return obj;
        if (obj->isTaggedPointer()) return obj;
        return obj->retain();
    }
    
    
    __attribute__((aligned(16), flatten, noinline))
    void 
    objc_release(id obj)
    {
        if (!obj) return;
        if (obj->isTaggedPointer()) return;
        return obj->release();
    }
    
    • 分析:
      很明显我们可以看到:if (obj->isTaggedPointer()) return; 如果obj->isTaggedPointer()就直接return,再次说明了苹果利用了TaggedPointer做了相应内存的处理。所以在上面的面试题中我们就能很好的解释,为什么第一个不会崩溃,因为它在多线程时,没有不断的进行retain和release的本质操作,所以不存在过度释放的可能,则第二个相反。

    那么TaggedPointer 到底是什么呢???

    还记得之前分析【类的加载下】read_image的时候如下:

    void _read_images(header_info **hList, uint32_t hCount, int totalClasses, int unoptimizedTotalClasses)
    {
       ....省略...
          initializeTaggedPointerObfuscator(); //初始化 TaggedPointer 模糊处理
       ....省略...
    }
    
    static void
    initializeTaggedPointerObfuscator(void)
    {
        if (sdkIsOlderThan(10_14, 12_0, 12_0, 5_0, 3_0) ||
            // Set the obfuscator to zero for apps linked against older SDKs,
            // in case they're relying on the tagged pointer representation.
            DisableTaggedPointerObfuscation) {
            objc_debug_taggedpointer_obfuscator = 0;
        } else {
            // Pull random data into the variable, then shift away all non-payload bits.
            // 这里 这里
            arc4random_buf(&objc_debug_taggedpointer_obfuscator,
                           sizeof(objc_debug_taggedpointer_obfuscator));
            objc_debug_taggedpointer_obfuscator &= ~_OBJC_TAG_MASK;
        }
    }
    

    可以看出在SDK10_14之后,经过了一次模糊处理:
    objc_debug_taggedpointer_obfuscator &= ~_OBJC_TAG_MASK;其中#define _OBJC_TAG_MASK 1UL

    为什么要做一次这个处理呢?

    这里要说明一点小对象的引用,在iOS中一个对象8个字节,占8位就是64个字节,然后一个@"HJ" 或者@"1",这种需要占用这么多字节吗?很明显造成了浪费,所以就引用了小对象这个概念。在苹果推出了 采用64位架构的A7双核处理器 iphone 5s的时候,为了节省内存提高执行效率,苹果提出了Tagged Pointer的概念。

    进入objc_debug_taggedpointer_obfuscator 我们可以看到进行了两次异或处理,类似于编码与解码的过程。

    static inline void * _Nonnull
    _objc_encodeTaggedPointer(uintptr_t ptr)
    {
        return (void *)(objc_debug_taggedpointer_obfuscator ^ ptr);
    }
    
    static inline uintptr_t
    _objc_decodeTaggedPointer(const void * _Nullable ptr)
    {
        return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
    }
    

    我们移植代码在模拟器上运行如下:

    {
    NSString *str2 = [NSString stringWithFormat:@"b"];
    NSLog(@"%p-%@",str2,str2);
    NSLog(@"0x%lx",_objc_decodeTaggedPointer_(str2));
    }
    uintptr_t
    _objc_decodeTaggedPointer_(id ptr)
    {
        return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
    }
    
    0xbdc43db247c3b1b0 - b
    0xa000000000000621
    

    很明显 0xa000000000000621 这是一个非常简单的指针 ,分析如下:

    • 在苹果的64位OC实现中,若对象指针的二进制第一位是1,则该指针为Tagged Pointer。
      例如0xa000000000000621其中a的2进制为1010,第一位1表示这是Tagged Pointer010就是2表示这是一个NSString类;
    #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,
        .....
    
    • 这个地址最后一位1表示字符串的数目,这里是0001表示有1位字符串;其中真正用来存储的位数只有中间的14位16进制。
    • bASCII码中查询对应的就是62
    • 说明 :这个地址本身其实就存储了字符串的值,可以说是存储在&strS内存中值,只是伪装成了地址,它不需要存储在数据区,也不需要申请堆空间

    关于更深的字符串研究可以参考:采用Tagged Pointer的字符串

    TaggedPointer总结:

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

    分析了iOS 底层探索:isa与类关联的原理,我们得出结论:在isa初始化obj->initInstanceIsa(cls, hasCxxDtor)的时候,通过isa_t联合体,在位域运算中,将类信息cls存进了存储类的指针的值shiftclx , 最后isa = newisa;isa中既有类的指针,又有类的信息,这个过程也叫做指针优化:NONPOINTER_ISA,所以isa其实不仅仅是一个指针,其中一些位仍然存储了每个对象的信息,例如:引用计数是否被弱引用。仔细一想,NONPOINTER_ISA的设计思想是不是和TaggetPointer类似呢!

    再次查看isa的本质,再次看看哪里存了引用计数等信息呢?

    union isa_t {
        isa_t() { }
        isa_t(uintptr_t value) : bits(value) { }
     
        Class cls;
        uintptr_t bits;
    #if defined(ISA_BITFIELD)
        struct {
            ISA_BITFIELD;  // defined in isa.h
        };
    #endif
    };
    

    找到ISA_BITFIELD的结构体信息如下


    重点
    • nonpointer:表示是否对isa开启指针优化 。0代表是纯isa指针,1代表除了地址外,还包含了类的一些信息、对象的引用计数等。

    • extra_rc:表示该对象的引用计数值,实际上是引用计数减一。例如:如果引用计数为10,那么extra_rc为9。如果引用计数大于10,则需要使用has_sidetable_rc

    • has_sidetable_rc:当对象引用计数大于10时,则需要进位

    其中has_sidetable_rc就引入了散列表的概念:

    在runtime内存空间中,SideTables是一个hash数组,里面存储了SideTableSideTables的hash键值就是一个对象obj的address。 因此可以说,一个obj,对应了一个SideTable。但是一个SideTable,会对应多个obj。因为SideTable的数量有限,所以会有很多obj共用同一个SideTable

    我们先看看SideTable的结构

    struct SideTable {
        spinlock_t slock;      // 自旋锁,用于上锁/解锁 SideTable。
        RefcountMap refcnts;    //用来存储OC对象的引用计数的 hash表 (仅在未开启isa优化或在isa优化情况下isa_t的引用计数溢出时才会用到)。
        weak_table_t weak_table;  //弱引用表 存储对象弱引用指针的 hash表 。是OC中weak功能实现的核心数据结构。
         ......
    }
    

    目前有了 NONPOINTER_ISA散列表初步概念之后,我们就去看看在内存管理ARC&MRC中是如何使用的呢?

    三、ARC&MRC

    我们早已经进入ARC(ARC是LLVM和Runtime配合的结果)时代,那些MRC的retain,release等操作已经消失在我们视野中了,绝大多数的内存管理细节已经由编译器代劳了。但是研究内存管理,还是逃不过他们。关于ARC&MRC定义,我建议参考这篇内容:iOS内存管理(MRC、ARC)深入浅出这里我不做过多的说明,我们主要来探索以下几个常用方法的本质:

    1.alloc

    关于alloc我们以前分析过,具体可见 iOS 底层探索:alloc & init
    这里贴一个流程图以便复习:

    alloc基本流程图

    2.reatinCount

    reatinCount:也就是引用计数。操作引用计数的方法:retainrelease

    在非 ARC 环境可以使用retainCount方法获取某个对象的引用计数,其会调用 objc_objectrootRetainCount()方法

    
    - (NSUInteger)retainCount {
        return _objc_rootRetainCount(self);
    }
    
    uintptr_t
    _objc_rootRetainCount(id obj)
    {
        ASSERT(obj);
    
        return obj->rootRetainCount();
    }
    
    

    在 ARC 时代除了使用Core Foundation库的CFGetRetainCount()方法,也可以使用Runtime_objc_rootRetainCount(id obj) 方法来获取引用计数,此时需要引入头文件。这个函数也是调用 objc_objectrootRetainCount()方法:

    // rootRetainCount() 方法对引用计数存储逻辑进行了判断
    inline uintptr_t 
    objc_object::rootRetainCount()
    {
        if (isTaggedPointer()) return (uintptr_t)this;
    
        sidetable_lock();
        isa_t bits = LoadExclusive(&isa.bits);
        ClearExclusive(&isa.bits);
        if (bits.nonpointer) {
            uintptr_t rc = 1 + bits.extra_rc;
            if (bits.has_sidetable_rc) {
                rc += sidetable_getExtraRC_nolock();
            }
            sidetable_unlock();
            return rc;
        }
    
        sidetable_unlock();
        return sidetable_retainCount();
    }
    

    rootRetainCount()方法对引用计数存储逻辑进行了判断,

    • 如果是TaggedPointer,可以直接获取引用计数;
    • 如果不是TaggedPointer, 调用的sidetable_retainCount()方法:

    注 : 这里暂时不讲解sidetable_retainCount()等方法,在下面讲解retainrelease可操作引用计数的的时候穿插理解。

    3.retain

    retain:MRC下 ,作为修饰属性的关键字,该属性在赋值的时候,先release之前的值,然后再赋新值给属性,引用再加1。 如果手动调用会对引用计数加1

    ///在MRC里,retain关键字表示会由编译器帮我们生成ivar和set/get方法,生成的set方法里自带内存管理
    
    @property (nonatomic, retain) Dog * dog;
    
     (void)setDog:(Dog *)dog;
    
    //编译时自动生成的代码
    - (void)setDog:(Dog *)dog {
        //这里必须要判断_dog != dog
        if (_dog != dog) {
             //先release之前的值
            [_dog release];
             //然后再赋新值给属性,引用再加1
            _dog = [dog retain];
        }
    }
    
    - (Dog *)dog {
        return _dog;
    }
    

    我们可以查看retain的调用栈,也可以直接进入源码中查找如下:

    + (id)retain {
        return (id)self;
    }
    
    // Replaced by ObjectAlloc
    - (id)retain {
        return _objc_rootRetain(self);
    }
    
    NEVER_INLINE id
    _objc_rootRetain(id obj)
    {
        ASSERT(obj);
    
        return obj->rootRetain();
    }
    
    ALWAYS_INLINE id 
    objc_object::rootRetain()
    {
        return rootRetain(false, false);
    }
    
    

    核心源码rootRetain的解析:

    ALWAYS_INLINE id 
    objc_object::rootRetain(bool tryRetain, bool handleOverflow)
    {
        // 如果如果是taggedPointer直接返回不需要引用计数
        if (isTaggedPointer()) return (id)this;
        // 默认不使用sideTable
        bool sideTableLocked = false;
        // 是否需要将引用计数转到sidetable
        bool transcribeToSideTable = false;
    
        // 记录新旧两个isa指针
        isa_t oldisa;
        isa_t newisa;
    
        do {
            transcribeToSideTable = false;
             通过 LoadExclusive 方法加载 isa 的值,加锁
            oldisa = LoadExclusive(&isa.bits);
            // 此时 newisa = oldisa
            newisa = oldisa;
            // slowpath表示if中的条件是小概率事件
            // 如果newisa(此时和oldisa相等) 如果没有采用isa优化
            if (slowpath(!newisa.nonpointer)) {
                // 解锁
                ClearExclusive(&isa.bits);
                //rawISA() = (Class)isa.bits
                // 如果当前对象的 isa 指向的类对象是元类(也就是说当前对象不是实例对象,而是类对象),直接返回
                if (rawISA()->isMetaClass()) return (id)this;
                // 如果不需要retain对象(引用计数+1) 且sideTable是锁上的
                if (!tryRetain && sideTableLocked)
                    // sidetable解锁
                    sidetable_unlock();
                if (tryRetain)
                    // sidetable_tryRetain 尝试对引用计数器进行+1的操作 返回+1操作是否成功
                    return sidetable_tryRetain() ? (id)this : nil;
                else
                    // 将sidetable中保存的引用计数+1同时返回引用计数
                    return sidetable_retain();
            }
            // 如果需要尝试 +1 但是当前对象正在销毁中
            if (slowpath(tryRetain && newisa.deallocating)) {
                // 解锁
                ClearExclusive(&isa.bits);
                // 如果不需要去尝试 +1 并且 SideTables 表锁住了,就将其解锁
                // 这里的条件 应该永远都不会被满足
                if (!tryRetain && sideTableLocked)
                    sidetable_unlock();
                // 如果对象正在被释放 执行retain是无效的
                return nil;
            }
            // 引用计数是否溢出标志位
            uintptr_t carry;
            //为 isa 中的 extra_rc 位 +1 ,并保存引用计数
            newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++
            // 如果 isa中的extra_rc 溢出
            if (slowpath(carry)) {
                // newisa.extra_rc++ 溢出
                // 是否需要处理溢出 这个变量是rootRetain函数外部传入的参数 是否需要处理溢出时的情况
                if (!handleOverflow) {
                    //解锁
                    ClearExclusive(&isa.bits);
                    // rootRetain_overflow 方法实际上就是递归调用了当前方法只是将handleOverflow
                    // 置为yes
                    return rootRetain_overflow(tryRetain);
                }
                // 保留isa中extra_rc一半的值 将另一半转移到sidetable中
                // 如果不需要尝试 +1 并且 sidetable 表未加锁,就将其加锁
                if (!tryRetain && !sideTableLocked) sidetable_lock();
                // sidetable加锁
                sideTableLocked = true;
                // 需要将引用计数转移到sidetable
                transcribeToSideTable = true;
                // 将newisa中的引用计数置为之前的一半 # define RC_HALF  (1ULL<<18)
                newisa.extra_rc = RC_HALF;
                // isa中是否使用sidetable存储retiancount的标志位置为1
                newisa.has_sidetable_rc = true;
            }
            //while循环开始 直到 isa.bits 中的值被成功更新成 newisa.bits
            // StoreExclusive(uintptr_t *dst, uintptr_t oldvalue, uintptr_t value)
            // 将更新后的newisa的值更新到isabit中
        } while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)));
    
        // 如果需要转移引用计数到sidetable中
        if (slowpath(transcribeToSideTable)) {
            // 将溢出的引用计数加到 sidetable 中
            sidetable_addExtraRC_nolock(RC_HALF);
        }
        // 如果不需要去尝试 +1 并且 SideTables 表锁住了,就将其解锁
        if (slowpath(!tryRetain && sideTableLocked)) sidetable_unlock();
        // 返回当前对象 引用计数已完成+1操作
        return (id)this;
    }
    

    根据对isa的理解和rootRetain源码的解读,接下来我们会分以下四种情况rootRetain进行分析。

    3.1 如果没有采用isa优化

    在这里我们假设的条件:!newisa.nonpointer

    objc_object::rootRetain(bool tryRetain, bool handleOverflow) {
    
        isa_t oldisa;
        isa_t newisa;
    
        do {
           transcribeToSideTable = false;
            oldisa = LoadExclusive(&isa.bits);
            newisa = oldisa;
           if (slowpath(!newisa.nonpointer)) {
                // 解锁
                ClearExclusive(&isa.bits);
                //rawISA() = (Class)isa.bits
                // 如果当前对象的 isa 指向的类对象是元类(也就是说当前对象不是实例对象,而是类对象),直接返回
                if (rawISA()->isMetaClass()) return (id)this;
                // 如果不需要retain对象(引用计数+1) 且sideTable是锁上的
                if (!tryRetain && sideTableLocked)
                    // sidetable解锁
                    sidetable_unlock();
                if (tryRetain)
                    // sidetable_tryRetain 尝试对引用计数器进行+1的操作 返回+1操作是否成功
                    return sidetable_tryRetain() ? (id)this : nil;
                else
                    // 将sidetable中保存的引用计数+1同时返回引用计数
                    return sidetable_retain();
            }
        } while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)));
        return (id)this;
    }
    
    • 3.1.1 这里没有优化isa的情况下,并且 tryRetain = false 我们全局搜索rootRetain(,我们会发现,只有当执行rootTryRetain(),才会tryRetain = true,源码如下:
    objc_object::rootTryRetain()
    {
        return rootRetain(true, false) ? true : false;
    }
    

    那么谁引用了rootTryRetain()? 再次搜索会发现在objc_loadWeakRetained这个函数中调用了。看名字就知道这里使用了weak弱引用计数。这里后面再分析。

    • 3.1.2. 这里没有优化isa的情况下,并且 tryRetain = false ,那么它会走sidetable_retain()这个函数,进入源码分析如下:
    // 将 SideTable 表中的引用计数 +1
    id
    objc_object::sidetable_retain()
    {
    #if SUPPORT_NONPOINTER_ISA
        ASSERT(!isa.nonpointer);
    #endif
        // 根据对象获取 存储引用计数的sidetable
        SideTable& table = SideTables()[this];
        
        table.lock();
        // 获取sidetable中存储的引用计数值
        size_t& refcntStorage = table.refcnts[this];
        // 如果引用计数值没有溢出
        if (! (refcntStorage & SIDE_TABLE_RC_PINNED)) {
            // 引用计数值+SIDE_TABLE_RC_ONE
            // #define SIDE_TABLE_RC_ONE            (1UL<<2)
            // SIDE_TABLE_RC_ONE = 4 为什么这里会+4我们下面会介绍
            refcntStorage += SIDE_TABLE_RC_ONE;
        }
        table.unlock();
    
        return (id)this;
    }
    

    通过上面这个方法,我们对引用计数器完成了+1(实际上是+4)的操作,那么这里为什么会+4呢?
    那是因为对于table.refcnts,实际上并不完全是表示引用计数的值,refcnts的最后两位有特殊的标示意义:

    #define SIDE_TABLE_WEAKLY_REFERENCED (1UL<<0)
    #define SIDE_TABLE_DEALLOCATING      (1UL<<1) 
    倒数第一位标记当前对象是否被weak指针指向(1:有weak指针指向);
    倒数第二位标记当前对象是否正在销毁状态(1:处在正在销毁状态);
    

    因此,我们每次执行retain方法时,虽然每次都是+4,但是对于引用计数真实的值来说就是+1 , 64位环境下只有62位是保存溢出的引用计数的.

    3.2 正常的 rootRetain(足以保存引用计数)

    在这里我们假设的条件:isa 中的 extra_rc的位数足以存储retainCount

    //简化rootRetain 
    objc_object::rootRetain(bool tryRetain, bool handleOverflow) {
    
        isa_t oldisa;
        isa_t newisa;
    
        do {
           transcribeToSideTable = false;
            oldisa = LoadExclusive(&isa.bits);
            newisa = oldisa;
    
            uintptr_t carry;
            newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++
        } while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)));
        return (id)this;
    }
    

    方法执行的流程如下:

    1.使用 LoadExclusive 加载 isa 的值
    2.调用 addc(newisa.bits, RC_ONE, 0, &carry) 方法将 isa 的值加一
    3.调用 StoreExclusive(&isa.bits, oldisa.bits, newisa.bits) 更新 isa 的值
    4.返回当前对象

    3.3 rootRetain(extra_rc不足以保存引用计数)

    在这里我们假设的条件:调用 addc 方法为extra_rc时,8 位的 extra_rc 可能不足以保存引用计数。

    //简化rootRetain 
    objc_object:::rootRetain(bool tryRetain, bool handleOverflow) {
        .....省略......
       do {
        transcribeToSideTable = false;
        oldisa = LoadExclusive(&isa.bits);
        newisa = oldisa;
        .....省略......
        uintptr_t carry;
        newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);
       // 是否需要处理溢出 这个变量是rootRetain函数外部传入的参数 是否需要处理溢出时的情况
        if (carry && !handleOverflow)
          // rootRetain_overflow 方法实际上就是递归调用了当前方法只是将handleOverflow
          // 置为yes
            return rootRetain_overflow(tryRetain);
        } while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)));
    }
    

    extra_rc不足以保存引用计数,并且handleOverflow =false

    当方法传入的 handleOverflow =false 时(这也是通常情况),我们会调用 rootRetain_overflow 方法

    NEVER_INLINE id 
    objc_object::rootRetain_overflow(bool tryRetain)
    {
        return rootRetain(tryRetain, true);
    }
    

    这个方法其实就是重新执行rootRetain 方法,并传入handleOverflow =true

    • 拓展一个面试题: 下面打印是多少?
        NSObject *objc = [NSObject alloc];
        NSLog(@"%ld",CFGetRetainCount((__bridge CFTypeRef)objc));// 1
        NSLog(@"%ld",CFGetRetainCount((__bridge CFTypeRef)objc)); // 1
        NSLog(@"%ld",CFGetRetainCount((__bridge CFTypeRef)objc)); // 1
    
        // alloc 引用计数为多少 : 0
        // extrc
        // + 1
    

    这个题实际上是问objc的引用计数是多少,答案是都是1

    分析流程如下:

    - (NSUInteger)retainCount {
        return _objc_rootRetainCount(self);
    }
    
    uintptr_t
    _objc_rootRetainCount(id obj)
    {
        ASSERT(obj);
    
        return obj->rootRetainCount();
    }
    
    inline uintptr_t 
    objc_object::rootRetainCount()
    {
        if (isTaggedPointer()) return (uintptr_t)this;
    
        sidetable_lock();
        isa_t bits = LoadExclusive(&isa.bits);
        ClearExclusive(&isa.bits);
        //如果是nonpointer isa,才有引用计数的下层处理
        if (bits.nonpointer) {
            //alloc创建的对象引用计数为0,包括sideTable,所以对于alloc来说,是 0+1=1,这也是为什么通过retaincount获取的引用计数为1的原因
            uintptr_t rc = 1 + bits.extra_rc;
            if (bits.has_sidetable_rc) {
                rc += sidetable_getExtraRC_nolock();
            }
            sidetable_unlock();
            return rc;
        }
        //如果不是,则正常返回
        sidetable_unlock();
        return sidetable_retainCount();
    }
    

    因为alloc 不做引用计数加1的操作,为0 ,但是在读取器引用计数的时候会出现调用CFGetRetainCount时实际调用了retainCount,最后到rootRetainCount,其中rc = 1 + bits.extra_rc。只做了读取没有写入,所以后面读取的都是1。为了防止alloc创建的对象被释放(引用计数为0会被释放),所以在编译阶段,程序底层默认进行了+1操作。实际上在extra_rc中的引用计数仍然为0

    3.4 rootRetain(处理引用计数的溢出)

    当传入的 handleOverflow=true 时,我们就会在 rootRetain方法中处理引用计数的溢出。

    //简化rootRetain 
    objc_object::rootRetain(bool tryRetain, bool handleOverflow) {
    
        .....省略......
        isa_t oldisa;
        isa_t newisa;
    
        do {
           transcribeToSideTable = false;
            oldisa = LoadExclusive(&isa.bits);
            newisa = oldisa;
            .....省略......
            uintptr_t carry;
    
            newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++
           // 如果 isa中的extra_rc 溢出
            if (slowpath(carry)) {
                .....省略......
                // 保留isa中extra_rc一半的值 将另一半转移到sidetable中
                // 如果不需要尝试 +1 并且 sidetable 表未加锁,就将其加锁
                if (!tryRetain && !sideTableLocked) sidetable_lock();
                // sidetable加锁
                sideTableLocked = true;
                // 需要将引用计数转移到sidetable
                transcribeToSideTable = true;
                // 将newisa中的引用计数置为之前的一半 # define RC_HALF  (1ULL<<18)
                // 我们都知道NSTaggedPointer预留了19个bit位用来存放引用计数,RC_HALF的值刚好为 2^19 次方的一半。
                newisa.extra_rc = RC_HALF;
               // isa中是否使用sidetable存储retiancount的标志位置为1
                newisa.has_sidetable_rc = true;
            }
        } while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)));
    
    // 如果需要转移引用计数到sidetable中
     if (slowpath(transcribeToSideTable)) {
            // Copy the other half of the retain counts to the side table.
         //拷贝 另外一半的 引用计数到 side table
         //分一半的原因: 减少散列表的操作次数,如果release的时候可以直接在ExtraRC 上操作了。
            sidetable_addExtraRC_nolock(RC_HALF);
        }
         .....省略......
    // 返回当前对象 引用计数已完成+1操作
        return (id)this;
    }
    

    当调用这个方法,并且 handleOverflow = true 时,我们就可以确定carry一定是存在的了,

    因为extra_rc 已经溢出了,所以要更新它的值为 RC_HALF(define RC_HALF (1ULL<<7)),extra_rc总共为 8 位,RC_HALF = 0b10000000

    此时设置 has_sidetable_rc 为真,存储新的isa的值之后,调用 sidetable_addExtraRC_nolock 方法。

    // Move some retain counts to the side table from the isa field.
    // Returns true if the object is now pinned.
    // 将isa中的引用计数移动到sidetable中 当引用计数达到最大值(溢出)是返回true
    bool 
    objc_object::sidetable_addExtraRC_nolock(size_t delta_rc)
    {
    // 根据对象地址获取到存放引用计数对应的sidetable
        SideTable& table = SideTables()[this]; //散列表数组
    // 从table.refcnts中获取当前对象的引用计数
        size_t& refcntStorage = table.refcnts[this];
    // 声明一个局部变量存储旧的引用计数
        size_t oldRefcnt = refcntStorage;
    
    // 这时候 引用计数已经超过了三十二位所能表达的最大值 直接返回true
        if (oldRefcnt & SIDE_TABLE_RC_PINNED) return true;
    // 溢出标志位
        uintptr_t carry;
    //------- 这里我们将溢出的一位 RC_HALF 添加到 oldRefcnt 中 ----
        size_t newRefcnt = 
            addc(oldRefcnt, delta_rc << SIDE_TABLE_RC_SHIFT, 0, &carry);
    // 如果引用计数溢出 设置标识为已满
        if (carry) {
            // 如果是32位的情况 SIDE_TABLE_RC_PINNED = 1<< (32-1)
            // int的最大值 SIDE_TABLE_RC_PINNED = 2147483648
            //  SIDE_TABLE_FLAG_MASK = 3
            // refcntStorage = 2147483648 | (oldRefcnt & 3)
            // 如果溢出,直接把refcntStorage 设置成最大值
            refcntStorage =
                SIDE_TABLE_RC_PINNED | (oldRefcnt & SIDE_TABLE_FLAG_MASK);
            return true;
        }
        else {
          // 如果没有溢出 那么直接将新的引用计数赋值给refcntStorage
            refcntStorage = newRefcnt;
            return false;
        }
    }
    

    进入散列表的操作: SideTables()

    static objc::ExplicitInit<StripedMap<SideTable>> SideTablesMap;
    
    static StripedMap<SideTable>& SideTables() {
        return SideTablesMap.get();
    }
    
    class StripedMap {
    #if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
        enum { StripeCount = 8 };  // iPhone时这个值为8
    #else
        enum { StripeCount = 64 }; //否则为64
    #endif
       struct PaddedT {
            T value alignas(CacheLineSize);
        };
     
        PaddedT array[StripeCount];
     
        static unsigned int indexForPointer(const void *p) {
            //这里是做类型转换
            uintptr_t addr = reinterpret_cast<uintptr_t>(p);
     
            //这就是哈希算法了
            return ((addr >> 4) ^ (addr >> 9)) % StripeCount;
        }
    public:
        T& operator[] (const void *p) { 
            //返回sideTable
            return array[indexForPointer(p)].value; 
    
    }
    
    • 可以看到,通过indexForPointe r,在对StripeCount取余后,通过哈希函数来获取到了sideTable的下标,然后再根据value取到所需的sideTable

    • 我们通过 table.refcnts[this]StripeCount = 8 可以看出是用SideTables去管理多个SideTable,那么可以提出疑问:为什么为什么不直接用一张SideTable,而是用SideTables去管理多个SideTable

    回答 : SideTable里有一个自旋锁,如果把所有的类都放在同一个SideTable,有任何一个类有改动都会对整个table做操作,并且在操作一个类的同时,操作别的类会被锁住等待,这样会导致操作效率和查询效率都很低。而有多个SideTable的话,操作的都是单个Table,并不会影响其他的table,这就是分离锁。

    流程图如下:

    综上分析:在 iOS 的内存管理中,我们使用了isa结构体中的extra_rcSideTable 来存储某个对象的自动引用计数。

    rootRetain分析中我们看到如果tryRetain && newisa.deallocating 为真,就会ClearExclusive(&isa.bits) 并且return nil,自此我们开始引入dealloc的分析

    4. dealloc

    dealloc执行时机 :当一个对象retain count为0 (不再有强引用指向)时会触发dealloc。
    dealloc 方法的作用是什么 ?就是释放当前接收器占用的内存空间,暴露出来是提供给开发者重写的 ,用来释放编码过程中用到的通知、Observer 等需要手动管理释放的操作方法。

    dealloc 的源码分析:

    void
    _objc_rootDealloc(id obj)
    {
        ASSERT(obj);
    
        obj->rootDealloc();
    }
    

    核心源码 : rootDealloc

    inline void
    objc_object::rootDealloc()
    {
        if (isTaggedPointer()) return;  // isTaggedPointer 为真直接返回
    
        if (fastpath(isa.nonpointer  &&   //是否优化过isa指针
                     !isa.weakly_referenced  &&   //是否存在弱引用指向
                     !isa.has_assoc  &&  //是否设置过关联对象
                     !isa.has_cxx_dtor  &&   //是否有cpp的析构函数(.cxx_destruct)
                     !isa.has_sidetable_rc))  //引用计数器是否过大无法存储在isa中
        {
            assert(!sidetable_present());
            free(this); // 都为空直接释放
        } 
        else {
            object_dispose((id)this);  // 如果不是进入object_dispose
        }
    }
    

    直接free 这个比较简单,我们直接分析 object_dispose()源码:

    id 
    object_dispose(id obj)
    {
        if (!obj) return nil;
    
        objc_destructInstance(obj);     // 销毁实例
        free(obj);  // 释放对象
        return nil;
    }
    
    void *objc_destructInstance(id obj) 
    {
        if (obj) {
            // Read all of the flags at once for performance.
            bool cxx = obj->hasCxxDtor(); //是否有析构函数
            bool assoc = obj->hasAssociatedObjects(); //是否有关联对象
    
            // This order is important.
            if (cxx) object_cxxDestruct(obj); //有析构函数就释放,清楚成员变量
            if (assoc) _object_remove_assocations(obj); //移除当前对象的关联对象
            obj->clearDeallocating(); //将当前对象的指针指为nil
        }
    
        return obj;
    }
    
    inline void 
    objc_object::clearDeallocating()
    {
        if (slowpath(!isa.nonpointer)) {
            // Slow path for raw pointer isa.
            //如果是优化过的isa 先取出sideTable拿到引用表和当前对象 ,然后执行weak_clear_no_lock()
            sidetable_clearDeallocating();
        }
        else if (slowpath(isa.weakly_referenced  ||  isa.has_sidetable_rc)) {
            // Slow path for non-pointer isa with weak refs and/or side table data.
            //如果引用计数表中还有数据,就会擦除引用计数表中的数据 执行 table.refcnts.erase()
            clearDeallocating_slow();
        }
    }
    

    整体流程图如下:


    dealloc流程图

    5.release

    release作用 : 使引用计数器值-1
    release源码如下:

    inline void
    objc_object::release()
    {
        ASSERT(!isTaggedPointer());
       // 如果没有自定义的release方法 就直接系统默认调用rootRelease
        if (fastpath(!ISA()->hasCustomRR())) {
            sidetable_release();
            return;
        }
       // 如果有自定义的release方法那么调用对象的release方法
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(release));
    }
    
    objc_object::rootRelease()
    {
        return rootRelease(true, false);
    }
    

    核心源码rootRelease

    // 两个参数分别是 是否需要调用dealloc函数,是否需要处理 向下溢出的问题
    ALWAYS_INLINE bool 
    objc_object::rootRelease(bool performDealloc, bool handleUnderflow)
    {
        // 如果是TaggedPointer 不需要进行release操作
        if (isTaggedPointer()) return false;
        // 局部变量sideTable是否上锁 默认false
        bool sideTableLocked = false;
       // 两个局部变量用来记录这个对象的isa指针
        isa_t oldisa;
        isa_t newisa;
    
     retry:
        do {
         // 加载这个isa指针
            oldisa = LoadExclusive(&isa.bits);
            newisa = oldisa;
           // 如果没有进行nonpointer优化
            if (slowpath(!newisa.nonpointer)) {
                ClearExclusive(&isa.bits);
               // 如果是类对象直接返回false 不需要释放
                if (rawISA()->isMetaClass()) return false;
               // 如果sideTableLocked 则解锁 这里默认是false
                if (sideTableLocked) sidetable_unlock();
               // 调用sidetable_release 进行引用计数-1操作
                return sidetable_release(performDealloc);
            }
            // don't check newisa.fast_rr; we already called any RR overrides
           // 溢出标记位
            uintptr_t carry;
           // newisa 对象的extra_rc 进行-1操作
            newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc--
           // 如果-1操作后 向下溢出了 结果为负数
            if (slowpath(carry)) {
                // don't ClearExclusive()
                // 调用underflow 进行向下溢出的处理
                goto underflow;
            }
        //  循环,直到 isa.bits 中的值被成功更新成 newisa.bits
        } while (slowpath(!StoreReleaseExclusive(&isa.bits, 
                                                 oldisa.bits, newisa.bits)));
    
       //走到这说明引用计数的 -1 操作已完成
        if (slowpath(sideTableLocked)) sidetable_unlock();
        return false;
    
    underflow:
        {
           ......省略........
       }
    }
    
    

    我们发现rootRelease里的内容基本和rootRetain反过来,主要是由两个内部方法retry,underflow组成,下面我们来一步步的整理下引用计数-1操作的具体步骤

    同理rootDealloc ,分为以下几种情况

    5.1 如果没有采用isa优化

    假设条件:如果这个对象没有nonpointer优化,且不是一个类对象,那么我们直接通过对sidetable进行-1操作 ,直接return sidetable_release(performDealloc);

    // 将 SideTable 表中的引用计数 -1
    uintptr_t
    objc_object::sidetable_release(bool performDealloc)
    {
    #if SUPPORT_NONPOINTER_ISA
        ASSERT(!isa.nonpointer);
    #endif
        // 根据对象地址获取SideTable
        SideTable& table = SideTables()[this];
        // 是否需要执行dealloc方法 默认是false
        bool do_dealloc = false;
    
        table.lock();
        // 获取当前对象的销毁状态 方法的返回值有2个
        // 引用计数和当前对象是否已存在与map中
        auto it = table.refcnts.try_emplace(this, SIDE_TABLE_DEALLOCATING);
        auto &refcnt = it.first->second;
        // 如果当前对象之前不存在与map中
        if (it.second) {
            do_dealloc = true;
        } else if (refcnt < SIDE_TABLE_DEALLOCATING) {
            // 如果引用计数的值小于 SIDE_TABLE_DEALLOCATING = 2(0010)
            // refcnt 低两位分别是SIDE_TABLE_WEAKLY_REFERENCED 0  SIDE_TABLE_DEALLOCATING 1
            // 这个对象需要被销毁
            do_dealloc = true;
            refcnt |= SIDE_TABLE_DEALLOCATING;
        } else if (! (refcnt & SIDE_TABLE_RC_PINNED)) {
            // 如果引用计数有值且未溢出那么-1
            refcnt -= SIDE_TABLE_RC_ONE;
        }
        table.unlock();
        // 如果需要执行dealloc 那么就调用这个对象的dealloc
        if (do_dealloc  &&  performDealloc) {
            ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
        }
        return do_dealloc;
    }
    
    5.2 如果有采用isa优化

    假设条件:如果该对象做了nonpointer优化,那么我们直接对extra_rc进行-1操作,操作如下:

    // newisa 对象的extra_rc 进行-1操作
    newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc--
    // 如果-1操作后 向下溢出了 结果为负数
    if (slowpath(carry)) {
        // don't ClearExclusive()
        // 调用underflow 进行向下溢出的处理
        goto underflow;
    }
    

    其中subc()extra_rc计数-1

    • 【情况1】: 如果发现-1操作之后,extra_rc的个数为0,那么就出现了向下溢出,执行goto underflow;,我们需要将sideTable中的部分引用计数拿到extra_rc中记录。
    • 【情况2】: 如果没有向下溢出,执行sidetable_unlock(),那么我们就直接将修改后的newisa同步到isa中即完成了release操作。
    underflow的具体流程分析
    underflow:
        //newisa的extra_rc在执行-1操作后导致了向下溢出
        // 放弃对newisa的修改 使用之前的oldisa
        newisa = oldisa;
    
        // 如果 isa 的 has_sidetable_rc 标志位标识引用计数已溢出
        // has_sidetable_rc 用于标识是否当前的引用计数过大,无法在isa中存储,
        // 而需要借用sidetable来存储。(这种情况大多不会发生)
        if (slowpath(newisa.has_sidetable_rc)) {
            // 是否需要处理下溢
            if (!handleUnderflow) {
                // 清除原 isa 中的数据的原子独占
                ClearExclusive(&isa.bits);
                // 如果不需要处理下溢 直接调用 rootRelease_underflow方法
                return rootRelease_underflow(performDealloc);
            }
    
            // 如果sidetable是上锁状态
            if (!sideTableLocked) {
                // 解除清除原 isa 中的数据的原子独占
                ClearExclusive(&isa.bits);
                // sidetable 上锁
                sidetable_lock();
                sideTableLocked = true;
                // 跳转到 retry 重新开始,避免 isa 从 nonpointer 类型转换成原始类型导致的问题
                goto retry;
            }
    
            // sidetable_subExtraRC_nolock 放回要从sidetable移动到isa的extra_rc的值
            // 默认是获取extra_rc可存储的长度一半的值
            size_t borrowed = sidetable_subExtraRC_nolock(RC_HALF);
    
            //  为了避免冲突 has_sidetable_rc 标志位必须保留1的状态 及时sidetable中的个数为0
            if (borrowed > 0) {
                // 将newisa中引用计数值extra_rc 设置为borrowed - 1
                // -1 是因为 本身这次是release操作
                newisa.extra_rc = borrowed - 1;
                // 然后将修改同步到isa中
                bool stored = StoreReleaseExclusive(&isa.bits, 
                                                    oldisa.bits, newisa.bits);
                // 如果保存失败
                if (!stored) {
                    // dropped the reservation.
                    // 从新装载isa
                    isa_t oldisa2 = LoadExclusive(&isa.bits);
                    isa_t newisa2 = oldisa2;
                    // 如果newisa2是nonpointer类型
                    if (newisa2.nonpointer) {
                        // 下溢出标志位
                        uintptr_t overflow;
                        // 将从 SideTables 表中获取的引用计数保存到 newisa2 的 extra_rc 标志位中
                        newisa2.bits = 
                            addc(newisa2.bits, RC_ONE * (borrowed-1), 0, &overflow);
                        //
                        if (!overflow) {
                            // 如果没有溢出再次将 isa.bits 中的值更新为 newisa2.bits
                            stored = StoreReleaseExclusive(&isa.bits, oldisa2.bits, 
                                                           newisa2.bits);
                        }
                    }
                }
    
                // 如果重试之后依然失败
                if (!stored) {
                    // 将从sidetable中取出的引用计数borrowed 重新加到sidetable中
                    sidetable_addExtraRC_nolock(borrowed);
                    // 重新尝试
                    goto retry;
                }
                // 完成对 SideTables 表中数据的操作后,为其解锁
                sidetable_unlock();
                return false;
            }
            else {
                // 在从Side table拿出一部分引用计数之后 Side table为空
                // Side table is empty after all. Fall-through to the dealloc path.
            }
        }
    
        // 如果当前的对象正在被释放
        if (slowpath(newisa.deallocating)) {
            ClearExclusive(&isa.bits);
            // 如果sideTableLocked被锁 那么解锁
            if (sideTableLocked) sidetable_unlock();
            // 兑现被过度释放
            return overrelease_error();
            // does not actually return
        }
        // 将对象被释放的标志位置为true
        newisa.deallocating = true;
        // 将newisa同步到isa中 如果失败 进行重试
        if (!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits))
            goto retry;
    
        // 如果sideTableLocked= true
        if (slowpath(sideTableLocked))
            // Side table解锁
            sidetable_unlock();
    
        __c11_atomic_thread_fence(__ATOMIC_ACQUIRE);
    
        // 如果需要执行dealloc方法 那么调用该对象的dealloc方法
        if (performDealloc) {
            ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
        }
        return true;
    
    • 先判断has_sidetable_rc是否有sidetable引用计数,如果有我们要确认是否需要处理向下溢出,如果不需要处理向下溢出,那么我们直接调用rootRelease_underflow方法,
    NEVER_INLINE uintptr_t
    objc_object::rootRelease_underflow(bool performDealloc)
    {
        return rootRelease(performDealloc, true);
    }
    

    很明显这个方法实际上与retain操作时处理溢出逻辑相同,将rootRelease方法中的handleUnderflow参数置为true,要处理向下溢出。

    • 需要处理向下溢出时,如果当前的sidetable处于未上锁的状态时,将sidetable上锁然后进行重试,如果sidetable已经上锁了,那么我们会执行下面这句代码
    size_t borrowed = sidetable_subExtraRC_nolock(RC_HALF);
    

    sidetable_subExtraRC_nolock 返回要从sidetable移动到isaextra_rc的值,默认是获取extra_rc可存储的长度一半的值。

    • 如果此时从sidetable中拿到的值 > 0,那么我们要将这部分值放到isaextra_rc中进行存储,如果取到的borrowed的值为0,那么说明sidetable中的引用计数为0,那么我们直接释放该对象即可。
    StoreReleaseExclusive

    上面说到如果从sidetable中获取到的值borrowed大于0,那么我们直接将newisa.extra_rc设置为borrowed - 1即可。然后我们在调用StoreReleaseExclusive方法将newisa同步到isa中。

    1. 如果这里StoreReleaseExclusive方法保存失败了,那么我们需要重新调用LoadExclusive重新声明两个变量newisa2,oldisa2。通过addc方法将extra_rc置为borrowed-1

    2. 执行:newisa2.bits = addc(newisa2.bits, RC_ONE * (borrowed-1), 0, &overflow);

    3. 然后再次调用StoreReleaseExclusive方法将newisa2的改动同步到isa中。

    4. 如果StoreReleaseExclusive方法依然保存失败,那么我们就把从sidetable中获取的borrowed重新加到sideTable中。然后调用retry方法。

    5. 经过StoreReleaseExclusive这一步,引用计数更新操作完成。

    • 如果引用计数更新成功,那么我们需要先判断,当前对象是否正在被释放,如果正在被释放 那么调用过度释放方法overrelease_error

    • 当一个用户正在被释放时,再次调用release方法时会导致crash。 (过渡释放crash的由来)

    overrelease_error

    这个方法主要是定义了crash信息:

    NEVER_INLINE uintptr_t
    objc_object::overrelease_error()
    {
        _objc_inform_now_and_on_crash("%s object %p overreleased while already deallocating; break on objc_overrelease_during_dealloc_error to debug", object_getClassName((id)this), this);
        objc_overrelease_during_dealloc_error();
        return 0; // allow rootRelease() to tail-call this
    }
    
    • 如果当前对象没有被正在释放,那么我们将当前对象正在被释放标志位置为true newisa.deallocating = true; 同时将状态的更新同步到isa中。如果同步失败,那么会重复走一次retry。

    • 最后,更新状态成功后,对sidetable的操作也结束了,我们就可以将sidetable解锁,如果需要执行dealloc方法,那么我们调用dealloc进行对象释放

    6.autorelease

    ----- 下一篇着重分析 --------

    相关文章

      网友评论

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

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