iOS 底層探索 - cache_t

在這裏插入圖片描述

上一篇我們一起探索了 iOS 類的底層原理,其中比較重要的四個屬性我們都簡單的過了一遍,我們接下來要重點探索第三個屬性 cache_t,對於這個屬性,我們可以學習到蘋果對於緩存的設計與理解,同時也會接觸到消息發送相關的知識。

一、探索 cache_t

1.1 cache_t 基本結構

我們還是先過一遍 OC 中類的結構:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    class_rw_t *data() { 
        return bits.data();
    }

    ...省略代碼...    
}

接着我們查看源碼中 cache_t 的定義:

struct cache_t {
    struct bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
    
    ...省略代碼... 
}

然後我們發現 cache_t 結構體的第一個成員 _buckets 也是一個結構體類型 bucket_t,我們再查看一下 bucket_t 的定義:

struct bucket_t {
private:
    // IMP-first is better for arm64e ptrauth and no worse for arm64.
    // SEL-first is better for armv7* and i386 and x86_64.
#if __arm64__
    MethodCacheIMP _imp;
    cache_key_t _key;
#else
    cache_key_t _key;
    MethodCacheIMP _imp;
#endif

public:
    inline cache_key_t key() const { return _key; }
    inline IMP imp() const { return (IMP)_imp; }
    inline void setKey(cache_key_t newKey) { _key = newKey; }
    inline void setImp(IMP newImp) { _imp = newImp; }

    void set(cache_key_t newKey, IMP newImp);
};

從源碼定義中不難看出,bucket_t 其實緩存的是方法實現 IMP。這裏有一個注意點,就是 IMP-firstSEL-first

IMP-first is better for arm64e ptrauth and no worse for arm64.

  • IMP-first 對 arm64e 的效果更好,對 arm64 不會有壞的影響。

SEL-first is better for armv7* and i386 and x86_64.

  • SEL-first 適用於 armv7 * 和 i386 和 x86_64。

如果對 SELIMP 不是很熟悉的同學可以去 objc4-756 源碼中查看方法 method_t 的定義:

struct method_t {
    SEL name;   // 方法選擇器
    const char *types; // 方法類型字符串
    MethodListIMP imp;  // 方法實現

    ...省略代碼... 
};

通過上面的源碼,我們大致瞭解了 bucket_t 類型的結構,那麼現在問題來了,類中的 cache 是在什麼時候以什麼樣的方式來進行緩存的呢?

1.2 LLDB 大法

瞭解到 cache_tbucket_t 的基本結構後,我們可以通過 LLDB 來打印驗證一下:

image.png

cache_t 內部的這三個屬性,我們從其名稱不難看出 _occupied 應該是表示當前已經佔用了多少緩存,_mask 暫時不知道,_buckets 應該是存放具體緩存的地方。那麼爲了驗證我們的猜想,我們調用代碼來測試:

image.png

我們發現,斷點斷到 45 行的時候,_ocuupied 的值爲 1,我們打印一下 _buckets 裏面的內容看看:

image.png

我們可以看到,打印到 _buckets 的第三個元素的時候,我們的 init 方法被緩存了,也就是說 _ocuupied 確實是表示當前被緩存方法的個數。這裏可能讀者會說爲什麼 allocclass 爲什麼沒有被緩存呢?其實這是因爲 allocclass 是類方法,而根據我們前面探索類底層原理的時候,類方法是存儲在元類裏面的,所以這裏類的緩存裏面只會存儲對象方法。
我們接着把斷點過到 46 行:

image.png

_ocuupied 的值果然發生了變化,我們剛纔的猜想進一步得到了驗證,我們再往下面走一行:

image.png

此時 _ocuupied 值已經爲 3 了,我們回顧一下當前緩存裏面緩存的方法:

_ocuupied 的值 緩存的方法
1 NSObject下的init
2 NSObject下的init,person下的 sayHello
3 NSObject下的init,person下的 sayHello, person下的 sayCode

那麼,當我們的斷點斷到下一行的時候,是不是 _ocuupied 就會變爲 4 呢? 我們接着往下走:

image.png

令人驚奇的事情發生了,_ocuupied 的值變成了 1,而 _mask 變成了 7。這是爲什麼呢?

如果讀者瞭解並掌握散列表這種數據結構的話,相信已經看出端倪了。是的,這裏其實就是用到了 開放尋址法 來解決散列衝突(哈希衝突)。

關於哈希衝突,可以藉助鴿籠理論,即把 11 只鴿子放進 10 個抽屜裏面,肯定會有一個抽屜裏面有 2 只鴿子。是不是理解起來很簡單? 😃

通過上面的測試,我們明確了方法緩存使用的是哈希表存儲,並且爲了解決無法避免的哈希衝突使用的是開放尋址法,而開放尋址法必然要在合適的時機進行擴容,這個時機肯定不是會在數據已經裝滿的時候,我們可以進源碼探索一下,我們快速定位到 cache_t 的源碼處:

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) {
        // mask overflow - can't grow further
        // fixme this wastes one bit of mask
        newCapacity = oldCapacity;
    }

    reallocate(oldCapacity, newCapacity);
}

從上面的代碼不難看出 expand 方法就是擴容的核心算法,我們梳理一下里面的邏輯:

cacheUpdateLock.assertLocked();
  • 緩存鎖斷言一下判斷當前執行上下文是否已經上鎖
uint32_t oldCapacity = capacity();
  • 通過 capacity() 方法獲取當前的容量大小
uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;
  • 判斷當前的容量大小,如果爲0,則賦值爲 INIT_CACHE_SIZE,而根據
enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};

可知 INIT_CACHE_SIZE 初始值爲 4;如果當前容量大小不爲 0,則直接翻倍。

到了這裏相信聰明的讀者根據我們上面的測試應該猜到了,我們的 _mask 其實就是容量大小減 1 後的結果。

reallocate(oldCapacity, newCapacity);
  • 最後調用 reallocate 方法進行緩存大小的重置

我們接着進入 reallocate 內部一探究竟:

void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
    bool freeOld = canBeFreed();

    bucket_t *oldBuckets = buckets();
    bucket_t *newBuckets = allocateBuckets(newCapacity);

    assert(newCapacity > 0);
    assert((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1);

    setBucketsAndMask(newBuckets, newCapacity - 1);
    
    if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
}

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    mega_barrier();

    _buckets = newBuckets;
    
    mega_barrier();
    
    _mask = newMask;
    _occupied = 0;
}

顯然,_mask 是這一步 setBucketsAndMask(newBuckets, newCapacity - 1); 被賦值爲容量減 1 的。

同樣的,我們還可以通過 capacity 方法來驗證

mask_t cache_t::capacity() 
{
    return mask() ? mask()+1 : 0; 
}

二、深入 cache_t

其實我們在探索 iOS 底層的時候,儘量不要站在上帝視角去審視相應的技術點,我們可以儘量給自己多拋出幾個問題,然後嘗試去解決每個問題,通過這樣的探索,對提高我們閱讀源碼的能力十分重要。

通過前面的探索,我們知道了 cache_t 實質上是緩存了我們類的實例方法,那麼對於類方法來說,自然就是緩存在了元類上了。這一點我相信讀者應該都能理解。

2.1 方法緩存策略

按照最常規的思維,緩存內容最省時省力的辦法肯定是來一個緩存一個,那麼我們的 cache_t 是不是這麼做的呢,實踐出真知,我們一試便知。

我們在源碼中搜索 capacity() 方法,我們找到了 cache_fill_nolock 方法:

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
    cacheUpdateLock.assertLocked();

    // Never cache before +initialize is done
    if (!cls->isInitialized()) return;

    // Make sure the entry wasn't added to the cache by some other thread 
    // before we grabbed the cacheUpdateLock.
    if (cache_getImp(cls, sel)) return;

    cache_t *cache = getCache(cls);
    cache_key_t key = getKey(sel);

    // Use the cache as-is if it is less than 3/4 full
    mask_t newOccupied = cache->occupied() + 1;
    mask_t capacity = cache->capacity();
    if (cache->isConstantEmptyCache()) {
        // Cache is read-only. Replace it.
        cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
    }
    else if (newOccupied <= capacity / 4 * 3) {
        // Cache is less than 3/4 full. Use it as-is.
    }
    else {
        // Cache is too full. Expand it.
        cache->expand();
    }

    // Scan for the first unused slot and insert there.
    // There is guaranteed to be an empty slot because the 
    // minimum size is 4 and we resized at 3/4 full.
    bucket_t *bucket = cache->find(key, receiver);
    if (bucket->key() == 0) cache->incrementOccupied();
    bucket->set(key, imp);
}

cache_fill_nolock 方法乍一看有些複雜,我們不妨將它分解一下:

第一行代碼還是加鎖的判斷,我們直接略過,來到第二行:

if (cache_getImp(cls, sel)) return;
  • 通過 cache_getImp 來判斷當前 cls 下的 sel 是否已經被緩存了,如果是,直接返回。而 cache_getImp 底層實現是 _cache_getImp,並且是在彙編層實現的。
cache_t *cache = getCache(cls);
cache_key_t key = getKey(sel);
  • 調用 getCache 來獲取 cls 的方法緩存,然後通過 getKey 來獲取到緩存的 key,這裏的 getKey 其實是將 SEL 類型強轉成 cache_key_t 類型。
mask_t newOccupied = cache->occupied() + 1;
  • cache 已經佔用的基礎上進行加 1,得到的是新的緩存佔用大小 newOccupied
mask_t capacity = cache->capacity();
  • 然後讀取現在緩存的容量 capacity

然後接下來是一系列的判斷:

if (cache->isConstantEmptyCache()) {
    // Cache is read-only. Replace it.
    cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
}
  • 如果緩存爲空了,那麼就重新申請一下內存並覆蓋之前的緩存,之所以這樣做是因爲緩存是隻讀的。
else if (newOccupied <= capacity / 4 * 3) {
        // Cache is less than 3/4 full. Use it as-is.
}
  • 如果新的緩存佔用大小 小於等於 緩存容量的四分之三,則可以進行緩存流程
else {
        // Cache is too full. Expand it.
        cache->expand();
}
  • 如果緩存不爲空,且緩存佔用大小已經超過了容量的四分之三,則需要進行擴容。
bucket_t *bucket = cache->find(key, receiver);
  • 通過前面生成的 key 在緩存中查找對應的 bucket_t,也就是對應的方法實現。
if (bucket->key() == 0) cache->incrementOccupied();
bucket->set(key, imp);
  • 判斷獲取到的 bucket 是否是新的桶,如果是的話,就在緩存裏面增加一個佔用大小。然後把 keyimp 放到桶裏面。

cache_fill_nolock 的基本流程我們分析完了,這個方法主要針對的是沒有緩存的情況。
但是這個方法裏面的 cache->find 我們並不知道是怎麼實現的,我們接着探索這個方法:

2.2 查找緩存策略

bucket_t * cache_t::find(cache_key_t k, id receiver)
{
    assert(k != 0);

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

    // hack
    Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
    cache_t::bad_cache(receiver, (SEL)k, cls);
}

find 方法我們乍一看會發現有一個 do-while 循環,因爲這個方法的作用是根據 key 查找 IMP,但需要注意的是,這裏返回的並不是一個 IMP,而是 bucket_t 結構體指針。

  • 通過 buckets() 方法獲取當前 cache_t 下所有的緩存
  • 通過 mask() 方法獲取當前 cache_t 的緩存大小減一的值 mask_t
  • 然後把 mask_t 的值作爲循環的索引。
  • do-while 循環裏遍歷整個 bucket_t,如果 key 爲 0,說明當前索引位置上還沒有緩存過方法,則需要停止循環,返回當前位置上的 bucket_t;如果 key 爲要查詢的 k,說明緩存命中了,則直接返回結果。
  • 這裏的循環遍歷是通過 cache_next 方法實現的,這個方法內部就是當前下標 imask_t 的值進行與操作,來實現索引更新的。

三、cache_t 探索後的疑問點

整個 cache_t 的工作流程,簡略描述如下:

  • 當前查找的 IMP 沒有被緩存,調用 cache_fill_nolock 方法進行填充緩存。
  • 當前查找的 IMP 已經被緩存了,然後判斷緩存容量是否已經達到 3/4 的臨界點
    • 如果已經到了臨界點,則需要進行擴容,擴容大小爲原來緩存大小的 2 倍。擴容後處於效率的考慮,會清空之前的內容,然後把當前要查找的 IMP 通過 cache_fill_nolock 方法緩存起來。
    • 如果沒有到臨界點,那麼直接返回找到的 IMP

我們梳理完 cache_t 的大致流程之後,我們還有一些遺留問題沒有解決,接下來一一來解決一下。

3.1 mask 的作用

我們先回顧一下 mask 出現在了哪些地方:

setBucketsAndMask(newBuckets, newCapacity - 1);

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    mega_barrier();

    _buckets = newBuckets;
    
    mega_barrier();
    
    _mask = newMask;
    _occupied = 0;
}

mask_t cache_t::capacity() 
{
    return mask() ? mask()+1 : 0; 
}

首先,mask 是作爲 cache_t 的屬性存在的,它代表的是緩存容量的大小減一的值。這一點在 setBucketsAndMaskcapacity 方法中可以得到證實。

cache_fill_nolock {
    cache_key_t key = getKey(sel);
    
    bucket_t *bucket = cache->find(key, receiver);
}

find { 

    // Class points to cache. SEL is key. Cache buckets store SEL+IMP.
    // Caches are never built in the dyld shared cache.
    static inline mask_t cache_hash(cache_key_t key, mask_t mask) 
    {
        return (mask_t)(key & mask);
    }
    
    static inline mask_t cache_next(mask_t i, mask_t mask) {
        return (i+1) & mask;
    }
}

根據上面的僞代碼,cache_fill_nolock 方法裏面,會先根據要查找的 sel 強轉成 cache_key_t 結構,這是因爲 sel 其實爲方法名:

而經過強轉之後爲:

也就是說最後緩存的 key 其實是一個無符號長整型值,這樣相對於直接拿字符串來作爲鍵值,明顯效率更高。

經過強轉之後,把 key 傳給 find 方法。然後會有一個 cache_hash 方法,其註釋如下:

類指向緩存,SEL 是鍵,buckets緩存存儲的是 SEL + IMP
方法緩存永遠不會存儲在 dyld 共享緩存裏面。

實際測試如上圖所示,cache_hash 方法其實就是哈希算法,得到的是一個哈希值。拿到這個哈希值後就可以在哈希表中進行查詢。在 find 方法中就是獲得索引的起始值。

通過上圖的測試我們可以得出這裏是使用的 LRU 緩存算法。

LRU 算法的全稱是 Least Recently Used ,也就是最近最少使用策略。這個策略的核心思想就是先淘汰最近最少使用的內容。

3.2 capacity 的變化

capacity 的變化主要發生在擴容的時候,當緩存已經佔滿了四分之三的時候,會進行兩倍原來緩存空間大小的擴容,這一步是爲了避免哈希衝突。

3.3 爲什麼是在 3/4 時進行擴容

在哈希這種數據結構裏面,有一個概念叫裝載因子,裝載因子是用來表示空位的多少。其公式爲:

散列表的裝載因子=填入表中的元素個數/散列表的長度

裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會下降。
蘋果這裏設計的裝載因子顯然爲 1 - 3/4 = 1/4 => 0.25
因爲本質上方法緩存就是爲了更快的執行效率,所以爲了避免發生哈希衝突,在採用開放尋址法的前提下,儘可能小的裝載因子可以提高散列表的性能。

/* Initial cache bucket count. INIT_CACHE_SIZE must be a power of two. */
enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};

cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);

初始化的緩存大小是 1 左移 2,結果爲 4。然後在 reallocate 方法進行一下緩存的重新開闢。這也就意味着初始的緩存空間大小爲 4。

3.4 方法緩存是否有序

方法緩存是無序的,這是因爲計算緩存下標是一個哈希算法:

static inline mask_t cache_hash(cache_key_t key, mask_t mask) 
{
    return (mask_t)(key & mask);
}

通過 cache_hash 之後計算出來的下標並不是有序的,下標值取決於 keymask 的值。

3.5 bucket 與 mask, capacity, sel, imp 的關係

一個類有一個屬性 cache_t,而一個 cache_tbuckets 會有多個 bucket。一個 bucket 存儲的是 impcache_key_t

mask 的值對於 bucket 來說,主要是用來在緩存查找時的哈希算法。
capacity 則可以獲取到 cache_tbucket 的數量。

sel 在緩存的時候是被強轉成了 cache_key_t 的形式,更方便查詢使用。
imp 則是函數指針,也就是方法的具體實現,緩存的主要目的就是通過一系列策略讓編譯器更快的執行消息發送的邏輯。

四、總結

  • OC 中實例方法緩存在類上面,類方法緩存在元類上面。
  • cache_t 緩存會提前進行擴容防止溢出。
  • 方法緩存是爲了最大化的提高程序的執行效率。
  • 蘋果在方法緩存這裏用的是開放尋址法來解決哈希衝突。
  • 通過 cache_t 我們可以進一步延伸去探究 objc_msgSend,因爲查找方法緩存是屬於 objc_msgSend 查找方法實現的快速流程。

我們下一篇將開始探索 iOS 中方法的底層原理,敬請期待~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章