美文网首页iOS程序犭袁iOS CollectioniOS开发
iOS管理对象内存的数据结构以及操作算法--SideTables

iOS管理对象内存的数据结构以及操作算法--SideTables

作者: iOS入门级攻城尸 | 来源:发表于2017-03-07 17:59 被阅读10196次

第一次写文章语言表达能力太差。如果有哪里表达的不够清晰可以直接评论回复我,我来加以修改。这篇文章力求脱离语言的特性,咱们多讲结构和算法。即使你不懂iOS开发,不懂Objective-C语言也可以看这篇文章。
    通过阅读本文你可以了解iOS管理对象内存的数据结构是什么样的,以及操作逻辑。对象的reatin、release、dealloc操作是该通过怎样的算法实现的,weak指针是如何自动变nil的。
    本文所阐述的内容代码部分在苹果的开源项目objc4-706中。

** 本文流程:**
一、引用计数的概念
二、抛出问题
三、数据结构分析(* SideTables、RefcountMap、weak_table_t*)

一、引用计数的概念

这一部分是写给非iOS工程师的,便于大家了解引用计数、循环引用、弱引用的概念。如果已经了解相关概念可以直接跳过第一部分。

大家都知道想要占用一块内存很容易,咱们 new 一个对象就完事儿了。但是什么时候回收?不回收自然是不成的,内存再大也不能完全不回收利用。回收早了的话,真正用到的时候会出现野指针问题。回收晚了又浪费宝贵的内存资源。咱们得拿出一套管理内存的方法才成。本文只讨论iOS管理对象内存的引用计数法。
    内存中每一个对象都有一个属于自己的引用计数器。当某个对象A被另一个家伙引用时,A的引用计数器就+1,如果再有一个家伙引用到A,那么A的引用计数器就再+1。当其中某个家伙不再引用A了,A的引用计数器会-1。直到A的引用计数减到了0,那么就没有人再需要它了,就是时候把它释放掉了。

在引用计数中,每一个对象负责维护对象所有引用的计数值。当一个新的引用指向对象时,引用计数器就递增,当去掉一个引用时,引用计数就递减。当引用计数到零时,该对象就将释放占有的资源。

采用上述机制看似就可以知道对象在内存中应该何时释放了,但是还有一个循环引用的问题需要我们解决。

9F4E7963-0B4B-4153-A9FD-C3E9689B545B.png
现在内存中有两个对象,A和B。
A.x = B;
B.y = A;
  • 假如A是做视频处理的,B是处理音频的。
  • 现在A的引用计数是1(被B.y引用)。
  • 现在B的引用计数也是1(被A.x引用)。
  • 那么当A处理完它的视频工作以后,发现自己的引用计数是1不是0,他心里想"哦还有人需要我,我还不能被释放。"
  • 当B处理完音频操作以后他发现他的引用计数也是1,他心里也觉得"我还不能被释放还有人需要我。"

这样两个对象互相循环引用着对方谁都不会被释放就造成了内存泄露。为了解决这个问题我们来引入弱引用的概念。
    弱引用指向要引用的对象,但是不会增加那个对象的引用计数。就像下面这个图这样。虚线为弱引用 (艾玛我画图画的真丑)

EFDCA2C8-4E42-48EF-AE5F-3D4607B6CF68.png
        A.x = B;
 __weak B.y = A;

这里我们让B的y是一个弱引用,它还可以指向A但是不增加A的引用计数。

  • 所以A的引用计数是0,B的引用计数是1(被A.x引用)。
  • 当A处理完他的视频操作以后,发现自己的引用计数是0了,ok他可以释放了。
  • 随之A.x也被释放了。(A.x是对象A内部的一个变量)
  • A.x被释放了以后B的引用计数就也变成0了。
  • 然后B处理完他的音频操作以后也可以释放了。

循环引用的问题解决了。我们不妨思考一下,这套方案还会不会有其它的问题?
</br></br></br></br></br></br></br></br>思考中...</br></br></br></br></br></br></br></br>
还有一个野指针的问题等待我们解决。

  • 如果A先处理完他的视频任务之后被释放了。
  • 这时候B还在处理中。
  • 但是处理过程中B需要访问A (B.y)来获取一些数据。
  • 由于A已经被释放了,所以再访问的时候就造成了野指针错误。

因此我们还需要一个机制,可以让A释放之后,我再访问所有指向A的指针(比如B.y)的时候都可以友好的得知A已经不存在了,从而避免出错。
    我们这里假设用一个数组,把所有指向A的弱引用都存起来,然后当A被释放的时候把数组内所有的弱引用都设置成nil(相当于其他语言中的NULL)。这样当B再访问B.y的时候就会返回nil。通过判空的方式就可以避免野指针错误了。当然说起来简单,下面我们来看看苹果是如何实现的。

二、抛出问题

前面絮絮叨叨说了一大堆,其实真正现在才抛出本次讨论的问题。

  • 1、如何实现的引用计数管理,控制加一减一和释放?
  • 2、如何维护weak指针防止野指针错误?

三、数据结构分析(* SideTables、RefcountMap、weak_table_t*)

WechatIMG24.jpeg

很多人反应看了这篇文章后还是对SideTables、SideTable、RefcountMap三者的关系不太清楚。可能是我这篇文章讲述的不太好。大家可以看我第二篇文章中有一个大学宿舍楼的例子,结合这个例子看或许可以有助于理解三者关系。
咱们先来讨论最顶层的SideTables

WechatIMG21.jpeg

为了管理所有对象的引用计数和weak指针,苹果创建了一个全局的SideTables,虽然名字后面有个"s"不过他其实是一个全局的Hash表,里面的内容装的都是SideTable结构体而已。它使用对象的内存地址当它的key。管理引用计数和weak指针就靠它了。
    因为对象引用计数相关操作应该是原子性的。不然如果多个线程同时去写一个对象的引用计数,那就会造成数据错乱,失去了内存管理的意义。同时又因为内存中对象的数量是非常非常庞大的需要非常频繁的操作SideTables,所以能对整个Hash表加锁。苹果采用了分离锁技术。

分离锁和分拆锁的区别
    降低锁竞争的另一种方法是降低线程请求锁的频率。分拆锁 (lock splitting) 和分离锁 (lock striping) 是达到此目的两种方式。相互独立的状态变量,应该使用独立的锁进行保护。有时开发人员会错误地使用一个锁保护所有的状态变量。这些技术减小了锁的粒度,实现了更好的可伸缩性。但是,这些锁需要仔细地分配,以降低发生死锁的危险。
    如果一个锁守护多个相互独立的状态变量,你可能能够通过分拆锁,使每一个锁守护不同的变量,从而改进可伸缩性。通过这样的改变,使每一个锁被请求的频率都变小了。分拆锁对于中等竞争强度的锁,能够有效地把它们大部分转化为非竞争的锁,使性能和可伸缩性都得到提高。
    分拆锁有时候可以被扩展,分成若干加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁。

因为是使用对象的内存地址当key所以Hash的分部也很平均。假设Hash表有n个元素,则可以将Hash的冲突减少到n分之一,支持n路的并发写操作。

SideTable

当我们通过SideTables[key]来得到SideTable的时候,SideTable的结构如下:

1,一把自旋锁。spinlock_t  slock;

自旋锁比较适用于锁使用者保持锁时间比较短的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用,而自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用。

它的作用是在操作引用技术的时候对SideTable加锁,避免数据错误。
    苹果在对锁的选择上可以说是精益求精。苹果知道对于引用计数的操作其实是非常快的。所以选择了虽然不是那么高级但是确实效率高的自旋锁,我在这里只能说"双击666,老铁们! 没毛病!"

2,引用计数器 RefcountMap  refcnts;

对象具体的引用计数数量是记录在这里的。
    这里注意RefcountMap其实是个C++的Map。为什么Hash以后还需要个Map?其实苹果采用的是分块化的方法。
    举个例子
    假设现在内存中有16个对象。
0x0000、0x0001、...... 0x000e、0x000f
    咱们创建一个SideTables[8]来存放这16个对象,那么查找的时候发生Hash冲突的概率就是八分之一。
    假设SideTables[0x0000]和SideTables[0x0x000f]冲突,映射到相同的结果。

SideTables[0x0000] == SideTables[0x0x000f]  ==> 都指向同一个SideTable

苹果把两个对象的内存管理都放到里同一个SideTable中。你在这个SideTable中需要再次调用table.refcnts.find(0x0000)或者table.refcnts.find(0x000f)来找到他们真正的引用计数器。
    这里是一个分流。内存中对象的数量实在是太庞大了我们通过第一个Hash表只是过滤了第一次,然后我们还需要再通过这个Map才能精确的定位到我们要找的对象的引用计数器。
引用计数器的存储结构如下
注意这里讨论的是table.refcnts.find(this)得到的value的结构,至于RefcountMap是什么结构我们在下一篇文章中讨论

WechatIMG22.jpeg

引用计数器的数据类型是:

typedef __darwin_size_t        size_t;

再进一步看它的定义其实是unsigned long,在32位和64位操作系统中,它分别占用32和64个bit。
苹果经常使用bit mask技术。这里也不例外。拿32位系统为例的话,可以理解成有32个盒子排成一排横着放在你面前。盒子里可以装0或者1两个数字。我们规定最后边的盒子是低位,左边的盒子是高位。

  • (1UL<<0)的意思是将一个"1"放到最右侧的盒子里,然后将这个"1"向左移动0位(就是原地不动):0b0000 0000 0000 0000 0000 0000 0000 0001
  • (1UL<<1)的意思是将一个"1"放到最右侧的盒子里,然后将这个"1"向左移动1位:0b0000 0000 0000 0000 0000 0000 0000 0010

下面来分析引用计数器(图中右侧)的结构,从低位到高位。

  • (1UL<<0)    WEAKLY_REFERENCED
    表示是否有弱引用指向这个对象,如果有的话(值为1)在对象释放的时候需要把所有指向它的弱引用都变成nil(相当于其他语言的NULL),避免野指针错误。

  • (1UL<<1)    DEALLOCATING
    表示对象是否正在被释放。1正在释放,0没有。

  • REAL COUNT
    图中REAL COUNT的部分才是对象真正的引用计数存储区。所以咱们说的引用计数加一或者减一,实际上是对整个unsigned long加四或者减四,因为真正的计数是从2^2位开始的。

  • (1UL<<(WORD_BITS-1))    SIDE_TABLE_RC_PINNED
    其中WORD_BITS在32位和64位系统的时候分别等于32和64。随着对象的引用计数不断变大。如果这一位都变成1了,就表示引用计数已经最大了不能再增加了。

3,维护weak指针的结构体 weak_table_t   weak_table;

WechatIMG24.jpeg

上面的RefcountMap  refcnts;是一个一层结构,可以通过key直接找到对应的value。而这里是一个两层结构。
    第一层结构体中包含两个元素。
    第一个元素weak_entry_t *weak_entries;是一个数组,上面的RefcountMap是要通过find(key)来找到精确的元素的。weak_entries则是通过循环遍历来找到对应的entry。
    (上面管理引用计数器苹果使用的是Map,这里管理weak指针苹果使用的是数组,有兴趣的朋友可以思考一下为什么苹果会分别采用这两种不同的结构)
    第二个元素num_entries是用来维护保证数组始终有一个合适的size。比如数组中元素的数量超过3/4的时候将数组的大小乘以2。

第二层weak_entry_t的结构包含3个部分

  • 1,referent:
    被指对象的地址。前面循环遍历查找的时候就是判断目标地址是否和他相等。
  • 2,referrers
    可变数组,里面保存着所有指向这个对象的弱引用的地址。当这个对象被释放的时候,referrers里的所有指针都会被设置成nil。
  • 3,inline_referrers
    只有4个元素的数组,默认情况下用它来存储弱引用的指针。当大于4个的时候使用referrers来存储指针。

OK大家来看着图看着伪代码走一遍流程

1,alloc

这时候其实并不操作SideTable,具体可以参考:

深入浅出ARC(上)
Objc使用了类似散列表的结构来记录引用计数。并且在初始化的时候设为了一。

2,retain: NSObject.mm line:1402-1417

//1、通过对象内存地址,在SideTables找到对应的SideTable
SideTable& table = SideTables()[this];

//2、通过对象内存地址,在refcnts中取出引用计数
//这里是table是SideTable。refcnts是RefcountMap
size_t& refcntStorage = table.refcnts[this];

//3、判断PINNED位,不为1则+4
if (! (refcntStorage & PINNED)) {
    refcntStorage += (1UL<<2);
}

3,release NSObject.mm line:1524-1551

table.lock();
引用计数器 = table.refcnts.find(this);
//table.refcnts.end()表示使用一个iterator迭代器到达了end()状态
if (引用计数器 == table.refcnts.end()) {
    //标记对象为正在释放
    table.refcnts[this] = SIDE_TABLE_DEALLOCATING;
} else if (引用计数器 < SIDE_TABLE_DEALLOCATING) {
    //这里很有意思,当出现小余(1UL<<1) 的情况的时候
    //就是前面引用计数位都是0,后面弱引用标记位WEAKLY_REFERENCED可能有弱引用1
    //或者没弱引用0

    //为了不去影响WEAKLY_REFERENCED的状态
    引用计数器 |= SIDE_TABLE_DEALLOCATING;
} else if ( SIDE_TABLE_RC_PINNED位为0) {
    引用计数器 -= SIDE_TABLE_RC_ONE;
}
table.unlock();
如果做完上述操作后如果需要释放对象,则调用dealloc

4,dealloc NSObject.mm line:1555-1571

dealloc操作也做了大量了逻辑判断和其它处理,咱们这里抛开那些逻辑只讨论下面部分sidetable_clearDeallocating()

SideTable& table = SideTables()[this];
table.lock();
引用计数器 = table.refcnts.find(this);
if (引用计数器 != table.refcnts.end()) {
    if (引用计数器中SIDE_TABLE_WEAKLY_REFERENCED标志位为1) {
        weak_clear_no_lock(&table.weak_table, (id)this);
    }
    //从refcnts中删除引用计数器
    table.refcnts.erase(it);
}
table.unlock();

weak_clear_no_lock()是关键,它才是在对象被销毁的时候处理所有弱引用指针的方法。

weak_clear_no_lock objc-weak.mm line:461-504

void 
weak_clear_no_lock(weak_table_t *weak_table, id referent_id) 
{
    //1、拿到被销毁对象的指针
    objc_object *referent = (objc_object *)referent_id;

    //2、通过 指针 在weak_table中查找出对应的entry
    weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
    if (entry == nil) {
        /// XXX shouldn't happen, but does with mismatched CF/objc
        //printf("XXX no entry for clear deallocating %p\n", referent);
        return;
    }

    //3、将所有的引用设置成nil
    weak_referrer_t *referrers;
    size_t count;
    
    if (entry->out_of_line()) {
        //3.1、如果弱引用超过4个则将referrers数组内的弱引用都置成nil。
        referrers = entry->referrers;
        count = TABLE_SIZE(entry);
    } 
    else {
        //3.2、不超过4个则将inline_referrers数组内的弱引用都置成nil
        referrers = entry->inline_referrers;
        count = WEAK_INLINE_COUNT;
    }
    
    //循环设置所有的引用为nil
    for (size_t i = 0; i < count; ++i) {
        objc_object **referrer = referrers[I];
        if (referrer) {
            if (*referrer == referent) {
                *referrer = nil;
            }
            else if (*referrer) {
                _objc_inform("__weak variable at %p holds %p instead of %p. "
                             "This is probably incorrect use of "
                             "objc_storeWeak() and objc_loadWeak(). "
                             "Break on objc_weak_error to debug.\n", 
                             referrer, (void*)*referrer, (void*)referent);
                objc_weak_error();
            }
        }
    }
    
    //4、从weak_table中移除entry
    weak_entry_remove(weak_table, entry);
}

讲到这里我们就已经把SideTables的操作流程过一遍了,希望大家看的开心。
  欢迎加我的微博http://weibo.com/xuyang186
  转载请注明出处,谢谢。

参考文献

相关文章

网友评论

  • 夜空下最亮的亮点:weak_entries 老铁我想知道这个“有兴趣的朋友可以思考一下为什么苹果会分别采用这两种不同的结构”
  • 英俊神武:哎,有点看不懂啊,:joy:
  • qBryant:又学习了,读的很过瘾,希望大神再继续出一些高质量的文章!:grin:
  • 进击的iOS开发:大神您好,我看好多资料显示 weak_table_t 结构是 hashMap 并非数组https://www.jianshu.com/p/eff6b9443800 ,您有怎样判定的?
    WhisperKarl:哈希表的本质其实就是数组
  • fd39714bae0b://1、通过对象内存地址,在SideTables找到对应的SideTable
    SideTable& table = SideTables()[this];

    问:this是在什么时机加入到SideTables的呢?没有在源码中发现add push之类的方法
  • dec7c847c96d:weak_entries则是通过循环遍历来找到对应的entry。这里是错的。weak_table_t 中虽然weak_entry_t 是数组。但是hash方法找到相应的entry。
    dec7c847c96d:static weak_entry_t *
    weak_entry_for_referent(weak_table_t *weak_table, objc_object *referent)
    {
    assert(referent);

    weak_entry_t *weak_entries = weak_table->weak_entries;

    if (!weak_entries) return nil;

    size_t index = hash_pointer(referent) & weak_table->mask;
    size_t hash_displacement = 0;
    while (weak_table->weak_entries[index].referent != referent) {
    index = (index+1) & weak_table->mask;
    hash_displacement++;
    if (hash_displacement > weak_table->max_hash_displacement) {
    return nil;
    }
    }

    return &weak_table->weak_entries[index];
    }
  • 仰望星空之Rocky:reatin--》retain
  • NSXiaochuan:看了一天终于全看明白了,感谢作者!
  • SpursGo: if (entry->out_of_line()) {
    //3.1、如果弱引用超过4个则将referrers数组内的弱引用都置成nil。
    referrers = entry->referrers;
    count = TABLE_SIZE(entry);
    }
    else {
    //3.2、不超过4个则将inline_referrers数组内的弱引用都置成nil
    referrers = entry->inline_referrers;
    count = WEAK_INLINE_COUNT;
    }
    这一段代码该怎么理解
  • 4b04926d3bb8:写的很易懂,做的图很有个性哈,不过最后一张图有点会让人误解,根据伪代码
    //2、通过 指针 在weak_table中查找出对应的entry
    weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);

    weak_table就是weak_entry_t数组和num_entries(控制size)组成的,通过weak_entry_t数组循环找出entry。
    不过图会让人误解为weak_table是一个数组,里面每个元素都是weak_entry_t和num_entries组成的。
  • 乄_伤大雅:这里管理weak指针苹果使用的是数组,有兴趣的朋友可以思考一下为什么苹果会分别采用这两种不同的结构,这里是不是 但我们需要查询WEAK_TABLE__T,说明该属性现在已经在deallocing,这里采用数组可以循环遍历讲weak弱引用全部设置为nil,希望作者解惑一下。:relaxed:
    4b04926d3bb8:不是这样的,是先通过WEAK_TABLE__T比对referent,找到销毁对象对用的weak_entry_t。在把weak_entry_t里存储的weak指针数组循环遍历设置nil。
    这是数据结构的问题,因为存储的对象很多,hash表肯定没那么长,肯定会有hash后存在相同位置的对象,这些对象可以存在数组里,链表里,map里都行,就是二层hash,这里苹果采用一个map,一个数组,原因不清楚。 要是我来做,我甚至都不会有WEAK_TABLE__T,weak_entry_t数组直接放在上面的map里,和计数存一起
  • 笨驴爱吃胡萝卜:很想知道怎么验证的,这个我还是存疑,和msgSend方法一样,好多自己写的根据自己的理解
    iOS入门级攻城尸:@笨驴爱吃胡萝卜 不知道你所说的不能验证 具体是哪部分?
    笨驴爱吃胡萝卜:@iOS入门级攻城尸 哦,如果能验证就最好了,我也看了源码,可是还是有一部分没有开源,而且找不到太好的验证方法。
    iOS入门级攻城尸:@笨驴爱吃胡萝卜 本文所讲的所有内容都是对苹果的开源项目objc4-706中NSObject.mm的源码进行的分析。如果想要验证,只要阅读源码即可。本文是一篇源码分析,并不是没有基础的凭空假设。
  • 破弓:清晰朴实的好文章,图我喜欢!!!
    当然我有问题,在(https://github.com/Draveness/Analyze/blob/master/contents/objc/%E9%BB%91%E7%AE%B1%E4%B8%AD%E7%9A%84%20retain%20%E5%92%8C%20release.md)这篇文章中,作者说对象的isa内有8位的extra_rc来存储引用计数,在extra_rc不够用的时候才会开启哈希表对引用计数的记录,不知道哪个讲法是对的,还请指教,谢谢
    破弓:@iOS入门级攻城尸 谢谢您的回复!而外再讲一句,你的文章真的写的超好,为什么只有两篇,像您这样的真应该多多的发布心得才好
    iOS入门级攻城尸:@破弓 我们的说法并不矛盾呀。isa内部那部分是为了优化速度,因为那部分内容已经被大神们讲烂了,我就没提那部分。本文所讲的是isa内部不够用的情况下所使用的数据结构和算法。不过多谢你的提醒,是我文章中没有讲全面。我会修改一下文章提醒读者,在这个数据结构之前还有苹果为了优化速度而设计的一个机制。:joy:
  • 开发者头条_程序员必装的App:感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/xlq71f 欢迎点赞支持!
    欢迎订阅《iOS入门级攻城尸的独家号》https://toutiao.io/subjects/14381
  • TerryD:3,release NSObject.mm line:1524-1551 这部分:
    引用计数器 = table.refcnts.find(this);
    if (引用计数器 == table.refcnts.end()) {
    //标记对象为正在释放
    table.refcnts[this] = SIDE_TABLE_DEALLOCATING;
    }
    这是说没找到this的引用计数器吗?如果是为什么不直接返回啊,还要标记正在释放是做什么的啊?:grin:
    iOS入门级攻城尸:还可以看看这个 http://weibo.com/p/1001603902135409471398
    TerryD:@iOS入门级攻城尸 又学习到了,谢谢指点:+1:
    iOS入门级攻城尸:为了进一步提升速度和充分利用64位指针的空间,苹果有时候会把对象的一些状态(比如引用计数)直接存在指针里,当指针里的空间存不下引用计数的时候,才去存到SideTables里。所以可能会出现SideTables里没找到对象,但是对象其实是真实存在的,所以还是需要设置它的状态是DEALLOCATING。可以看参考文献里的《深入理解Tagged Pointer》
  • Somerr态:一时半会看不明白,mark一下,有时间慢慢琢磨:joy:
  • 89b77899bab7:0x0000 0000 0000 0000 0000 0000 0000 0001 这个地方用0x不妥吧,0x是表示十六进制
    iOS入门级攻城尸:哈哈 多谢提醒!已改正:pray: :pray: :pray:
  • WillyGeek:看到SideTable有些蒙圈, mark回来再看
  • 大墙66370:朴实清晰谦逊的好文章,写个圈子不多了。大都很浮躁。:+1:
    iOS入门级攻城尸:@大墙66370 :pray::pray:过奖啦
  • 377841418262:RefcountMap 是不是可以理解成解决hash冲突的一个方法。
    iOS入门级攻城尸:SideTables相当于做了一个收拢的工作,把一些对象放到同一个SideTable里。RefcountMap做的又是分发的工作,提供在SideTable里精确定位具体对象的功能。之所以这样先收拢再分发,是因为这样就可以把锁的粒度控制在一个SideTable一把锁。
    所以说hash冲突是苹果自己造出来的,目的是控制锁的粒度。
  • Mr_XBD:里面有个“弱”字打成了“若”
    iOS入门级攻城尸::pray: 已改正,谢谢哈。
  • 25ff43235a7c:还不错,能很好的了解内存的管理,比较细致!
  • 伽蓝香:mark 一下 有点难懂 待会儿再来看看
    iOS入门级攻城尸:@伽蓝香 我又把文章重新读一遍。修改了一些细节。希望可以表达的清楚一些。
  • 三十一_iOS:第一篇看到晕乎乎,缓过神来再读。
    iOS入门级攻城尸:@三十一_iOS 我又把文章重新读一遍。修改了一些细节。希望可以表达的清楚一些。
  • 水哥一点也不水:卧虎藏龙.高手在民间!你应该改名为iOS工程师之--老司机!

本文标题:iOS管理对象内存的数据结构以及操作算法--SideTables

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