iOS底層--方法查找流程分析(附面試坑點)

歡迎閱讀iOS底層系列(建議按順序)c++

iOS底層 - alloc和init探索面試

iOS底層 - 一應俱全的isaapi

iOS底層 - 類的本質分析緩存

iOS底層 - cache_t流程分析安全

iOS底層 - 方法查找流程分析bash

本文概述

本文主要分析方法在底層的本質,方法發送的幾種狀況,方法查找流程等,結合cache_t,對消息發送流程有一個更宏觀的理解。多線程

面試坑點

先拋出一個面試題:函數

爲何子類能夠調用類方法來實現NSObject的對象方法?post

若是不深刻了解方法查找流程,可能會有被卡住。下面就是對方法查找流程的分析(最後附加答案)。性能

runtime簡述

上篇文章iOS底層-cache_t流程分析說明了cache_t緩存的是方法,那方法是什麼,調用方法實際是在作什麼。這些都和runtime有密切關係。

a.runtime是什麼

咱們都知道oc具備運行時特性,但是oc底層是編譯成cc++這樣的靜態語言,是不具有運行時的。這時候iOS底層就封裝了一套由cc++彙編寫的api,用來給oc提供運行時功能,這就是runtime

b.runtime版本

runtime是有個兩個版本的:

  • legacy

  • modern

底層源碼中使用!__OBJC2____OBJC2__來區分它們。如今使用的通常都是__OBJC2__,因此咱們基本能夠忽略legacy版本。

c.runtime的調用類型

runtime的調用只有三種類型

  • Objective - C Code (例:@Selector())

  • NSObject的方法 (例:performSelector())

  • runtime api(例:sel_registerName())

方法的本質

方法其實只是靜靜躺在class_rw_t中的代碼段,嚴格來講,這裏應該是調用方法的本質。

先建立一個CJPerson類,初始化並調用方法,下面同時調用一個自定義函數。

void play(){
    NSLog(@"%s",__func__);
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        CJPerson *person = [CJPerson alloc];
        [person work];
        
        play();
    }
    return 0;
}
複製代碼

以前探索類的本質時,使用了clang編譯,這裏也故技重施

clang -rewrite-objc main.m -o main.cpp

打開main.cpp,直接來到最後

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 
        CJPerson *person = ((CJPerson *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("CJPerson"), sel_registerName("alloc"));
        ((void (*)(id, SEL))(void *)objc_msgSend)((id)person, sel_registerName("work"));

        play();
    }
    return 0;
}
複製代碼

整理下,去掉強轉

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 
        CJPerson *person = objc_msgSend(objc_getClass("CJPerson"), sel_registerName("alloc"));
        objc_msgSend(person, sel_registerName("work"));

        play();
    }
    return 0;
}
複製代碼

能夠看到,調用方法就是經過objc_msgSend來發送消息,但是調用play()函數卻沒有發送消息。

其實,發送消息就是在找函數實現imp的過程,paly()函數指針直接對標到了函數實現,也就不須要發送消息了

objc_msgSend這裏有兩個參數

  • id 消息接收者
  • sel 方法編號

假設存在緩存的狀況,有了這兩個參數就能夠用id在對應的cls中的cache_t,把sel生成的key&mask獲得哈希下標,經過了解過iOS底層-cache_t流程分析,這裏會比較清晰。

消息發送的幾種區別

根據開發經驗,方法通常有四種調用狀況:本類對象方法本類類方法父類對象方法父類類方法

依次驗證下,在建立一個CJStudent類,繼承於CJPerson,二者聲明各自的對象方法和類方法。在CJStudent中調用,而後clang編譯(不要在乎這裏遞歸死循環,只看編譯結果,不運行)

- (void)study{
    [super work];//父類對象方法
    [self study];//本類對象方法
}

+ (void)play{
    [super buy];//父類類方法
    [CJStudent play];//本類類方法
}
複製代碼

對應clang的結果部分(簡化後):

static void _I_CJStudent_study(CJStudent * self, SEL _cmd) {
    //父類對象方法
    objc_msgSendSuper({self, class_getSuperclass(objc_getClass("CJStudent"))}, sel_registerName("work"));
    //本類對象方法
    objc_msgSend(self, sel_registerName("study"));
}

static void _C_CJStudent_play(Class self, SEL _cmd) {
    //父類類方法
    objc_msgSendSuper({self, class_getSuperclass(objc_getMetaClass("CJStudent"))}, sel_registerName("buy"));
    //本類類方法
    objc_msgSend(objc_getClass("CJStudent"), sel_registerName("play"));
}
複製代碼

綜合結果以下:

方法類型 底層調用 消息接收者 傳遞父類
本類對象方法 objc_msgSend self
本類類方法 objc_msgSend self.class
父類對象方法 objc_msgSendSuper self 類的父類
父類類方法 objc_msgSendSuper self 元類的父類

能夠看出,這裏最明顯的區別在於,objc_msgSend和objc_msgSendSuper

基本能夠確認,致使消息發送不一致的主要緣由在於objc_msgSendSuper,咱們常常說的都是objc_msgSend,那objc_msgSendSuper又是怎麼回事,只能點擊它看下:

能夠看到,和 objc_msgSend的主要區別在於第一個參數 objc_super

struct objc_super {
    __unsafe_unretained _Nonnull id receiver;
#if !defined(__cplusplus) && !__OBJC2__
    __unsafe_unretained _Nonnull Class class;
#else
    __unsafe_unretained _Nonnull Class super_class;
#endif
};
複製代碼

objc_super是個結構體,須要兩個參數,一個是id receiver,由於如今runtime__OBJC2__版本,因此第二個是Class super_class

明白了參數的意思,上面的結論也就好理解了:

  • 父類對象方法要去父類的方法列表查找
  • 父類類方法要去父類的元類的方法列表查找
  • 本類類方法對調用者主體是class
  • 須要注意,super 調用 objc_msgSendSuper 告訴系統 去父類方法列表裏面去找,可是調用者主體仍是 self

方法查找流程

1.尋找切入點

到了這裏,一切的源頭都指向objc_msgSend,老規矩,仍是要去源碼看一看。但是問題又來了,這源碼這麼多份,要去哪份裏看呢?

提供一個思路:

根據目前已知條件,調用方法會執行objc_msgSend,那在工程下一個objc_msgSend符號斷點,等跑到調用方法那一步時開啓符號斷點。斷點來到:

竟然發現,objc_msgSendobjc源碼裏面,總算定位到了一個小範圍。

開開心心打開objc源碼,仍是嘗試搜索下objc_msgSend

有600多個相關,直接奔潰,看來此路不通,還要再想一想換個搜索關鍵字。

換個角度想,objc_msgSend是要被調用的方法,調用方法的通常格式爲方法名(),那就能夠搜索下objc_msgSend(,

搜索結果只有兩個部分, .h部分彙編部分,首先, .h能夠排除,源碼實現和調用不可能在 .h,那隻剩下 彙編了,難道 objc_msgSend在底層是用匯編來實現的嘛。

回頭想一想,objc_msgSend是可變參數的,對於靜態語言c來講,不能有效識別,確實頗有可能使用匯編來實現。

通過各方面資料研究後,確認了objc_msgSend的快速查找由彙編實現,並得出兩個緣由:

  • 在c語言中不可能經過函數來保留未知參數並跳轉到任意的函數指針
  • objc_msgSend在底層屬於高頻事件,對性能要求較高,必須足夠快
  • 使用匯編能夠有效防止系統函數被hook,更加安全

2.快速查找

既然知道了objc_msgSend是彙編實現,那隻能硬着頭皮去看看彙編了。

這裏選擇從經常使用的 arm64着手,通常看彙編是從入口 ENTRY開始,直接找到相似 ENTRY objc_msgSend 的地方就是要開始探索的地方

小知識點:x0 ~ x7存放參數,而且x0還存放返回值

1.對比第一個參數是否爲空,也就是接收者self
2.判斷self是不是TaggedPoint類型,此類型無需發送消息.
3.拿到第一個參數id的x0地址的值放在p13,也就是isa(參照對象和類的結構,首地址都是isa)
4.經過isa_mask獲得class,這是p16等於class的緣由,也就是獲取方法所在的地方
5.查找isa完畢,先看看緩存裏面有沒有,也就是快速查找流程開始
複製代碼

這裏擴展下④和⑤

④:GetClassFromIsa_p16

內部經過isa_mask獲得class

⑤:CacheLookup NORMAL

* CacheLookup NORMAL|GETIMP|LOOKUP
 * 
 * Locate the implementation for a selector in a class method cache.
 *
 * Takes:
 *	 x1 = selector //第二個參數sel
 *	 x16 = class to be searched //經過isa獲得
 *
複製代碼

CacheLookup三種類型:正常(快速)查找|GETIMP|慢速查找

#define SUPERCLASS __SIZEOF_POINTER__
#define CACHE (2 * __SIZEOF_POINTER__)

1.x16平移CACHE(這裏CACHE是定義爲16字節的宏)獲得cache_t,將cache_t的值取出來放在p10和p11。p10放buckets獨佔8位,p11放occupied和mask各佔先後四位。
複製代碼
struct cache_t {
    struct bucket_t *_buckets;//前8位
    mask_t _mask;//4位
    mask_t _occupied;//4位
}
複製代碼
2.用w1的_cmd & w11的mask 獲得w12,也就是找方法的哈希下標。這裏用w,由於mask類型是32位就夠了,而且由於小端模式取後四位即爲mask。
複製代碼
static inline mask_t cache_hash(cache_key_t key, mask_t mask) {
    return (mask_t)(key & mask);
}
複製代碼
3.經過平移獲得bucket的有效地址,而後從x12的bucket拿出imp放p17和sel放p9
複製代碼
4.開始對比bucket內的sel和傳進來的cmd,NoEqual時候走2fCheckMiss流程和循環查找buckets,不然CacheHit緩存命中。
複製代碼
5,雖然調用了CheckMiss,可是CheckMiss有cbz判斷sel是否爲0,若不爲0,則判斷bucket ==buckets,Equal時候開始跳轉到3f,不然開始循環遞減查找buckets,循環遞減查找buckets時,爲了防止多線程更新緩存,有跳轉1b從新查找流程;若是爲0,也就是沒有緩存,就開始__objc_msgSend_uncached慢速流程
複製代碼
.macro CheckMiss
	// miss if bucket->sel == 0
.if $0 == GETIMP。
	cbz	p9, LGetImpMiss
.elseif $0 == NORMAL
	cbz	p9, __objc_msgSend_uncached
.elseif $0 == LOOKUP
	cbz	p9, __objc_msgLookup_uncached
複製代碼
6.重複上面③④⑤的查找流程,若是都找完了仍是沒有找到緩存,則直接跳轉到JumpMiss,再強制跳轉__objc_msgSend_uncached。
複製代碼
.macro JumpMiss
.if $0 == GETIMP
	b	LGetImpMiss
.elseif $0 == NORMAL
	b	__objc_msgSend_uncached
.elseif $0 == LOOKUP
	b	__objc_msgLookup_uncached
複製代碼

__objc_msgLookup_uncached

目前已知,若是緩存未命中,會來到__objc_msgLookup_uncached,那看看其流程:

STATIC_ENTRY __objc_msgLookup_uncached
UNWIND __objc_msgLookup_uncached, FrameWithNoSaves

MethodTableLookup
ret
複製代碼

__objc_msgLookup_uncached流程內,只有MethodTableLookup,看字面意思是方法列表查找,那麼是直接就開始查找方法列表了嘛?也是用匯編進行查找嘛?只能繼續往下看:

.macro MethodTableLookup

	// push frame
	SignLR
	stp	fp, lr, [sp, #-16]!
	mov	fp, sp

	// save parameter registers: x0..x8, q0..q7
	sub	sp, sp, #(10*8 + 8*16)
	stp	q0, q1, [sp, #(0*16)]
	stp	q2, q3, [sp, #(2*16)]
	stp	q4, q5, [sp, #(4*16)]
	stp	q6, q7, [sp, #(6*16)]
	stp	x0, x1, [sp, #(8*16+0*8)]
	stp	x2, x3, [sp, #(8*16+2*8)]
	stp	x4, x5, [sp, #(8*16+4*8)]
	stp	x6, x7, [sp, #(8*16+6*8)]
	str	x8,     [sp, #(8*16+8*8)]

	// receiver and selector already in x0 and x1
	mov	x2, x16
	bl	__class_lookupMethodAndLoadCache3

複製代碼

挺長,前面一大段具體不太明白,但能夠看出是地址操做,不影響咱們閱讀總體流程,準備完地址參數後,直接來到__class_lookupMethodAndLoadCache3

老規矩繼續搜索__class_lookupMethodAndLoadCache3

會發現所有都是調用,盡然沒有相似的實現過程,可能會以爲是蘋果沒有開源吧,若是是這樣,到這裏探索彷佛就走到了盡頭。

絕望的時候冷靜下來想一想,__class_lookupMethodAndLoadCache3是在__objc_msgLookup_uncached後,__objc_msgLookup_uncached又是在objc_msgSend後。繼續走剛纔的objc_msgSend符號斷點,看看彙編調用是否是這樣吧。

objc_msgSend以後確實會來到__objc_msgLookup_uncached,但是細看倒是_objc_msgLookup_uncached前面少了個下劃線。再去_objc_msgLookup_uncached裏面看看是否有調用__class_lookupMethodAndLoadCache3

_objc_msgLookup_uncached內確實調用了__class_lookupMethodAndLoadCache3,但是細看倒是_class_lookupMethodAndLoadCache3前面也少了個下劃線。而且標註了是在objc-runtime-new.mm的4846行

看到這裏,好像發現了新大陸,難道底層是調用_class_lookupMethodAndLoadCache3,直接搜索,

先找到標註所定位的地方,果真就找到了_class_lookupMethodAndLoadCache3的實現。

這裏從彙編跳轉到c,由於慢速查找流程將要開始,這裏也反向解釋了爲何在調用_class_lookupMethodAndLoadCache3前,有一段地址參數處理:

  • 由於慢速查找流程要開始去c,c++,靜態語言須要肯定肯定參數列表,因此須要準備工做)

以上就是objc_msgSend快速查找流程,也就是緩存查找。總的來講,快速查找就是在cache_t中查找緩存,緩存命中則直接結束;查找完全部還未找到,就開始作慢速查找前的準備工做,並跳轉到慢速查找流程

3.慢速查找

直接來到_class_lookupMethodAndLoadCache3,裏面只調用了lookUpImpOrForward

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

lookUpImpOrForward方法較長,分爲準備部分和查找部分給出,最後並給出驗證和結論部分

a.準備部分

1.判斷緩存是否存在,存在則直接經過cls和sel直接獲取imp,並返回。
複製代碼
2.相關類信息判斷
  a.根據全部已知類的列表檢查給定的類,有問題直接內部拋出異常。
  b.判斷類是否已經被實現,未實現則去實現,這部分後面類的加載章節會詳細分析,主要是按照superclass和isa走向去遞歸實現父類和元類,同時準備好對象方法和類方法的查找鏈。
  c.判斷類是否被初始化,未初始化則去初始化。
複製代碼

b.查找部分

查找部分代碼仍是比較長,一個屏幕都容不下,所以分爲上下兩張

1.類準備好後,再次判斷是否存在緩存,存在則直接經過cls和sel直接獲取imp,並返回。
複製代碼
2.經過本類的方法列表查找(使用二分查找),若是找到meth,則先填充到緩存,而後返回。這裏外層多用類一個{},造成局部做用域,防止meth重名。
複製代碼
3.遞歸查找父類的緩存
  a.存在則先填充到緩存,而後返回
  b.不存在跳出本次循環
複製代碼
4.找父類緩存結束且未找到時,查找父類的方法列表,若是找到meth,則先填充到緩存,而後返回。
複製代碼
5.遞歸查找完全部父類依然找不到imp時,開始方法轉發流程,且只有一次。方法轉發在下一章進行詳細分析,
複製代碼

以上就是objc_msgSend慢速查找流程。總的來講,慢速查找就是從本類到父類最後到NSObject的方法查找鏈。先找本類的method_list,找到則填充緩存;找不到在找父類的cachemethod_list,找到則填充緩存;都找不到最後進行轉發

c.驗證和結論部分

這裏根據開發經驗直接給出結論,只驗證一個面試坑點

對象方法:
1.對象方法 - 本身有 - 成功;
2.對象方法 - 本身沒有 - 找老爸的 - 成功;
3.對象方法 - 本身沒有 - 老爸沒有 - 找老爸的老爸 - NSObject - 成功;
4.對象方法 - 本身沒有 - 老爸沒有 - 找老爸的老爸 -> NSObject 也沒有 - 崩潰;
複製代碼
類方法:
1.類方法 - 本身有 - 成功;
2.類方法 - 本身沒有 - 老爸有 - 成功;
3.類方法 - 本身沒有 - 老爸沒有 - 找老爸的老爸 -> NSObject 也沒有 - 沒有有對象方法 - 奔潰
4.類方法 - 本身沒有 - 老爸沒有 - 找老爸的老爸 -> NSObject 也沒有 - 有對象方法 - 成功
複製代碼

以上都符合咱們認知中的查找流程,只有類方法的34,竟然最後會去調用對象方法?天啦,這不符合oc世界觀,調用類方法,竟然去實現了對象方法。

提供一個驗證方法:

在NSObject的分類中定義一個對象方法並實現,而後用任意類的類名調用此定義的對象方法。
複製代碼
分類聲明
- (void)instanceMethod{
    NSLog(@"%s--我是對象方法",__func__);
}

類名調用
int main(int argc, const char * argv[]) {
    @autoreleasepool {
         [CJPerson instanceMethod];
    }
    return 0;
}
複製代碼

執行後,果真成功了。這不符合常理的狀況要怎麼解釋,其實從 isasupclass的走位圖,能夠一探究竟。

回到開始的面試題:爲何子類能夠調用類方法來實現NSObject的對象方法?

解釋:經過類名調用,會依次走元類的方法列表,最後找到根元類的方法列表,可是都找不到對應的類方法;這時候,根元類的supclass指向了根類NSObject,因此去查找了NSObject的方法列表,由於NSObject的方法列表存放的是對象方法,所以找到了名爲instanceMethod的對象方法。

寫在最後

objc_msgSend是iOS開發繞不過去的坎,其流程與cache_t流程分析有緊密聯繫。下一章是發送消息的最後一個部分--消息轉發流程分析。敬請關注。

相關文章
相關標籤/搜索