iOS 底層探索系列
上一篇我們一起探索了 iOS
類(lèi)的底層原理,其中比較重要的四個(gè)屬性我們都簡(jiǎn)單的過(guò)了一遍,我們接下來(lái)要重點(diǎn)探索第三個(gè)屬性 cache_t
,對(duì)于這個(gè)屬性,我們可以學(xué)習(xí)到蘋(píng)果對(duì)于緩存的設(shè)計(jì)與理解,同時(shí)也會(huì)接觸到消息發(fā)送相關(guān)的知識(shí)。
一、探索 cache_t
1.1 cache_t
基本結(jié)構(gòu)
我們還是先過(guò)一遍 OC
中類(lèi)的結(jié)構(gòu):
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;
...省略代碼...
}
然后我們發(fā)現(xiàn) cache_t
結(jié)構(gòu)體的第一個(gè)成員 _buckets
也是一個(gè)結(jié)構(gòu)體類(lèi)型 bucket_t
,我們?cè)俨榭匆幌?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
其實(shí)緩存的是方法實(shí)現(xiàn) IMP
。這里有一個(gè)注意點(diǎn),就是 IMP-first
和 SEL-first
。
IMP-first is better for arm64e ptrauth and no worse for arm64.
- IMP-first 對(duì) arm64e 的效果更好,對(duì) arm64 不會(huì)有壞的影響。
SEL-first is better for armv7* and i386 and x86_64.
- SEL-first 適用于 armv7 * 和 i386 和 x86_64。
如果對(duì) SEL
和 IMP
不是很熟悉的同學(xué)可以去 objc4-756
源碼中查看方法 method_t
的定義:
struct method_t {
SEL name; // 方法選擇器
const char *types; // 方法類(lèi)型字符串
MethodListIMP imp; // 方法實(shí)現(xiàn)
...省略代碼...
};
通過(guò)上面的源碼,我們大致了解了 bucket_t 類(lèi)型的結(jié)構(gòu),那么現(xiàn)在問(wèn)題來(lái)了,類(lèi)中的 cache 是在什么時(shí)候以什么樣的方式來(lái)進(jìn)行緩存的呢?
1.2 LLDB
大法
了解到 cache_t
和 bucket_t
的基本結(jié)構(gòu)后,我們可以通過(guò) LLDB
來(lái)打印驗(yàn)證一下:
cache_t
內(nèi)部的這三個(gè)屬性,我們從其名稱(chēng)不難看出 _occupied
應(yīng)該是表示當(dāng)前已經(jīng)占用了多少緩存,_mask
暫時(shí)不知道,_buckets
應(yīng)該是存放具體緩存的地方。那么為了驗(yàn)證我們的猜想,我們調(diào)用代碼來(lái)測(cè)試:
我們發(fā)現(xiàn),斷點(diǎn)斷到 45 行的時(shí)候,_ocuupied
的值為 1,我們打印一下 _buckets
里面的內(nèi)容看看:
我們可以看到,打印到 _buckets
的第三個(gè)元素的時(shí)候,我們的 init
方法被緩存了,也就是說(shuō) _ocuupied
確實(shí)是表示當(dāng)前被緩存方法的個(gè)數(shù)。這里可能讀者會(huì)說(shuō)為什么 alloc
和 class
為什么沒(méi)有被緩存呢?其實(shí)這是因?yàn)?alloc
和 class
是類(lèi)方法,而根據(jù)我們前面探索類(lèi)底層原理的時(shí)候,類(lèi)方法是存儲(chǔ)在元類(lèi)里面的,所以這里類(lèi)的緩存里面只會(huì)存儲(chǔ)對(duì)象方法。
我們接著把斷點(diǎn)過(guò)到 46 行:
_ocuupied
的值果然發(fā)生了變化,我們剛才的猜想進(jìn)一步得到了驗(yàn)證,我們?cè)偻旅孀咭恍?
此時(shí) _ocuupied
值已經(jīng)為 3 了,我們回顧一下當(dāng)前緩存里面緩存的方法:
_ocuupied 的值 | 緩存的方法 |
---|---|
1 | NSObject下的init
|
2 | NSObject下的init ,person下的 sayHello
|
3 | NSObject下的init ,person下的 sayHello , person下的 sayCode
|
那么,當(dāng)我們的斷點(diǎn)斷到下一行的時(shí)候,是不是 _ocuupied
就會(huì)變?yōu)?4 呢? 我們接著往下走:
令人驚奇的事情發(fā)生了,_ocuupied
的值變成了 1,而 _mask
變成了 7。這是為什么呢?
如果讀者了解并掌握散列表這種數(shù)據(jù)結(jié)構(gòu)的話(huà),相信已經(jīng)看出端倪了。是的,這里其實(shí)就是用到了 開(kāi)放尋址法 來(lái)解決散列沖突(哈希沖突)。
關(guān)于哈希沖突,可以借助鴿籠理論,即把 11 只鴿子放進(jìn) 10 個(gè)抽屜里面,肯定會(huì)有一個(gè)抽屜里面有 2 只鴿子。是不是理解起來(lái)很簡(jiǎn)單? :)
通過(guò)上面的測(cè)試,我們明確了方法緩存使用的是哈希表存儲(chǔ),并且為了解決無(wú)法避免的哈希沖突使用的是開(kāi)放尋址法,而開(kāi)放尋址法必然要在合適的時(shí)機(jī)進(jìn)行擴(kuò)容,這個(gè)時(shí)機(jī)肯定不是會(huì)在數(shù)據(jù)已經(jīng)裝滿(mǎn)的時(shí)候,我們可以進(jìn)源碼探索一下,我們快速定位到 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
方法就是擴(kuò)容的核心算法,我們梳理一下里面的邏輯:
cacheUpdateLock.assertLocked();
- 緩存鎖斷言一下判斷當(dāng)前執(zhí)行上下文是否已經(jīng)上鎖
uint32_t oldCapacity = capacity();
- 通過(guò)
capacity()
方法獲取當(dāng)前的容量大小
uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;
- 判斷當(dāng)前的容量大小,如果為0,則賦值為
INIT_CACHE_SIZE
,而根據(jù)
enum {
INIT_CACHE_SIZE_LOG2 = 2,
INIT_CACHE_SIZE = (1 << INIT_CACHE_SIZE_LOG2)
};
可知 INIT_CACHE_SIZE
初始值為 4;如果當(dāng)前容量大小不為 0,則直接翻倍。
到了這里相信聰明的讀者根據(jù)我們上面的測(cè)試應(yīng)該猜到了,我們的 _mask
其實(shí)就是容量大小減 1 后的結(jié)果。
reallocate(oldCapacity, newCapacity);
- 最后調(diào)用
reallocate
方法進(jìn)行緩存大小的重置
我們接著進(jìn)入 reallocate
內(nèi)部一探究竟:
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 的。
同樣的,我們還可以通過(guò) capacity
方法來(lái)驗(yàn)證
mask_t cache_t::capacity()
{
return mask() ? mask()+1 : 0;
}
二、深入 cache_t
其實(shí)我們?cè)谔剿?iOS
底層的時(shí)候,盡量不要站在上帝視角去審視相應(yīng)的技術(shù)點(diǎn),我們可以盡量給自己多拋出幾個(gè)問(wèn)題,然后嘗試去解決每個(gè)問(wèn)題,通過(guò)這樣的探索,對(duì)提高我們閱讀源碼的能力十分重要。
通過(guò)前面的探索,我們知道了 cache_t
實(shí)質(zhì)上是緩存了我們類(lèi)的實(shí)例方法,那么對(duì)于類(lèi)方法來(lái)說(shuō),自然就是緩存在了元類(lèi)上了。這一點(diǎn)我相信讀者應(yīng)該都能理解。
2.1 方法緩存策略
按照最常規(guī)的思維,緩存內(nèi)容最省時(shí)省力的辦法肯定是來(lái)一個(gè)緩存一個(gè),那么我們的 cache_t
是不是這么做的呢,實(shí)踐出真知,我們一試便知。
我們?cè)谠创a中搜索 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
方法乍一看有些復(fù)雜,我們不妨將它分解一下:
第一行代碼還是加鎖的判斷,我們直接略過(guò),來(lái)到第二行:
if (cache_getImp(cls, sel)) return;
- 通過(guò)
cache_getImp
來(lái)判斷當(dāng)前cls
下的sel
是否已經(jīng)被緩存了,如果是,直接返回。而cache_getImp
底層實(shí)現(xiàn)是_cache_getImp
,并且是在匯編層實(shí)現(xiàn)的。
cache_t *cache = getCache(cls);
cache_key_t key = getKey(sel);
- 調(diào)用
getCache
來(lái)獲取cls
的方法緩存,然后通過(guò)getKey
來(lái)獲取到緩存的key
,這里的getKey
其實(shí)是將SEL
類(lèi)型強(qiáng)轉(zhuǎn)成cache_key_t
類(lèi)型。
mask_t newOccupied = cache->occupied() + 1;
- 在
cache
已經(jīng)占用的基礎(chǔ)上進(jìn)行加 1,得到的是新的緩存占用大小newOccupied
。
mask_t capacity = cache->capacity();
- 然后讀取現(xiàn)在緩存的容量
capacity
。
然后接下來(lái)是一系列的判斷:
if (cache->isConstantEmptyCache()) {
// Cache is read-only. Replace it.
cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
}
- 如果緩存為空了,那么就重新申請(qǐng)一下內(nèi)存并覆蓋之前的緩存,之所以這樣做是因?yàn)榫彺媸侵蛔x的。
else if (newOccupied <= capacity / 4 * 3) {
// Cache is less than 3/4 full. Use it as-is.
}
- 如果新的緩存占用大小
小于等于
緩存容量的四分之三,則可以進(jìn)行緩存流程
else {
// Cache is too full. Expand it.
cache->expand();
}
- 如果緩存不為空,且緩存占用大小已經(jīng)超過(guò)了容量的四分之三,則需要進(jìn)行擴(kuò)容。
bucket_t *bucket = cache->find(key, receiver);
- 通過(guò)前面生成的
key
在緩存中查找對(duì)應(yīng)的bucket_t
,也就是對(duì)應(yīng)的方法實(shí)現(xiàn)。
if (bucket->key() == 0) cache->incrementOccupied();
bucket->set(key, imp);
- 判斷獲取到的桶
bucket
是否是新的桶,如果是的話(huà),就在緩存里面增加一個(gè)占用大小。然后把key
和imp
放到桶里面。
cache_fill_nolock
的基本流程我們分析完了,這個(gè)方法主要針對(duì)的是沒(méi)有緩存的情況。<br />但是這個(gè)方法里面的 cache->find
我們并不知道是怎么實(shí)現(xiàn)的,我們接著探索這個(gè)方法:
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
方法我們乍一看會(huì)發(fā)現(xiàn)有一個(gè) do-while
循環(huán),因?yàn)檫@個(gè)方法的作用是根據(jù) key
查找 IMP
,但需要注意的是,這里返回的并不是一個(gè) IMP
,而是 bucket_t
結(jié)構(gòu)體指針。
- 通過(guò)
buckets()
方法獲取當(dāng)前cache_t
下所有的緩存桶
。 - 通過(guò)
mask()
方法獲取當(dāng)前cache_t
的緩存大小減一的值mask_t
。 - 然后把
mask_t
的值作為循環(huán)的索引。 - 在
do-while
循環(huán)里遍歷整個(gè)bucket_t
,如果key
為 0,說(shuō)明當(dāng)前索引位置上還沒(méi)有緩存過(guò)方法,則需要停止循環(huán),返回當(dāng)前位置上的bucket_t
;如果key
為要查詢(xún)的k
,說(shuō)明緩存命中了,則直接返回結(jié)果。 - 這里的循環(huán)遍歷是通過(guò)
cache_next
方法實(shí)現(xiàn)的,這個(gè)方法內(nèi)部就是當(dāng)前下標(biāo)i
與mask_t
的值進(jìn)行與操作,來(lái)實(shí)現(xiàn)索引更新的。
三、cache_t
探索后的疑問(wèn)點(diǎn)
整個(gè) cache_t
的工作流程,簡(jiǎn)略描述如下:
- 當(dāng)前查找的
IMP
沒(méi)有被緩存,調(diào)用cache_fill_nolock
方法進(jìn)行填充緩存。 - 當(dāng)前查找的
IMP
已經(jīng)被緩存了,然后判斷緩存容量是否已經(jīng)達(dá)到3/4
的臨界點(diǎn)- 如果已經(jīng)到了臨界點(diǎn),則需要進(jìn)行擴(kuò)容,擴(kuò)容大小為原來(lái)緩存大小的 2 倍。擴(kuò)容后處于效率的考慮,會(huì)清空之前的內(nèi)容,然后把當(dāng)前要查找的
IMP
通過(guò)cache_fill_nolock
方法緩存起來(lái)。 - 如果沒(méi)有到臨界點(diǎn),那么直接返回找到的
IMP
。
- 如果已經(jīng)到了臨界點(diǎn),則需要進(jìn)行擴(kuò)容,擴(kuò)容大小為原來(lái)緩存大小的 2 倍。擴(kuò)容后處于效率的考慮,會(huì)清空之前的內(nèi)容,然后把當(dāng)前要查找的
我們梳理完 cache_t
的大致流程之后,我們還有一些遺留問(wèn)題沒(méi)有解決,接下來(lái)一一來(lái)解決一下。
3.1 mask
的作用
我們先回顧一下 mask
出現(xiàn)在了哪些地方:
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
的屬性存在的,它代表的是緩存容量的大小減一的值。這一點(diǎn)在 setBucketsAndMask
與 capacity
方法中可以得到證實(shí)。
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;
}
}
根據(jù)上面的偽代碼,cache_fill_nolock
方法里面,會(huì)先根據(jù)要查找的 sel
強(qiáng)轉(zhuǎn)成 cache_key_t
結(jié)構(gòu),這是因?yàn)?sel
其實(shí)為方法名:
而經(jīng)過(guò)強(qiáng)轉(zhuǎn)之后為:
也就是說(shuō)最后緩存的 key
其實(shí)是一個(gè)無(wú)符號(hào)長(zhǎng)整型值,這樣相對(duì)于直接拿字符串來(lái)作為鍵值,明顯效率更高。
經(jīng)過(guò)強(qiáng)轉(zhuǎn)之后,把 key
傳給 find
方法。然后會(huì)有一個(gè) cache_hash
方法,其注釋如下:
類(lèi)指向緩存,
SEL
是鍵,buckets
緩存存儲(chǔ)的是SEL
+IMP
。
方法緩存永遠(yuǎn)不會(huì)存儲(chǔ)在dyld
共享緩存里面。
實(shí)際測(cè)試如上圖所示,cache_hash
方法其實(shí)就是哈希算法,得到的是一個(gè)哈希值。拿到這個(gè)哈希值后就可以在哈希表中進(jìn)行查詢(xún)。在 find
方法中就是獲得索引的起始值。
通過(guò)上圖的測(cè)試我們可以得出這里是使用的 LRU
緩存算法。
LRU
算法的全稱(chēng)是Least Recently Used
,也就是最近最少使用策略。這個(gè)策略的核心思想就是先淘汰最近最少使用的內(nèi)容。
3.2 capacity
的變化
capacity
的變化主要發(fā)生在擴(kuò)容的時(shí)候,當(dāng)緩存已經(jīng)占滿(mǎn)了四分之三的時(shí)候,會(huì)進(jìn)行兩倍原來(lái)緩存空間大小的擴(kuò)容,這一步是為了避免哈希沖突。
3.3 為什么是在 3/4
時(shí)進(jìn)行擴(kuò)容
在哈希這種數(shù)據(jù)結(jié)構(gòu)里面,有一個(gè)概念叫裝載因子,裝載因子是用來(lái)表示空位的多少。其公式為:
散列表的裝載因子=填入表中的元素個(gè)數(shù)/散列表的長(zhǎng)度
裝載因子越大,說(shuō)明空閑位置越少,沖突越多,散列表的性能會(huì)下降。
蘋(píng)果這里設(shè)計(jì)的裝載因子顯然為 1 - 3/4 = 1/4 => 0.25 。
因?yàn)楸举|(zhì)上方法緩存就是為了更快的執(zhí)行效率,所以為了避免發(fā)生哈希沖突,在采用開(kāi)放尋址法
的前提下,盡可能小的裝載因子可以提高散列表的性能。
/* 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,結(jié)果為 4。然后在 reallocate
方法進(jìn)行一下緩存的重新開(kāi)辟。這也就意味著初始的緩存空間大小為 4。
3.4 方法緩存是否有序
方法緩存是無(wú)序的,這是因?yàn)橛?jì)算緩存下標(biāo)是一個(gè)哈希算法:
static inline mask_t cache_hash(cache_key_t key, mask_t mask)
{
return (mask_t)(key & mask);
}
通過(guò) cache_hash
之后計(jì)算出來(lái)的下標(biāo)并不是有序的,下標(biāo)值取決于 key
和 mask
的值。
3.5 bucket 與 mask, capacity, sel, imp 的關(guān)系
一個(gè)類(lèi)有一個(gè)屬性 cache_t
,而一個(gè) cache_t
的 buckets
會(huì)有多個(gè) bucket
。一個(gè) bucket
存儲(chǔ)的是 imp
和 cache_key_t
。
mask
的值對(duì)于 bucket
來(lái)說(shuō),主要是用來(lái)在緩存查找時(shí)的哈希算法。
而 capacity
則可以獲取到 cache_t
中 bucket
的數(shù)量。
sel
在緩存的時(shí)候是被強(qiáng)轉(zhuǎn)成了 cache_key_t
的形式,更方便查詢(xún)使用。
imp
則是函數(shù)指針,也就是方法的具體實(shí)現(xiàn),緩存的主要目的就是通過(guò)一系列策略讓編譯器更快的執(zhí)行消息發(fā)送的邏輯。
四、總結(jié)
-
OC
中實(shí)例方法緩存在類(lèi)上面,類(lèi)方法緩存在元類(lèi)上面。 -
cache_t
緩存會(huì)提前進(jìn)行擴(kuò)容防止溢出。 - 方法緩存是為了最大化的提高程序的執(zhí)行效率。
- 蘋(píng)果在方法緩存這里用的是
開(kāi)放尋址法
來(lái)解決哈希沖突。 - 通過(guò)
cache_t
我們可以進(jìn)一步延伸去探究objc_msgSend
,因?yàn)椴檎曳椒ň彺媸菍儆?objc_msgSend
查找方法實(shí)現(xiàn)的快速流程。
我們下一篇將開(kāi)始探索 iOS
中方法的底層原理,敬請(qǐng)期待~