欢迎阅读iOS底层系列(建议按顺序)
本文概述
本文旨在通过源码分析cache_t相关,如缓存策略,动态扩容等,了解方法查找的前置流程。因其与objc_msgSend流程有密切联系,而发送消息又是iOS方法的本质,故了解cache_t是有必要的。
cache_t初探
1.cache_t 结构
上一篇iOS底层 -- 类的本质分析中对cache_t结构有初步的了解,其底层的结构:
struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache;
class_data_bits_t bits;
..
}
复制代码
cache_t
位于objc_class
中
struct cache_t {
struct bucket_t *_buckets;
mask_t _mask;
mask_t _occupied;
}
复制代码
自身的结构由三个属性组成:
buckets
是装方法的桶子,里面放着方式实现imp
,根据方法编号sel
生成的key
mask
是扩容因子,同时和key
共同计算得出寻找bucket_t
的哈希索引occupied
是当前占用的容量
2.lldb调试
在objc源码中新建CJPerson类,自定义四个方法,在外部对其进行调用
CJPerson * person = [[CJPerson alloc]init];
Class cls1 = object_getClass(person);
[person firstAction];
Class cls2 = object_getClass(person);
[person secondAction];
Class cls3 = object_getClass(person);
[person thirdAction];
Class cls4 = object_getClass(person);
[person fourthAction];
Class cls5 = object_getClass(person);
复制代码
在每个方法调用前输出下各个cls,以cls1为例,在[person firstAction]处打断点
x/4gx
打印cls1
内存地址的前四位,因为cache_t
前面的两个属性isa
和superclass
合占16字节,故cache_t
的地址等于isa
的地址偏移16个字节,即是图中标注的地址,强转输出这个地址得到cls1
的cache_t
内的属性值,依次类推,输出其他的cls
类对象 | _mask | _occupied |
---|---|---|
cls1 | 3 | 1 |
cls2 | 3 | 2 |
cls3 | 3 | 3 |
cls4 | 7 | 1 |
cls5 | 7 | 2 |
根据这个结果,会发现前面三个cls
还有一点规律可循,occupied
每次+1,可是到cls4
的时候,这个规律又不符合了,并且mask
的值也发生了变化。看来只看打印结果是没办法看出是谁在作怪,还是要去源码瞧一瞧。
cache_t源码
1.寻找切入点
虽说从源码入手这个想法是好的,可是那么多源码,究竟要从哪里看起。思来想去,还是先搜索下cache_t
,
cache_t
是个结构体,那系统应该会
初始化它,然后在操作它,那尝试搜索下cache_t *
,
1.第一条
cache_t *getCache(Class cls)
{
assert(cls);
return &cls->cache;
}
复制代码
这只是个get
方法,排除
2.第二条
void cache_t::bad_cache(id receiver, SEL sel, Class isa)
{
// Log in separate steps in case the logging itself causes a crash.
_objc_inform_now_and_on_crash
("Method cache corrupted. This may be a message to an "
"invalid object, or a memory error somewhere else.");
cache_t *cache = &isa->cache;
_objc_inform_now_and_on_crash
("%s %p, SEL %p, isa %p, cache %p, buckets %p, "
"mask 0x%x, occupied 0x%x",
receiver ? "receiver" : "unused", receiver,
sel, isa, cache, cache->_buckets,
cache->_mask, cache->_occupied);
...
}
复制代码
这是在bad_cache
方法中,看名字和实现,不难判断出,这是坏缓存的时候发出的警告和崩溃消息
3.第三条
static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
cacheUpdateLock.assertLocked();
if (!cls->isInitialized()) return;
if (cache_getImp(cls, sel)) return;
cache_t *cache = getCache(cls);
...
}
复制代码
这是在cache_fill_nolock
方法中,方法名意为缓存填充,感觉可能是这个,但还不能确定
4.第四条
void cache_erase_nolock(Class cls)
{
cacheUpdateLock.assertLocked();
cache_t *cache = getCache(cls);
...
}
复制代码
这是在cache_erase_nolock
方法中,方法名意为缓存擦除
综上所述,第三条的可能性是最高的,但还需要验证,在每条方法内打断点,[person firstAction]调用后,看下执行结果
cache_fill_nolock
内,可以确认方法调用时,是执行这个方法进行缓存的。
2.cache_fill_nolock解析
确认了这是方法调用时执行缓存的方法,那就有必要对其实现做深入的研究了。
static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
cacheUpdateLock.assertLocked(); ①
if (!cls->isInitialized()) return; ②
if (cache_getImp(cls, sel)) return; ③
cache_t *cache = getCache(cls); ④
cache_key_t key = getKey(sel); ⑤
mask_t newOccupied = cache->occupied() + 1; ⑥
mask_t capacity = cache->capacity(); ⑦
if (cache->isConstantEmptyCache()) { ⑧
cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE); ⑨
}
else if (newOccupied <= capacity / 4 * 3) { ⑩
}
else {
cache->expand(); ⑪
}
bucket_t *bucket = cache->find(key, receiver); ⑫
if (bucket->key() == 0) cache->incrementOccupied(); ⑬
bucket->set(key, imp); ⑭
}
复制代码
① 缓存锁
② 判断类是否被初始化,如果未初始化,直接返回
③ 判断sel是否已经被cls缓存,如果已经缓存就没必要再走下面的缓存流程,直接返回
④ 通过cls获取cache_t的内存地址
⑤ 通过sel强转生成key
⑥ 获取cache中当前占有量occupied + 1的值,给下面进行容量判断
⑦ 取出cache中的容量capacity
⑧ 判断当前占有量是否为0并且桶子的容量是否为空的
⑨占有量为0并且桶子容量为空时,进行reallocate重新分配Buckets和Mask
⑩新的占有量小于等于总容量的3/4时,无另外操作
⑪新的占有量大于总容量的3/4时,进行expand扩容
⑫ 通过key当作哈希下标寻找对应bucket
⑬ 如果key等于0,说明没找到,需要缓存,则cache中的当前占有量 occupied + 1
⑭ 把key和imp装进桶子bucket
复制代码
总结:
cache_fill_nolock
先获取类的cache
,判断cache
为空时,创建cache
,如果cache
已经存在,判断存储后的占有量大于容量的3/4时,就扩容。做完这些后,key
和imp
放入bucket
中,把bucket
放入cache
中。
明白了缓存的主流程,在对里面比较重要的方法,如reallocate
和expand
等做进一步解析
a.reallocate(mask_t oldCapacity, mask_t newCapacity)
INIT_CACHE_SIZE定义
enum {
INIT_CACHE_SIZE_LOG2 = 2,
INIT_CACHE_SIZE = (1 << INIT_CACHE_SIZE_LOG2)
};
复制代码
实现
void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
...
setBucketsAndMask(newBuckets, newCapacity - 1);
if (freeOld) {
cache_collect_free(oldBuckets, oldCapacity);
cache_collect(false);
}
}
复制代码
可以看到,在第一次调用时,capacity
为空,则传入4,然后给setBucketsAndMask
入参,重新生成_buckets
,并且最后需要丢弃旧的桶子oldBuckets
,清空原来的缓存
void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
mega_barrier();
_buckets = newBuckets;
mega_barrier();
_mask = newMask;
_occupied = 0;
}
复制代码
处理完后,_mask
= 4 - 1 = 3,_occupied
= 0。mask = newCapacity - 1,newCapacity = 2^n(初始化最小为4,n为扩容次数+2),故mask = 2^n - 1,lldb调试的值得以验证
b.expand()
void cache_t::expand()
{
cacheUpdateLock.assertLocked();
uint32_t oldCapacity = capacity();
uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;
if ((uint32_t)(mask_t)newCapacity != newCapacity) {
newCapacity = oldCapacity;
}
reallocate(oldCapacity, newCapacity);
}
复制代码
先获取oldCapacity
旧容量,
mask_t cache_t::capacity()
{
return mask() ? mask()+1 : 0;
}
复制代码
等于mask + 1,初始化后mask = 3 ,那这里oldCapacity
= 4,新容量newCapacity
等于旧容量oldCapacity
* 2 = 8,然后给reallocate
入参,重新分配桶子buckets
和mask
c.find(cache_key_t k, id receiver)
bucket_t * cache_t::find(cache_key_t k, id receiver)
{
assert(k != 0);
bucket_t *b = buckets();
mask_t m = mask();
mask_t i = begin;
do {
if (b[i].key() == 0 || b[i].key() == k) {
return &b[i];
}
} while ((i = cache_next(i, m)) != begin);
Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
cache_t::bad_cache(receiver, (SEL)k, cls);
}
复制代码
通过cache_hash
函数【begin = k & m】计算出key
值 k
对应的 index
值begin
,用来记录查询起始索引,然后把begin
赋值给 i
,用于切换索引。
用这个i
从散列表取值,如果取出来的bucket_t
的 key
= k
,则查询成功,返回该bucket_t
;如果key
= 0,说明在索引i
的位置上还没有缓存过方法,同样需要返回该bucket_t
,用于中止缓存查询。然后while循环其实就相当于绕圈,把散列表头尾连起来,从begin
值开始,递减索引值,当走过一圈之后,必然会重新回到begin
值,如果此时还没有找到key
对应的bucket_t
,或者是空的bucket_t
,则循环结束,说明查找失败,调用bad_cache
方法抛出异常。
cache_t部分问题汇总
1.类的cache_t会对任何方法进行缓存嘛?
类的cache_t
只会缓存对象方法,类方法缓存在元类中
2.为什方法编号sel需要强转成key?
方法编号sel是字符串类型,在底层传递的效率不如long类型的key
3.mask的作用?
mask
做为判断是否扩容的参数之一,可以称之为扩容因子;同时mask
和key
共同生成哈希下表用来查找bucket_t
。这里的mask
和isa_mask
做为面具的功能不一样,需要进行区分。
4.方法缓存是否有序?
方法缓存的bucket_t
是根据哈希下表得到的,找到空位子就直接坐下占有,不存在有序
5.为什么扩容是3/4?
HashMap
的负载因子等于0.75时,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
6.bucket和mask,capacity,sel和imp的关系
bucket
内装着imp
和sel
生成的key
bucket
是通过key
和mask
进行寻找的bucket
的数量取决于capacity
7.为什么扩容后需要丢弃原先所有的缓存?
感觉上,扩容后丢弃缓存,下次调用在重新缓存的行为使得缓存变的没有意义。实际上,一般类中有较多方法,如果扩容后,还需要保存原先的方法,新bucket_t
需要从旧的bucket_t
中获取所有方法,在一个个放入,这本身是比较耗时的行为。方法缓存的目的就是使消息发送流程更快速,苹果为了更快,还使方法查找走汇编流程,总之这一切都是为了一个字--快。扩容后丢弃原先缓存就是在这个目的下被设计出来,而且这样设计,从某种意义上来说,也符合LRU
缓存策略,最近被缓存的方法使用率也较高,较早被缓存的使用率较低。
写在最后
以上就是我对cache_t
的部分理解,cache_t
是消息发送的一个切入点。下一章objc_msgSend
会对cache_t
有一个更宏观的理解。敬请期待。