更多精彩文章,欢迎关注作者微信公众号:码工笔记
背景 & 问题
了解Objective-C语言的同学都知道objc_msgSend在这门语言里的核心地位,每个ObjC方法调用最终都会调到这个方法,因此其执行效率要求非常高。
当我们调用一个Objective-C方法时,ObjC会根据class和selector来查找IMP(具体的函数地址),然后将(selector地址, IMP)二元组对放到此class对应的一个哈希表中,以提高下次调用的效率。
但之前使用的普通哈希表有两个问题:
- 哈希函数只是对selector的地址值做了二进制掩码(mask),如果同一个class的两个selector地址的低位相同,就会出现哈希冲突(冲突使用线性探测法来解决),从而影响查找效率
- 哈希表占用内存较大:移动设备上一般占70MB左右
如果能将这种动态哈希表改为静态数据,并存储到shared cache文件中,就可以实现:
- 为每个class构造出一个完美哈希表,避免哈希冲突带来的开销
- 哈希表内容全部存储在clean memory中(随时可以换出,节省内存)
Apple在2020年对IMP缓存机制做了优化,本文就介绍一下此优化的具体方案(具体实现代码见参考资料[2][3])。
IMP缓存哈希表优化
1、哈希函数
对于任意一个class,会为它生成一个静态哈希表用来缓存selector对应的IMP地址,所用的哈希函数是:
hash(x) = (x >> shift) & mask
其中:
-
shift和mask是两个跟class相关的参数(每个class都有自己的shift和mask值),每个class有一张独立的哈希表。
-
x是selector的地址。因为所有的selector都是由dyld中的shared cache builder负责排布的,我们实际上可以任意决定所有x的取值,也就是说我们不光可以控制哈希函数,还可以控制自变量的取值空间。
所以,现在问题变成了:
- 给定一个class集合,以及这些class实现的一些selector集合,如何决定各个selector的地址,同时如何为每个class找到合适的shift和mask值,使得对每个class来说,
h(selector)=(selector>>shift)&mask
是class内selector的完美哈希函数?
注:对一个内存地址进行shift和mask,相当于是取这个地址二进制的不同bit段
2、具体算法
算法主要分两个阶段:
1)找合适的shift和mask值
先给selector地址的部分高位部分进行赋位,然后再为每个class找一个合适的shift和mask值。
这是个回溯算法,其流程如下。
顺序遍历所有class:
在遍历过程中,需要保证为当前class找到的shift和mask值能够与当前class中所有selector地址上已赋值的bit相匹配(不改变已赋值的bit)。
- 举例:假设class1中实现了selector1、selector2,class2实现了selector2、selector3,遍历顺序是先class1,后class2,那么当遍历到class2时,class2的shift2、mask2既要满足使class2的IMP缓存哈希表完美,即h(selector2) != h(selector3),还要保证它对selector2地址的修改与处理class1对selector2的修改不冲突。假设h(selector2) == 0x0,h(selector2) = 0x1,根据哈希函数及shift2、mask2就能算出来selector2相应bit的取值,这些bit如果在之前遍历class1时已经赋过值,且与现在要赋的值不同,则说明不匹配,则此shift2、mask2是不合理的,需要重新寻找。
在匹配的情况下,再将各selector地址中新找到的(shift,mask)对应的bit进行置位。
在处理每个class的过程中,我们穷举所有合乎条件的shift和mask值,直到找到能满足要求的参数。
如果不存在满足条件的shift和mask,则需要回溯;如果尝试了多次回溯还是找不到合适的解,我们就放弃这个class,将它放入黑名单(后续流程再处理)。
因为控制的是selector的地址,而selector结构本身是有长度的,为了防止多个selector地址靠得太近导致selector结构放不下,我们将selector地址低位的7个bit空出来不使用,这样得到的每个selector地址就对应了128byte的内存空间。我们可以用这些内存空间存放selector内容,剩余的部分则可填入其他selector。
本步骤主要是给selector地址的高位赋值,确定其所在的128byte内存块(桶),然后再将属于这个桶的所有selector内容顺序填入桶的内存块中。
2)对桶内放不下的selector进行重排
如果步骤 1)中某些桶中selector数量过多导致128byte放不下了,就需要将其中某些selector移到别的合适的桶。
其流程如下:
遍历selector所在的所有class,为selector所在的桶地址建立一个“约束集合”:
- 检查各class的哈希表中哪些槽是空的,也就是selector地址中的哪些位可选哪些值,作为约束条件
- 前提是所有class对应的shift和mask值都不变,所以上面说的“哪些位”是确定的
如果能找到一个满足所有约束的解,则可以据此来得到selector的新值。
- 通过改变各class哈希表中selector对应IMP所占的槽位,来改变selector的地址,使所有class哈希表保持完美
如果找不到满足约束的解,则也可以丢弃与此selector相关的class。
所有selector都找到合适的桶(128byte内存块)后,就可以按顺序将selector结构依次填入桶中了。selector地址的低7位即可由同一桶内其所有前序selector的内容长度相加得到。
3、运行时使用IMP Cache
构建dyld shared cache阶段:
- dyld依照上述算法算出的位置排布selector
- dyld将各class的存了(selector, IMP)二元组的哈希表内容放到shared cache中
- dyld将上述算法计算出的每个class对应的shift和mask值存到class结构中
objc_msgSend运行时阶段:
- 根据class结构中存储的指针找到其IMP cache 哈希表起始位置(bucket)
- 根据class结构中存储的shift、mask的值以及selector地址(实际是偏移),通过一次哈希计算
(selector >> shift) & mask
得到哈希表内偏移 - 偏移处存放的就是(selector, IMP),从而得到IMP地址。
下面我们通过分析objc_msgSend的部分源码来看一下它使用IMP缓存哈希表的详细流程。
一进入objc_msgSend后,先是做了判空和取isa,然后就会调用CacheLookup:
MSG_ENTRY _objc_msgSend
UNWIND _objc_msgSend, NoFrame
cmp p0, #0 // nil check and tagged pointer check
b.le LNilOrTagged // (MSB tagged pointer looks negative)
ldr p14, [x0] // p14 = raw isa
GetClassFromIsa_p16 p14, 1, x0 // p16 = class
LGetIsaDone:
// calls imp or objc_msgSend_uncached
CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached
LNilOrTagged:
b.eq LReturnZero // nil check
GetTaggedClass
b LGetIsaDone
LReturnZero:
...
END_ENTRY _objc_msgSend
CacheLookup是个汇编宏,其具体实现如下:
.macro CacheLookup Mode, Function, MissLabelDynamic, MissLabelConstant
// - x0 contains the receiver
// - x1 contains the selector
// - x16 contains the isa
// - other registers are set as per calling conventions
//
mov x15, x16 // stash the original isa
LLookupStart\Function:
// p1 = SEL, p16 = isa
ldr p11, [x16, #CACHE] // p11 = mask|buckets
and p10, p11, #0x0000fffffffffffe // p10 = buckets
tbnz p11, #0, LLookupPreopt\Function
;省略
...
其中#CACHE是16,就是取了一下isa内offset为16的数据,根据注释可以看出,取出来的是mask|buckets,存到了p11中,接着又把buckets分离出来看它是否为空,不为空(即IMP缓存哈希表存在)则跳转到LLookupPreopt\Function。
接着看LLookupPreopt\Function的具体过程(关键流程解释见代码中的相关注释):
LLookupPreopt\Function:
and p10, p11, #0x007ffffffffffffe // p10 = buckets
autdb x10, x16 // auth as early as possible
// x12 = (_cmd - first_shared_cache_sel)
adrp x9, _MagicSelRef@PAGE
ldr p9, [x9, _MagicSelRef@PAGEOFF]
sub p12, p1, p9
// w9 = ((_cmd - first_shared_cache_sel) >> hash_shift & hash_mask)
// bits 63..60 of x11 are the number of bits in hash_mask
// bits 59..55 of x11 is hash_shift
lsr x17, x11, #55 // w17 = (hash_shift, ...)
lsr w9, w12, w17 // >>= shift
lsr x17, x11, #60 // w17 = mask_bits
mov x11, #0x7fff
lsr x11, x11, x17 // p11 = mask (0x7fff >> mask_bits)
and x9, x9, x11 // &= mask
// sel_offs is 26 bits because it needs to address a 64 MB buffer (~ 20 MB as of writing)
// keep the remaining 38 bits for the IMP offset, which may need to reach
// across the shared cache. This offset needs to be shifted << 2. We did this
// to give it even more reach, given the alignment of source (the class data)
// and destination (the IMP)
;注:
;1)x17 = *(x10 + x9 * 8),其中x10中存放的是bucket首地址,x9是hash函数的结果
;2)x17中存的就是IMP缓存哈希表中的目标条目,它是个bitmap,格式为:
; 高26位是sel_offs(即selector离shared cache中第一条selector之间的偏移量)
; 低38位是imp_offs(IMP地址右移2位得到);
ldr x17, [x10, x9, LSL #3] // x17 == (sel_offs << 38) | imp_offs
;注:x12中存放的是当前selector的sel_offs,用它跟x17中的sel_offs进行对比,若二者不相等,则说明cache miss了,此selector没有缓存
cmp x12, x17, LSR #38
b.ne 5f // cache miss
sbfiz x17, x17, #2, #38 // imp_offs = combined_imp_and_sel[0..37] << 2
sub x17, x16, x17 // imp = isa - imp_offs
br x17
5: ldur x9, [x10, #-16] // offset -16 is the fallback offset
add x16, x16, x9 // compute the fallback isa
b LLookupStart\Function // lookup again with a new isa
...
总体流程还是比较清晰的,也可以看出来,在objc_msgSend这种关键路径上,Apple做了大量的优化,每个64位里都encode很多信息,再加上完美哈希表,的确可谓极致。
【完】
参考资料
- [1] IMPCache介绍:github.com/apple-opens…
- [2] IMPCache源码链接:github.com/apple-opens…
- [3] objc_msgSend源码链接:github.com/apple-oss-d…