想要成为一名
iOS开发高手
,免不了阅读源码。如下是笔者在OC源码探索
中梳理的一个小系列——类与对象篇,欢迎你们阅读指正,同时也但愿对你们有所帮助。html
本文是针对 方法缓存——cache_t
的分析(且源码版本是 objc4-756.2),下面进入正文。git
cache_t
源码分析当你的OC
项目编译完成后,类的实例方法(方法编号SEL
和 函数地址IMP
)就保存在类的方法列表中。咱们知道 OC
为了实现其动态性,将 方法的调用包装成了 SEL
寻找 IMP
的过程。试想一下,若是每次调用方法,都要去类的方法列表(甚至父类、根类的方法列表)中查询其函数地址,势必会对性能形成极大的损耗。为了解决这一问题,OC
采用了方法缓存的机制来提升调用效率,也就是cache_t
,其做用就是缓存已调用的方法。当调用方法时,objc_msgSend
会先去缓存中查找,若是找到就执行该方法;若是不在缓存中,则去类的方法列表(包括父类、根类的方法列表)查找,找到后会将方法的SEL
和IMP
缓存到cache_t
中,以便下次调用时可以快速执行。github
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
是否缓存了已调用的方法。缓存
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
复制代码
cache_t
在方法调用前打个断点,看看cache_t
的缓存状况安全
说明:bash
objc_class
结构很容易推导得出,0x1000011d8
是cache_t
首地址。(对类的结构感兴趣的同窗请戳 OC源码分析之类的结构解读)_mask
和_occupied
都是0cache_t
执行alloc
和init
这两个方法后,cache_t
变化以下多线程
从上图可知,调用init
后,_mask
的值是3,_occupied
则是1。_buckets
指针的值(数组首地址)发生了变化(从0x1003db250
变成0x101700090
),同时缓存了init
方法的SEL
和IMP
。架构
思考: 1. alloc 方法调用后,缓存在哪里? 2. 为何 init 方法不在 _buckets 第一个位置? app
继续执行methodFirst
,再看cache_t
此时,_mask
的值是3(没发生变化),_occupied
则变成了2,_buckets
指针地址没变,增长缓存了methodFirst
方法的SEL
和IMP
。
接着是执行methodSecond
,且看
显然,_occupied
变成了3,而_buckets
指针地址不改变,同时新增methodSecond
的方法缓存。
最后执行methodThird
后,再看cache_t
变化
此次的结果就彻底不一样了。_mask
的值变成7,_occupied
则从新变成了1,而_buckets
不只首地址变了,以前缓存的init
、methodFirst
和methodSecond
方法也没了,仅存在的只有新增的methodThird
方法。看来,cache_t
并不是是如咱们所愿的那样——调用一个方法就缓存一个方法。
思考:以前缓存的方法(init、methodFirst 和 methodSecond)哪去了?
cache_t
小结让咱们梳理一下上面的例子。在依次执行Person
的实例方法init
、methodFirst
、methodSecond
、methodThird
后,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
从侧面反映了桶的容量。
cache_t
的方法缓存原理接下来,笔者将从方法的调用过程开始分析cache_t
的方法缓存原理。
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
的具体流程笔者将另起一文分析,这里不做赘述。
cache_fill_nolock
cache_fill
又会来到cache_fill_nolock
,这个函数的做用是将方法的SEL
和IMP
写入_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
缓存方法的调度中心,在这里会
_buckets
的哪种缓存策略(初始化后缓存、直接缓存、扩容后缓存,三者取一);sel
找到一个bucket
,并更新这个bucket
的sel
和imp
。(若是这个bucket
的sel
为0,说明是个空桶,正好能够缓存方法,因而执行_occupied++
)。思考:为何扩容临界点是 3/4?
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
不只指针地址变了,同时以前缓存的init
、methodFirst
和methodSecond
方法也都不在了注意,_occupied
的变化是在回到cache_fill_nolock
后发生的。
思考:扩容后,为何不直接把以前缓存的方法加入新的buckets中?
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
find
在执行完相应的buckets
策略后,接下来就须要找到合适的位置(bucket
),以存储 方法的SEL
和IMP
。find
具体作的事情就是根据方法的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
复制代码
从源码能够发现,find
找bucket
的方式用到了hash
的思想:以_buckets
做为哈希桶,以cache_hash
做为哈希函数,进行哈希运算后得出索引值index
(本质是xx & mask
,因此index
最大值就是_mask
的值)。因为索引值是经过哈希运算
得出的,其结果天然是无序的,这也是为何上例中init
方法不在_buckets
第一个位置的缘由。
既然哈希桶的数量是在运行时动态增长的,那么在多线程环境下调用方法时,对方法的缓存有没有什么影响呢?且看下面的分析。
在整个objc_msgSend
函数中,为了达到最佳的性能,对方法缓存的读取操做是没有添加任何锁的。而多个线程同时调用已缓存的方法,并不会引起_buckets
和_mask
的变化,所以多个线程同时读取方法缓存的操做是不会有安全隐患的。
从源码咱们知道在桶数量扩容和写桶数据以前,系统使用了一个全局的互斥锁(cacheUpdateLock.assertLocked()
)来保证写入的同步处理,而且在锁住的范围内部还作了一次查缓存的操做(if (cache_getImp(cls, sel)) return;
),这样就 保证了哪怕多个线程同时写同一个方法的缓存也只会产生写一次的效果,即多线程同时写缓存的操做也不会有安全隐患。
这个状况就比较复杂了,咱们先看一下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
整个结构体成员分别读取到x10
和x11
两个寄存器中,而且CacheLookup
的后续代码没有再次读取cache_t
的成员数据,而是一直使用x10
和x11
中的值进行哈希查找。因为CPU能保证单条指令执行的原子性,因此 只要保证ldp x10, x11, [x16, #CACHE]
这段代码读取到的_buckets
与_mask
是互相匹配的(即要么同时是扩容前的数据,要么同时是扩容后的数据),那么多个线程同时读写方法缓存也是没有安全隐患的。
这里有个疑问,即系统是如何确保_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(内存屏障)
咱们知道,在多线程读写方法缓存时,写线程可能会扩容_buckets
(开辟新的_buckets
内存,同时销毁旧的_buckets
),此时,若是其余线程读取到的_buckets
是旧的内存,就有可能会发生读内存异常而系统崩溃。为了解决这个问题,OC
使用了两个全局数组objc_entryPoints
、objc_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_entryPoints
和objc_exitPoints
这个范围内),若是没有则说明没有任何线程访问cache
,能够放心地对garbage_refs
中的全部待销毁的哈希桶内存块执行真正的销毁操做;若是有则说明有线程访问cache
,此次就不作处理,下次再检查并在适当的时候进行销毁。
以上,OC 2.0
的runtime
巧妙的利用了ldp汇编指令
、编译内存屏障技术、内存垃圾回收技术等多种手段来解决多线程读写的无锁处理方案,既保证了安全,又提高了系统的性能。
在这里,特别感谢 欧阳大哥!他的 深刻解构objc_msgSend函数的实现 这篇博文会帮助你进一步了解Runtime的实现,其在多线程读写方法缓存方面也让笔者受益不浅,强烈推荐你们一读!
来到这里,相信你们对cache_t
缓存方法的原理已经有了必定的理解。如今请看下面的几个问题:
Q:Person
类调用alloc
方法后,缓存在哪里?
A:缓存在 Person
元类 的 cache_t
中。证实以下图
_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
),也就不会出现越界的状况。
3/4
的讨论Q:为何扩容临界点是3/4?
A:通常设定临界点就不得不权衡 空间利用率 和 时间利用率 。在 3/4
这个临界点的时候,空间利用率比较高,同时又避免了至关多的哈希冲突,时间利用率也比较高。
题解:扩容临界点直接影响循环查找哈希桶的效率。设想两个极端状况:
当临界点是1的时候,也就是说当所有的哈希桶都缓存有方法时,才会扩容。这虽然让开辟出来的内存空间的利用率达到100%,可是会形成大量的哈希冲突,加重了查找索引的时间成本,致使时间利用率低下,这与高速缓存的目的相悖;
当临界点是0.5的时候,意味着哈希桶的占用量达到总数一半的时候,就会扩容。这虽然极大避免了哈希冲突,时间利用率很是高,却浪费了一半的空间,使得空间利用率低下。这种以空间换取时间的作法一样不可取;
两相权衡下,当扩容临界点是3/4的时候,空间利用率 和 时间利用率 都相对比较高。
Q:缓存循环查找哈希桶是否会出现死循环的状况?
A:不会出现。
题解:当哈希桶的利用率达到3/4的时候,下次缓存的时候就会进行扩容,即空桶的数量最少也会有总数的1/4,所以循环查询索引的时候,必定会出现命中缓存或者空桶的状况,从而结束循环。
经过以上例子的验证、源码的分析以及问题的讨论,如今总结一下cache_t
的几个结论:
cache_t
能缓存调用过的方法。cache_t
的三个成员变量中,
_buckets
的类型是struct bucket_t *
,也就是指针数组,它表示一系列的哈希桶(已调用的方法的SEL
和IMP
就缓存在哈希桶中),一个桶能够缓存一个方法。_mask
的类型是mask_t
(mask_t
在64
位架构下就是uint32_t
,长度为4个字节),它的值等于哈希桶的总数-1(capacity - 1
),侧面反映了哈希桶的总数。_occupied
的类型也是mask_t
,它表明的是当前_buckets
已缓存的方法数。_occupied
为1。_buckets
和_mask
,因此并没有安全隐患。OC
用了个全局的互斥锁(cacheUpdateLock.assertLocked()
)来保证不会出现写两次缓存的状况。OC
使用了ldp汇编指令
、编译内存屏障技术、内存垃圾回收技术等多种手段来解决多线程读写的无锁处理方案,既保证了安全,又提高了系统的性能。github
上,请戳 objc4-756.2源码