iOS 底層探索系列算法
上一篇咱們一塊兒探索了 iOS
類的底層原理,其中比較重要的四個屬性咱們都簡單的過了一遍,咱們接下來要重點探索第三個屬性 cache_t
,對於這個屬性,咱們能夠學習到蘋果對於緩存的設計與理解,同時也會接觸到消息發送相關的知識。緩存
cache_t
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-first
和 SEL-first
。post
IMP-first is better for arm64e ptrauth and no worse for arm64.
SEL-first is better for armv7* and i386 and x86_64.
若是對 SEL
和 IMP
不是很熟悉的同窗能夠去 objc4-756
源碼中查看方法 method_t
的定義:性能
struct method_t { SEL name; // 方法選擇器 const char *types; // 方法類型字符串 MethodListIMP imp; // 方法實現 ...省略代碼... };
經過上面的源碼,咱們大體瞭解了 bucket_t 類型的結構,那麼如今問題來了,類中的 cache 是在何時以什麼樣的方式來進行緩存的呢?學習
LLDB
大法瞭解到 cache_t
和 bucket_t
的基本結構後,咱們能夠經過 LLDB
來打印驗證一下:測試
cache_t
內部的這三個屬性,咱們從其名稱不難看出 _occupied
應該是表示當前已經佔用了多少緩存,_mask
暫時不知道,_buckets
應該是存放具體緩存的地方。那麼爲了驗證咱們的猜測,咱們調用代碼來測試:ui
咱們發現,斷點斷到 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;
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
實質上是緩存了咱們類的實例方法,那麼對於類方法來講,天然就是緩存在了元類上了。這一點我相信讀者應該都能理解。
按照最常規的思惟,緩存內容最省時省力的辦法確定是來一個緩存一個,那麼咱們的 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
咱們並不知道是怎麼實現的,咱們接着探索這個方法:
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
的臨界點
IMP
經過 cache_fill_nolock
方法緩存起來。IMP
。咱們梳理完 cache_t
的大體流程以後,咱們還有一些遺留問題沒有解決,接下來一一來解決一下。
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
,也就是最近最少使用策略。這個策略的核心思想就是先淘汰最近最少使用的內容。
capacity
的變化capacity
的變化主要發生在擴容的時候,當緩存已經佔滿了四分之三的時候,會進行兩倍原來緩存空間大小的擴容,這一步是爲了不哈希衝突。
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。
方法緩存是無序的,這是由於計算緩存下標是一個哈希算法:
static inline mask_t cache_hash(cache_key_t key, mask_t mask) { return (mask_t)(key & mask); }
經過 cache_hash
以後計算出來的下標並非有序的,下標值取決於 key
和 mask
的值。
一個類有一個屬性 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
中方法的底層原理,敬請期待~