探秘Runtime - Runtime消息发送机制

该文章属于刘小壮原创,转载请注明:刘小壮


方法调用

OC中方法调用是通过Runtime实现的,Runtime进行方法调用本质上是发送消息,通过objc_msgSend()函数进行消息发送。

例如下面的OC代码会被转换为Runtime代码。

原方法:[object testMethod]
转换后的调用:objc_msgSend(object, @selector(testMethod));

发送消息的第二个参数是一个SEL类型的参数,在项目里经常会出现,不同的类定义了相同的方法,这样就会有相同的SEL。那么问题就来了,也是很多人博客里都问过的一个问题,不同类的SEL是同一个吗?

然而,事实是通过我们的验证,创建两个不同的类,并定义两个相同的方法,通过@selector()获取SEL并打印。我们发现SEL都是同一个对象,地址都是相同的。由此证明,不同类的相同SEL是同一个对象。

@interface TestObject : NSObject
- (void)testMethod;
@end

@interface TestObject2 : NSObject
- (void)testMethod;
@end

// TestObject2实现文件也一样
@implementation TestObject
- (void)testMethod {
    NSLog(@"TestObject testMethod %p", @selector(testMethod));
}
@end

// 结果:
TestObject testMethod 0x100000f81
TestObject2 testMethod 0x100000f81

Runtime中维护了一个SEL的表,这个表存储SEL不按照类来存储,只要相同的SEL就会被看做一个,并存储到表中。在项目加载时,会将所有方法都加载到这个表中,而动态生成的方法也会被加载到表中。

隐藏参数

我们在方法内部可以通过self获取到当前对象,但是self又是从哪来的呢?

方法实现的本质也是C函数,C函数除了方法传入的参数外,还会有两个默认参数,这两个参数在通过objc_msgSend()调用时也会传入。这两个参数在Runtime中并没有声明,而是在编译时自动生成的。

objc_msgSend的声明中可以看出这两个隐藏参数的存在。

objc_msgSend(void /* id self, SEL op, ... */ )
  • self,调用当前方法的对象。
  • _cmd,当前被调用方法的SEL

虽然这两个参数在调用和实现方法中都没有明确声明,但是我们仍然可以使用它。响应对象就是self,被调用方法的selector_cmd

- (void)method {
    id  target = getTheReceiver();
    SEL method = getTheMethod();
 
    if ( target == self || method == _cmd )
        return nil;
    return [target performSelector:method];
}

函数调用

一个对象被创建后,自身的类及其父类一直到NSObject类的部分,都会包含在对象的内存中,例如其父类的实例变量。当通过[super class]的方式调用其父类的方法时,会创建一个结构体。

struct objc_super { id receiver; Class class; };

super的调用会被转化为objc_msgSendSuper()的调用,并在其内部调用objc_msgSend()函数。有一点需要注意,尽管是通过[super class]的方式调用的,但传入的receiver对象仍然是self,返回结果也是selfclass。由此可知,当前对象无论调用任何方法,receiver都是当前对象。

objc_msgSend(objc_super->receiver, @selector(class))

objc_msg.s中,存在多个版本的objc_msgSend函数。内部实现逻辑大体一致,都是通过汇编实现的,只是根据不同的情况有不同的调用。

objc_msgSend
objc_msgSend_fpret
objc_msgSend_fp2ret
objc_msgSend_stret
objc_msgSendSuper
objc_msgSendSuper_stret
objc_msgSendSuper2
objc_msgSendSuper2_stret

在上面源码中,带有super的会在外界传入一个objc_super的结构体对象。stret表示返回的是struct类型,super2objc_msgSendSuper()的一种实现方式,不对外暴露。

struct objc_super {
    id  receiver;
    Class   class;
};

fp则表示返回一个long double的浮点型,而fp2则返回一个complex long double的复杂浮点型,其他floatdouble的普通浮点型都用objc_msgSend。除了上面这些情况外,其他都通过objc_msgSend()调用。

消息发送流程

当一个对象被创建时,系统会为其分配内存,并完成默认的初始化工作,例如对实例变量进行初始化。对象第一个变量是指向其类对象的指针-isaisa指针可以访问其类对象,并且通过其类对象拥有访问其所有继承者链中的类。

调用顺序

isa指针不是语言的一部分,主要为Runtime机制提供服务。

当对象接收到一条消息时,消息函数随着对象isa指针到类的结构体中,在method list中查找方法selector。如果在本类中找不到对应的selector,则objc_msgSend会向其父类的method list中查找selector,如果还不能找到则沿着继承关系一直向上查找,直到找到NSObject类。

Runtimeselector查找的过程做了优化,为类的结构体中增加了cache字段,每个类都有独立的cache,在一个selector被调用后就会加入到cache中。在每次搜索方法列表之前,都会先检查cache中有没有,如果没有才调用方法列表,这样会提高方法的查找效率。

如果通过OC代码的调用都会走消息发送的阶段,如果不想要消息发送的过程,可以获取到方法的函数指针直接调用。通过NSObjectmethodForSelector:方法可以获取到函数指针,获取到指针后需要对指针进行类型转换,转换为和调用函数相符的函数指针,然后发起调用即可。

void (*setter)(id, SEL, BOOL);
int i;
 
setter = (void (*)(id, SEL, BOOL))[target
    methodForSelector:@selector(setFilled:)];
for ( i = 0 ; i < 1000 ; i++ )
    setter(targetList[i], @selector(setFilled:), YES);

实现原理

Runtime中,objc_msgSend函数也是开源的,但其是通过汇编代码实现的,arm64架构代码可以在objc-msg-arm64.s中找到。在Runtime中,很多执行频率比较高的函数,都是用汇编写的。

objc_msgSend并不是完全开源的,在_class_lookupMethodAndLoadCache3函数中已经获取到Class参数了。所以在下面中有一个肯定是对象中获取isa_t的过程,从方法命名和注释来看,应该是GetIsaFast汇编命令。如果这样的话,就可以从消息发送到调用流程衔接起来了。

ENTRY   _objc_msgSend
    MESSENGER_START

    NilTest NORMAL

    GetIsaFast NORMAL       // r11 = self->isa
    CacheLookup NORMAL      // calls IMP on success

    NilTestSupport  NORMAL

    GetIsaSupport      NORMAL

// cache miss: go search the method lists
LCacheMiss:
    // isa still in r11
    MethodTableLookup %a1, %a2  // r11 = IMP
    cmp %r11, %r11      // set eq (nonstret) for forwarding
    jmp *%r11           // goto *imp

    END_ENTRY   _objc_msgSend
  • MESSENGER_START:消息开始执行。
  • NilTest:判断接收消息的对象是否为nil,如果为nil则直接返回,这就是对nil发送消息无效的原因。
  • GetIsaFast:快速获取到isa指向的对象,是一个类对象或元类对象。
  • CacheLookup:从ache list中获取缓存selector,如果查到则调用其对应的IMP
  • LCacheMiss:缓存没有命中,则执行此条汇编下面的方法。
  • MethodTableLookup:如果缓存中没有找到,则从method list中查找。

cache_t

如果每次进行方法调用时,都按照对象模型来进行方法列表的查找,这样是很消耗时间的。Runtime为了优化调用时间,在objc_class中添加了一个cache_t类型的cache字段,通过缓存来优化调用时间。

在执行objc_msgSend函数的消息发送过程中,同一个方法第一次调用是没有缓存的,但调用之后就会存在缓存,之后的调用就直接调用缓存。所以方法的调用,可以分为有缓存和无缓存两种,这两种情况下的调用堆栈是不同的。

首先是从缓存中查找IMP,但是由于cache3调用lookUpImpOrForward函数时,已经查找过cache了,所以传入的是NO,不进入查找cahce的代码块中。

struct cache_t {
    // 存储被缓存方法的哈希表
    struct bucket_t *_buckets;
    // 占用的总大小
    mask_t _mask;
    // 已使用大小
    mask_t _occupied;
}

struct bucket_t {
    cache_key_t _key;
    IMP _imp;
};

当给一个对象发送消息时,Runtime会沿着isa找到对应的类对象,但并不会立刻查找method_list,而是先查找cache_list,如果有缓存的话优先查找缓存,没有再查找方法列表。

这是Runtime对查找method的优化,理论上来说在cache中的method被访问的频率会更高。cache_listcache_t定义,内部有一个bucket_t的数组,数组中保存IMPkey,通过key找到对应的IMP并调用。具体源码可以查看objc-cache.mm

如果类对象没有被初始化,并且lookUpImpOrForward函数的initialize参数为YES,则表示需要对该类进行创建。函数内部主要是一些基础的初始化操作,而且会递归检查父类,如果父类未初始化,则先初始化其父类对象。

    STATIC_ENTRY _cache_getImp

    mov r9, r0
    CacheLookup NORMAL
    // cache hit, IMP in r12
    mov r0, r12
    bx  lr          // return imp
    
    CacheLookup2 GETIMP
    // cache miss, return nil
    mov r0, #0
    bx  lr

    END_ENTRY _cache_getImp

下面会进入cache_getImp的代码中,然而这个函数不是开源的,但是有一部分源码可以看到,是通过汇编写的。其内部调用了CacheLookupCacheLookup2两个函数,这两个函数也都是汇编写的。

经过第一次调用后,就会存在缓存。进入objc_msgSend后会调用CacheLookup命令,如果找到缓存则直接调用。但是Runtime并不是完全开源的,内部很多实现我们依然看不到,CacheLookup命令内部也一样,只能看到调用完命令后就开始执行我们的方法了。

CacheLookup NORMAL, CALL

源码分析

在上面objc_msgSend汇编实现中,存在一个MethodTableLookup的汇编调用。在这条汇编调用中,调用了查找方法列表的C函数。下面是精简版代码。

.macro MethodTableLookup
    
  // 调用MethodTableLookup并在内部执行cache3函数(C函数)
    blx __class_lookupMethodAndLoadCache3
    mov r12, r0         // r12 = IMP

.endmacro

MethodTableLookup中通过调用_class_lookupMethodAndLoadCache3函数,来查找方法列表。函数内部是通过lookUpImpOrForward函数实现的,在调用时cache字段传入NO,表示不需要查找缓存了,因为在cache3函数上面已经通过汇编查找过了。

IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{
    // 通过cache3内部调用lookUpImpOrForward函数
    return lookUpImpOrForward(cls, sel, obj, 
                              YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}

lookUpImpOrForward函数是支持多线程的,所以内部会有很多锁操作。其内部有一个rwlock_t类型的runtimeLock变量,有runtimeLock控制读写锁。其内部有很多逻辑代码,这里把函数内部实现做了精简,把核心代码贴到下面。

通过类对象的isRealized函数,判断当前类是否被实现,如果没有被实现,则通过realizeClass函数实现该类。在realizeClass函数中,会设置versionrwsuperClass等一些信息。

// 执行查找imp和转发的代码
IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
                       bool initialize, bool cache, bool resolver)
{
    IMP imp = nil;
    bool triedResolver = NO;

    runtimeLock.assertUnlocked();

    // 如果cache是YES,则从缓存中查找IMP。如果是从cache3函数进来,则不会执行cache_getImp()函数
    if (cache) {
        // 通过cache_getImp函数查找IMP,查找到则返回IMP并结束调用
        imp = cache_getImp(cls, sel);
        if (imp) return imp;
    }

    runtimeLock.read();

    // 判断类是否已经被创建,如果没有被创建,则将类实例化
    if (!cls->isRealized()) {
        // 对类进行实例化操作
        realizeClass(cls);
    }

    // 第一次调用当前类的话,执行initialize的代码
    if (initialize  &&  !cls->isInitialized()) {
        // 对类进行初始化,并开辟内存空间
        _class_initialize (_class_getNonMetaClass(cls, inst));
    }
    
 retry:    
    runtimeLock.assertReading();

    // 尝试获取这个类的缓存
    imp = cache_getImp(cls, sel);
    if (imp) goto done;
    
    {
        // 如果没有从cache中查找到,则从方法列表中获取Method
        Method meth = getMethodNoSuper_nolock(cls, sel);
        if (meth) {
            // 如果获取到对应的Method,则加入缓存并从Method获取IMP
            log_and_fill_cache(cls, meth->imp, sel, inst, cls);
            imp = meth->imp;
            goto done;
        }
    }

    {
        unsigned attempts = unreasonableClassCount();
        // 循环获取这个类的缓存IMP 或 方法列表的IMP
        for (Class curClass = cls->superclass;
             curClass != nil;
             curClass = curClass->superclass)
        {
            if (--attempts == 0) {
                _objc_fatal("Memory corruption in class list.");
            }
            
            // 获取父类缓存的IMP
            imp = cache_getImp(curClass, sel);
            if (imp) {
                if (imp != (IMP)_objc_msgForward_impcache) {
                    // 如果发现父类的方法,并且不再缓存中,在下面的函数中缓存方法
                    log_and_fill_cache(cls, imp, sel, inst, curClass);
                    goto done;
                }
                else {
                    break;
                }
            }
            
            // 在父类的方法列表中,获取method_t对象。如果找到则缓存查找到的IMP
            Method meth = getMethodNoSuper_nolock(curClass, sel);
            if (meth) {
                log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
                imp = meth->imp;
                goto done;
            }
        }
    }

    // 如果没有找到,则尝试动态方法解析
    if (resolver  &&  !triedResolver) {
        runtimeLock.unlockRead();
        _class_resolveMethod(cls, sel, inst);
        runtimeLock.read();
        triedResolver = YES;
        goto retry;
    }

    // 如果没有IMP被发现,并且动态方法解析也没有处理,则进入消息转发阶段
    imp = (IMP)_objc_msgForward_impcache;
    cache_fill(cls, sel, imp, inst);

 done:
    runtimeLock.unlockRead();

    return imp;
}

在方法第一次调用时,可以通过cache_getImp函数查找到缓存的IMP。但如果是第一次调用,就查不到缓存的IMP,就会进入到getMethodNoSuper_nolock函数中执行。下面是getMethod函数的关键代码。

getMethodNoSuper_nolock(Class cls, SEL sel) {
    // 根据for循环,从methodList列表中,从头开始遍历,每次遍历后向后移动一位地址。
    for (auto mlists = cls->data()->methods.beginLists(), 
              end = cls->data()->methods.endLists(); 
         mlists != end;
         ++mlists)
    {
        // 对sel参数和method_t做匹配,如果匹配上则返回。
        method_t *m = search_method_list(*mlists, sel);
        if (m) return m;
    }

    return nil;
}

当调用一个对象的方法时,查找对象的方法,本质上就是遍历对象isa所指向类的方法列表,并用调用方法的SEL和遍历的method_t结构体的name字段做对比,如果相等则将IMP函数指针返回。

// 根据传入的SEL,查找对应的method_t结构体
static method_t *search_method_list(const method_list_t *mlist, SEL sel)
{
    int methodListIsFixedUp = mlist->isFixedUp();
    int methodListHasExpectedSize = mlist->entsize() == sizeof(method_t);
    
    if (__builtin_expect(methodListIsFixedUp && methodListHasExpectedSize, 1)) {
        return findMethodInSortedMethodList(sel, mlist);
    } else {
        for (auto& meth : *mlist) {
            // SEL本质上就是字符串,查找的过程就是进行字符串对比
            if (meth.name == sel) return &meth;
        }
    }

    if (mlist->isFixedUp()) {
        for (auto& meth : *mlist) {
            if (meth.name == sel) {
                _objc_fatal("linear search worked when binary search did not");
            }
        }
    }

    return nil;
}

getMethod函数中,主要是对Classmethods方法列表进行查找和匹配。类的方法列表都在Classclass_data_bits_t中,通过data()函数从bits中获取到class_rw_t的结构体,然后获取到方法列表methods,并遍历方法列表。

如果从当前类中获取不到对应的IMP,则进入循环中。循环是从当前类出发,沿着继承者链的关系,一直向根类查找,直到找到对应的IMP实现。

查找步骤和上面也一样,先通过cache_getImp函数查找父类的缓存,如果找到则调用对应的实现。如果没找到缓存,表示第一次调用父类的方法,则调用getMethodNoSuper_nolock函数从方法列表中获取实现。

for (Class curClass = cls->superclass;
             curClass != nil;
             curClass = curClass->superclass)
{
    imp = cache_getImp(curClass, sel);
    if (imp) {
        if (imp != (IMP)_objc_msgForward_impcache) {
            log_and_fill_cache(cls, imp, sel, inst, curClass);
            goto done;
        }
    }
    
    Method meth = getMethodNoSuper_nolock(curClass, sel);
    if (meth) {
        log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
        imp = meth->imp;
        goto done;
    }
}

如果没有找到方法实现,则会进入动态方法决议的步骤。在if语句中会判断传入的resolver参数是否为YES,并且会判断是否已经有过动态决议,因为下面是goto retry,所以这段代码可能会执行多次。

if (resolver  &&  !triedResolver) {
    _class_resolveMethod(cls, sel, inst);
    triedResolver = YES;
    goto retry;
}

如果满足条件并且是第一次进行动态方法决议,则进入if语句中调用_class_resolveMethod函数。动态方法决议有两种,_class_resolveClassMethod类方法决议和_class_resolveInstanceMethod实例方法决议。

BOOL (*msg)(Class, SEL, SEL) = (__typeof__(msg))objc_msgSend;
bool resolved = msg(cls, SEL_resolveInstanceMethod, sel);

在这两个动态方法决议的函数实现中,本质上都是通过objc_msgSend函数,调用NSObject中定义的resolveInstanceMethod:resolveClassMethod:两个方法。

可以在这两个方法中动态添加方法,添加方法实现后,会在下面执行goto retry,然后再次进入方法查找的过程中。从triedResolver参数可以看出,动态方法决议的机会只有一次,如果这次再没有找到,则进入消息转发流程。

imp = (IMP)_objc_msgForward_impcache;
cache_fill(cls, sel, imp, inst);

如果经过上面这些步骤,还是没有找到方法实现的话,则进入动态消息转发中。在动态消息转发中,还可以对没有实现的方法做一些弥补措施。

下面是通过objc_msgSend函数发送一条消息后,所经过的调用堆栈,调用顺序是从上到下的。

CacheLookup NORMAL, CALL
__objc_msgSend_uncached
MethodTableLookup NORMAL
_class_lookupMethodAndLoadCache3
lookUpImpOrForward

调用总结

在调用objc_msgSend函数后,会有一系列复杂的判断逻辑,总结如下。

  1. 判断当前调用的SEL是否需要忽略,例如Mac OS中的垃圾处理机制启动的话,则忽略retainrelease等方法,并返回一个_objc_ignored_methodIMP,用来标记忽略。
  2. 判断接收消息的对象是否为nil,因为在OC中对nil发消息是无效的,这是因为在调用时就通过判断条件过滤掉了。
  3. 从方法的缓存列表中查找,通过cache_getImp函数进行查找,如果找到缓存则直接返回IMP
  4. 查找当前类的method list,查找是否有对应的SEL,如果有则获取到Method对象,并从Method对象中获取IMP,并返回IMP(这步查找结果是Method对象)。
  5. 如果在当前类中没有找到SEL,则去父类中查找。首先查找cache list,如果缓存中没有则查找method list,并以此类推直到查找到NSObject为止。
  6. 如果在类的继承体系中,始终没有查找到对应的SEL,则进入动态方法解析中。可以在resolveInstanceMethodresolveClassMethod两个方法中动态添加实现。
  7. 动态消息解析如果没有做出响应,则进入动态消息转发阶段。此时可以在动态消息转发阶段做一些处理,否则就会Crash

整体分析

总体可以被分为三部分:

  1. 刚调用objc_msgSend函数后,内部的一些处理逻辑。
  2. 复杂的查找IMP的过程,会涉及到cache listmethod list等。
  3. 进入消息转发阶段。

cache list中找不到方法的情况下,会通过MethodTableLookup宏定义从类的方法列表中,查找对应的方法。在MethodTableLookup中本质上也是调用_class_lookupMethodAndLoadCache3函数,只是在传参时cache字段传NO,表示不从cache list中查找。

cache3函数中,是直接调用的lookUpImpOrForward函数,这个函数内部实现很复杂,可以看一下Runtime Analyze。在这个里面直接搜lookUpImpOrForward函数名即可,可以详细看一下内部实现逻辑。


简书由于排版的问题,阅读体验并不好,布局、图片显示、代码等很多问题。所以建议到我Github上,下载Runtime PDF合集。把所有Runtime文章总计九篇,都写在这个PDF中,而且左侧有目录,方便阅读。

Runtime PDF

下载地址:Runtime PDF
麻烦各位大佬点个赞,谢谢!😁

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342