iOS底层探究-----慢速查找流程

579 阅读8分钟

前言

根据上篇文章iOS底层探究--------Runimte 运行时&方法的本质中,通过汇编对objc_msgSend进行分析。当在缓存中找到我们要查找的方法时,进入的是缓存命中--CacheHit,直接返回找到的方法,但是假如没有找到,就会执行MissLabelDynamic(也就是__objc_msgSend_uncached)。

方法的查找,分为两步:

  • 汇编缓存查找 ---- 快速查找;
  • MethodList查找(方法库里面查找) ---- 慢速查找(不断遍历方法库); 在上篇文章中,已经详细的探究了汇编缓存查找。如果在汇编缓存中找不到对应方法,那么将会到MethodList里面再去查找,也就是到C++底层库里面找。

那么这里就有疑问了,为什么方法缓存要用汇编写,而不是C++写了?

  • 汇编语言更接近机器语言,执行起来,速度快、效率高;
  • 安全,相同的结果,可能有很多种过程实现,被hook的概率小;
  • 更加动态化,因为在C++中查找,如果遇到参数未知(如:动态添加的可变参数),就不能进行精确的查找,但是汇编却并不要求这样。

那么接下来,我们就验证在汇编缓存找不到对应方法后,是去MethodList里面查找的。

资源准备

进入主题

汇编如何切入到C++

因为不断遍历MethodList是十分耗时的过程,所以,就放到了C++里面进行。

通过__objc_msgSend_uncached传值imp

方法的查找,是通过方法对应的imp去匹配的,所以,想要在MethodList里面查找方法,一定是有该方法的imp的。

STATIC_ENTRY __objc_msgSend_uncached
UNWIND __objc_msgSend_uncached, FrameWithNoSaves
//---- ②要想查找方法,imp必不可缺,所以imp必然存储在MethodTableLookup里面(重点)
MethodTableLookup
//---- ①TailCallFunctionPointer是直接返回$0的,$0相当于查找时的起点了
TailCallFunctionPointer x17

END_ENTRY __objc_msgSend_uncached

MethodTableLookup切入C++方法

由于MethodList是由C++实现的,所以,查找也是需要进入到C++方法里面去。

.macro MethodTableLookup
	
	SAVE_REGS MSGSEND

	// lookUpImpOrForward(obj, sel, cls, LOOKUP_INITIALIZE | LOOKUP_RESOLVER)
	// receiver and selector already in x0 and x1
	mov	x2, x16
	mov	x3, #3
//---- ②这里执行的跳转的操作,那么lookUpImpOrForwar对应着是C++的一个API方法了。
	bl	_lookUpImpOrForward

	// IMP in x0
//---- ①因为0x是第一个寄存器,是返回值的存储位置,那么必然就会有返回的操作,和切入到C++方法完全不着边
	mov	x17, x0

	RESTORE_REGS MSGSEND

.endmacro

objc源码中,全文索引lookUpImpOrForward关键词就能得到: 54E6DDAE-7D6B-49CE-8D16-61BAB03CE375.png 那么这样就有汇编进入到了C++

简单介绍lookUpImpOrForward方法

源码太长,就把实现的判断方法给粘贴上了,{...}里面的内容折叠起来。lookUpImpOrForward方法是带返回值的IMP,所以方法的核心是在处位置。

IMP lookUpImpOrForward(id inst, SEL sel, Class cls, int behavior)
{
    const IMP forward_imp = (IMP)_objc_msgForward_impcache;
    IMP imp = nil;//---imp初始化
    Class curClass;
    runtimeLock.assertUnlocked();
//---- 初始化判断    
    if (slowpath(!cls->isInitialized())) {...}
    runtimeLock.lock();
//---- 检查class是否注册到当前的缓存表里面(注册类)    
    checkIsKnownClass(cls);
//----子类、父类、元类的初始化和实现   
    cls = realizeAndInitializeIfNeeded_locked(inst, cls, behavior & LOOKUP_INITIALIZE);

    runtimeLock.assertLocked();
    curClass = cls;
//---- 这个for循环返回了不同情况下得到的imp(方法的核心)
    for (unsigned attempts = unreasonableClassCount();;) {...}
    if (slowpath(behavior & LOOKUP_RESOLVER)) {...}
 done:
//---- 在ro和rw的methodlist里面找到该方法之后,把方法加入到缓存中,方便下次快速查找
    if (fastpath((behavior & LOOKUP_NOCACHE) == 0)) {...}
 done_unlock:
    if (slowpath((behavior & LOOKUP_NIL) && imp == forward_imp)) {...}
    return imp;
}

源码回溯:

  • imp初始化;
  • 判断类是否初始化;
  • 检查class是否注册到当前的缓存表里面(注册类);
  • 通过for循环得到的imp
  • 最后把imp返回出去;

realizeAndInitializeIfNeeded_locked简析

未命名文件-4.png 这就像一个连锁反应,当对象调用方法的时候,马上判断当前的类是否初始化,如果已经初始化,就判断当前类的父类以及元类是否初始化。当父类和元类在初始化的时候,这两者的内部又继续分别调用各自的父类或元类进行初始化,这就相当于一个递归的操作。如下图(前面分析isa的文章有): isa流程图.png

只要其中有一个初始化了,那么跟这个有关系的所有的环节,都要初始化,相当于遍地开花。

之所以这么做,就是为了找方法,就比如,当某个实例方法在子类找不到,就可以去父类找,父类找不到,就去父类的父类找。。。这样逐级查找,所以需要逐级的初始化。

相同的,如果是类方法,就在元类里面进行逐级查找。

iOS底层探究--------cache分析一文中,类里面的rorw里面有propertylistmethodlist,正因为在realizeAndInitializeIfNeeded_locked()方法里面,rorw做了准备工作,所以,才能在接下来的for循环( for (unsigned attempts = unreasonableClassCount();;){...} )中来查找对应方法的imp

for循环的递归查找

我们先来梳理下,我们所知道一般的查找流程(也就是慢速查找流程):

  • 先查找自己:methodlist --> sel -- imp;

  • 如果没找到,就到父类查找:父类 --> NSObject --> nil --> 跳出去了。 如源码,在for循环中: F5C92333-A9F9-4A04-A0B3-8658CB7453A9.png 源码回溯:

  • 、先在缓存中查找一次,因为是为了防止所查找的方法,在类初始化的时候,就已经加入缓存中了,所以先找一遍,以防万一。在缓存中找到了,就直接返回imp,没找到就进入

  • 、如果在缓存中没找到,就得在methodlist里面进行二分法查找。如果找到了,就直接写入缓存中去,方便下次在缓存中进行快速查找,没找到就进入

  • 、如果当前子类找不到,就直接到父类里面找,父类里面查找也分为快速慢速两个流程,首先是快速流程,那就得真机运行,在汇编里面找。快速流程找不到,再进行慢速流程找。如果父类先快速查找,没找到,再慢速查找,还是没找到,就去父类的父类里面,再进行快速和慢速查找,以此类推,所以是一个递归的过程。如果整个递归的过程还是找不到,那么就进入

  • 、如果所有的父类都找完了,还是没有的话,会返回一个外传的imp

二分法查找方法

先来个简易的二分法查找流程 二分查找流程.png 回到源码中,分析其中的二分法查找流程未命名文件.png 源码回溯:

  • 假设条件:的有8个方法,那么count = 8,正确的方法在7号位置

  • 第一次for循环:base = 0count = 8,其二进制:1000,此时查找区间就是(0,8)。计算probe = base + (count >> 1) = 0 + (1000 >> 1) = 4;比较传入方法value 和 遍历的方法的value的大小,如果是等于,那么就直接返回该方法,如果是大于,计算base = base = probe + 1 = 4 + 1 = 5count-- = 7;此时的查找区间(5,8),那么就意味着只能在67里面取值了;

  • 第二次for循环:base = 5count = 7count先执行for循环的count >>= 1,那么就是:0111 >>= 1 ,得到:count = 0011 = 3,再次计算probe = base + (count >> 1) = 5 + (0011 >> 1) = 6。接着往下执行,当传入方法value 大于 遍历的方法的value时,计算base = base = probe + 1 = 6 + 1 = 7count-- = 3 - 1 =2;还是没找到,接着进行第for循环;

  • 第三次for循环:base = 7count = 2count先执行for循环的count >>= 1,那么就是:0010 >>= 1 ,得到:count = 0001 = 1,计算probe = base + (count >> 1) = 7 + (0001 >> 1) = 7,最终找到7号方法,结束循环;

看到这里,有童鞋就会问了,要是前面列举的都是大于等于的情况,如果是小于了,又是怎样了?

  • 假设条件:的有8个方法,那么count = 8,正确的方法在2号位置

  • 在第一次循环中,计算probe = base + (count >> 1) = 0 + (1000 >> 1) = 4,如果传入方法value 小于 遍历的方法的value,不会走等于大于的判断,base一直为0,那么就只执行count >>= 1count = 4,此时还没找到,就进行第二次for循环,此时,计算probe = base + (count >> 1) = 0 + (0100 >> 1) = 2,那就直接找到了方法。

  • 注:这里的只是用count = 8 作为例子运算,但是在实际当中,count可能是个很大的数值,执行多次for循环。还有注意,count>>1count >>= 1,是有区别的,前者并没有赋值运算,所以count的值不改变,后者才有赋值运算,count的值才改变

二分法查找到的方法写入缓存log_and_fill_cache

未命名文件-2.png 源码回溯:

  • 方法的写入是在第一次查找调用方法的时候,如果缓存里面没有,就开始进行慢速查找,慢速查找必然是在rwromethodlist里面进行,如果这里面都没有,就回报错。如果找到了该方法,就回在缓存里面insert,那么下次查找的时候,CacheHit缓存命中。

慢速查找流程图

未命名文件-6.png