阅读 1369

OC源码分析之方法的缓存原理

前言

想要成为一名iOS开发高手,免不了阅读源码。以下是笔者在OC源码探索中梳理的一个小系列——类与对象篇,欢迎大家阅读指正,同时也希望对大家有所帮助。

  1. OC源码分析之对象的创建
  2. OC源码分析之isa
  3. OC源码分析之类的结构解读
  4. OC源码分析之方法的缓存原理
  5. OC源码分析之方法的查找原理
  6. OC源码分析之方法的解析与转发原理

本文是针对 方法缓存——cache_t 的分析(且源码版本是 objc4-756.2),下面进入正文。

1 cache_t源码分析

当你的OC项目编译完成后,类的实例方法(方法编号SEL 和 函数地址IMP)就保存在类的方法列表中。我们知道 OC 为了实现其动态性,将 方法的调用包装成了 SEL 寻找 IMP 的过程。试想一下,如果每次调用方法,都要去类的方法列表(甚至父类、根类的方法列表)中查询其函数地址,势必会对性能造成极大的损耗。为了解决这一问题,OC 采用了方法缓存的机制来提高调用效率,也就是cache_t,其作用就是缓存已调用的方法。当调用方法时,objc_msgSend会先去缓存中查找,如果找到就执行该方法;如果不在缓存中,则去类的方法列表(包括父类、根类的方法列表)查找,找到后会将方法的SELIMP缓存到cache_t中,以便下次调用时能够快速执行。

1.1 cache_t结构

首先看一下cache_t的结构

struct cache_t {
    struct bucket_t *_buckets;  // 缓存数组,即哈希桶
    mask_t _mask;               // 缓存数组的容量临界值
    mask_t _occupied;           // 缓存数组中已缓存方法数量

    ... // 一些函数
};

#if __LP64__
typedef uint32_t mask_t;
#else
typedef uint16_t mask_t;
#endif

struct bucket_t {
private:
#if __arm64__
    uintptr_t _imp;
    SEL _sel;
#else
    SEL _sel;
    uintptr_t _imp;
#endif
    ... // 一些方法
};
复制代码

从上面源码不难看出,在64位CPU架构下,cache_t长度是16字节。单从结构来看,方法是缓存在bucket_t(又称哈希桶)中,接下来用个例子验证一下cache_t是否缓存了已调用的方法。

1.2 方法缓存的验证

  1. 创建一个简单的Person类,代码如下
@interface Person : NSObject

- (void)methodFirst;
- (void)methodSecond;
- (void)methodThird;

@end

@implementation Person

- (void)methodFirst {
    NSLog(@"%s", __FUNCTION__);
}

- (void)methodSecond {
    NSLog(@"%s", __FUNCTION__);
}

- (void)methodThird {
    NSLog(@"%s", __FUNCTION__);
}

@end
复制代码
  1. 方法调用前的cache_t

在方法调用前打个断点,看看cache_t的缓存情况

说明:

  • objc_class结构很容易推导得出,0x1000011d8cache_t首地址。(对类的结构感兴趣的同学请戳 OC源码分析之类的结构解读
  • 由于还没有任何方法调用,所以_mask_occupied都是0
  1. 方法调用后的cache_t

执行allocinit这两个方法后,cache_t变化如下

从上图可知,调用init后,_mask的值是3,_occupied则是1。_buckets指针的值(数组首地址)发生了变化(从0x1003db250变成0x101700090),同时缓存了init方法的SELIMP

思考: 1. alloc 方法调用后,缓存在哪里? 2. 为什么 init 方法不在 _buckets 第一个位置?

继续执行methodFirst,再看cache_t

此时,_mask的值是3(没发生变化),_occupied则变成了2,_buckets指针地址没变,增加缓存了methodFirst方法的SELIMP

接着是执行methodSecond,且看

显然,_occupied变成了3,而_buckets指针地址不改变,同时新增methodSecond的方法缓存。

最后执行methodThird后,再看cache_t变化

这次的结果就完全不同了。_mask的值变成7,_occupied则重新变成了1,而_buckets不仅首地址变了,之前缓存的initmethodFirstmethodSecond方法也没了,仅存在的只有新增的methodThird方法。看来,cache_t并非是如我们所愿的那样——调用一个方法就缓存一个方法。

思考:之前缓存的方法(init、methodFirst 和 methodSecond)哪去了?

1.3 cache_t小结

让我们梳理一下上面的例子。在依次执行Person的实例方法initmethodFirstmethodSecondmethodThird后,cache_t变化如下

调用的方法 _buckets _mask _occupied
未调用方法 0 0
init init 3 1
init、methodFirst init、methodFirst 3 2
init、methodFirst、methodSecond init、methodFirst、methodSecond 3 3
init、methodFirst、methodSecond、methodThird methodThird 7 1

可见,cache_t的确能实时缓存已调用的方法

上面的验证过程也可以帮助我们理解cache_t三个成员变量的意义。直接从单词含义上解析,bucket可译为桶(即哈希桶),用于装方法;occupied可译为已占有,表示已缓存的方法数量;mask可译为面具、掩饰物,乍看无头绪,但是注意到cache_t中有获取容量的函数(capacity),其源码如下

struct cache_t {
    ...
    mask_t mask();
    mask_t capacity();
    ...
}

mask_t cache_t::mask() 
{
    return _mask; 
}

mask_t cache_t::capacity() 
{
    return mask() ? mask()+1 : 0; 
}
复制代码

由此可以得出,如果_mask是0,说明未调用实例方法,即桶的容量为0;当_mask不等于0的时候,意味着已经调用过实例方法,此时桶的容量为_mask + 1。故,_mask从侧面反映了桶的容量。

2 cache_t的方法缓存原理

接下来,笔者将从方法的调用过程开始分析cache_t的方法缓存原理。

2.1 cache_fill

OC方法的本质是 消息发送(即objc_msgSend),底层是通过方法的 SEL 查找 IMP。调用方法时,objc_msgSend会去cache_t中查询方法的函数实现(这部分是由汇编代码实现的,非常高效),在缓存中找的过程暂且不表;当缓存中没有的时候,则去类的方法列表中查找,直至找到后,再调用cache_fill,目的是为了将方法缓存到cache_t中,其源码如下

void cache_fill(Class cls, SEL sel, IMP imp, id receiver)
{
#if !DEBUG_TASK_THREADS
    mutex_locker_t lock(cacheUpdateLock);
    cache_fill_nolock(cls, sel, imp, receiver);
#else
    _collecting_in_critical();
    return;
#endif
}
复制代码

objc_msgSend的具体流程笔者将另起一文分析,这里不作赘述。

2.2 cache_fill_nolock

cache_fill又会来到cache_fill_nolock,这个函数的作用是将方法的SELIMP写入_buckets,同时更新_mask_occupied

其源码以及详细分析如下:

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
    cacheUpdateLock.assertLocked();

    // 如果类未初始化
    if (!cls->isInitialized()) return;

    // 在获取cacheUpdateLock之前,确保其他线程没有将该方法写入缓存
    if (cache_getImp(cls, sel)) return;

    // 获取 cls 的 cache_t指针
    cache_t *cache = getCache(cls);

    // newOccupied为新的方法缓存数,等于 当前方法缓存数+1
    mask_t newOccupied = cache->occupied() + 1;
    // 获取当前cache_t的总容量,即 mask+1
    mask_t capacity = cache->capacity();
    if (cache->isConstantEmptyCache()) {
        // 当第一次调用类的实例方法时(如本文的【1.2】例中的`init`)
        cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
    }
    else if (newOccupied <= capacity / 4 * 3) {
        // 新的方法缓存数 不大于 总容量的3/4,按原样使用,无需扩容
    }
    else {
        // 新的方法缓存数 大于 总容量的3/4,需要扩容
        cache->expand();
    }

    // 根据sel获取bucket,此bucket的sel一般为0(说明这个位置还没缓存方法),
    // 也可能与实参sel相等(hash冲突,可能性很低)
    bucket_t *bucket = cache->find(sel, receiver);
    // 当且仅当bucket的sel为0时,执行_occupied++
    if (bucket->sel() == 0) cache->incrementOccupied();
    // 更新bucket的sel和imp
    bucket->set<Atomic>(sel, imp);
}

// INIT_CACHE_SIZE 即为4
enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};
复制代码

从上面的源码不难看出,cache_fill_nolock主要是cache_t缓存方法的调度中心,在这里会

  1. 决定执行_buckets的哪一种缓存策略(初始化后缓存、直接缓存、扩容后缓存,三者取一);
  2. 然后通过方法的sel找到一个bucket,并更新这个bucketselimp。(如果这个bucketsel为0,说明是个空桶,正好可以缓存方法,于是执行_occupied++)。

思考:为什么扩容临界点是 3/4?

2.3 reallocate

在下面这两种情况下会执行reallocate

  • 一是第一次初始化_buckets的时候
  • 另一种则是_buckets扩容的时候

我们来看一下reallocate做了哪些事情

void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
    // 当且仅当`_buckets`中有缓存方法时,feeOld为true
    bool freeOld = canBeFreed();

    // 获取当前buckets指针,即_buckets
    bucket_t *oldBuckets = buckets();
    // 开辟新的buckets指针
    bucket_t *newBuckets = allocateBuckets(newCapacity);

    // Cache's old contents are not propagated. 
    // This is thought to save cache memory at the cost of extra cache fills.
    // fixme re-measure this

    assert(newCapacity > 0);
    assert((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1);

    // 将新buckets、新mask(newCapacity-1)分别赋值跟当前的 _buckets 和 _mask
    setBucketsAndMask(newBuckets, newCapacity - 1);
    
    if (freeOld) {
        // 释放旧的buckets内存空间
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
}
复制代码

reallocate完美解释了在例【1.2】中的几个情况:

  • init执行完后,_buckets指针地址变了,_mask变成了3;
  • methodThird执行完后,_buckets不仅指针地址变了,同时之前缓存的initmethodFirstmethodSecond方法也都不在了

注意,_occupied的变化是在回到cache_fill_nolock后发生的。

思考:扩容后,为什么不直接把之前缓存的方法加入新的buckets中?

2.4 expand

cache_fill_nolock源码来看,当新的方法缓存数(_occupied+1)大于总容量(_mask+1)时,会对_buckets进行扩容,也就是执行expand函数,其源码如下

void cache_t::expand()
{
    cacheUpdateLock.assertLocked();
    
    // 获取当前总容量,即_mask+1
    uint32_t oldCapacity = capacity();
    // 新的容量 = 旧容量 * 2
    uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;

    if ((uint32_t)(mask_t)newCapacity != newCapacity) {
        // mask overflow - can't grow further
        // fixme this wastes one bit of mask
        newCapacity = oldCapacity;
    }
    
    reallocate(oldCapacity, newCapacity);
}
复制代码

这个函数非常简单,仅仅是计算好新的容量后,就去调用reallocate函数。需要注意的是:

  • 在不超过uint32_t大小(4字节)时,每次扩容为原来的2倍
  • 如果超过了uint32_t,则重新申请跟原来一样大小的buckets

2.5 find

在执行完相应的buckets策略后,接下来就需要找到合适的位置(bucket),以存储 方法的SELIMPfind具体做的事情就是根据方法的SEL,返回一个符合要求的bucket,同样上源码

bucket_t * cache_t::find(SEL s, id receiver)
{
    assert(s != 0);
    // 获取当前buckets,即_buckets
    bucket_t *b = buckets();
    // 获取当前mask,即_mask
    mask_t m = mask();
    // 由 sel & mask 得出起始索引值
    mask_t begin = cache_hash(s, m);
    mask_t i = begin;
    do {
        // sel为0:说明 i 这个位置尚未缓存方法;
        // sel等于s:命中缓存,说明 i 这个位置已缓存方法,可能是hash冲突
        if (b[i].sel() == 0  ||  b[i].sel() == s) {
            return &b[i];
        }
    } while ((i = cache_next(i, m)) != begin);

    // hack
    // 找不到多余的哈希桶(出错的处理,打印问题)。一般不会走到这里!
    Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
    cache_t::bad_cache(receiver, (SEL)s, cls);
}

static inline mask_t cache_hash(SEL sel, mask_t mask) 
{
    return (mask_t)(uintptr_t)sel & mask;
}

#if __arm__  ||  __x86_64__  ||  __i386__
// objc_msgSend has few registers available.
// Cache scan increments and wraps at special end-marking bucket.
#define CACHE_END_MARKER 1
static inline mask_t cache_next(mask_t i, mask_t mask) {
    return (i+1) & mask;
}

#elif __arm64__
// objc_msgSend has lots of registers available.
// Cache scan decrements. No end marker needed.
#define CACHE_END_MARKER 0
static inline mask_t cache_next(mask_t i, mask_t mask) {
    return i ? i-1 : mask;
}

#else
#error unknown architecture
#endif
复制代码

从源码可以发现,findbucket的方式用到了hash的思想:以_buckets作为哈希桶,以cache_hash作为哈希函数,进行哈希运算后得出索引值index(本质是xx & mask,所以index最大值就是_mask的值)。由于索引值是通过哈希运算得出的,其结果自然是无序的,这也是为什么上例中init方法不在_buckets第一个位置的原因。

3 多线程对方法缓存的影响

既然哈希桶的数量是在运行时动态增加的,那么在多线程环境下调用方法时,对方法的缓存有没有什么影响呢?且看下面的分析。

3.1 多线程同时读取缓存

在整个objc_msgSend函数中,为了达到最佳的性能,对方法缓存的读取操作是没有添加任何锁的。而多个线程同时调用已缓存的方法,并不会引发_buckets_mask的变化,因此多个线程同时读取方法缓存的操作是不会有安全隐患的

3.2 多线程同时写缓存

从源码我们知道在桶数量扩容和写桶数据之前,系统使用了一个全局的互斥锁(cacheUpdateLock.assertLocked())来保证写入的同步处理,并且在锁住的范围内部还做了一次查缓存的操作(if (cache_getImp(cls, sel)) return;),这样就 保证了哪怕多个线程同时写同一个方法的缓存也只会产生写一次的效果,即多线程同时写缓存的操作也不会有安全隐患

3.3 多线程同时读写缓存

这个情况就比较复杂了,我们先看一下objc_msgSend读缓存的代码(以 arm64架构汇编 为例)

.macro CacheLookup
	// x1 = SEL, x16 = isa
	ldp	x10, x11, [x16, #CACHE]	// x10 = buckets, x11 = occupied|mask
	and	w12, w1, w11		// x12 = _cmd & mask
	add	x12, x10, x12, LSL #4	// x12 = buckets + ((_cmd & mask)<<4)

	ldp	x9, x17, [x12]		// {x9, x17} = *bucket
1:	cmp	x9, x1			// if (bucket->sel != _cmd)
	b.ne	2f			//     scan more
	CacheHit $0			// call or return imp
	
2:	// not hit: x12 = not-hit bucket
	CheckMiss $0			// miss if bucket->sel == 0
	cmp	x12, x10		// wrap if bucket == buckets
	b.eq	3f
	ldp	x9, x17, [x12, #-16]!	// {x9, x17} = *--bucket
	b	1b			// loop

3:	// wrap: x12 = first bucket, w11 = mask
	add	x12, x12, w11, UXTW #4	// x12 = buckets+(mask<<4)

	// Clone scanning loop to miss instead of hang when cache is corrupt.
	// The slow path may detect any corruption and halt later.

	ldp	x9, x17, [x12]		// {x9, x17} = *bucket
1:	cmp	x9, x1			// if (bucket->sel != _cmd)
	b.ne	2f			//     scan more
	CacheHit $0			// call or return imp
	
2:	// not hit: x12 = not-hit bucket
	CheckMiss $0			// miss if bucket->sel == 0
	cmp	x12, x10		// wrap if bucket == buckets
	b.eq	3f
	ldp	x9, x17, [x12, #-16]!	// {x9, x17} = *--bucket
	b	1b			// loop

3:	// double wrap
	JumpMiss $0
	
.endmacro
复制代码

其中,ldp指令的作用是将数据从内存读取出来存到寄存器,第一个ldp代码会 cache_t中的_buckets_occupied | _mask整个结构体成员分别读取到x10x11两个寄存器中,并且CacheLookup的后续代码没有再次读取cache_t的成员数据,而是一直使用x10x11中的值进行哈希查找。由于CPU能保证单条指令执行的原子性,所以 只要保证ldp x10, x11, [x16, #CACHE]这段代码读取到的_buckets_mask是互相匹配的(即要么同时是扩容前的数据,要么同时是扩容后的数据),那么多个线程同时读写方法缓存也是没有安全隐患的

3.3.1 编译内存屏障

这里有个疑问,即系统是如何确保_buckets_mask的这种一致性的呢?让我们看一下这两个变量的写入源码

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    // objc_msgSend uses mask and buckets with no locks.
    // It is safe for objc_msgSend to see new buckets but old mask.
    // (It will get a cache miss but not overrun the buckets' bounds).
    // It is unsafe for objc_msgSend to see old buckets and new mask.
    // Therefore we write new buckets, wait a lot, then write new mask.
    // objc_msgSend reads mask first, then buckets.

    // ensure other threads see buckets contents before buckets pointer
    mega_barrier();

    _buckets = newBuckets;
    
    // ensure other threads see new buckets before new mask
    mega_barrier();
    
    _mask = newMask;
    _occupied = 0;
}
复制代码

这段C++代码先修改_buckets,然后再更新_mask的值,为了确保这个顺序不被编译器优化,这里使用了mega_baerrier()来实现 编译内存屏障(Compiler Memory Barrier)

如果不设置 编译内存屏障 的话,编译器有可能会优化代码先赋值_mask,然后才是赋值_buckets,两者的赋值之间,如果另一个线程执行ldp x10, x11, [x16, #0x10]指令,得到的就是旧_buckets新_mask,进而出现内存数组越界引发程序崩溃。

而加入了编译内存屏障后,就算得到的是新_buckets旧_mask,也不会导致程序崩溃。

编译内存屏障仅仅是确保_buckets的赋值会优先于_mask的赋值,也就是说,在任何场景下当指令ldp x10, x11, [x16, #CACHE]执行后,得到的_buckets数组的长度一定是大于或等于_mask+1的,如此就保证了不会出现内存数组越界导致的程序崩溃。可见,借助编译内存屏障的技巧在一定的程度上可以实现无锁读写技术。

内存屏障感兴趣的同学可戳 理解 Memory barrier(内存屏障)

3.3.2 内存垃圾回收

我们知道,在多线程读写方法缓存时,写线程可能会扩容_buckets(开辟新的_buckets内存,同时销毁旧的_buckets),此时,如果其他线程读取到的_buckets是旧的内存,就有可能会发生读内存异常而系统崩溃。为了解决这个问题,OC使用了两个全局数组objc_entryPointsobjc_exitPoints,分别保存所有会访问到cache的函数的起始地址、结束地址

extern "C" uintptr_t objc_entryPoints[];
extern "C" uintptr_t objc_exitPoints[];
复制代码

下面列出这些函数(同样以 arm64架构汇编 为例)

.private_extern _objc_entryPoints
_objc_entryPoints:
	.quad   _cache_getImp
	.quad   _objc_msgSend
	.quad   _objc_msgSendSuper
	.quad   _objc_msgSendSuper2
	.quad   _objc_msgLookup
	.quad   _objc_msgLookupSuper2
	.quad   0

.private_extern _objc_exitPoints
_objc_exitPoints:
	.quad   LExit_cache_getImp
	.quad   LExit_objc_msgSend
	.quad   LExit_objc_msgSendSuper
	.quad   LExit_objc_msgSendSuper2
	.quad   LExit_objc_msgLookup
	.quad   LExit_objc_msgLookupSuper2
	.quad   0
复制代码

当线程扩容哈希桶时,会先把旧的桶内存保存在一个全局的垃圾回收数组变量garbage_refs中,然后再遍历当前进程(在iOS中,一个进程就是一个应用程序)中的所有线程,查看是否有线程正在执行objc_entryPoints列表中的函数(原理是PC寄存器中的值是否在objc_entryPointsobjc_exitPoints这个范围内),如果没有则说明没有任何线程访问cache,可以放心地对garbage_refs中的所有待销毁的哈希桶内存块执行真正的销毁操作;如果有则说明有线程访问cache,这次就不做处理,下次再检查并在适当的时候进行销毁。

以上,OC 2.0runtime巧妙的利用了ldp汇编指令、编译内存屏障技术、内存垃圾回收技术等多种手段来解决多线程读写的无锁处理方案,既保证了安全,又提升了系统的性能。

在这里,特别感谢 欧阳大哥!他的 深入解构objc_msgSend函数的实现 这篇博文会帮助你进一步了解Runtime的实现,其在多线程读写方法缓存方面也让笔者受益匪浅,强烈推荐大家一读!

4 问题讨论

来到这里,相信大家对cache_t缓存方法的原理已经有了一定的理解。现在请看下面的几个问题:

4.1 类方法的缓存位置

QPerson类调用alloc方法后,缓存在哪里?

A:缓存在 Person元类 的 cache_t 中。证明如下图

4.2 _mask的作用

Q:请说明cache_t_mask的作用

A_mask从侧面反映了cache_t中哈希桶的数量(哈希桶的数量 = _mask + 1),保证了查找哈希桶时不会出现越界的情况。

题解:从上面的源码分析,我们知道cache_t在任何一次缓存方法的时候,哈希桶的数量一定是 >=4且能被 4整除的_mask则等于哈希桶的数量-1,也就是说,缓存方法的时候,_mask的二进制位上全都是1。当循环查询哈希桶的时候,索引值是由xx & _mask运算得出的,因此索引值是小于哈希桶的数量的(index <= _mask,故index < capacity),也就不会出现越界的情况。

4.3 关于扩容临界点3/4的讨论

Q:为什么扩容临界点是3/4?

A:一般设定临界点就不得不权衡 空间利用率时间利用率 。在 3/4 这个临界点的时候,空间利用率比较高,同时又避免了相当多的哈希冲突,时间利用率也比较高。

题解:扩容临界点直接影响循环查找哈希桶的效率。设想两个极端情况:

当临界点是1的时候,也就是说当全部的哈希桶都缓存有方法时,才会扩容。这虽然让开辟出来的内存空间的利用率达到100%,但是会造成大量的哈希冲突,加剧了查找索引的时间成本,导致时间利用率低下,这与高速缓存的目的相悖;

当临界点是0.5的时候,意味着哈希桶的占用量达到总数一半的时候,就会扩容。这虽然极大避免了哈希冲突,时间利用率非常高,却浪费了一半的空间,使得空间利用率低下。这种以空间换取时间的做法同样不可取;

两相权衡下,当扩容临界点是3/4的时候,空间利用率 和 时间利用率 都相对比较高

4.4 缓存循环查找的死循环情况

Q:缓存循环查找哈希桶是否会出现死循环的情况?

A:不会出现。

题解:当哈希桶的利用率达到3/4的时候,下次缓存的时候就会进行扩容,即空桶的数量最少也会有总数的1/4,因此循环查询索引的时候,一定会出现命中缓存或者空桶的情况,从而结束循环。

5 总结

通过以上例子的验证、源码的分析以及问题的讨论,现在总结一下cache_t的几个结论:

  1. cache_t能缓存调用过的方法。
  2. cache_t的三个成员变量中,
    • _buckets的类型是struct bucket_t *,也就是指针数组,它表示一系列的哈希桶(已调用的方法的SELIMP就缓存在哈希桶中),一个桶可以缓存一个方法。
    • _mask的类型是mask_tmask_t64位架构下就是uint32_t,长度为4个字节),它的值等于哈希桶的总数-1(capacity - 1),侧面反映了哈希桶的总数。
    • _occupied的类型也是mask_t,它代表的是当前_buckets已缓存的方法数。
  3. 当缓存的方法数到达临界点(桶总数的3/4)时,下次再缓存新的方法时,首先会丢弃旧的桶,同时开辟新的内存,也就是扩容(扩容后都是全新的桶,以后每个方法都要重新缓存的),然后再把新的方法缓存下来,此时_occupied为1。
  4. 当多个线程同时调用一个方法时,可分以下几种情况:
    • 多线程读缓存:读缓存由汇编实现,无锁且高效,由于并没有改变_buckets_mask,所以并无安全隐患。
    • 多线程写缓存:OC用了个全局的互斥锁(cacheUpdateLock.assertLocked())来保证不会出现写两次缓存的情况。
    • 多线程读写缓存:OC使用了ldp汇编指令、编译内存屏障技术、内存垃圾回收技术等多种手段来解决多线程读写的无锁处理方案,既保证了安全,又提升了系统的性能。

6 参考资料

7 PS