iOS 底層拾遺:objc_msgSend 與方法快取
前言
Runtime 訊息傳送與轉發流程總是大家關注的重點,卻常常忽略方法快取機制這個顯著提升 objc_msgSend 效能的幕後功臣。
本文會通過原始碼梳理訊息傳送與轉發流程,重點分析方法快取機制的實現細節。行文過程中會涉及到一些彙編程式碼,不過不影響理解核心邏輯。
原始碼基於 Runtime 750,arm64 架構。
一、從 objc_msgSend 談起
注意: arm64 彙編程式碼會出現很多p
字母,實際上是一個巨集,64 位下是x
,32 位下是w
,p
就是暫存器。
在分析快取機制之前,先梳理一下訊息傳送與轉發的流程,找到何時進行快取的儲存與讀取。
objc_msgSend
objc_msgSend 程式碼如下:
ENTRY _objc_msgSend
UNWIND _objc_msgSend,NoFram
...// 處理物件是 tagged pointer 或 nil 的情況(x0 存的是 objc_object 物件地址)
ldr p13,[x0] // p13 = isa 把 x0 指向記憶體的前 64 位放到 p13(即是 objc_object 的 isa 成員變數)
GetClassFromIsa_p16 p13 // p16 = class 通過 isa 找到 class
LGetIsaDone:
CacheLookup NORMAL // 從方法快取或方法列表中找到 IMP 並呼叫
...
複製程式碼
在 64 位系統下GetClassFromIsa_p16
巨集程式碼為:
.macro GetClassFromIsa_p16
...
and p16,$0,#ISA_MASK // #define ISA_MASK 0x0000000ffffffff8ULL
...
複製程式碼
$0
獲取巨集的第一個引數,呼叫時傳的p13
,即是isa
。這一步做的操作就是使用ISA_MASK
掩碼找到isa
變數中的Class
並放入p16
(isa
是union isa_t
型別,在很多系統中已經不是單純的指向Class
,還包含了記憶體管理等資訊,所以需要用掩碼來獲取)。
CacheLookup
CacheLookup
目前只需要知道它會查詢當前Class
的方法快取,主要產生兩種結果:若快取命中,返回IMP
或呼叫IMP
;若快取未命中,呼叫__objc_msgSend_uncached
(找到IMP
會呼叫) 或__objc_msgLookup_uncached
(找到IMP
不會呼叫) 方法。
STATIC_ENTRY __objc_msgSend_uncached
UNWIND __objc_msgSend_uncached,FrameWithNoSaves
MethodTableLookup
TailCallFunctionPointer x17
END_ENTRY __objc_msgSend_uncached
複製程式碼
MethodTableLookup
後面就是較為複雜的方法查詢邏輯了,若找到了IMP
會放到x17
暫存器中,然後把x17
的值傳遞給TailCallFunctionPointer
巨集呼叫方法。
MethodTableLookup
.macro MethodTableLookup
// push frame
SignLR
stp fp,lr,[sp,#-16]!
mov fp,sp
...// save registers: x0..x8,q0..q7
// receiver and selector already in x0 and x1
mov x2,x16
bl __class_lookupMethodAndLoadCache3
// IMP in x0
mov x17,x0
...// restore registers
mov sp,fp
ldp fp,[sp],#16
AuthenticateLR
.endmacro
複製程式碼
由於這個巨集內部要跳轉函式,意味著lr
的變化,所以開闢棧空間後需要把之前的fp/lr
值儲存到棧上便於復位狀態。筆者刪除了save registers
和restore registers
的邏輯,其實就是將各個暫存器的值先儲存到棧上,內部函式幀釋放時便於復位暫存器的值。
在呼叫完__class_lookupMethodAndLoadCache3
後會把返回在x0
的IMP
值複製到x17
中。
__class_lookupMethodAndLoadCache3
是一個 C 函式,跳轉之前把x16
的值複製到x2
中(x16
目前儲存的就是GetClassFromIsa_p16
程式碼找到的物件的Class
),那麼此時暫存器佈局就是:x0 -> receiver / x1 -> selector / x2 -> class
,也就對應了這個方法的引數列表:
IMP _class_lookupMethodAndLoadCache3(id obj,SEL sel,Class cls) {
return lookUpImpOrForward(cls,sel,obj,YES/*initialize*/,NO/*cache*/,YES/*resolver*/);
}
複製程式碼
lookUpImpOrForward
lookUpImpOrForward
方法比較複雜,簡化邏輯如下:
IMP lookUpImpOrForward(Class cls,id inst,bool initialize,bool cache,bool resolver) {
IMP imp = nil;
bool triedResolver = NO;
...
// cache 為 YES 查詢方法快取
if (cache) {
imp = cache_getImp(cls,sel);
if (imp) return imp;
}
// 加鎖
runtimeLock.lock();
// 若需要,進行類的初始化以及呼叫 +initialize 等工作
...
retry:
// 在當前類方法快取中查詢 IMP
imp = cache_getImp(cls,sel);
if (imp) goto done;
// 在當前類方法列表中查詢 IMP
if (找到 IMP) {
把 IMP 存方法快取
goto done;
}
// 在父類的方法快取/方法列表中查詢 IMP
while (Class cur = cls->superClass; cur != nil; cur = cur->superClass) {
if (在方法快取中找到 IMP) {
if (IMP == _objc_msgForward_impcache) { break; }
把 IMP 存入當前類 cls 的方法快取
goto done;
}
if (在方法列表中找到 IMP) {
把 IMP 存入當前類 cls 的方法快取
goto done;
}
}
// 沒有找到 IMP,嘗試進行動態訊息處理
if (resolver && !triedResolver) {
runtimeLock.unlock();
_class_resolveMethod(cls,inst);
runtimeLock.lock();
triedResolver = YES;
goto retry;
}
// 若動態訊息處理失敗,IMP 指向一個函式並將 IMP 存方法快取
imp = (IMP)_objc_msgForward_impcache;
cache_fill(cls,imp,inst);
done:
runtimeLock.unlock();
return imp;
}
複製程式碼
方法快取的存取
方法快取儲存符合一般邏輯,只要找到了IMP
就會進行快取,加入方法快取都會呼叫cache_fill
方法。需要注意的是,如果是從父類鏈中找到的方法,仍然會加入當前類的快取列表,這樣能大大提高查詢在父類鏈中方法的效率。
可能讀者會疑惑這個方法為什麼還會去取快取?前面一堆彙編方法走到這裡的時候理論上當前類是已經沒有對應SEL
的方法快取了。前面個cache_getImp
方法是因為lookUpImpOrForward
函式會被其它函式呼叫,並不在前面筆者分析的流程中;而retry:
下面的cache_getImp
是因為在動態訊息處理的時候可能會插入相關IMP
然後goto retry
。
方法列表的查詢
類的方法列表的查詢通過getMethodNoSuper_nolock
-> search_method_list
方法處理,具體的邏輯不展開了,只需知道若方法列表是排過序的會使用二分搜尋去查;否則就是一個簡單的遍歷查詢。所以在沒有方法快取的情況下方法的查詢效率是很低的,時間複雜度要麼是 O(logn) 要麼是 O(n)。
訊息轉發的邏輯
在_class_resolveMethod
方法前面呼叫了unlock()
和lock()
,關閉了類的保護狀態,便於開發者改變類的方法列表等。
_class_resolveMethod
會向物件傳送+resolveInstanceMethod
(例項物件)或+resolveClassMethod
(類物件)方法,開發者可以在這兩個方法中為類動態加入IMP
,_class_resolveMethod
出棧後走goto retry
會重新嘗試查詢方法的邏輯。
當然,若開發者沒有做處理,IMP
仍然找不到,通過!triedResolver
避免二次動態訊息處理,然後就會讓imp = (IMP)_objc_msgForward_impcache
。如此一來,當lookUpImpOrForward
函式幀釋放時,在上層看來仍然是找到IMP
了,這個方法就是_objc_msgForward_impcache
。那麼在前面分析的__objc_msgSend_uncached
方法就仍然會呼叫這個IMP
,接下來就是真正的訊息轉發階段了。
STATIC_ENTRY __objc_msgForward_impcache
b __objc_msgForward
END_ENTRY __objc_msgForward_impcache
ENTRY __objc_msgForward
adrp x17,__objc_forward_handler@PAGE
ldr p17,[x17,__objc_forward_handler@PAGEOFF]
TailCallFunctionPointer x17
END_ENTRY __objc_msgForward
複製程式碼
可以發現通過頁地址加頁偏移的方式,拿到__objc_forward_handler
的地址並呼叫,它是一個函式指標,在OBJC2
下有預設實現:
__attribute__((noreturn)) void
objc_defaultForwardHandler(id self,SEL sel)
{
_objc_fatal("%c[%s %s]: unrecognized selector sent to instance %p "
"(no message forward handler is installed)",class_isMetaClass(object_getClass(self)) ? '+' : '-',object_getClassName(self),sel_getName(sel),self);
}
void *_objc_forward_handler = (void*)objc_defaultForwardHandler;
複製程式碼
最終看到了熟悉的unrecognized selector sent to instance
描述。
而對於開發者熟悉的-forwardingTargetForSelector:
重定向方法、-forwardInvocation:
轉發方法,Runtime 原始碼中沒有啥痕跡,在檔案後面只有一個更改_objc_forward_handler
指標的函式(筆者玩兒不動了,可以猜測方法重定向和方法轉發是通過改變這個指標做邏輯的,感興趣可以檢視楊帝的逆向分析訊息轉發文章:Objective-C 訊息傳送與轉發機制原理):
void objc_setForwardHandler(void *fwd,void *fwd_stret) {
_objc_forward_handler = fwd;
...
}
複製程式碼
小結
到目前為止,整個訊息傳送機制算是比較清晰了,在按圖索驥的過程中,發現了不少方法快取的存取操作,主要是cache_getImp
和cache_fill
函式。當然,方法快取還有清理操作,後面再談。接下來的部分就著重分析方法快取的實現細節。
二、方法快取的資料結構基礎
cache_t
是方法快取的資料結構,在objc_class
中cache
變數偏移64*2
位:
struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache;
class_data_bits_t bits;
...
複製程式碼
bits
儲存了類的屬性、協議、方法等,這裡不展開描述。cache_t
的結構也很簡單:
struct cache_t {
struct bucket_t *_buckets; // bucket_t 陣列
mask_t _mask; // 容量快取個數減1
mask_t _occupied; // 有效快取個數
...
複製程式碼
咋一看就像是一個散列表,這和weak
弱引用的底層資料結構(weak_table_t
/weak_entry_t
)如出一轍。bucket_t
在 arm64 下程式碼如下:
struct bucket_t {
MethodCacheIMP _imp;
cache_key_t _key;
...
複製程式碼
MethodCacheIMP
就是IMP
別名,cache_key_t
就是unsigned long
。
三、方法快取的寫入
cache_fill
cache_fill
是方法快取寫入的入口方法:
void cache_fill(Class cls,IMP imp,id receiver) {
mutex_locker_t lock(cacheUpdateLock);
cache_fill_nolock(cls,receiver);
}
複製程式碼
這個lock
看起來很奇怪,進去一看實際上是這樣一個類:
class locker : nocopy_t {
mutex_tt& lock;
public:
locker(mutex_tt& newLock)
: lock(newLock) { lock.lock(); }
~locker() { lock.unlock(); }
};
複製程式碼
在locker
構造時加鎖,析構時解鎖,正好保護了方法作用域內的方法呼叫。這和 EasyReact 中大量使用的__attribute__((cleanup(AnyFUNC),unused))
如出一轍,都是為了實現自動解鎖的效果。
cache_fill_nolock
cache_fill_nolock
是寫入的核心邏輯(為了簡短有所修改):
static void cache_fill_nolock(Class cls,id receiver)
{
...
// 在類初始化之前不允許寫入快取
if (!cls->isInitialized()) return;
// 在走到這裡的時候,可能在佔有 cacheUpdateLock 的時候快取已經被其它執行緒寫入了,所以先查詢一次快取
if (cache_getImp(cls,sel)) return;
cache_t *cache = getCache(cls);
cache_key_t key = getKey(sel);
mask_t newOccupied = cache->occupied() + 1;
mask_t capacity = cache->capacity();
if (cache->isConstantEmptyCache()) {
// 如果快取是隻讀的,重新分配記憶體
cache->reallocate(capacity,capacity ?: INIT_CACHE_SIZE);
} else if (newOccupied > capacity / 4 * 3) {
// 如果有效快取數量超過了 3/4 就進行擴容
cache->expand();
}
// 在散列表中找到一個空置的 bucket 寫入資料
bucket_t *bucket = cache->find(key,receiver);
if (bucket->key() == 0) cache->incrementOccupied();
bucket->set(key,imp);
}
複製程式碼
鎖的搶佔
cache_fill
方法雖然已經加了鎖,但有可能多個執行緒同時訪問,且它們都是往同一個Class
新增同一個SEL
,若有一個執行緒佔有鎖後更新成功,其它執行緒在空轉或掛起一段時間後,就沒必要再次寫入快取了,所以if (cache_getImp(cls,sel)) return;
這句話是必要的。
這也是個保險措施,因為呼叫方可能在沒有判斷Class
的某個SEL
是否有快取的時候就呼叫該方法。
散列表記憶體分配
void cache_t::reallocate(mask_t oldCapacity,mask_t newCapacity)
{
bool freeOld = canBeFreed();
bucket_t *oldBuckets = buckets();
bucket_t *newBuckets = allocateBuckets(newCapacity);
...
setBucketsAndMask(newBuckets,newCapacity - 1);
if (freeOld) {
cache_collect_free(oldBuckets,oldCapacity);
cache_collect(false);
}
}
複製程式碼
直接將舊的bucket_t
陣列釋放了,然後建立新的陣列,開闢記憶體方法allocateBuckets
很簡單,就是開闢newCapacity * sizeof(bucket_t)
的空間。那麼可以確定的是,方法快取散列表每次分配記憶體都會放棄之前的快取。
後面的賦值方法蠻有意思:
#define mega_barrier() \
__asm__ __volatile__( \
"dsb ish" \
: : : "memory")
void cache_t::setBucketsAndMask(struct bucket_t *newBuckets,mask_t newMask) {
mega_barrier();
_buckets = newBuckets;
mega_barrier();
_mask = newMask;
_occupied = 0;
}
複製程式碼
因為拋棄了之前的快取,所以_occupied
置為 0。mega_barrier
這個內聯彙編使用__volatile__
關鍵字阻止編譯器快取變數到暫存器不寫回,使用memory
記憶體屏障避免 CPU 使用暫存器來優化執行指令,使用dsb ish
隔離指令在它前面的儲存器訪問操作都執行完畢後,才執行在它後面的指令。這一個使盡渾身解數的巨集是為了幹嘛呢?
對於cache_t
來說,讀取_buckets
和_mask
都是沒有加鎖的,那麼就一定要保證_buckets
的實際長度始終大於_mask
,最壞的情況不過只是訪問不到已有的快取,不然在進行 hash 運算後很可能訪問到錯誤或非法的記憶體。
那麼第二個mega_barrier()
就是為了保證新的_buckets
始終會在新的_mask
之前賦好值。當然這有個前提,就是新_buckets
的長度始終大於舊的。在cache_t
演算法中並沒有削減_buckets
記憶體的邏輯,只有一個清空_buckets
陣列每個bucket
的key/imp
的邏輯(清空後記憶體為 readonly),所以這個前提是能保證的。
在前面cache_fill_nolock
方法的if (cache->isConstantEmptyCache())
分支正是記憶體被清空後標記為 readonly 的邏輯,重新分配記憶體時會開闢一個INIT_CACHE_SIZE
(8) 長度的空間,可能有讀者會疑問這個時候不就是新_buckets
的長度小於舊的麼?
其實不然,在清空_buckets
時雖然沒有削減記憶體,但_occupied
(有效快取數量)會置為 0,也就是說這種情況下是不會有其它執行緒訪問的。
第一個mega_barrier()
就比較夢幻了,筆者可能理解有誤:
從newBuckets
指標開闢記憶體到賦值給_buckets
的模擬如下:
1、開闢堆記憶體(地址 0x111)
2、x0 = 0x111
3、_buckets = x0
複製程式碼
由於記憶體訪問比暫存器訪問慢,很可能被作業系統優化成這樣:
1、x0 = 0x111
2、_buckets = x0
3、開闢堆記憶體(地址 0x111)
複製程式碼
那麼第三步執行之前_buckets
已經有值了,但這個記憶體還是非法的,所以dsb
應該是起到了關鍵作用,讓第 2 部執行之前必須把開闢堆記憶體的操作執行完畢。
散列表記憶體釋放
canBeFreed()
就是判斷這個舊的_buckets
是不是清理過後只讀的,若不是就可以釋放(清理邏輯後面分析)。
釋放有兩步操作:
第一步cache_collect_free(oldBuckets,oldCapacity);
是將待釋放的oldBuckets
插入一個全域性的二維陣列:
static bucket_t **garbage_refs = 0;
複製程式碼
具體的演算法不多說了,反正就是garbage_refs
滿了時會以兩倍的容量擴容。
第二步cache_collect(false);
內部會判斷garbage_refs
的大小,若小於32*1024
什麼也不做。否則會進入一個迴圈判斷,若程序中沒有快取的訪問操作才進行真正的記憶體釋放。
這麼做的目的應該也是為了訪問安全,保證在對一塊cache_t
記憶體訪問時不會去釋放這塊記憶體。
可以看出,為了訪問cache_t
的成員變數時不加鎖,付出了很大的努力,但是對於這樣一個高頻訪問的快取機制,這些努力都是值得的。
散列表的擴容
void cache_t::expand() {
...
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);
}
複製程式碼
cache_t
的_mask
成員變數是mask_t
型別的,定義為:
#if __LP64__
typedef uint32_t mask_t; // x86_64 & arm64 asm are less efficient with 16-bits
#else
typedef uint16_t mask_t;
#endif
複製程式碼
如註釋所說,64 位系統使用 32 位的整形效率較高。上面newCapacity
是使用uint32_t
運算的,所以若mask_t
是 16 位時可能越界,若越界就放棄擴容,只是呼叫reallocate
重新分配和之前等大的記憶體。
由於之前分析分配記憶體方法reallocate
總是建立新的記憶體放棄舊的,所以每次擴容都會放棄舊的快取。可能會擔心放棄舊快取導致訊息傳送效率下降,其實散列表容量是以兩倍的速度擴充套件的,初始也是 8 個,對於大部分類來說,拓展少許的幾次就夠了。
擴容時放棄之前的快取能帶來另外一個好處:不用把舊快取依次按照 hash 演算法寫入散列表(因為擴容後散列表的容量會變化,將直接影響 hash 值會被掩碼擷取的物件,所以不得不使用 hash 演算法重新插入所有物件),試想若不放棄舊快取,那將舊快取同步到新散列表至少有 O(n) 時間消耗,這個過程必然快取的讀取變得不再安全。
散列表的寫入
寫入操作的核心操作就是通過cache_t
的find
函式讀取一個可用的bucket_t
:
bucket_t * cache_t::find(cache_key_t k,id receiver) {
bucket_t *b = buckets();
mask_t m = mask();
mask_t begin = cache_hash(k,m);
mask_t i = begin;
do {
if (b[i].key() == 0 || b[i].key() == k) {
return &b[i];
}
} while ((i = cache_next(i,m)) != begin);
...
}
複製程式碼
cache_hash
雜湊演算法就是簡單的操作:(mask_t)(key & mask)
,然後直接到陣列中找出bucket.key()
比較,若key
為 0 或與目標一致就返回這個bucket
的地址。
當發生 hash 碰撞時,就使用cache_next
將 hash 值累加 1,以此輪詢直到找到空位。cache_next
程式碼為(i+1) & mask
,就算 hash 值累加到陣列最大值還未找到空位,又會回到陣列頭部繼續尋找。由於在容量達到 3/4 時散列表就會擴容,所以這個find
操作是必然能找到空位的。
由於bucket.key() == 0
表示這個bucket
為空,所以在上層方法中有這樣一句程式碼(_occupied++
):
if (bucket->key() == 0) cache->incrementOccupied();
複製程式碼
四、方法快取的讀取
呼叫objc_msgSend
或者cache_getImp
中都會呼叫CacheLookup
巨集,它們的區別是呼叫時傳的引數不同:
objc_msgSend -> CacheLookup NORMAL
cache_getImp -> CacheLookup GETIMP
複製程式碼
下面分析一下CacheLookup
的上半截核心程式碼:
.macro CacheLookup
// p1 = SEL,p16 = isa
1 ldp p10,p11,[x16,#CACHE] // p10 = buckets,p11 = occupied|mask
#if !__LP64__
and w11,w11,0xffff // p11 = mask
#endif
2 and w12,w1,w11 // x12 = _cmd & mask
3 add p12,p10,p12,LSL #(1+PTRSHIFT)
// p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
4 ldp p17,p9,[x12] // {imp,sel} = *bucket
5 1: cmp p9,p1 // if (bucket->sel != _cmd)
6 b.ne 2f // scan more
7 CacheHit $0 // call or return imp
2: // not hit: p12 = not-hit bucket
8 CheckMiss $0 // miss if bucket->sel == 0
9 cmp p12,p10 // wrap if bucket == buckets
10 b.eq 3f
11 ldp p17,[x12,#-BUCKET_SIZE]! // {imp,sel} = *--bucket
12 b 1b // loop
...
複製程式碼
實際上註釋就已經把整個邏輯說明得比較明白了,下面筆者進行一些解釋讓讀者看起來更容易(注意起始的暫存器狀態p1 = SEL,p16 = isa
):
- 第 1 行:有定義
#define CACHE (2 * __SIZEOF_POINTER__)
,所以 64 位系統下CACHE == 64*2
,根據資料結構可知這正是objc_class
中cache
成員變數的偏移量,而cache_t
中的第一個 64 位就是_buckets
指標,mask_t
是 32 位,所以第二個 64 位就是_mask + _occupied
。 - 第 2 行:
x11
暫存器放的_mask + _occupied
,那w11
就是低 32 位_mask
,_cmd & mask
就是方法快取散列表的 hash 演算法,所以x12
現在就是 hash key 了。 - 第 3 行:通過 hash key 算出指標偏移,找到其對應的
bucket_t
。PTRSHIFT
字面意思是指標偏移,雖然筆者沒有找到它的定義,但可以試著推斷。由於<< 1
就是翻一倍,那麼buckets + ((_cmd & mask) << (1+PTRSHIFT)
可以轉化為:buckets + ((_cmd & mask) * (2 的 1+PTRSHIFT 次方)
,一個bucket_t
128 位大小,那可以推斷這個PTRSHIFT == 6
。我們知道mask
是總長度 -1 的值,恰好適用於這裡的演算法,所以這可能也是為什麼儲存mask
要 -1 的一個原因。 - 第 4 行:
x12
存了 hash key 對應的bucket_t
物件地址了,將bucket
的兩個成員變數分別取出,現在p17 -> imp / p9 -> sel
。 - 第 5 行:
p1
存的是目標SEL
,所以這裡是比較一下。 - 第 6 行:如果狀態暫存器是 not equel (ne),則跳轉到
2:
,即第 8 行。 - 第 7 行:命中快取找到 IMP,呼叫
CacheHit
,CacheHit
根據$0
判斷,若是NORMAL
則呼叫IMP
;若是GETIMP
則返回IMP
。 - 第 8 行:呼叫
CheckMiss
檢查快取是否丟失,其實就是看p9
(sel
) 是否為 0。若為 0 表示快取丟失都會發生跳轉,CacheLookup
後面的彙編程式碼也不會走了。當$0
是NORMAL
則呼叫前面分析過的__objc_msgSend_uncached
;當$0
是GETIMP
則跳轉到LGetImpMiss
,不要奇怪LGetImpMiss
是個啥,CacheLookup
和CheckMiss
都是巨集,上層呼叫有可能就是cache_getImp
(跳到LGetImpMiss
就復位了):
STATIC_ENTRY _cache_getImp
GetClassFromIsa_p16 p0
CacheLookup GETIMP
LGetImpMiss:
mov p0,#0 // 復位
ret
END_ENTRY _cache_getImp
複製程式碼
- 第 9 行:
p10
就是陣列指標的頭部,與當前找到的bucket
比較。 - 第 10 行:若相等說明迴圈完成還沒找到快取,則跳轉到
3f
(暫時不管實現,反正就是跳出 hash 演算法查詢)。 - 第 11 行:說明 hash 衝突了,有定義
#define BUCKET_SIZE (2 * __SIZEOF_POINTER__)
,bucket_t
正好兩個指標大,所以這裡就是進行了指標的移動,即向快取陣列前一個下標移動(有點奇怪,方法快取寫入的時候出現 hash 衝突是 +1,這裡是 -1,不過總是能完整遍歷)。 - 第 12 行:跳轉到
1b
,形成迴圈。
CacheLookup
下半截做了些什麼
3: // wrap: p12 = first bucket,w11 = mask
add p12,UXTW #(1+PTRSHIFT)
// p12 = buckets + (mask << 1+PTRSHIFT)
...(省略了迴圈邏輯)
複製程式碼
將p12
指向散列表末尾,然後做了和前面一樣的向前遍歷查詢。
仔細看前面跳轉到3:
的指令,若到了這裡說明通過 hash key 找到的SEL
始終不為 0,但是也不等於目標SEL
,也就是始終是 hash 衝突狀態,向前遍歷完散列表都沒有找到目標SEL
。
那麼,這部分會從散列表尾遍歷到散列表頭:
散列表頭 (上半截遍歷部分) hash key (未遍歷部分) 散列表尾
複製程式碼
可能有讀者會覺得這個遍歷會重複查詢上半截程式碼遍歷過的部分,實際上不會。由於散列表會在滿 3/4 時就擴容,所以把3:
之前未遍歷的部分找完就肯定能拿到快取或者丟失(SEL == 目標
或SEL == 0
),那迴圈就會被打破。
五、方法快取的清理
快取清理分兩種模式,一種是清理散列表的內容,而不是削減散列表的容量;一種是直接釋放整個散列表。
清理內容
void cache_erase_nolock(Class cls) {
...
cache_t *cache = getCache(cls);
mask_t capacity = cache->capacity();
if (capacity > 0 && cache->occupied() > 0) {
auto oldBuckets = cache->buckets();
auto buckets = emptyBucketsForCapacity(capacity);
cache->setBucketsAndMask(buckets,capacity - 1); // also clears occupied
cache_collect_free(oldBuckets,capacity);
cache_collect(false);
}
}
複製程式碼
主要是將舊的oldBuckets
釋放掉,然後通過emptyBucketsForCapacity
函式獲取新的容量相同的buckets
陣列,這個方法獲取的陣列在語言上沒有限制只讀,但需要把它理解為只讀陣列。
emptyBucketsForCapacity
的大致邏輯:
- 若
capacity
足夠小,返回一個和bucket_t *
大小相同的全域性變數_objc_empty_cache
。 - 否則,從一個靜態 hash 表
static bucket_t **emptyBucketsList = nil;
獲取;若未找到,則初始化一個等大的空間,儲存進emptyBucketsList
,同時把中間空的陣列填滿,便於 hash key 落在之間的物件獲取bucket_t
陣列。
還記得前面的cache->isConstantEmptyCache()
呼叫判斷快取是否只讀麼?這個函式實際上就是呼叫了emptyBucketsForCapacity
判斷這個快取陣列是否屬於只讀陣列。
為什麼要做這麼複雜的邏輯來清空一個數組?其實在前面的散列表記憶體分配一節已經解釋了,就是為了保證快取散列表的讀安全。
搜尋一下原始碼,隨便列舉幾個需要呼叫這個清空方法的地方:
-
attachCategories
將 Category 資訊同步到 Class 時。 -
_method_setImplementation / method_exchangeImplementations
直接設定方法的實現或交換方法實現時。 -
addMethod / addMethods
新增方法時。 -
setSuperclass
設定父類時。
需要清空的情況一句話概括:可能會導致快取失效時。
直接釋放
cache_delete
先會通過isConstantEmptyCache
函式判斷陣列內容是否為只讀的,若不是隻讀則呼叫free
直接釋放。可能有讀者擔心這個釋放會讓方法快取的讀取變得不安全,實際上不會,因為筆者只看到free_class
時會呼叫。
後語
方法快取機制為了極致的效率而不給讀取邏輯加鎖,為了讓讀取安全做了很多額外複雜工作,不過帶來的收益是很大的,因為方法快取讀取頻率極高。
objc_msgSend 的邏輯無疑是比較複雜的,涉及了不少彙編與作業系統的知識,不過按圖索驥分析起來也不是一件很困難的事,在這最後筆者不得不說一句:
iOS 太難了。