Runtime源碼 —— 方法調(diào)用的過程

在寫這篇文章之前,我關于方法調(diào)用的知識是比較零散的,甚至一度以為消息轉(zhuǎn)發(fā)就是方法調(diào)用的過程。現(xiàn)有的文章大多根據(jù)蘋果的官方文檔Runtime Programming Guide進行分析,一般包含這些內(nèi)容:

  • 方法的調(diào)用會被轉(zhuǎn)換成objc_msgSend()
  • 如果找不到方法的實現(xiàn),會開始執(zhí)行動態(tài)方法解析
  • 如果動態(tài)方法解析失敗了,會啟動消息轉(zhuǎn)發(fā)

所以消息轉(zhuǎn)發(fā)應該只是方法調(diào)用中的一個步驟。這中間似乎缺了點什么,那就是:

  • 在啟動消息轉(zhuǎn)發(fā)之前,objc_msgSend()做了什么?

這也就是本文將要解答的:方法究竟是如何被調(diào)用的?

方法的調(diào)用棧

上一篇講方法加載的過程時,用過這么一張圖來講realizeClass()的調(diào)用棧:

realizeClass()調(diào)用棧.png

當時調(diào)用的是類的class方法,在調(diào)用棧里有這么一個關鍵的方法:

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

方法名字就是查找實現(xiàn)或者轉(zhuǎn)發(fā),看起來這就是我們要找的方法了。

沿用之前的TestObject類,再修改一下main函數(shù)的內(nèi)容,現(xiàn)在看起來是這個樣子的:

// 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()方法中添加斷點,繼續(xù)運行進入此方法:

hello()調(diào)用棧.png

左側的調(diào)用棧里面供包含了3層,按照調(diào)用的順序依次是:

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

一步步來看:

  • _objc_msgSend_uncached
    不對啊,官方文檔中說的是調(diào)用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的時候,會跳轉(zhuǎn)到uncached方法中,到底是不是這樣呢?重新運行程序,加個斷點測試一下:

(注意,這里也需要先運行進入main函數(shù)中[testObj hello]這一行之后再激活斷點)

objc_msgSend.png

沒有問題,調(diào)用棧顯示先進入了objc_msgSend,單步調(diào)試的圖我就不放了,感興趣的同學可以自己試一下,下面是過程:

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

這個時候調(diào)用棧的objc_msgSend已經(jīng)看不到了,取而代之的就是__objc_msgSend_uncached:

__objc_msgSend_uncached.png

所以之前調(diào)用棧中的結果就可以理解了,這里也告訴了我們一個很重要的信息:在objc_msgSend最開始的地方就已經(jīng)通過cache進行過一次查找。

  • _class_lookupMethodAndLoadCache3(id, SEL, Class)

現(xiàn)在斷點所在的行是這么一個方法:MethodTableLookup。看起來像是在方法列表里進行查找。沿著斷點繼續(xù)走,就會走到現(xiàn)在這個方面里面,這個方法的實現(xiàn)非常簡單:

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

就是完善了一下lookUpImpOrForward()的參數(shù)。話不多說,看看最關鍵的一步。

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

這個方法的實現(xiàn)有點長,我就不一起展示了,一步一步來分析:

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()初期就已經(jīng)查找過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的內(nèi)容,所以需要在這里查一下cache?先不深究,等研究category的時候看看能不能有所進展。

cache_getImp()方法同樣是用匯編實現(xiàn)的:


    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的,這里是首次調(diào)用hello()方法,所以肯定不會命中,繼續(xù)向下。

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()就是當前類的方法,所以在這一步會命中,命中時候的調(diào)用棧是這樣的:

當前類方法命中.png

中間的方法都比較簡單,我就不把源代碼一一貼上來了,稍微說一下每個方法做了些什么:

  • getMethodNoSuper_nolock(Class cls, SEL sel)
    遍歷class的methods列表,依次調(diào)用下一個方法
  • search_method_list(const method_list_t *mlist, SEL sel)
    如果是無序列表,直接匹配名字,成功則返回
    如果是有序列表,調(diào)用下一個方法
  • findMethodInSortedMethodList(SEL key, const method_list_t *list)
    匹配方法名,成功就直接返回

這些做完之后,會調(diào)用log_and_fill_cache()把方法加入緩存,這個方法的調(diào)用棧是這樣的:

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

在cache_fill_nolock()方法中把當前調(diào)用的方法加入到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已經(jīng)3/4滿的時候,就會調(diào)用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方法會將容量翻倍,通過調(diào)用reallocate方法給cache重新分配內(nèi)存,但出于性能考慮不會將老cache中的內(nèi)容拷貝到新cache中。

這里插一點題外話,如果對swift沒興趣就跳過吧。這里的操作讓我想起了swift中map的實現(xiàn):

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()方法已經(jīng)在上一步找到了,所以走不到下面的代碼了,但還是可以看一看:

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

如果當前類和父類都找不到方法的實現(xiàn),就進入了動態(tài)方法解析。這里面調(diào)用了_class_resolveMethod()方法,看看是怎么實現(xiàn)的:

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

還是很清楚的,如果類不是元類,調(diào)用_class_resolveInstanceMethod(),是元類則調(diào)用_class_resolveClassMethod()。這兩個方法很類似,就以第一個為例,注意看我添加的注釋:

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

    BOOL (*msg)(Class, SEL, SEL) = (__typeof__(msg))objc_msgSend;
    // 調(diào)用類里面實現(xiàn)的+ (BOOL)resolveInstanceMethod:(SEL)sel
    bool resolved = msg(cls, SEL_resolveInstanceMethod, sel);

    ...(略去了一些代碼,主要是驗證是否添加成功)
}

關于+ (BOOL)resolveInstanceMethod:(SEL)sel方法,這里就不細說了,有非常多的文章講解了這個方法該怎么寫,如果曾經(jīng)看過,就會知道在這個方面里面通常都會調(diào)用:

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

通過這個方法來給某個方法添加新的實現(xiàn)。在這個方法內(nèi)部,有這么一行:

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

將新的方法實現(xiàn)添加到了方法列表里面。這就完成了整個動態(tài)方法解析的過程。

這個時候回到part5最開始的地方,在調(diào)用完_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);

如果上一步依然沒有解決問題,還有最后一個辦法:消息轉(zhuǎn)發(fā)。這個過程實在是太復雜,簡單一點來說,如果你的類實現(xiàn)了這個方法:

- (void)forwardInvocation:(NSInvocation *)anInvocation

這個時候就會進到這個方法里面,在這里可以轉(zhuǎn)發(fā)給其他對象進行處理。如果消息轉(zhuǎn)發(fā)也失敗了,那么這次方法的調(diào)用就失敗了。

如果想要對消息轉(zhuǎn)發(fā)的全部過程有更深刻的理解,可以參考這篇文章,講的很詳細:

forwarding 中路漫漫的消息轉(zhuǎn)發(fā)

緩存命中

上面講了那么多,前提是objc_msgSend匯編代碼中的的緩存沒有命中,如果在最開始緩存就命中了,會怎么樣呢?

想要測試命中緩存很簡單,把方法連續(xù)調(diào)用兩次就可以了,第二次調(diào)用的時候上面那些方法都不會被調(diào)用到,直接就把hello()方法的log打印出來了。

總結

最后匯總一下正常方法調(diào)用的過程,總的來看還是很合情合理的:

  • 查找當前類的緩存和方法列表
  • 查找父類的緩存和方法列表
  • 動態(tài)方法解析
  • 消息轉(zhuǎn)發(fā)

參考資料

從源代碼看 ObjC 中消息的發(fā)送

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,578評論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,701評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,691評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,974評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,694評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,026評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,015評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,193評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,719評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,442評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,668評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,151評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,846評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,255評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,592評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,394評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380

推薦閱讀更多精彩內(nèi)容