Runtime源码 —— 方法调用的过程

在写这篇文章之前,我关于方法调用的知识是比较零散的,甚至一度以为消息转发就是方法调用的过程。现有的文章大多根据苹果的官方文档Runtime Programming Guide进行分析,一般包含这些内容:

  • 方法的调用会被转换成objc_msgSend()
  • 如果找不到方法的实现,会开始执行动态方法解析
  • 如果动态方法解析失败了,会启动消息转发

所以消息转发应该只是方法调用中的一个步骤。这中间似乎缺了点什么,那就是:

  • 在启动消息转发之前,objc_msgSend()做了什么?

这也就是本文将要解答的:方法究竟是如何被调用的?

方法的调用栈

上一篇讲方法加载的过程时,用过这么一张图来讲realizeClass()的调用栈:

realizeClass()调用栈.png

当时调用的是类的class方法,在调用栈里有这么一个关键的方法:

IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
                       bool initialize, bool cache, bool resolver)

方法名字就是查找实现或者转发,看起来这就是我们要找的方法了。

沿用之前的TestObject类,再修改一下main函数的内容,现在看起来是这个样子的:

// TestObject.h
#import <Foundation/Foundation.h>
@interface TestObject : NSObject
- (void)hello;
@end

// TestObject.m
#import "TestObject.h"
@implementation TestObject
- (void)hello {
    NSLog(@"hello");
}
@end

// main.m
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        TestObject *testObj = [TestObject new];
        [testObj hello];
    }
    return 0;
}

在[testObj hello]这一行添加一个断点,运行程序进入断点,这时候在lookUpImpOrForward()方法中添加断点,继续运行进入此方法:

hello()调用栈.png

左侧的调用栈里面供包含了3层,按照调用的顺序依次是:

  • _objc_msgSend_uncached
  • _class_lookupMethodAndLoadCache3(id, SEL, Class)
  • lookUpImpOrForward(Class, SEL, id, bool, bool, bool)

一步步来看:

  • _objc_msgSend_uncached
    不对啊,官方文档中说的是调用objc_msgSend,这个uncached是怎么回事。看看objc_msgSend:
        ...
        ENTRY _objc_msgSend
        UNWIND _objc_msgSend, NoFrame
        MESSENGER_START

        NilTest NORMAL

        GetIsaFast NORMAL       // r10 = self->isa
        CacheLookup NORMAL, CALL    // calls IMP on success

        NilTestReturnZero NORMAL

        GetIsaSupport NORMAL
// cache miss: go search the method lists
LCacheMiss:
        // isa still in r10
        MESSENGER_END_SLOW
        jmp __objc_msgSend_uncached

        END_ENTRY _objc_msgSend
        ...

源码是汇编,说实话我是不太懂的,但没关系,关注一下这一行:
jmp __objc_msgSend_uncached。
从注释可以看到当cache miss的时候,会跳转到uncached方法中,到底是不是这样呢?重新运行程序,加个断点测试一下:

(注意,这里也需要先运行进入main函数中[testObj hello]这一行之后再激活断点)

objc_msgSend.png

没有问题,调用栈显示先进入了objc_msgSend,单步调试的图我就不放了,感兴趣的同学可以自己试一下,下面是过程:

  1. 先进入:CacheLookup NORMAL, CALL
  2. cache miss,跳到这里:jmp __objc_msgSend_uncached
  3. 进入:__objc_msgSend_uncached

这个时候调用栈的objc_msgSend已经看不到了,取而代之的就是__objc_msgSend_uncached:

__objc_msgSend_uncached.png

所以之前调用栈中的结果就可以理解了,这里也告诉了我们一个很重要的信息:在objc_msgSend最开始的地方就已经通过cache进行过一次查找。

  • _class_lookupMethodAndLoadCache3(id, SEL, Class)

现在断点所在的行是这么一个方法:MethodTableLookup。看起来像是在方法列表里进行查找。沿着断点继续走,就会走到现在这个方面里面,这个方法的实现非常简单:

IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{
    return lookUpImpOrForward(cls, sel, obj, 
                              YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}

就是完善了一下lookUpImpOrForward()的参数。话不多说,看看最关键的一步。

  • lookUpImpOrForward(Class, SEL, id, bool, bool, bool)

这个方法的实现有点长,我就不一起展示了,一步一步来分析:

part1
    // Optimistic cache lookup
    if (cache) {
        imp = cache_getImp(cls, sel);
        if (imp) return imp;
    }

    if (!cls->isRealized()) {
        rwlock_writer_t lock(runtimeLock);
        realizeClass(cls);
    }

    if (initialize  &&  !cls->isInitialized()) {
        _class_initialize (_class_getNonMetaClass(cls, inst));
    }

还记得前面说到的关键信息吗,之所以传入cache=NO就是因为在objc_msgSend()初期就已经查找过cache了,不需要在这里再查找一次。这部分代码主要做的是初始化的相关工作,这里不做扩展。接着往下:

part2
retry:
    runtimeLock.read();

    // Try this class's cache.
    imp = cache_getImp(cls, sel);
    if (imp) goto done;

加锁这一部分只有一行简单的代码,其主要目的保证方法查找以及缓存填充(cache-fill)的原子性,保证在运行以下代码时不会有新方法添加导致缓存被冲洗(flush)。

这里又一次使用cache进行查找。这里我是有点疑问的,在这个时候cache有可能会命中吗?或者说在什么情况下才能在这里命中cache?

在上一篇方法加载的过程中提到,在realizeClass()方法深处会拷贝编译期确定的方法同时添加category中的方法,难道这个过程改变了cache的内容,所以需要在这里查一下cache?先不深究,等研究category的时候看看能不能有所进展。

cache_getImp()方法同样是用汇编实现的:


    STATIC_ENTRY _cache_getImp

// do lookup
    movq    %a1, %r10       // move class to r10 for CacheLookup
    CacheLookup NORMAL, GETIMP  // returns IMP on success

LCacheMiss:
// cache miss, return nil
    xorl    %eax, %eax
    ret

    END_ENTRY _cache_getImp

CacheLookup应该就是用来查找cache的,这里是首次调用hello()方法,所以肯定不会命中,继续向下。

part3
    // Try this class's method lists.
    meth = getMethodNoSuper_nolock(cls, sel);
    if (meth) {
        log_and_fill_cache(cls, meth->imp, sel, inst, cls);
        imp = meth->imp;
        goto done;
    }

在当前类的方法列表中查找,因为hello()就是当前类的方法,所以在这一步会命中,命中时候的调用栈是这样的:

当前类方法命中.png

中间的方法都比较简单,我就不把源代码一一贴上来了,稍微说一下每个方法做了些什么:

  • getMethodNoSuper_nolock(Class cls, SEL sel)
    遍历class的methods列表,依次调用下一个方法
  • search_method_list(const method_list_t *mlist, SEL sel)
    如果是无序列表,直接匹配名字,成功则返回
    如果是有序列表,调用下一个方法
  • findMethodInSortedMethodList(SEL key, const method_list_t *list)
    匹配方法名,成功就直接返回

这些做完之后,会调用log_and_fill_cache()把方法加入缓存,这个方法的调用栈是这样的:

屏幕快照 2017-02-16 上午7.49.31.png

在cache_fill_nolock()方法中把当前调用的方法加入到cache中:

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);

    // Use the cache as-is if it is less than 3/4 full
    mask_t newOccupied = cache->occupied() + 1;
    mask_t capacity = cache->capacity();
    if (cache->isConstantEmptyCache()) {
        // Cache is read-only. Replace it.
        cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
    }
    else if (newOccupied <= capacity / 4 * 3) {
        // Cache is less than 3/4 full. Use it as-is.
    }
    else {
        // Cache is too full. Expand it.
        cache->expand();
    }

    bucket_t *bucket = cache->find(key, receiver);
    if (bucket->key() == 0) cache->incrementOccupied();
    bucket->set(key, imp);
}

注释还是很清楚的,在cache已经3/4满的时候,就会调用expand()方法扩充,这样可以保证cache一直都是有空位的:

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);
}

中间的if判断是对溢出情况的处理。正常情况下,expand方法会将容量翻倍,通过调用reallocate方法给cache重新分配内存,但出于性能考虑不会将老cache中的内容拷贝到新cache中。

这里插一点题外话,如果对swift没兴趣就跳过吧。这里的操作让我想起了swift中map的实现:

public func map<T>(
    _ transform: (Iterator.Element) throws -> T
  ) rethrows -> [T] {
    let initialCapacity = underestimatedCount
    var result = ContiguousArray<T>()
    result.reserveCapacity(initialCapacity)

    var iterator = self.makeIterator()

    // Add elements up to the initial capacity without checking for regrowth.
    for _ in 0..<initialCapacity {
      result.append(try transform(iterator.next()!))
    }
    // Add remaining elements, if any.
    while let element = iterator.next() {
      result.append(try transform(element))
    }
    return Array(result)
  }

里面有这么一行:

result.reserveCapacity(initialCapacity)

就是先直接申请了一段空间用来存放结果,满了之后才需要检查是否需要扩充,所以result.append()操作才会分成两部分来做,应该也是出于性能的考虑。

part4

因为hello()方法已经在上一步找到了,所以走不到下面的代码了,但还是可以看一看:

    // Try superclass caches and method lists.
    curClass = cls;
    while ((curClass = curClass->superclass)) {
        // Superclass cache.
        imp = cache_getImp(curClass, sel);
        if (imp) {
            if (imp != (IMP)_objc_msgForward_impcache) {
                // Found the method in a superclass. Cache it in this class.
                log_and_fill_cache(cls, imp, sel, inst, curClass);
                goto done;
            }
            else {
                // Found a forward:: entry in a superclass.
                // Stop searching, but don't cache yet; call method 
                // resolver for this class first.
                break;
            }
        }

        // Superclass method list.
        meth = getMethodNoSuper_nolock(curClass, sel);
        if (meth) {
            log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
            imp = meth->imp;
            goto done;
        }
    }

这一块还是很好理解的,就是在父类的缓存和方法列表中查找,逻辑跟前面两步基本一样,就不再细说了。只需要注意一点,在父类中找到的方法,也会被添加到当前类的cache中。

part5
    // No implementation found. Try method resolver once.
    if (resolver  &&  !triedResolver) {
        runtimeLock.unlockRead();
        _class_resolveMethod(cls, sel, inst);
        // Don't cache the result; we don't hold the lock so it may have 
        // changed already. Re-do the search from scratch instead.
        triedResolver = YES;
        goto retry;
    }

如果当前类和父类都找不到方法的实现,就进入了动态方法解析。这里面调用了_class_resolveMethod()方法,看看是怎么实现的:

void _class_resolveMethod(Class cls, SEL sel, id inst)
{
    if (! cls->isMetaClass()) {
        _class_resolveInstanceMethod(cls, sel, inst);
    } 
    else {
        _class_resolveClassMethod(cls, sel, inst);
        if (!lookUpImpOrNil(cls, sel, inst, 
                            NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) 
        {
            _class_resolveInstanceMethod(cls, sel, inst);
        }
    }
}

还是很清楚的,如果类不是元类,调用_class_resolveInstanceMethod(),是元类则调用_class_resolveClassMethod()。这两个方法很类似,就以第一个为例,注意看我添加的注释:

static void _class_resolveInstanceMethod(Class cls, SEL sel, id inst)
{
    // 查找类是否实现了+ (BOOL)resolveInstanceMethod:(SEL)sel方法
    // 如果没有实现就直接返回
    if (! lookUpImpOrNil(cls->ISA(), SEL_resolveInstanceMethod, cls, 
                         NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) 
    {
        return;
    }

    BOOL (*msg)(Class, SEL, SEL) = (__typeof__(msg))objc_msgSend;
    // 调用类里面实现的+ (BOOL)resolveInstanceMethod:(SEL)sel
    bool resolved = msg(cls, SEL_resolveInstanceMethod, sel);

    ...(略去了一些代码,主要是验证是否添加成功)
}

关于+ (BOOL)resolveInstanceMethod:(SEL)sel方法,这里就不细说了,有非常多的文章讲解了这个方法该怎么写,如果曾经看过,就会知道在这个方面里面通常都会调用:

BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types)

通过这个方法来给某个方法添加新的实现。在这个方法内部,有这么一行:

cls->data()->methods.attachLists(&newlist, 1);

将新的方法实现添加到了方法列表里面。这就完成了整个动态方法解析的过程。

这个时候回到part5最开始的地方,在调用完_class_resolveMethod()方法之后,有一步goto retry,就是回到part2重新开始,只不过这个时候在类的方法列表里面就可以找到这个方法了。

part6
    // No implementation found, and method resolver didn't help. 
    // Use forwarding.
    imp = (IMP)_objc_msgForward_impcache;
    cache_fill(cls, sel, imp, inst);

如果上一步依然没有解决问题,还有最后一个办法:消息转发。这个过程实在是太复杂,简单一点来说,如果你的类实现了这个方法:

- (void)forwardInvocation:(NSInvocation *)anInvocation

这个时候就会进到这个方法里面,在这里可以转发给其他对象进行处理。如果消息转发也失败了,那么这次方法的调用就失败了。

如果想要对消息转发的全部过程有更深刻的理解,可以参考这篇文章,讲的很详细:

forwarding 中路漫漫的消息转发

缓存命中

上面讲了那么多,前提是objc_msgSend汇编代码中的的缓存没有命中,如果在最开始缓存就命中了,会怎么样呢?

想要测试命中缓存很简单,把方法连续调用两次就可以了,第二次调用的时候上面那些方法都不会被调用到,直接就把hello()方法的log打印出来了。

总结

最后汇总一下正常方法调用的过程,总的来看还是很合情合理的:

  • 查找当前类的缓存和方法列表
  • 查找父类的缓存和方法列表
  • 动态方法解析
  • 消息转发

参考资料

从源代码看 ObjC 中消息的发送

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

推荐阅读更多精彩内容