iOS 底層探索 - cache_t

iOS 底層探索系列算法

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

1、探索 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 的定義:less

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-firstpost

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 應該是存放具體緩存的地方。那麼爲了驗證咱們的猜測,咱們調用代碼來測試:ui

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

2、深刻 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 的值進行與操做,來實現索引更新的。

3、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 則是函數指針,也就是方法的具體實現,緩存的主要目的就是經過一系列策略讓編譯器更快的執行消息發送的邏輯。

4、總結

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

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

相關文章
相關標籤/搜索