Runtime源碼 —— 方法調用的過程

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

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

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

  • 在啟動消息轉發(fā)之前墅垮,objc_msgSend()做了什么惕医?

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

方法的調用棧

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

realizeClass()調用棧.png

當時調用的是類的class方法曹锨,在調用棧里有這么一個關鍵的方法:

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

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

沿用之前的TestObject類,再修改一下main函數的內容齐鲤,現(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()調用棧.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)

現(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()的參數帖池。話不多說奈惑,看看最關鍵的一步。

  • 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()初期就已經查找過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()方法同樣是用匯編實現(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的,這里是首次調用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()就是當前類的方法褐澎,所以在這一步會命中,命中時候的調用棧是這樣的:

當前類方法命中.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的實現(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()方法已經在上一步找到了,所以走不到下面的代碼了捐下,但還是可以看一看:

    // 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)方法解析档叔。這里面調用了_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);
        }
    }
}

還是很清楚的,如果類不是元類衙四,調用_class_resolveInstanceMethod()铃肯,是元類則調用_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;
    // 調用類里面實現(xiàn)的+ (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)

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

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

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

這個時候回到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);

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

- (void)forwardInvocation:(NSInvocation *)anInvocation

這個時候就會進到這個方法里面添忘,在這里可以轉發(fā)給其他對象進行處理。如果消息轉發(fā)也失敗了若锁,那么這次方法的調用就失敗了搁骑。

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

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

緩存命中

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

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

總結

最后匯總一下正常方法調用的過程识虚,總的來看還是很合情合理的:

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

參考資料

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末肢扯,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子担锤,更是在濱河造成了極大的恐慌鹃彻,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,542評論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件妻献,死亡現(xiàn)場離奇詭異蛛株,居然都是意外死亡团赁,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,822評論 3 394
  • 文/潘曉璐 我一進店門谨履,熙熙樓的掌柜王于貴愁眉苦臉地迎上來欢摄,“玉大人,你說我怎么就攤上這事笋粟』衬樱” “怎么了?”我有些...
    開封第一講書人閱讀 163,912評論 0 354
  • 文/不壞的土叔 我叫張陵害捕,是天一觀的道長绿淋。 經常有香客問我,道長尝盼,這世上最難降的妖魔是什么吞滞? 我笑而不...
    開封第一講書人閱讀 58,449評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮盾沫,結果婚禮上裁赠,老公的妹妹穿的比我還像新娘。我一直安慰自己赴精,他們只是感情好佩捞,可當我...
    茶點故事閱讀 67,500評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著蕾哟,像睡著了一般一忱。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上谭确,一...
    開封第一講書人閱讀 51,370評論 1 302
  • 那天帘营,我揣著相機與錄音,去河邊找鬼琼富。 笑死,一個胖子當著我的面吹牛庄新,可吹牛的內容都是我干的鞠眉。 我是一名探鬼主播,決...
    沈念sama閱讀 40,193評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼择诈,長吁一口氣:“原來是場噩夢啊……” “哼械蹋!你這毒婦竟也來了?” 一聲冷哼從身側響起羞芍,我...
    開封第一講書人閱讀 39,074評論 0 276
  • 序言:老撾萬榮一對情侶失蹤哗戈,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后荷科,有當地人在樹林里發(fā)現(xiàn)了一具尸體唯咬,經...
    沈念sama閱讀 45,505評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡纱注,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,722評論 3 335
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了胆胰。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片狞贱。...
    茶點故事閱讀 39,841評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖蜀涨,靈堂內的尸體忽然破棺而出瞎嬉,到底是詐尸還是另有隱情,我是刑警寧澤厚柳,帶...
    沈念sama閱讀 35,569評論 5 345
  • 正文 年R本政府宣布氧枣,位于F島的核電站,受9級特大地震影響别垮,放射性物質發(fā)生泄漏便监。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,168評論 3 328
  • 文/蒙蒙 一宰闰、第九天 我趴在偏房一處隱蔽的房頂上張望茬贵。 院中可真熱鬧,春花似錦移袍、人聲如沸解藻。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,783評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽螟左。三九已至,卻和暖如春觅够,著一層夾襖步出監(jiān)牢的瞬間胶背,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,918評論 1 269
  • 我被黑心中介騙來泰國打工喘先, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留钳吟,地道東北人。 一個月前我還...
    沈念sama閱讀 47,962評論 2 370
  • 正文 我出身青樓窘拯,卻偏偏與公主長得像红且,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子涤姊,可洞房花燭夜當晚...
    茶點故事閱讀 44,781評論 2 354

推薦閱讀更多精彩內容