iOS底層-cache_t流程分析

本文概述

本文旨在經過源碼分析cache_t相關,如緩存策略,動態擴容等,瞭解方法查找的流程。因其與objc_msgSend流程有密切聯繫,而發送消息又是iOS方法的本質,故瞭解cache_t是有必要的。緩存

cache_t初探

1.cache_t 結構

上一篇iOS底層 -- 類的本質分析中對cache_t結構有初步的瞭解,其底層的結構:bash

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;              
    class_data_bits_t bits;   
    ..
}
複製代碼

cache_t位於objc_class函數

struct cache_t {
    struct bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
}
複製代碼

自身的結構由三個屬性組成:源碼分析

  • buckets是裝方法的桶子,裏面放着方式實現imp,根據方法編號sel生成的key
  • mask是擴容因子,同時和key共同計算得出尋找bucket_t的哈希索引
  • occupied是當前佔用的容量

2.lldb調試

在objc源碼中新建CJPerson類,自定義四個方法,在外部對其進行調用post

CJPerson * person = [[CJPerson alloc]init];
Class cls1 = object_getClass(person);
   
[person firstAction];
Class cls2 = object_getClass(person);
    
[person secondAction];
Class cls3 = object_getClass(person);

[person thirdAction];
Class cls4 = object_getClass(person);
 
[person fourthAction];
Class cls5 = object_getClass(person);
複製代碼

在每一個方法調用前輸出下各個cls,以cls1爲例,在[person firstAction]處打斷點 ui

x/4gx打印 cls1內存地址的前四位,由於 cache_t前面的兩個屬性 isasuperclass合佔16字節,故 cache_t的地址等於 isa的地址偏移16個字節,便是圖中標註的地址,強轉輸出這個地址獲得 cls1cache_t內的屬性值,依次類推,輸出其餘的 cls

類對象 _mask _occupied
cls1 3 1
cls2 3 2
cls3 3 3
cls4 7 1
cls5 7 2

根據這個結果,會發現前面三個cls還有一點規律可循,occupied每次+1,但是到cls4的時候,這個規律又不符合了,而且mask的值也發生了變化。看來只看打印結果是沒辦法看出是誰在做怪,仍是要去源碼瞧一瞧。this

cache_t源碼

1.尋找切入點

雖然說從源碼入手這個想法是好的,但是那麼多源碼,究竟要從哪裏看起。思來想去,仍是先搜索下cache_tspa

有28個相關結果,一個個點過去查找量也不小啊,再想一想,既然 cache_t是個結構體,那系統應該會 初始化它,而後在操做它,那嘗試搜索下 cache_t *
只有4條相關結果,這個壓力瞬間減小,一個個查看也沒什麼問題了。

1.第一條

cache_t *getCache(Class cls) 
{
    assert(cls);
    return &cls->cache;
}
複製代碼

這只是個get方法,排除設計

2.第二條

void cache_t::bad_cache(id receiver, SEL sel, Class isa)
{
    // Log in separate steps in case the logging itself causes a crash.
    _objc_inform_now_and_on_crash
        ("Method cache corrupted. This may be a message to an "
         "invalid object, or a memory error somewhere else.");
    cache_t *cache = &isa->cache;
    _objc_inform_now_and_on_crash
        ("%s %p, SEL %p, isa %p, cache %p, buckets %p, "
         "mask 0x%x, occupied 0x%x", 
         receiver ? "receiver" : "unused", receiver, 
         sel, isa, cache, cache->_buckets, 
         cache->_mask, cache->_occupied);
         ...
}
複製代碼

這是在bad_cache方法中,看名字和實現,不難判斷出,這是壞緩存的時候發出的警告和崩潰消息調試

3.第三條

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_fill_nolock方法中,方法名意爲緩存填充,感受多是這個,但還不能肯定

4.第四條

void cache_erase_nolock(Class cls)
{
    cacheUpdateLock.assertLocked();

    cache_t *cache = getCache(cls);
    ...
}
複製代碼

這是在cache_erase_nolock方法中,方法名意爲緩存擦除

綜上所述,第三條的可能性是最高的,但還須要驗證,在每條方法內打斷點,[person firstAction]調用後,看下執行結果

斷點來到 cache_fill_nolock內,能夠確認方法調用後,是執行這個方法進行緩存的。

2.cache_fill_nolock解析

確認了這是方法調用後執行緩存的方法,那就有必要對其實現作深刻的研究了。

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); ⑤

    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) { ⑩
    
    }
    else {
        cache->expand(); ⑪
    }
    bucket_t *bucket = cache->find(key, receiver); ⑫
    if (bucket->key() == 0) cache->incrementOccupied(); ⑬
    bucket->set(key, imp); ⑭
}
複製代碼
① 緩存鎖
② 判斷類是否被初始化,若是未初始化,直接返回
③ 判斷sel是否已經被cls緩存,若是已經緩存就不必再走下面的緩存流程,直接返回
④ 經過cls獲取cache_t的內存地址
⑤ 經過sel強轉生成key
⑥ 獲取cache中當前佔有量occupied + 1的值,給下面進行容量判斷
⑦ 取出cache中的容量capacity
⑧ 判斷當前佔有量是否爲0而且桶子的容量是否爲空的
   ⑨佔有量爲0而且桶子容量爲空時,進行reallocate從新分配Buckets和Mask
   ⑩新的佔有量小於等於總容量的3/4時,無另外操做
   ⑪新的佔有量大於總容量的3/4時,進行expand擴容
⑫ 經過key看成哈希下標尋找對應bucket
⑬ 若是key等於0,說明沒找到,須要緩存,則cache中的當前佔有量 occupied + 1
⑭ 把key和imp裝進桶子bucket
複製代碼

總結:

cache_fill_nolock先獲取類的cache,判斷cache爲空時,建立cache,若是cache已經存在,判斷存儲後的佔有量大於容量的3/4時,就擴容。作完這些後,keyimp放入bucket中,把bucket放入cache中。

明白了緩存的主流程,在對裏面比較重要的方法,如reallocateexpand等作進一步解析

a.reallocate(mask_t oldCapacity, mask_t newCapacity)

INIT_CACHE_SIZE定義

enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};
複製代碼

實現

void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
    ...
    setBucketsAndMask(newBuckets, newCapacity - 1);
    if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
}
複製代碼

能夠看到,在第一次調用時,capacity爲空,則傳入4,而後給setBucketsAndMask入參,從新生成_buckets,而且最後須要丟棄舊的桶子oldBuckets,清空原來的緩存

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    mega_barrier();
    _buckets = newBuckets;
    mega_barrier();
    _mask = newMask;
    _occupied = 0;
}
複製代碼

處理完後,_mask = 4 - 1 = 3,_occupied = 0。mask = newCapacity - 1,newCapacity = 2^n(初始化最小爲4,n爲擴容次數+2),故mask = 2^n - 1,lldb調試的值得以驗證

b.expand()

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);
}
複製代碼

先獲取oldCapacity舊容量,

mask_t cache_t::capacity() 
{
    return mask() ? mask()+1 : 0; 
}
複製代碼

等於mask + 1,初始化後mask = 3 ,那這裏oldCapacity = 4,新容量newCapacity等於舊容量oldCapacity * 2 = 8,而後給reallocate入參,從新分配桶子bucketsmask

c.find(cache_key_t k, id receiver)

bucket_t * cache_t::find(cache_key_t k, id receiver)
{
    assert(k != 0);
    bucket_t *b = buckets();
    mask_t m = mask();
    mask_t i = begin;
    do {
        if (b[i].key() == 0  ||  b[i].key() == k) {
            return &b[i];
        }
    } while ((i = cache_next(i, m)) != begin);
    Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
    cache_t::bad_cache(receiver, (SEL)k, cls);
}
複製代碼

經過cache_hash函數【begin = k & m】計算出keyk對應的 indexbegin,用來記錄查詢起始索引,而後把begin賦值給 i,用於切換索引。 用這個i從散列表取值,若是取出來的bucket_tkey= k,則查詢成功,返回該bucket_t;若是key = 0,說明在索引i的位置上尚未緩存過方法,一樣須要返回該bucket_t,用於停止緩存查詢。而後while循環其實就至關於繞圈,把散列表頭尾連起來,從begin值開始,遞減索引值,當走過一圈以後,必然會從新回到begin值,若是此時尚未找到key對應的bucket_t,或者是空的bucket_t,則循環結束,說明查找失敗,調用bad_cache方法拋出異常。

cache_t部分問題彙總

1.類的cache_t會對任何方法進行緩存嘛?

類的cache_t只會緩存對象方法,類方法緩存在元類中

2.爲什方法編號sel須要強轉成key?

方法編號sel是字符串類型,在底層傳遞的效率不如long類型的key

3.mask的做用?

mask作爲判斷是否擴容的參數之一,能夠稱之爲擴容因子;同時maskkey共同生成哈希下表用來查找bucket_t。這裏的maskisa_mask 作爲面具的功能不同,須要進行區分。

4.方法緩存是否有序?

方法緩存的bucket_t是根據哈希下表獲得的,找到空位子就直接坐下佔有,不存在有序

5.爲何擴容是3/4?

HashMap的負載因子等於0.75時,空間利用率比較高,並且避免了至關多的Hash衝突,使得底層的鏈表或者是紅黑樹的高度比較低,提高了空間效率。

6.bucket和mask,capacity,sel和imp的關係

  • bucket內裝着impsel生成的key
  • bucket是經過keymask進行尋找的
  • bucket的數量取決於capacity

7.爲何擴容後須要丟棄原先全部的緩存?

感受上,擴容後丟棄緩存,下次調用在從新緩存的行爲使得緩存變的沒有意義。實際上,通常類中有較多方法,若是擴容後,還須要保存原先的方法,新bucket_t須要從舊的bucket_t中獲取全部方法,在一個個放入,這自己是比較耗時的行爲。方法緩存的目的就是使消息發送流程更快速,蘋果爲了更快,還使方法查找走彙編流程,總之這一切都是爲了一個字--快。擴容後丟棄原先緩存就是在這個目的下被設計出來,並且這樣設計,從某種意義上來講,也符合LRU緩存策略,最近被緩存的方法使用率也較高,較早被緩存的使用率較低。

寫在最後

以上就是我對cache_t的部分理解,cache_t是消息發送的一個切入點。下一章objc_msgSend會對cache_t有一個更宏觀的理解。敬請期待。

相關文章
相關標籤/搜索