想要成爲一名
iOS開發高手
,免不了閱讀源碼。如下是筆者在OC源碼探索
中梳理的一個小系列——類與對象篇,歡迎你們閱讀指正,同時也但願對你們有所幫助。html
本文是針對 方法緩存——cache_t
的分析(且源碼版本是 objc4-756.2),下面進入正文。git
cache_t
源碼分析當你的OC
項目編譯完成後,類的實例方法(方法編號SEL
和 函數地址IMP
)就保存在類的方法列表中。咱們知道 OC
爲了實現其動態性,將 方法的調用包裝成了 SEL
尋找 IMP
的過程。試想一下,若是每次調用方法,都要去類的方法列表(甚至父類、根類的方法列表)中查詢其函數地址,勢必會對性能形成極大的損耗。爲了解決這一問題,OC
採用了方法緩存的機制來提升調用效率,也就是cache_t
,其做用就是緩存已調用的方法。當調用方法時,objc_msgSend
會先去緩存中查找,若是找到就執行該方法;若是不在緩存中,則去類的方法列表(包括父類、根類的方法列表)查找,找到後會將方法的SEL
和IMP
緩存到cache_t
中,以便下次調用時可以快速執行。github
cache_t
結構首先看一下cache_t
的結構數組
struct cache_t {
struct bucket_t *_buckets; // 緩存數組,即哈希桶
mask_t _mask; // 緩存數組的容量臨界值
mask_t _occupied; // 緩存數組中已緩存方法數量
... // 一些函數
};
#if __LP64__
typedef uint32_t mask_t;
#else
typedef uint16_t mask_t;
#endif
struct bucket_t {
private:
#if __arm64__
uintptr_t _imp;
SEL _sel;
#else
SEL _sel;
uintptr_t _imp;
#endif
... // 一些方法
};
複製代碼
從上面源碼不難看出,在64
位CPU架構下,cache_t
長度是16字節。單從結構來看,方法是緩存在bucket_t
(又稱哈希桶)中,接下來用個例子驗證一下cache_t
是否緩存了已調用的方法。緩存
Person
類,代碼以下@interface Person : NSObject
- (void)methodFirst;
- (void)methodSecond;
- (void)methodThird;
@end
@implementation Person
- (void)methodFirst {
NSLog(@"%s", __FUNCTION__);
}
- (void)methodSecond {
NSLog(@"%s", __FUNCTION__);
}
- (void)methodThird {
NSLog(@"%s", __FUNCTION__);
}
@end
複製代碼
cache_t
在方法調用前打個斷點,看看cache_t
的緩存狀況安全
說明:bash
objc_class
結構很容易推導得出,0x1000011d8
是cache_t
首地址。(對類的結構感興趣的同窗請戳 OC源碼分析之類的結構解讀)_mask
和_occupied
都是0cache_t
執行alloc
和init
這兩個方法後,cache_t
變化以下多線程
從上圖可知,調用init
後,_mask
的值是3,_occupied
則是1。_buckets
指針的值(數組首地址)發生了變化(從0x1003db250
變成0x101700090
),同時緩存了init
方法的SEL
和IMP
。架構
思考: 1. alloc 方法調用後,緩存在哪裏? 2. 爲何 init 方法不在 _buckets 第一個位置? app
繼續執行methodFirst
,再看cache_t
此時,_mask
的值是3(沒發生變化),_occupied
則變成了2,_buckets
指針地址沒變,增長緩存了methodFirst
方法的SEL
和IMP
。
接着是執行methodSecond
,且看
顯然,_occupied
變成了3,而_buckets
指針地址不改變,同時新增methodSecond
的方法緩存。
最後執行methodThird
後,再看cache_t
變化
此次的結果就徹底不一樣了。_mask
的值變成7,_occupied
則從新變成了1,而_buckets
不只首地址變了,以前緩存的init
、methodFirst
和methodSecond
方法也沒了,僅存在的只有新增的methodThird
方法。看來,cache_t
並不是是如咱們所願的那樣——調用一個方法就緩存一個方法。
思考:以前緩存的方法(init、methodFirst 和 methodSecond)哪去了?
cache_t
小結讓咱們梳理一下上面的例子。在依次執行Person
的實例方法init
、methodFirst
、methodSecond
、methodThird
後,cache_t
變化以下
調用的方法 | _buckets | _mask | _occupied |
---|---|---|---|
未調用方法 | 空 | 0 | 0 |
init | init | 3 | 1 |
init、methodFirst | init、methodFirst | 3 | 2 |
init、methodFirst、methodSecond | init、methodFirst、methodSecond | 3 | 3 |
init、methodFirst、methodSecond、methodThird | methodThird | 7 | 1 |
可見,cache_t
的確能實時緩存已調用的方法。
上面的驗證過程也能夠幫助咱們理解cache_t
三個成員變量的意義。直接從單詞含義上解析,bucket
可譯爲桶(即哈希桶),用於裝方法;occupied
可譯爲已佔有,表示已緩存的方法數量;mask
可譯爲面具、掩飾物,乍看無頭緒,可是注意到cache_t
中有獲取容量的函數(capacity
),其源碼以下
struct cache_t {
...
mask_t mask();
mask_t capacity();
...
}
mask_t cache_t::mask()
{
return _mask;
}
mask_t cache_t::capacity()
{
return mask() ? mask()+1 : 0;
}
複製代碼
由此能夠得出,若是_mask
是0,說明未調用實例方法,即桶的容量爲0;當_mask
不等於0的時候,意味着已經調用過實例方法,此時桶的容量爲_mask + 1
。故,_mask
從側面反映了桶的容量。
cache_t
的方法緩存原理接下來,筆者將從方法的調用過程開始分析cache_t
的方法緩存原理。
cache_fill
OC
方法的本質是 消息發送(即objc_msgSend
),底層是經過方法的 SEL
查找 IMP
。調用方法時,objc_msgSend
會去cache_t
中查詢方法的函數實現(這部分是由彙編代碼實現的,很是高效),在緩存中找的過程暫且不表;當緩存中沒有的時候,則去類的方法列表中查找,直至找到後,再調用cache_fill
,目的是爲了將方法緩存到cache_t
中,其源碼以下
void cache_fill(Class cls, SEL sel, IMP imp, id receiver) {
#if !DEBUG_TASK_THREADS
mutex_locker_t lock(cacheUpdateLock);
cache_fill_nolock(cls, sel, imp, receiver);
#else
_collecting_in_critical();
return;
#endif
}
複製代碼
objc_msgSend
的具體流程筆者將另起一文分析,這裏不做贅述。
cache_fill_nolock
cache_fill
又會來到cache_fill_nolock
,這個函數的做用是將方法的SEL
和IMP
寫入_buckets
,同時更新_mask
和_occupied
。
其源碼以及詳細分析以下:
static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) {
cacheUpdateLock.assertLocked();
// 若是類未初始化
if (!cls->isInitialized()) return;
// 在獲取cacheUpdateLock以前,確保其餘線程沒有將該方法寫入緩存
if (cache_getImp(cls, sel)) return;
// 獲取 cls 的 cache_t指針
cache_t *cache = getCache(cls);
// newOccupied爲新的方法緩存數,等於 當前方法緩存數+1
mask_t newOccupied = cache->occupied() + 1;
// 獲取當前cache_t的總容量,即 mask+1
mask_t capacity = cache->capacity();
if (cache->isConstantEmptyCache()) {
// 當第一次調用類的實例方法時(如本文的【1.2】例中的`init`)
cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
}
else if (newOccupied <= capacity / 4 * 3) {
// 新的方法緩存數 不大於 總容量的3/4,按原樣使用,無需擴容
}
else {
// 新的方法緩存數 大於 總容量的3/4,須要擴容
cache->expand();
}
// 根據sel獲取bucket,此bucket的sel通常爲0(說明這個位置還沒緩存方法),
// 也可能與實參sel相等(hash衝突,可能性很低)
bucket_t *bucket = cache->find(sel, receiver);
// 當且僅當bucket的sel爲0時,執行_occupied++
if (bucket->sel() == 0) cache->incrementOccupied();
// 更新bucket的sel和imp
bucket->set<Atomic>(sel, imp);
}
// INIT_CACHE_SIZE 即爲4
enum {
INIT_CACHE_SIZE_LOG2 = 2,
INIT_CACHE_SIZE = (1 << INIT_CACHE_SIZE_LOG2)
};
複製代碼
從上面的源碼不難看出,cache_fill_nolock
主要是cache_t
緩存方法的調度中心,在這裏會
_buckets
的哪種緩存策略(初始化後緩存、直接緩存、擴容後緩存,三者取一);sel
找到一個bucket
,並更新這個bucket
的sel
和imp
。(若是這個bucket
的sel
爲0,說明是個空桶,正好能夠緩存方法,因而執行_occupied++
)。思考:爲何擴容臨界點是 3/4?
reallocate
在下面這兩種狀況下會執行reallocate
:
_buckets
的時候_buckets
擴容的時候咱們來看一下reallocate
作了哪些事情
void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
// 當且僅當`_buckets`中有緩存方法時,feeOld爲true
bool freeOld = canBeFreed();
// 獲取當前buckets指針,即_buckets
bucket_t *oldBuckets = buckets();
// 開闢新的buckets指針
bucket_t *newBuckets = allocateBuckets(newCapacity);
// Cache's old contents are not propagated.
// This is thought to save cache memory at the cost of extra cache fills.
// fixme re-measure this
assert(newCapacity > 0);
assert((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1);
// 將新buckets、新mask(newCapacity-1)分別賦值跟當前的 _buckets 和 _mask
setBucketsAndMask(newBuckets, newCapacity - 1);
if (freeOld) {
// 釋放舊的buckets內存空間
cache_collect_free(oldBuckets, oldCapacity);
cache_collect(false);
}
}
複製代碼
reallocate
完美解釋了在例【1.2】中的幾個狀況:
init
執行完後,_buckets
指針地址變了,_mask
變成了3;methodThird
執行完後,_buckets
不只指針地址變了,同時以前緩存的init
、methodFirst
和methodSecond
方法也都不在了注意,_occupied
的變化是在回到cache_fill_nolock
後發生的。
思考:擴容後,爲何不直接把以前緩存的方法加入新的buckets中?
expand
從cache_fill_nolock
源碼來看,當新的方法緩存數(_occupied+1)大於總容量(_mask+1)時,會對_buckets
進行擴容,也就是執行expand
函數,其源碼以下
void cache_t::expand()
{
cacheUpdateLock.assertLocked();
// 獲取當前總容量,即_mask+1
uint32_t oldCapacity = capacity();
// 新的容量 = 舊容量 * 2
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);
}
複製代碼
這個函數很是簡單,僅僅是計算好新的容量後,就去調用reallocate
函數。須要注意的是:
uint32_t
大小(4字節)時,每次擴容爲原來的2倍uint32_t
,則從新申請跟原來同樣大小的buckets
find
在執行完相應的buckets
策略後,接下來就須要找到合適的位置(bucket
),以存儲 方法的SEL
和IMP
。find
具體作的事情就是根據方法的SEL
,返回一個符合要求的bucket
,一樣上源碼
bucket_t * cache_t::find(SEL s, id receiver)
{
assert(s != 0);
// 獲取當前buckets,即_buckets
bucket_t *b = buckets();
// 獲取當前mask,即_mask
mask_t m = mask();
// 由 sel & mask 得出起始索引值
mask_t begin = cache_hash(s, m);
mask_t i = begin;
do {
// sel爲0:說明 i 這個位置還沒有緩存方法;
// sel等於s:命中緩存,說明 i 這個位置已緩存方法,多是hash衝突
if (b[i].sel() == 0 || b[i].sel() == s) {
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)s, cls);
}
static inline mask_t cache_hash(SEL sel, mask_t mask) {
return (mask_t)(uintptr_t)sel & mask;
}
#if __arm__ || __x86_64__ || __i386__
// objc_msgSend has few registers available.
// Cache scan increments and wraps at special end-marking bucket.
#define CACHE_END_MARKER 1
static inline mask_t cache_next(mask_t i, mask_t mask) {
return (i+1) & mask;
}
#elif __arm64__
// objc_msgSend has lots of registers available.
// Cache scan decrements. No end marker needed.
#define CACHE_END_MARKER 0
static inline mask_t cache_next(mask_t i, mask_t mask) {
return i ? i-1 : mask;
}
#else
#error unknown architecture
#endif
複製代碼
從源碼能夠發現,find
找bucket
的方式用到了hash
的思想:以_buckets
做爲哈希桶,以cache_hash
做爲哈希函數,進行哈希運算後得出索引值index
(本質是xx & mask
,因此index
最大值就是_mask
的值)。因爲索引值是經過哈希運算
得出的,其結果天然是無序的,這也是爲何上例中init
方法不在_buckets
第一個位置的緣由。
既然哈希桶的數量是在運行時動態增長的,那麼在多線程環境下調用方法時,對方法的緩存有沒有什麼影響呢?且看下面的分析。
在整個objc_msgSend
函數中,爲了達到最佳的性能,對方法緩存的讀取操做是沒有添加任何鎖的。而多個線程同時調用已緩存的方法,並不會引起_buckets
和_mask
的變化,所以多個線程同時讀取方法緩存的操做是不會有安全隱患的。
從源碼咱們知道在桶數量擴容和寫桶數據以前,系統使用了一個全局的互斥鎖(cacheUpdateLock.assertLocked()
)來保證寫入的同步處理,而且在鎖住的範圍內部還作了一次查緩存的操做(if (cache_getImp(cls, sel)) return;
),這樣就 保證了哪怕多個線程同時寫同一個方法的緩存也只會產生寫一次的效果,即多線程同時寫緩存的操做也不會有安全隱患。
這個狀況就比較複雜了,咱們先看一下objc_msgSend
讀緩存的代碼(以 arm64架構彙編 爲例)
.macro CacheLookup
// x1 = SEL, x16 = isa
ldp x10, x11, [x16, #CACHE] // x10 = buckets, x11 = occupied|mask
and w12, w1, w11 // x12 = _cmd & mask
add x12, x10, x12, LSL #4 // x12 = buckets + ((_cmd & mask)<<4)
ldp x9, x17, [x12] // {x9, x17} = *bucket
1: cmp x9, x1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp
2: // not hit: x12 = not-hit bucket
CheckMiss $0 // miss if bucket->sel == 0
cmp x12, x10 // wrap if bucket == buckets
b.eq 3f
ldp x9, x17, [x12, #-16]! // {x9, x17} = *--bucket
b 1b // loop
3: // wrap: x12 = first bucket, w11 = mask
add x12, x12, w11, UXTW #4 // x12 = buckets+(mask<<4)
// Clone scanning loop to miss instead of hang when cache is corrupt.
// The slow path may detect any corruption and halt later.
ldp x9, x17, [x12] // {x9, x17} = *bucket
1: cmp x9, x1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp
2: // not hit: x12 = not-hit bucket
CheckMiss $0 // miss if bucket->sel == 0
cmp x12, x10 // wrap if bucket == buckets
b.eq 3f
ldp x9, x17, [x12, #-16]! // {x9, x17} = *--bucket
b 1b // loop
3: // double wrap
JumpMiss $0
.endmacro
複製代碼
其中,ldp
指令的做用是將數據從內存讀取出來存到寄存器,第一個ldp
代碼會 把cache_t
中的_buckets
和 _occupied | _mask
整個結構體成員分別讀取到x10
和x11
兩個寄存器中,而且CacheLookup
的後續代碼沒有再次讀取cache_t
的成員數據,而是一直使用x10
和x11
中的值進行哈希查找。因爲CPU能保證單條指令執行的原子性,因此 只要保證ldp x10, x11, [x16, #CACHE]
這段代碼讀取到的_buckets
與_mask
是互相匹配的(即要麼同時是擴容前的數據,要麼同時是擴容後的數據),那麼多個線程同時讀寫方法緩存也是沒有安全隱患的。
這裏有個疑問,即系統是如何確保_buckets
與_mask
的這種一致性的呢?讓咱們看一下這兩個變量的寫入源碼
void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
// objc_msgSend uses mask and buckets with no locks.
// It is safe for objc_msgSend to see new buckets but old mask.
// (It will get a cache miss but not overrun the buckets' bounds).
// It is unsafe for objc_msgSend to see old buckets and new mask.
// Therefore we write new buckets, wait a lot, then write new mask.
// objc_msgSend reads mask first, then buckets.
// ensure other threads see buckets contents before buckets pointer
mega_barrier();
_buckets = newBuckets;
// ensure other threads see new buckets before new mask
mega_barrier();
_mask = newMask;
_occupied = 0;
}
複製代碼
這段C++
代碼先修改_buckets
,而後再更新_mask
的值,爲了確保這個順序不被編譯器優化,這裏使用了mega_baerrier()
來實現 編譯內存屏障(Compiler Memory Barrier)。
若是不設置
編譯內存屏障
的話,編譯器有可能會優化代碼先賦值_mask
,而後纔是賦值_buckets
,二者的賦值之間,若是另外一個線程執行ldp x10, x11, [x16, #0x10]
指令,獲得的就是舊_buckets
和新_mask
,進而出現內存數組越界引起程序崩潰。而加入了
編譯內存屏障
後,就算獲得的是新_buckets
和舊_mask
,也不會致使程序崩潰。
編譯內存屏障
僅僅是確保_buckets
的賦值會優先於_mask
的賦值,也就是說,在任何場景下當指令ldp x10, x11, [x16, #CACHE]
執行後,獲得的_buckets
數組的長度必定是大於或等於_mask+1
的,如此就保證了不會出現內存數組越界致使的程序崩潰。可見,藉助編譯內存屏障的技巧在必定的程度上能夠實現無鎖讀寫技術。
對
內存屏障
感興趣的同窗可戳 理解 Memory barrier(內存屏障)
咱們知道,在多線程讀寫方法緩存時,寫線程可能會擴容_buckets
(開闢新的_buckets
內存,同時銷燬舊的_buckets
),此時,若是其餘線程讀取到的_buckets
是舊的內存,就有可能會發生讀內存異常而系統崩潰。爲了解決這個問題,OC
使用了兩個全局數組objc_entryPoints
、objc_exitPoints
,分別保存全部會訪問到cache
的函數的起始地址、結束地址
extern "C" uintptr_t objc_entryPoints[];
extern "C" uintptr_t objc_exitPoints[];
複製代碼
下面列出這些函數(一樣以 arm64架構彙編 爲例)
.private_extern _objc_entryPoints
_objc_entryPoints:
.quad _cache_getImp
.quad _objc_msgSend
.quad _objc_msgSendSuper
.quad _objc_msgSendSuper2
.quad _objc_msgLookup
.quad _objc_msgLookupSuper2
.quad 0
.private_extern _objc_exitPoints
_objc_exitPoints:
.quad LExit_cache_getImp
.quad LExit_objc_msgSend
.quad LExit_objc_msgSendSuper
.quad LExit_objc_msgSendSuper2
.quad LExit_objc_msgLookup
.quad LExit_objc_msgLookupSuper2
.quad 0
複製代碼
當線程擴容哈希桶時,會先把舊的桶內存保存在一個全局的垃圾回收數組變量garbage_refs
中,而後再遍歷當前進程(在iOS
中,一個進程就是一個應用程序)中的全部線程,查看是否有線程正在執行objc_entryPoints
列表中的函數(原理是PC寄存器
中的值是否在objc_entryPoints
和objc_exitPoints
這個範圍內),若是沒有則說明沒有任何線程訪問cache
,能夠放心地對garbage_refs
中的全部待銷燬的哈希桶內存塊執行真正的銷燬操做;若是有則說明有線程訪問cache
,此次就不作處理,下次再檢查並在適當的時候進行銷燬。
以上,OC 2.0
的runtime
巧妙的利用了ldp彙編指令
、編譯內存屏障技術、內存垃圾回收技術等多種手段來解決多線程讀寫的無鎖處理方案,既保證了安全,又提高了系統的性能。
在這裏,特別感謝 歐陽大哥!他的 深刻解構objc_msgSend函數的實現 這篇博文會幫助你進一步瞭解Runtime的實現,其在多線程讀寫方法緩存方面也讓筆者受益不淺,強烈推薦你們一讀!
來到這裏,相信你們對cache_t
緩存方法的原理已經有了必定的理解。如今請看下面的幾個問題:
Q:Person
類調用alloc
方法後,緩存在哪裏?
A:緩存在 Person
元類 的 cache_t
中。證實以下圖
_mask
的做用Q:請說明cache_t
中_mask
的做用
A:_mask
從側面反映了cache_t
中哈希桶的數量(哈希桶的數量 = _mask + 1
),保證了查找哈希桶時不會出現越界的狀況。
題解:從上面的源碼分析,咱們知道cache_t
在任何一次緩存方法的時候,哈希桶的數量必定是 >=4
且能被 4整除的,_mask
則等於哈希桶的數量-1,也就是說,緩存方法的時候,_mask
的二進制位上全都是1。當循環查詢哈希桶的時候,索引值是由xx & _mask
運算得出的,所以索引值是小於哈希桶的數量的(index <= _mask
,故index < capacity
),也就不會出現越界的狀況。
3/4
的討論Q:爲何擴容臨界點是3/4?
A:通常設定臨界點就不得不權衡 空間利用率 和 時間利用率 。在 3/4
這個臨界點的時候,空間利用率比較高,同時又避免了至關多的哈希衝突,時間利用率也比較高。
題解:擴容臨界點直接影響循環查找哈希桶的效率。設想兩個極端狀況:
當臨界點是1的時候,也就是說當所有的哈希桶都緩存有方法時,纔會擴容。這雖然讓開闢出來的內存空間的利用率達到100%,可是會形成大量的哈希衝突,加重了查找索引的時間成本,致使時間利用率低下,這與高速緩存的目的相悖;
當臨界點是0.5的時候,意味着哈希桶的佔用量達到總數一半的時候,就會擴容。這雖然極大避免了哈希衝突,時間利用率很是高,卻浪費了一半的空間,使得空間利用率低下。這種以空間換取時間的作法一樣不可取;
兩相權衡下,當擴容臨界點是3/4的時候,空間利用率 和 時間利用率 都相對比較高。
Q:緩存循環查找哈希桶是否會出現死循環的狀況?
A:不會出現。
題解:當哈希桶的利用率達到3/4的時候,下次緩存的時候就會進行擴容,即空桶的數量最少也會有總數的1/4,所以循環查詢索引的時候,必定會出現命中緩存或者空桶的狀況,從而結束循環。
經過以上例子的驗證、源碼的分析以及問題的討論,如今總結一下cache_t
的幾個結論:
cache_t
能緩存調用過的方法。cache_t
的三個成員變量中,
_buckets
的類型是struct bucket_t *
,也就是指針數組,它表示一系列的哈希桶(已調用的方法的SEL
和IMP
就緩存在哈希桶中),一個桶能夠緩存一個方法。_mask
的類型是mask_t
(mask_t
在64
位架構下就是uint32_t
,長度爲4個字節),它的值等於哈希桶的總數-1(capacity - 1
),側面反映了哈希桶的總數。_occupied
的類型也是mask_t
,它表明的是當前_buckets
已緩存的方法數。_occupied
爲1。_buckets
和_mask
,因此並沒有安全隱患。OC
用了個全局的互斥鎖(cacheUpdateLock.assertLocked()
)來保證不會出現寫兩次緩存的狀況。OC
使用了ldp彙編指令
、編譯內存屏障技術、內存垃圾回收技術等多種手段來解決多線程讀寫的無鎖處理方案,既保證了安全,又提高了系統的性能。github
上,請戳 objc4-756.2源碼