本文授權轉載,作者:左書祺(關注倉庫,及時獲得更新:iOS-Source-Code-Analyze)
因為ObjC的runtime只能在Mac OS下才能編譯,所以文章中的代碼都是在Mac OS,也就是x86_64架構下運行的,對於在arm64中運行的代碼會特別說明。
寫在前面
如果你點開這篇文章,相信你對Objective-C比較熟悉,並且有多年使用Objective-C編程的經驗,這篇文章會假設你知道:
在Objective-C中的“方法調用”其實應該叫做消息傳遞
[receivermessage]會被翻譯為objc_msgSend(receiver,@selector(message))
在消息的響應鏈中可能會調用-resolveInstanceMethod:或者-forwardInvocation:等方法
關於選擇子SEL的知識
如果對於上述的知識不夠了解,可以看一下這篇文章Objective-C Runtime,但是其中關於objc_class的結構體的代碼已經過時了,不過不影響閱讀以及理解。
方法在內存中存儲的位置,《深入解析ObjC中方法的結構》。文章中不會刻意區別方法和函數、消息傳遞和方法調用之間的區別。
能翻牆(會有一個Youtube的鏈接)
概述
關於Objective-C中的消息傳遞的文章真的是太多了,而這篇文章又與其它文章有什麼不同呢?
由於這個系列的文章都是對Objective-C源代碼的分析,所以會從Objective-C源代碼中分析並合理地推測一些關於消息傳遞的問題。
關於@selector()你需要知道的
因為在Objective-C中,所有的消息傳遞中的“消息“都會被轉換成一個selector作為objc_msgSend函數的參數:
[objecthello]->objc_msgSend(object,@selector(hello))
這裡面使用@selector(hello)生成的選擇子SEL是這一節中關注的重點。
我們需要預先解決的問題是:使用@selector(hello)生成的選擇子,是否會因為類的不同而不同?各位讀者可以自己思考一下。
先放出結論:使用@selector()生成的選擇子不會因為類的不同而改變,其內存地址在編譯期間就已經確定了。也就是說向不同的類發送相同的消息時,其生成的選擇子是完全相同的。
XXObject*xx=[[XXObjectalloc]init] YYObject*yy=[[YYObjectalloc]init] objc_msgSend(xx,@selector(hello)) objc_msgSend(yy,@selector(hello))
接下來,我們開始驗證這一結論的正確性,這是程序主要包含的代碼:
//XXObject.h #import@interfaceXXObject:NSObject -(void)hello; @end //XXObject.m #import"XXObject.h" @implementationXXObject -(void)hello{ NSLog(@"Hello"); } @end //main.m #import#import"XXObject.h" intmain(intargc,constchar*argv[]){ @autoreleasepool{ XXObject*object=[[XXObjectalloc]init]; [objecthello]; } return0; }
在主函數任意位置打一個斷點,比如->[objecthello];這裡,然後在lldb中輸入:
這裡面我們打印了兩個選擇子的地址@selector(hello)以及@selector(undefined_hello_method),需要注意的是:
@selector(hello)是在編譯期間就聲明的選擇子,而後者在編譯期間並不存在,undefined_hello_method選擇子由於是在運行時生成的,所以內存地址明顯比hello大很多
如果我們修改程序的代碼:
在這裡,由於我們在代碼中顯示地寫出了@selector(undefined_hello_method),所以在lldb中再次打印這個sel內存地址跟之前相比有了很大的改變。
更重要的是,我沒有通過指針的操作來獲取hello選擇子的內存地址,而只是通過@selector(hello)就可以返回一個選擇子。
從上面的這些現象,可以推斷出選擇子有以下的特性:
Objective-C為我們維護了一個巨大的選擇子表
在使用@selector()時會從這個選擇子表中根據選擇子的名字查找對應的SEL。如果沒有找到,則會生成一個SEL並添加到表中
在編譯期間會掃描全部的頭文件和實現文件將其中的方法以及使用@selector()生成的選擇子加入到選擇子表中
在運行時初始化之前,打印hello選擇子的的內存地址:
message.h文件
Objective-C中objc_msgSend的實現並沒有開源,它只存在於message.h這個頭文件中。
/** *@noteWhenitencountersamethodcall,thecompilergeneratesacalltooneofthe *functions\cobjc_msgSend,\cobjc_msgSend_stret,\cobjc_msgSendSuper,or\cobjc_msgSendSuper_stret. *Messagessenttoanobject’ssuperclass(usingthe\csuperkeyword)aresentusing\cobjc_msgSendSuper; *othermessagesaresentusing\cobjc_msgSend.Methodsthathavedatastructuresasreturnvalues *aresentusing\cobjc_msgSendSuper_stretand\cobjc_msgSend_stret. */ OBJC_EXPORTidobjc_msgSend(idself,SELop,...)
在這個頭文件的注釋中對消息發送的一系列方法解釋得非常清楚:
當編譯器遇到一個方法調用時,它會將方法的調用翻譯成以下函數中的一個objc_msgSend、objc_msgSend_stret、objc_msgSendSuper和objc_msgSendSuper_stret。發送給對象的父類的消息會使用objc_msgSendSuper有數據結構作為返回值的方法會使用objc_msgSendSuper_stret或objc_msgSend_stret其它的消息都是使用objc_msgSend發送的
在這篇文章中,我們只會對消息發送的過程進行分析,而不會對上述消息發送方法的區別進行分析,默認都使用objc_msgSend函數。
objc_msgSend調用棧
這一小節會以向XXObject的實例發送hello消息為例,在Xcode中觀察整個消息發送的過程中調用棧的變化,再來看一下程序的代碼:
//XXObject.h #import@interfaceXXObject:NSObject -(void)hello; @end //XXObject.m #import"XXObject.h" @implementationXXObject -(void)hello{ NSLog(@"Hello"); } @end //main.m #import#import"XXObject.h" intmain(intargc,constchar*argv[]){ @autoreleasepool{ XXObject*object=[[XXObjectalloc]init]; [objecthello]; } return0; }
在調用hello方法的這一行打一個斷點,當我們嘗試進入(Stepin)這個方法只會直接跳入這個方法的實現,而不會進入objc_msgSend:
因為objc_msgSend是一個私有方法,我們沒有辦法進入它的實現,但是,我們卻可以在objc_msgSend的調用棧中“截下”這個函數調用的過程。
調用objc_msgSend時,傳入了self以及SEL參數。
既然要執行對應的方法,肯定要尋找選擇子對應的實現。
在objc-runtime-new.mm文件中有一個函數lookUpImpOrForward,這個函數的作用就是查找方法的實現,於是運行程序,在運行到hello這一行時,激活lookUpImpOrForward函數中的斷點。
由於轉成gif實在是太大了,筆者試著用各種方法生成動圖,然而效果也不是很理想,只能貼一個Youtube的視頻鏈接,不過對於能夠翻牆的開發者們,應該也不是什麼問題吧(手動微笑)
如果跟著視頻看這個方法的調用棧有些混亂的話,也是正常的。在下一個節中會對其調用棧進行詳細的分析。
解析objc_msgSend
對objc_msgSend解析總共分兩個步驟,我們會向XXObject的實例發送兩次hello消息,分別模擬無緩存和有緩存兩種情況下的調用棧。
無緩存
在->[objecthello]這裡增加一個斷點,當程序運行到這一行時,再向lookUpImpOrForward函數的第一行添加斷點,確保是捕獲@selector(hello)的調用棧,而不是調用其它選擇子的調用棧。
由圖中的變量區域可以了解,傳入的選擇子為"hello",對應的類是XXObject。所以我們可以確信這就是當調用hello方法時執行的函數。在Xcode左側能看到方法的調用棧:
0lookUpImpOrForward 1_class_lookupMethodAndLoadCache3 2objc_msgSend 3main 4start
調用棧在這裡告訴我們:lookUpImpOrForward並不是objc_msgSend直接調用的,而是通過_class_lookupMethodAndLoadCache3方法:
IMP_class_lookupMethodAndLoadCache3(idobj,SELsel,Classcls) { returnlookUpImpOrForward(cls,sel,obj, YES/*initialize*/,NO/*cache*/,YES/*resolver*/); }
這是一個僅提供給派發器(dispatcher)用於方法查找的函數,其它的代碼都應該使用lookUpImpOrNil()(不會進行方法轉發)。_class_lookupMethodAndLoadCache3會傳入cache=NO避免在沒有加鎖的時候對緩存進行查找,因為派發器已經做過這件事情了。
實現的查找lookUpImpOrForward
由於實現的查找方法lookUpImpOrForward涉及很多函數的調用,所以我們將它分成以下幾個部分來分析:
無鎖的緩存查找
如果類沒有實現(isRealized)或者初始化(isInitialized),實現或者初始化類
加鎖
緩存以及當前類中方法的查找
嘗試查找父類的緩存以及方法列表
沒有找到實現,嘗試方法解析器
進行消息轉發
解鎖、返回實現
無鎖的緩存查找
下面是在沒有加鎖的時候對緩存進行查找,提高緩存使用的性能:
runtimeLock.assertUnlocked(); //Optimisticcachelookup if(cache){ imp=cache_getImp(cls,sel); if(imp)returnimp; }
不過因為_class_lookupMethodAndLoadCache3傳入的cache=NO,所以這裡會直接跳過if中代碼的執行,在objc_msgSend中已經使用匯編代碼查找過了。
類的實現和初始化
在Objective-C運行時初始化的過程中會對其中的類進行第一次初始化也就是執行realizeClass方法,為類分配可讀寫結構體class_rw_t的空間,並返回正確的類結構體。
而_class_initialize方法會調用類的initialize方法,我會在之後的文章中對類的初始化進行分析。
if(!cls->isRealized()){ rwlock_writer_tlock(runtimeLock); realizeClass(cls); } if(initialize&&!cls->isInitialized()){ _class_initialize(_class_getNonMetaClass(cls,inst)); }
加鎖
加鎖這一部分只有一行簡單的代碼,其主要目的保證方法查找以及緩存填充(cache-fill)的原子性,保證在運行以下代碼時不會有新方法添加導致緩存被沖洗(flush)。
runtimeLock.read();
在當前類中查找實現
實現很簡單,先調用了cache_getImp從某個類的cache屬性中獲取選擇子對應的實現:
imp=cache_getImp(cls,sel); if(imp)gotodone;
不過cache_getImp的實現目測是不開源的,同時也是匯編寫的,在我們嘗試stepin的時候進入了如下的匯編代碼。
它會進入一個CacheLookup的標簽,獲取實現,使用匯編的原因還是因為要加速整個實現查找的過程,其原理推測是在類的cache中尋找對應的實現,只是做了一些性能上的優化。
如果查找到實現,就會跳轉到done標簽,因為我們在這個小結中的假設是無緩存的(第一次調用hello方法),所以會進入下面的代碼塊,從類的方法列表中尋找方法的實現:
meth=getMethodNoSuper_nolock(cls,sel); if(meth){ log_and_fill_cache(cls,meth->imp,sel,inst,cls); imp=meth->imp; gotodone; }
調用getMethodNoSuper_nolock方法查找對應的方法的結構體指針method_t:
staticmethod_t*getMethodNoSuper_nolock(Classcls,SELsel){ for(automlists=cls->data()->methods.beginLists(), end=cls->data()->methods.endLists(); mlists!=end; ++mlists) { method_t*m=search_method_list(*mlists,sel); if(m)returnm; } returnnil; }
因為類中數據的方法列表methods是一個二維數組method_array_t,寫一個for循環遍歷整個方法列表,而這個search_method_list的實現也特別簡單:
staticmethod_t*search_method_list(constmethod_list_t*mlist,SELsel) { intmethodListIsFixedUp=mlist->isFixedUp(); intmethodListHasExpectedSize=mlist->entsize()==sizeof(method_t); if(__builtin_expect(methodListIsFixedUp&&methodListHasExpectedSize,1)){ returnfindMethodInSortedMethodList(sel,mlist); }else{ for(auto&meth:*mlist){ if(meth.name==sel)return&meth; } } returnnil; }
findMethodInSortedMethodList方法對有序方法列表進行線性探測,返回方法結構體method_t。
如果在這裡找到了方法的實現,將它加入類的緩存中,這個操作最後是由cache_fill_nolock方法來完成的:
staticvoidcache_fill_nolock(Classcls,SELsel,IMPimp,idreceiver) { if(!cls->isInitialized())return; if(cache_getImp(cls,sel))return; cache_t*cache=getCache(cls); cache_key_tkey=getKey(sel); mask_tnewOccupied=cache->occupied()+1; mask_tcapacity=cache->capacity(); if(cache->isConstantEmptyCache()){ cache->reallocate(capacity,capacity?:INIT_CACHE_SIZE); }elseif(newOccupiedexpand(); } bucket_t*bucket=cache->find(key,receiver); if(bucket->key()==0)cache->incrementOccupied(); bucket->set(key,imp); }
如果緩存中的內容大於容量的3/4就會擴充緩存,使緩存的大小翻倍。
在緩存翻倍的過程中,當前類全部的緩存都會被清空,Objective-C出於性能的考慮不會將原有緩存的bucket_t拷貝到新初始化的內存中。
找到第一個空的bucket_t,以(SEL,IMP)的形式填充進去。
在父類中尋找實現
這一部分與上面的實現基本上是一樣的,只是多了一個循環用來判斷根類:
查找緩存
搜索方法列表
curClass=cls; while((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); gotodone; }else{ break; } } meth=getMethodNoSuper_nolock(curClass,sel); if(meth){ log_and_fill_cache(cls,meth->imp,sel,inst,curClass); imp=meth->imp; gotodone; } }
與當前類尋找實現的區別是:在父類中尋找到的_objc_msgForward_impcache實現會交給當前類來處理。
方法決議
選擇子在當前類和父類中都沒有找到實現,就進入了方法決議(methodresolve)的過程:
if(resolver&&!triedResolver){ _class_resolveMethod(cls,sel,inst); triedResolver=YES; gotoretry; }
這部分代碼調用_class_resolveMethod來解析沒有找到實現的方法。
void_class_resolveMethod(Classcls,SELsel,idinst) { 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中選擇一個進行調用。
staticvoid_class_resolveInstanceMethod(Classcls,SELsel,idinst){ if(!lookUpImpOrNil(cls->ISA(),SEL_resolveInstanceMethod,cls, NO/*initialize*/,YES/*cache*/,NO/*resolver*/)){ //沒有找到resolveInstanceMethod:方法,直接返回。 return; } BOOL(*msg)(Class,SEL,SEL)=(__typeof__(msg))objc_msgSend; boolresolved=msg(cls,SEL_resolveInstanceMethod,sel); //緩存結果,以防止下次在調用resolveInstanceMethod:方法影響性能。 IMPimp=lookUpImpOrNil(cls,sel,inst, NO/*initialize*/,YES/*cache*/,NO/*resolver*/); }
這兩個方法的實現其實就是判斷當前類是否實現了resolveInstanceMethod:或者resolveClassMethod:方法,然後用objc_msgSend執行上述方法,並傳入需要決議的選擇子。
關於resolveInstanceMethod之後可能會寫一篇文章專門介紹,不過關於這個方法的文章也確實不少,在Google上搜索會有很多的文章。
在執行了resolveInstanceMethod:之後,會跳轉到retry標簽,重新執行查找方法實現的流程,只不過不會再調用resolveInstanceMethod:方法了(將triedResolver標記為YES)。
消息轉發
在緩存、當前類、父類以及resolveInstanceMethod:都沒有解決實現查找的問題時,Objective-C還為我們提供了最後一次翻身的機會,進行方法轉發:
imp=(IMP)_objc_msgForward_impcache; cache_fill(cls,sel,imp,inst);
返回實現_objc_msgForward_impcache,然後加入緩存。
====
這樣就結束了整個方法第一次的調用過程,緩存沒有命中,但是在當前類的方法列表中找到了hello方法的實現,調用了該方法。
緩存命中
如果使用對應的選擇子時,緩存命中了,那麼情況就大不相同了,我們修改主程序中的代碼:
intmain(intargc,constchar*argv[]){ @autoreleasepool{ XXObject*object=[[XXObjectalloc]init]; [objecthello]; [objecthello]; } return0; }
然後在第二次調用hello方法時,加一個斷點:
objc_msgSend並沒有走lookupImpOrForward這個方法,而是直接結束,打印了另一個hello字符串。
我們如何確定objc_msgSend的實現到底是什麼呢?其實我們沒有辦法來確認它的實現,因為這個函數的實現使用匯編寫的,並且實現是不開源的。
不過,我們需要確定它是否真的訪問了類中的緩存來加速實現尋找的過程。
好,現在重新運行程序至第二個hello方法調用之前:
打印緩存中bucket的內容:
(lldb)p(objc_class*)[XXObjectclass] (objc_class*)$0=0x0000000100001230 (lldb)p(cache_t*)0x0000000100001240 (cache_t*)$1=0x0000000100001240 (lldb)p*$1 (cache_t)$2={ _buckets=0x0000000100604bd0 _mask=3 _occupied=2 } (lldb)p$2.capacity() (mask_t)$3=4 (lldb)p$2.buckets()[0] (bucket_t)$4={ _key=0 _imp=0x0000000000000000 } (lldb)p$2.buckets()[1] (bucket_t)$5={ _key=0 _imp=0x0000000000000000 } (lldb)p$2.buckets()[2] (bucket_t)$6={ _key=4294971294 _imp=0x0000000100000e60(debug-objc`-[XXObjecthello]atXXObject.m:17) } (lldb)p$2.buckets()[3] (bucket_t)$7={ _key=4300169955 _imp=0x00000001000622e0(libobjc.A.dylib`-[NSObjectinit]atNSObject.mm:2216) }
在這個緩存中只有對hello和init方法實現的緩存,我們要將其中hello的緩存清空:
(lldb)expr$2.buckets()[2]=$2.buckets()[1] (bucket_t)$8={ _key=0 _imp=0x0000000000000000 }
這樣XXObject中就不存在hello方法對應實現的緩存了。然後繼續運行程序:
雖然第二次調用hello方法,但是因為我們清除了hello的緩存,所以,會再次進入lookupImpOrForward方法。
下面會換一種方法驗證猜測:在hello調用之前添加緩存。
添加一個新的實現cached_imp:
#import#import#import"XXObject.h" intmain(intargc,constchar*argv[]){ @autoreleasepool{ __unusedIMPcached_imp=imp_implementationWithBlock(^(){ NSLog(@"CachedHello"); }); XXObject*object=[[XXObjectalloc]init]; [objecthello]; [objecthello]; } return0; }
我們將以@selector(hello),cached_imp為鍵值對,將其添加到類結構體的緩存中,這裡的實現cached_imp有一些區別,它會打印@"CachedHello"而不是@"Hello"字符串:
在第一個hello方法調用之前將實現加入緩存:
可以看到,我們雖然沒有改變hello方法的實現,但是在objc_msgSend的消息發送鏈路中,使用錯誤的緩存實現cached_imp攔截了實現的查找,打印出了CachedHello。
由此可以推定,objc_msgSend在實現中確實檢查了緩存。如果沒有緩存會調用lookupImpOrForward進行方法查找。
為了提高消息傳遞的效率,ObjC對objc_msgSend以及cache_getImp使用了匯編語言來編寫。
小結
這篇文章與其說是講ObjC中的消息發送的過程,不如說是講方法的實現是如何查找的。
Objective-C中實現查找的路徑還是比較符合直覺的:
緩存命中
查找當前類的緩存及方法
查找父類的緩存及方法
方法決議
消息轉發
文章中關於方法調用棧的視頻最開始是用gif做的,不過由於gif時間較長,試了很多的gif轉換器,都沒有得到一個較好的質量和合適的大小,所以最後選擇用一個Youtube的視頻。
參考資料
深入解析ObjC中方法的結構
Objective-CRuntime
Let'sBuildobjc_msgSend