iOS 底层拾遗:objc_msgSend 与方法缓存

Runtime 消息发送与转发流程总是大家关注的重点,却常常忽略方法缓存机制这个显著提升 objc_msgSend 性能的幕后功臣。
本文会通过源码梳理消息发送与转发流程,重点分析方法缓存机制的实现细节。行文过程中会涉及到一些汇编代码,不过不影响理解核心逻辑。
源码基于 Runtime 750,arm64 架构。
一、从 objc_msgSend 谈起
注意: arm64 汇编代码会出现很多p字母,实际上是一个宏,64 位下是x,32 位下是w,p就是寄存器。
在分析缓存机制之前,先梳理一下消息发送与转发的流程,找到何时进行缓存的存储与读取。
objc_msgSend
objc_msgSend 代码如下:
ENTRY _objc_msgSend
UNWIND _objc_msgSend, NoFram

...// 处理对象是 tagged pointer 或 nil 的情况(x0 存的是 objc_object 对象地址)

ldr	p13, [x0]       // p13 = isa 把 x0 指向内存的前 64 位放到 p13(即是 objc_object 的 isa 成员变量)
GetClassFromIsa_p16 p13 // p16 = class 通过 isa 找到 class

LGetIsaDone:
CacheLookup NORMAL // 从方法缓存或方法列表中找到 IMP 并调用

复制代码在 64 位系统下GetClassFromIsa_p16宏代码为:
.macro GetClassFromIsa_p16

and p16, $0, #ISA_MASK // #define ISA_MASK 0x0000000ffffffff8ULL

复制代码$0获取宏的第一个参数,调用时传的p13,即是isa。这一步做的操作就是使用ISA_MASK掩码找到isa变量中的Class并放入p16(isa是union isa_t类型,在很多系统中已经不是单纯的指向Class,还包含了内存管理等信息,所以需要用掩码来获取)。
CacheLookup
CacheLookup包含读取方法缓存的核心逻辑,代码后面分析。
目前只需要知道它会查询当前Class的方法缓存,主要产生两种结果:若缓存命中,返回IMP或调用IMP;若缓存未命中,调用__objc_msgSend_uncached (找到IMP会调用) 或__objc_msgLookup_uncached (找到IMP不会调用) 方法。
STATIC_ENTRY __objc_msgSend_uncached
UNWIND __objc_msgSend_uncached, FrameWithNoSaves

MethodTableLookup
TailCallFunctionPointer x17

END_ENTRY __objc_msgSend_uncached

复制代码MethodTableLookup后面就是较为复杂的方法查询逻辑了,若找到了IMP会放到x17寄存器中,然后把x17的值传递给TailCallFunctionPointer宏调用方法。
MethodTableLookup
.macro MethodTableLookup
// push frame
SignLR
stp fp, lr, [sp, #-16]!
mov fp, sp

...// save registers: x0..x8, q0..q7

// receiver and selector already in x0 and x1
mov	x2, x16
bl	__class_lookupMethodAndLoadCache3

// IMP in x0
mov	x17, x0

...// restore registers

mov	sp, fp
ldp	fp, lr, [sp], #16
AuthenticateLR

.endmacro
复制代码由于这个宏内部要跳转函数,意味着lr的变化,所以开辟栈空间后需要把之前的fp/lr值存储到栈上便于复位状态。笔者删除了save registers和restore registers的逻辑,其实就是将各个寄存器的值先存储到栈上,内部函数帧释放时便于复位寄存器的值。
在调用完__class_lookupMethodAndLoadCache3后会把返回在x0的IMP值复制到x17中。
__class_lookupMethodAndLoadCache3是一个 C 函数,跳转之前把x16的值复制到x2中(x16目前存储的就是GetClassFromIsa_p16代码找到的对象的Class),那么此时寄存器布局就是:x0 -> receiver / x1 -> selector / x2 -> class,也就对应了这个方法的参数列表:
IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls) {
return lookUpImpOrForward(cls, sel, obj,
YES/initialize/, NO/cache/, YES/resolver/);
}
复制代码lookUpImpOrForward
lookUpImpOrForward方法比较复杂,简化逻辑如下:
IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
bool initialize, bool cache, bool resolver) {
IMP imp = nil;
bool triedResolver = NO;

// cache 为 YES 查找方法缓存
if (cache) {
imp = cache_getImp(cls, sel);
if (imp) return imp;
}
// 加锁
runtimeLock.lock();
// 若需要,进行类的空间分配初始化等工作

retry:
// 在当前类方法缓存中查找 IMP
imp = cache_getImp(cls, sel);
if (imp) goto done;
// 在当前类方法列表中查找 IMP
if (找到 IMP) {
把 IMP 存方法缓存
goto done;
}
// 在父类的方法缓存/方法列表中查找 IMP
while (Class cur = cls->superClass; cur != nil; cur = cur->superClass) {
if (在方法缓存中找到 IMP) {
if (IMP == _objc_msgForward_impcache) { break; }
把 IMP 存入当前类 cls 的方法缓存
goto done;
}
if (在方法列表中找到 IMP) {
把 IMP 存入当前类 cls 的方法缓存
goto done;
}
}
// 没有找到 IMP,尝试进行动态消息处理
if (resolver && !triedResolver) {
runtimeLock.unlock();
_class_resolveMethod(cls, sel, inst);
runtimeLock.lock();
triedResolver = YES;
goto retry;
}
// 若动态消息处理失败,IMP 指向一个函数并将 IMP 存方法缓存
imp = (IMP)_objc_msgForward_impcache;
cache_fill(cls, sel, imp, inst);

done:
runtimeLock.unlock();
return imp;
}
复制代码方法缓存的存取
方法缓存存储符合一般逻辑,只要找到了IMP就会进行缓存,加入方法缓存都会调用cache_fill方法。需要注意的是,如果是从父类链中找到的方法,仍然会加入当前类的缓存列表,这样能大大提高查找在父类链中方法的效率。
可能读者会疑惑这个方法为什么还会去取缓存?前面一堆汇编方法走到这里的时候理论上当前类是已经没有对应SEL的方法缓存了。前面个cache_getImp方法是因为lookUpImpOrForward函数会被其它函数调用,并不在前面笔者分析的流程中;而retry:下面的cache_getImp是因为在动态消息处理的时候可能会插入相关IMP然后goto retry。
方法列表的查询
类的方法列表的查询通过getMethodNoSuper_nolock-> search_method_list方法处理,具体的逻辑不展开了,只需知道若方法列表是排过序的会使用二分搜索去查;否则就是一个简单的遍历查询。所以在没有方法缓存的情况下方法的查询效率是很低的,时间复杂度要么是 O(logn) 要么是 O(n)。
消息转发的逻辑
在_class_resolveMethod方法前面调用了unlock()和lock(),关闭了类的保护状态,便于开发者改变类的方法列表等。
_class_resolveMethod会向对象发送+resolveInstanceMethod(实例对象)或+resolveClassMethod(类对象)方法,开发者可以在这两个方法中为类动态加入IMP,_class_resolveMethod出栈后走goto retry会重新尝试查找方法的逻辑。
当然,若开发者没有做处理,IMP仍然找不到,通过!triedResolver避免二次动态消息处理,然后就会让imp = (IMP)_objc_msgForward_impcache。如此一来,当lookUpImpOrForward函数帧释放时,在上层看来仍然是找到IMP了,这个方法就是_objc_msgForward_impcache。那么在前面分析的__objc_msgSend_uncached方法就仍然会调用这个IMP,接下来就是真正的消息转发阶段了。
STATIC_ENTRY __objc_msgForward_impcache
b __objc_msgForward
END_ENTRY __objc_msgForward_impcache

ENTRY __objc_msgForward
adrp	x17, __objc_forward_handler@PAGE
ldr	p17, [x17, __objc_forward_handler@PAGEOFF]
TailCallFunctionPointer x17
END_ENTRY __objc_msgForward

复制代码可以发现通过页地址加页偏移的方式,拿到__objc_forward_handler的地址并调用,它是一个函数指针,在OBJC2下有默认实现:
attribute((noreturn)) void
objc_defaultForwardHandler(id self, SEL sel)
{
_objc_fatal("%c[%s %s]: unrecognized selector sent to instance %p "
“(no message forward handler is installed)”,
class_isMetaClass(object_getClass(self)) ? ‘+’ : ‘-’,
object_getClassName(self), sel_getName(sel), self);
}
void _objc_forward_handler = (void)objc_defaultForwardHandler;
复制代码最终看到了熟悉的unrecognized selector sent to instance描述。
而对于开发者熟悉的-forwardingTargetForSelector:重定向方法、-forwardInvocation:转发方法,Runtime 源码中没有啥痕迹,在文件后面只有一个更改_objc_forward_handler指针的函数(笔者玩儿不动了,可以猜测方法重定向和方法转发是通过改变这个指针做逻辑的,感兴趣可以查看杨帝的逆向分析消息转发文章:Objective-C 消息发送与转发机制原理)

你可能感兴趣的:(iOS 底层拾遗:objc_msgSend 与方法缓存)