iOS 底層拾遺:objc_msgSend 與方法緩存

前言

Runtime 消息發(fā)送與轉發(fā)流程總是大家關注的重點,卻常常忽略方法緩存機制這個顯著提升 objc_msgSend 性能的幕后功臣。

本文會通過源碼梳理消息發(fā)送與轉發(fā)流程,重點分析方法緩存機制的實現(xiàn)細節(jié)。行文過程中會涉及到一些匯編代碼,不過不影響理解核心邏輯。

源碼基于 Runtime 750,arm64 架構。

一、從 objc_msgSend 談起

注意:arm64 匯編代碼會出現(xiàn)很多p字母,實際上是一個宏,64 位下是x,32 位下是w,p就是寄存器。

在分析緩存機制之前,先梳理一下消息發(fā)送與轉發(fā)的流程,找到何時進行緩存的存儲與讀取。

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 位系統(tǒng)下GetClassFromIsa_p16宏代碼為:

.macro GetClassFromIsa_p16
    ...
    and p16, $0, #ISA_MASK  // #define ISA_MASK 0x0000000ffffffff8ULL
    ...

$0獲取宏的第一個參數(shù),調用時傳的p13,即是isa。這一步做的操作就是使用ISA_MASK掩碼找到isa變量中的Class并放入p16isaunion isa_t類型,在很多系統(tǒng)中已經(jīng)不是單純的指向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, lr, [sp], #16
    AuthenticateLR
.endmacro

由于這個宏內部要跳轉函數(shù),意味著lr的變化,所以開辟棧空間后需要把之前的fp/lr值存儲到棧上便于復位狀態(tài)。筆者刪除了save registersrestore registers的邏輯,其實就是將各個寄存器的值先存儲到棧上,內部函數(shù)幀釋放時便于復位寄存器的值。

在調用完__class_lookupMethodAndLoadCache3后會把返回在x0IMP值復制到x17中。

__class_lookupMethodAndLoadCache3是一個 C 函數(shù),跳轉之前把x16的值復制到x2中(x16目前存儲的就是GetClassFromIsa_p16代碼找到的對象的Class),那么此時寄存器布局就是:x0 -> receiver / x1 -> selector / x2 -> class,也就對應了這個方法的參數(shù)列表:

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, SEL sel, 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,嘗試進行動態(tài)消息處理
    if (resolver  &&  !triedResolver) {
        runtimeLock.unlock();
        _class_resolveMethod(cls, sel, inst);
        runtimeLock.lock();
        triedResolver = YES;
        goto retry;
    }
    // 若動態(tài)消息處理失敗,IMP 指向一個函數(shù)并將 IMP 存方法緩存
    imp = (IMP)_objc_msgForward_impcache;
    cache_fill(cls, sel, imp, inst);

done:
    runtimeLock.unlock();
    return imp;
}

方法緩存的存取

方法緩存存儲符合一般邏輯,只要找到了IMP就會進行緩存,加入方法緩存都會調用cache_fill方法。需要注意的是,如果是從父類鏈中找到的方法,仍然會加入當前類的緩存列表,這樣能大大提高查找在父類鏈中方法的效率。

可能讀者會疑惑這個方法為什么還會去取緩存?前面一堆匯編方法走到這里的時候理論上當前類是已經(jīng)沒有對應SEL的方法緩存了。前面?zhèn)€cache_getImp方法是因為lookUpImpOrForward函數(shù)會被其它函數(shù)調用,并不在前面筆者分析的流程中;而retry:下面的cache_getImp是因為在動態(tài)消息處理的時候可能會插入相關IMP然后goto retry。

方法列表的查詢

類的方法列表的查詢通過getMethodNoSuper_nolock-> search_method_list方法處理,具體的邏輯不展開了,只需知道若方法列表是排過序的會使用二分搜索去查;否則就是一個簡單的遍歷查詢。所以在沒有方法緩存的情況下方法的查詢效率是很低的,時間復雜度要么是 O(logn) 要么是 O(n)。

消息轉發(fā)的邏輯

_class_resolveMethod方法前面調用了unlock()lock(),關閉了類的保護狀態(tài),便于開發(fā)者改變類的方法列表等。

_class_resolveMethod會向對象發(fā)送+resolveInstanceMethod(實例對象)或+resolveClassMethod(類對象)方法,開發(fā)者可以在這兩個方法中為類動態(tài)加入IMP,_class_resolveMethod出棧后走goto retry會重新嘗試查找方法的邏輯。

當然,若開發(fā)者沒有做處理,IMP仍然找不到,通過!triedResolver避免二次動態(tài)消息處理,然后就會讓imp = (IMP)_objc_msgForward_impcache。如此一來,當lookUpImpOrForward函數(shù)幀釋放時,在上層看來仍然是找到IMP了,這個方法就是_objc_msgForward_impcache。那么在前面分析的__objc_msgSend_uncached方法就仍然會調用這個IMP,接下來就是真正的消息轉發(fā)階段了。

    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

可以發(fā)現(xiàn)通過頁地址加頁偏移的方式,拿到__objc_forward_handler的地址并調用,它是一個函數(shù)指針,在OBJC2下有默認實現(xiàn):

__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描述。

而對于開發(fā)者熟悉的-forwardingTargetForSelector:重定向方法、-forwardInvocation:轉發(fā)方法,Runtime 源碼中沒有啥痕跡,在文件后面只有一個更改_objc_forward_handler指針的函數(shù)(可以猜測方法重定向和方法轉發(fā)是通過改變這個指針做邏輯的,感興趣可以查看楊帝的逆向分析消息轉發(fā)文章:Objective-C 消息發(fā)送與轉發(fā)機制原理):

void objc_setForwardHandler(void *fwd, void *fwd_stret) {
    _objc_forward_handler = fwd;
    ...
}

小結

到目前為止,整個消息發(fā)送機制算是比較清晰了,在按圖索驥的過程中,發(fā)現(xiàn)了不少方法緩存的存取操作,主要是cache_getImpcache_fill函數(shù)。當然,方法緩存還有清理操作,后面再談。接下來的部分就著重分析方法緩存的實現(xiàn)細節(jié)。

二、方法緩存的數(shù)據(jù)結構基礎

cache_t是方法緩存的數(shù)據(jù)結構,在objc_classcache變量偏移64*2位:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache; 
    class_data_bits_t bits; 
...

bits存儲了類的屬性、協(xié)議、方法等,這里不展開描述。cache_t的結構也很簡單:

struct cache_t {
    struct bucket_t *_buckets;  // bucket_t 數(shù)組
    mask_t _mask;               // 容量緩存?zhèn)€數(shù)減1
    mask_t _occupied;           // 有效緩存?zhèn)€數(shù)
...

咋一看就像是一個散列表,這和weak弱引用的底層數(shù)據(jù)結構(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, SEL sel, IMP imp, id receiver) {
    mutex_locker_t lock(cacheUpdateLock);
    cache_fill_nolock(cls, sel, imp, 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))如出一轍,都是為了實現(xiàn)自動解鎖的效果。

cache_fill_nolock

cache_fill_nolock是寫入的核心邏輯(為了簡短有所修改):

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
    ...
    // 在類初始化之前不允許寫入緩存
    if (!cls->isInitialized()) return;
    // 在走到這里的時候,可能在占有 cacheUpdateLock 的時候緩存已經(jīng)被其它線程寫入了,所以先查詢一次緩存
    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) {
        // 如果有效緩存數(shù)量超過了 3/4 就進行擴容
        cache->expand();
    }

    // 在散列表中找到一個空置的 bucket 寫入數(shù)據(jù)
    bucket_t *bucket = cache->find(key, receiver);
    if (bucket->key() == 0) cache->incrementOccupied();
    bucket->set(key, imp);
}

鎖的搶占

cache_fill方法雖然已經(jīng)加了鎖,但有可能多個線程同時訪問,且它們都是往同一個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數(shù)組釋放了,然后創(chuàng)建新的數(shù)組,開辟內存方法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這個內聯(lián)匯編使用__volatile__關鍵字阻止編譯器緩存變量到寄存器不寫回,使用memory內存屏障避免 CPU 使用寄存器來優(yōu)化執(zhí)行指令,使用dsb ish隔離指令在它前面的存儲器訪問操作都執(zhí)行完畢后,才執(zhí)行在它后面的指令。這一個使盡渾身解數(shù)的宏是為了干嘛呢?

對于cache_t來說,讀取_buckets_mask都是沒有加鎖的,那么就一定要保證_buckets的實際長度始終大于_mask,最壞的情況不過只是訪問不到已有的緩存,不然在進行 hash 運算后很可能訪問到錯誤或非法的內存。

那么第二個mega_barrier()就是為了保證新的_buckets始終會在新的_mask之前賦好值。當然這有個前提,就是新_buckets的長度始終大于舊的。在cache_t算法中并沒有削減_buckets內存的邏輯,只有一個清空_buckets數(shù)組每個bucketkey/imp的邏輯(清空后內存為 readonly),所以這個前提是能保證的。

在前面cache_fill_nolock方法的if (cache->isConstantEmptyCache())分支正是內存被清空后標記為 readonly 的邏輯,重新分配內存時會開辟一個INIT_CACHE_SIZE (8) 長度的空間,可能有讀者會疑問這個時候不就是新_buckets的長度小于舊的么?

其實不然,在清空_buckets時雖然沒有削減內存,但_occupied(有效緩存數(shù)量)會置為 0,也就是說這種情況下是不會有其它線程訪問的。

第一個mega_barrier()就比較夢幻了,筆者可能理解有誤:

newBuckets指針開辟內存到賦值給_buckets的模擬如下:

1、開辟堆內存(地址 0x111)
2、x0 = 0x111
3、_buckets = x0

由于內存訪問比寄存器訪問慢,很可能被操作系統(tǒng)優(yōu)化成這樣:

1、x0 = 0x111
2、_buckets = x0
3、開辟堆內存(地址 0x111)

那么第三步執(zhí)行之前_buckets已經(jīng)有值了,但這個內存還是非法的,所以mega_barrier()起到了關鍵作用,讓第 2 部執(zhí)行之前必須把開辟堆內存的操作執(zhí)行完畢。

散列表內存釋放

canBeFreed()就是判斷這個舊的_buckets是不是清理過后只讀的,若不是就可以釋放(清理邏輯后面分析)。

釋放有兩步操作:

第一步cache_collect_free(oldBuckets, oldCapacity);是將待釋放的oldBuckets插入一個全局的二維數(shù)組:

static bucket_t **garbage_refs = 0;

具體的算法不多說了,反正就是garbage_refs滿了時會以兩倍的容量擴容。

第二步cache_collect(false);內部會判斷garbage_refs的大小,若小于32*1024什么也不做。否則會進入一個循環(huán)判斷,若進程中沒有緩存的訪問操作才進行真正的內存釋放。

這么做的目的應該也是為了訪問安全,保證在對一塊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 位系統(tǒng)使用 32 位的整形效率較高。上面newCapacity是使用uint32_t運算的,所以若mask_t是 16 位時可能越界,若越界就放棄擴容,只是調用reallocate重新分配和之前等大的內存。

由于之前分析分配內存方法reallocate總是創(chuàng)建新的內存放棄舊的,所以每次擴容都會放棄舊的緩存??赡軙姆艞壟f緩存導致消息發(fā)送效率下降,其實散列表容量是以兩倍的速度擴展的,初始也是 8 個,對于大部分類來說,拓展少許的幾次就夠了。

擴容時放棄之前的緩存能帶來另外一個好處:不用把舊緩存依次按照 hash 算法寫入散列表(因為擴容后散列表的容量會變化,將直接影響 hash 值會被掩碼截取的對象,所以不得不使用 hash
算法重新插入所有對象),試想若不放棄舊緩存,那將舊緩存同步到新散列表至少有 O(n) 時間消耗,這個過程必然緩存的讀取變得不再安全。

散列表的寫入

寫入操作的核心操作就是通過cache_tfind函數(shù)讀取一個可用的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),然后直接到數(shù)組中找出bucket.key()比較,若key為 0 或與目標一致就返回這個bucket的地址。

當發(fā)生 hash 碰撞時,就使用cache_next將 hash 值累加 1,以此輪詢直到找到空位。cache_next代碼為(i+1) & mask,就算 hash 值累加到數(shù)組最大值還未找到空位,又會回到數(shù)組頭部繼續(xù)尋找。由于在容量達到 3/4 時散列表就會擴容,所以這個find操作是必然能找到空位的。

由于bucket.key() == 0表示這個bucket為空,所以在上層方法中有這樣一句代碼(_occupied++):

if (bucket->key() == 0) cache->incrementOccupied();

四、方法緩存的讀取

調用objc_msgSend或者cache_getImp中都會調用CacheLookup宏,它們的區(qū)別是調用時傳的參數(shù)不同:

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, p9, [x12, #-BUCKET_SIZE]!  // {imp, sel} = *--bucket
12       b  1b          // loop
         ...

實際上注釋就已經(jīng)把整個邏輯說明得比較明白了,下面筆者進行一些解釋讓讀者看起來更容易(注意起始的寄存器狀態(tài)p1 = SEL, p16 = isa):

  • 第 1 行:有定義#define CACHE (2 * __SIZEOF_POINTER__),所以 64 位系統(tǒng)下CACHE == 64*2,根據(jù)數(shù)據(jù)結構可知這正是objc_classcache成員變量的偏移量,而cache_t中的第一個 64 位就是_buckets指針,mask_t是 32 位,所以第二個 64 位就是_mask + _occupied。
  • 第 2 行:x11寄存器放的_mask + _occupied,那w11就是低 32 位_mask_cmd & mask就是方法緩存散列表的 hash 算法,所以x12現(xiàn)在就是 hash key 了。
  • 第 3 行:通過 hash key 算出指針偏移,找到其對應的bucket_tPTRSHIFT字面意思是指針偏移,雖然筆者沒有找到它的定義,但可以試著推斷。由于<< 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的兩個成員變量分別取出,現(xiàn)在p17 -> imp / p9 -> sel。
  • 第 5 行:p1存的是目標SEL,所以這里是比較一下。
  • 第 6 行:如果狀態(tài)寄存器是 not equel (ne),則跳轉到2:,即第 8 行。
  • 第 7 行:命中緩存找到 IMP,調用CacheHitCacheHit根據(jù)$0判斷,若是NORMAL則調用IMP;若是GETIMP則返回IMP
  • 第 8 行:調用CheckMiss檢查緩存是否丟失,其實就是看p9 (sel) 是否為 0。若為 0 表示緩存丟失都會發(fā)生跳轉,CacheLookup后面的匯編代碼也不會走了。當$0NORMAL則調用前面分析過的__objc_msgSend_uncached;當$0GETIMP則跳轉到LGetImpMiss,不要奇怪LGetImpMiss是個啥,CacheLookupCheckMiss都是宏,上層調用有可能就是cache_getImp(跳到LGetImpMiss就復位了):
    STATIC_ENTRY _cache_getImp
    GetClassFromIsa_p16 p0
    CacheLookup GETIMP
LGetImpMiss:
    mov p0, #0  // 復位
    ret
    END_ENTRY _cache_getImp
  • 第 9 行:p10就是數(shù)組指針的頭部,與當前找到的bucket比較。
  • 第 10 行:若相等說明循環(huán)完成還沒找到緩存,則跳轉到3f (暫時不管實現(xiàn),反正就是跳出 hash 算法查找)。
  • 第 11 行:說明 hash 沖突了,有定義#define BUCKET_SIZE (2 * __SIZEOF_POINTER__),bucket_t正好兩個指針大,所以這里就是進行了指針的移動,即向緩存數(shù)組前一個下標移動(有點奇怪,方法緩存寫入的時候出現(xiàn) hash 沖突是 +1,這里是 -1,不過總是能完整遍歷)。
  • 第 12 行:跳轉到1b,形成循環(huán)。

CacheLookup下半截做了些什么

3:  // wrap: p12 = first bucket, w11 = mask
    add p12, p12, w11, UXTW #(1+PTRSHIFT)
    // p12 = buckets + (mask << 1+PTRSHIFT)
    ...(省略了循環(huán)邏輯)

p12指向散列表末尾,然后做了和前面一樣的向前遍歷查詢。

仔細看前面跳轉到3:的指令,若到了這里說明通過 hash key 找到的SEL始終不為 0,但是也不等于目標SEL,也就是始終是 hash 沖突狀態(tài),向前遍歷完散列表都沒有找到目標SEL。

那么,這部分會從散列表尾遍歷到散列表頭:

散列表頭  (上半截遍歷部分)  hash key  (未遍歷部分)  散列表尾

可能有讀者會覺得這個遍歷會重復查詢上半截代碼遍歷過的部分,實際上不會。由于散列表會在滿 3/4 時就擴容,所以把3:之前未遍歷的部分找完就肯定能拿到緩存或者丟失(SEL == 目標SEL == 0),那循環(huán)就會被打破。

五、方法緩存的清理

緩存清理分兩種模式,一種是清理散列表的內容,而不是削減散列表的容量;一種是直接釋放整個散列表。

清理內容

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函數(shù)獲取新的容量相同的buckets數(shù)組,這個方法獲取的數(shù)組在語言上沒有限制只讀,但需要把它理解為只讀數(shù)組

emptyBucketsForCapacity的大致邏輯:

  • capacity足夠小,返回一個和bucket_t *大小相同的全局變量_objc_empty_cache。
  • 否則,從一個靜態(tài) hash 表static bucket_t **emptyBucketsList = nil;獲?。蝗粑凑业?,則初始化一個等大的空間,存儲進emptyBucketsList,同時把中間空的數(shù)組填滿,便于 hash key 落在之間的對象獲取bucket_t數(shù)組。

還記得前面的cache->isConstantEmptyCache()調用判斷緩存是否只讀么?這個函數(shù)實際上就是調用了emptyBucketsForCapacity判斷這個緩存數(shù)組是否屬于只讀數(shù)組。

為什么要做這么復雜的邏輯來清空一個數(shù)組?其實在前面的散列表內存分配一節(jié)已經(jīng)解釋了,就是為了保證緩存散列表的讀安全。

搜索一下源碼,隨便列舉幾個需要調用這個清空方法的地方:

  • attachCategories將 Category 信息同步到 Class 時。
  • _method_setImplementation / method_exchangeImplementations直接設置方法的實現(xiàn)或交換方法實現(xiàn)時。
  • addMethod / addMethods添加方法時。
  • setSuperclass設置父類時。

需要清空的情況一句話概括:可能會導致緩存失效時。

直接釋放

cache_delete先會通過isConstantEmptyCache函數(shù)判斷數(shù)組內容是否為只讀的,若不是只讀則調用free直接釋放??赡苡凶x者擔心這個釋放會讓方法緩存的讀取變得不安全,實際上不會,因為筆者只看到free_class時會調用。

后語

方法緩存機制為了極致的效率而不給讀取邏輯加鎖,為了讓讀取安全做了很多額外復雜工作,不過帶來的收益是很大的,因為方法緩存讀取頻率極高。

objc_msgSend 的邏輯無疑是比較復雜的,涉及了不少匯編與操作系統(tǒng)的知識,不過按圖索驥分析起來也不是一件很困難的事,在這最后筆者不得不說一句:

iOS 太難了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容