上一篇我們一起探索了 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-first
和 SEL-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。
如果對 SEL
和 IMP
不是很熟悉的同學可以去 objc4-756
源碼中查看方法 method_t
的定義:
struct method_t {
SEL name; // 方法選擇器
const char *types; // 方法類型字符串
MethodListIMP imp; // 方法實現
...省略代碼...
};
通過上面的源碼,我們大致瞭解了 bucket_t 類型的結構,那麼現在問題來了,類中的 cache 是在什麼時候以什麼樣的方式來進行緩存的呢?
1.2 LLDB
大法
瞭解到 cache_t
和 bucket_t
的基本結構後,我們可以通過 LLDB
來打印驗證一下:
cache_t
內部的這三個屬性,我們從其名稱不難看出 _occupied
應該是表示當前已經佔用了多少緩存,_mask
暫時不知道,_buckets
應該是存放具體緩存的地方。那麼爲了驗證我們的猜想,我們調用代碼來測試:
我們發現,斷點斷到 45 行的時候,_ocuupied
的值爲 1,我們打印一下 _buckets
裏面的內容看看:
我們可以看到,打印到 _buckets
的第三個元素的時候,我們的 init
方法被緩存了,也就是說 _ocuupied
確實是表示當前被緩存方法的個數。這裏可能讀者會說爲什麼 alloc
和 class
爲什麼沒有被緩存呢?其實這是因爲 alloc
和 class
是類方法,而根據我們前面探索類底層原理的時候,類方法是存儲在元類裏面的,所以這裏類的緩存裏面只會存儲對象方法。
我們接着把斷點過到 46 行:
_ocuupied
的值果然發生了變化,我們剛纔的猜想進一步得到了驗證,我們再往下面走一行:
此時 _ocuupied
值已經爲 3 了,我們回顧一下當前緩存裏面緩存的方法:
_ocuupied 的值 | 緩存的方法 |
---|---|
1 | NSObject下的init |
2 | NSObject下的init ,person下的 sayHello |
3 | NSObject下的init ,person下的 sayHello , person下的 sayCode |
那麼,當我們的斷點斷到下一行的時候,是不是 _ocuupied
就會變爲 4 呢? 我們接着往下走:
令人驚奇的事情發生了,_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
是否是新的桶,如果是的話,就在緩存裏面增加一個佔用大小。然後把key
和imp
放到桶裏面。
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
方法實現的,這個方法內部就是當前下標i
與mask_t
的值進行與操作,來實現索引更新的。
三、cache_t
探索後的疑問點
整個 cache_t
的工作流程,簡略描述如下:
- 當前查找的
IMP
沒有被緩存,調用cache_fill_nolock
方法進行填充緩存。 - 當前查找的
IMP
已經被緩存了,然後判斷緩存容量是否已經達到3/4
的臨界點- 如果已經到了臨界點,則需要進行擴容,擴容大小爲原來緩存大小的 2 倍。擴容後處於效率的考慮,會清空之前的內容,然後把當前要查找的
IMP
通過cache_fill_nolock
方法緩存起來。 - 如果沒有到臨界點,那麼直接返回找到的
IMP
。
- 如果已經到了臨界點,則需要進行擴容,擴容大小爲原來緩存大小的 2 倍。擴容後處於效率的考慮,會清空之前的內容,然後把當前要查找的
我們梳理完 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
的屬性存在的,它代表的是緩存容量的大小減一的值。這一點在 setBucketsAndMask
與 capacity
方法中可以得到證實。
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
之後計算出來的下標並不是有序的,下標值取決於 key
和 mask
的值。
3.5 bucket 與 mask, capacity, sel, imp 的關係
一個類有一個屬性 cache_t
,而一個 cache_t
的 buckets
會有多個 bucket
。一個 bucket
存儲的是 imp
和 cache_key_t
。
mask
的值對於 bucket
來說,主要是用來在緩存查找時的哈希算法。
而 capacity
則可以獲取到 cache_t
中 bucket
的數量。
sel
在緩存的時候是被強轉成了 cache_key_t
的形式,更方便查詢使用。
imp
則是函數指針,也就是方法的具體實現,緩存的主要目的就是通過一系列策略讓編譯器更快的執行消息發送的邏輯。
四、總結
OC
中實例方法緩存在類上面,類方法緩存在元類上面。cache_t
緩存會提前進行擴容防止溢出。- 方法緩存是爲了最大化的提高程序的執行效率。
- 蘋果在方法緩存這裏用的是
開放尋址法
來解決哈希衝突。 - 通過
cache_t
我們可以進一步延伸去探究objc_msgSend
,因爲查找方法緩存是屬於objc_msgSend
查找方法實現的快速流程。
我們下一篇將開始探索 iOS
中方法的底層原理,敬請期待~