objc_class中cache存儲的是什么?
首先,我們需要知道cache
中存儲
的到底是什么?
- 查看cache_t的源碼,發現分成了3個架構的處理,其中真機的架構中,
mask和bucket
是寫在一起,目的是為了優化
,可以通過各自的掩碼
來獲取相應的數據-
CACHE_MASK_STORAGE_OUTLINED
表示運行的環境模擬器
或者macOS
-
CACHE_MASK_STORAGE_HIGH_16
表示運行環境是64
位的真機
-
CACHE_MASK_STORAGE_LOW_4
表示運行環境是非64
位 的真機
-
struct cache_t {
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_OUTLINED//macOS、模擬器 -- 主要是架構區分
// explicit_atomic 顯示原子性,目的是為了能夠 保證 增刪改查時 線程的安全性
//等價于 struct bucket_t * _buckets;
//_buckets 中放的是 sel imp
//_buckets的讀取 有提供相應名稱的方法 buckets()
explicit_atomic<struct bucket_t *> _buckets;
explicit_atomic<mask_t> _mask;
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16 //64位真機
explicit_atomic<uintptr_t> _maskAndBuckets;//寫在一起的目的是為了優化
mask_t _mask_unused;
//以下都是掩碼,即面具 -- 類似于isa的掩碼,即位域
// 掩碼省略....
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4 //非64位 真機
explicit_atomic<uintptr_t> _maskAndBuckets;
mask_t _mask_unused;
//以下都是掩碼,即面具 -- 類似于isa的掩碼,即位域
// 掩碼省略....
#else
#error Unknown cache mask storage type.
#endif
#if __LP64__
uint16_t _flags;
#endif
uint16_t _occupied;
//方法省略.....
}
- 查看
bucket_t
的源碼,同樣分為兩個版本,真機
和非真機
,不同的區別在于sel
和imp
的順序不一致
struct bucket_t {
private:
#if __arm64__ //真機
//explicit_atomic 是加了原子性的保護
explicit_atomic<uintptr_t> _imp;
explicit_atomic<SEL> _sel;
#else //非真機
explicit_atomic<SEL> _sel;
explicit_atomic<uintptr_t> _imp;
#endif
//方法等其他部分省略
}
所以通過上面兩個結構體源碼可知,cache
中緩存的是sel-imp
整體的結構如下圖所示
在cache中查找sel-imp
cache_t
中查找存儲的sel-imp
,有以下兩種方式
- 通過源碼查找
- 脫離源碼在項目中查找
準備工作
-
定義一個
LGPerson
類,并定義兩個屬性
及5個實例方法
及其實現imageimage -
在
main
中定義LGPerson
類的對象p
,并調用其中的3個實例方法,在p調用第一個方法處加一個斷點image
通過源碼查找
-
運行執行,斷在
[p sayHello];
部分,此時執行以下lldb調試流程imagecache屬性
的獲取,需要通過pclass
的首地址平移16字節,即首地址+0x10
獲取cache的地址從源碼的分析中,我們知道
sel-imp
是在cache_t
的_buckets屬性
中(目前處于macOS
環境),而在cache_t結構體中提供了獲取_buckets
屬性的方法buckets()
獲取了
_buckets
屬性,就可以獲取sel-imp
了,這兩個的獲取在bucket_t
結構體中同樣提供了相應的獲取方法sel()
以及imp(pClass)
由上圖可知,在沒有執行方法調用時,此時的cache是沒有緩存的,執行了一次方法調用,cache
中就有了一個緩存,即調用一次方法就會緩存一次方法
。
我們現在了解了如何獲取cache中sel-imp,如何驗證
打印的sel和imp就是我們調用的呢?可以通過machoView
打開target的可執行文件
,在方法列表
中查看其imp
的值是否是一致的,如下所示,發現是一致的,所以打印的這個sel-imp
就是LGPerson
的實例方法
-
接著上面的步驟,我們再次調用一個方法,這次我們想要獲取第二個sel,其調試的lldb如下
image第一個調用方法的存儲獲取很簡單,直接通過_buckets的首地址調用對應的方法即可,那么獲取第二個呢?
指針偏移
,所以我們這里可以通過_buckets屬性的首地址偏移,即p *($9+1)
即可獲取第二個方法的sel 和imp
如果有多個方法需要獲取,以此類推,例如p *($9+i)
脫離源碼通過項目查找
脫離源碼環境,就是將所需的源碼
的部分拷貝至項目
中,其完整代碼如下
typedef uint32_t mask_t; // x86_64 & arm64 asm are less efficient with 16-bits
struct lg_bucket_t {
SEL _sel;
IMP _imp;
};
struct lg_cache_t {
struct lg_bucket_t * _buckets;
mask_t _mask;
uint16_t _flags;
uint16_t _occupied;
};
struct lg_class_data_bits_t {
uintptr_t bits;
};
struct lg_objc_class {
Class ISA;
Class superclass;
struct lg_cache_t cache; // formerly cache pointer and vtable
struct lg_class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
};
int main(int argc, const char * argv[]) {
@autoreleasepool {
LGPerson *p = [LGPerson alloc];
Class pClass = [LGPerson class]; // objc_clas
[p say1];
[p say2];
//[p say3];
//[p say4];
struct lg_objc_class *lg_pClass = (__bridge struct lg_objc_class *)(pClass);
NSLog(@"%hu - %u",lg_pClass->cache._occupied,lg_pClass->cache._mask);
for (mask_t i = 0; i<lg_pClass->cache._mask; i++) {
// 打印獲取的 bucket
struct lg_bucket_t bucket = lg_pClass->cache._buckets[I];
NSLog(@"%@ - %p",NSStringFromSelector(bucket._sel),bucket._imp);
}
NSLog(@"Hello, World!");
}
return 0;
}
-
這里有個問題需要注意,在源碼中,
objc_class
的ISA
屬性是繼承自objc_object
的,但在我們將其拷貝過來時,去掉了objc_class
的繼承關系,需要將這個屬性明確,否則打印的結果是有問題,如下圖所示,image -
加上ISA屬性后,增加兩個方法的調用,其正確的打印結果應該是這樣的
image -
在增加兩個方法的調用,即解開say3、say4的注釋,其打印結果如下
image
針對上面的打印結果,有以下幾點疑問
- 1、
_mask
是什么? - 2、
_occupied
是什么? - 3、為什么隨著方法調用的增多,其打印的occupied 和 mask
會變化
? - 4、
bucket
數據為什么會有丟失的情況
?,例如2-7中,只有say3、say4方法有函數指針 - 5、2-7中say3、say4的打印順序為什么是say4先打印,say3后打印,且還是挨著的,即
順序有問題
? - 6、打印的
cache_t
中的_ocupied
為什么是從2
開始?
帶著上述的這些疑問,下面來進行cache底層原理的探索
cache_t底層原理分析
-
首先,從
cache_t
中的_mask
屬性開始分析,找cache_t
中引起變化的函數,發現了incrementOccupied()
函數image該函數的具體實現為
void incrementOccupied(); //Occupied自增
//??具體實現
void cache_t::incrementOccupied()
{
_occupied++;
}
-
源碼中,全局搜索
incrementOccupied()
函數,發現只在cache_t
的insert
方法有調用image -
insert
方法,理解為cache_t
的插入,而cache
中存儲的就是sel-imp
,所以cache的原理從insert
方法開始分析,以下是cache原理分析的流程圖image -
全局搜索
insert(
方法,發現只有cache_fill
方法中的調用符合image -
全局搜索
cache_fill
,發現在寫入之前,還有一步操作,即cache讀取,即查找sel-imp,如下所示image
但本文的重點還是分析cache存儲的原理,接下來根據cache_t
寫入的流程圖,著重分析insert
方法
insert方法分析
在insert
方法中,其源碼實現如下
主要分為以下幾部分
- 【第一步】
計算
出當前的緩存占用量
- 【第二步】根據
緩存占用量``判斷
執行的操作
- 【第三步】針對需要存儲的
bucket
進行內部imp和sel賦值
其中,第一步,根據occupied
的值計算出當前的緩存占用量,當屬性未賦值及無方法調用
時,此時的occupied()
為0
,而newOccupied
為1
,如下所示
mask_t newOccupied = occupied() + 1;
關于緩存占用量的計算,有以下幾點說明:
alloc
申請空間時,此時的對象已經創建
,如果再調用init
方法,occupied
也會+1
當
有屬性賦值
時,會隱式調用set
方法,occupied
也會增加,即有幾個屬性賦值,occupied就會在原有的基礎上加幾個
當
有方法調用
時,occupied
也會增加,即有幾次調用,occupied就會在原有的基礎上加幾個
【第二步】根據緩存占用量判斷執行的操作
- 如果是
第一次創建
,則默認開辟4
個
if (slowpath(isConstantEmptyCache())) { //小概率發生的 即當 occupied() = 0時,即創建緩存,創建屬于小概率事件
// Cache is read-only. Replace it.
if (!capacity) capacity = INIT_CACHE_SIZE; //初始化時,capacity = 4(1<<2 -- 100)
reallocate(oldCapacity, capacity, /* freeOld */false); //開辟空間
//到目前為止,if的流程的操作都是初始化創建
}
- 如果緩存占用量
小于等于3/4
,則不作任何處理
else if (fastpath(newOccupied + CACHE_END_MARKER <= capacity / 4 * 3)) {
// Cache is less than 3/4 full. Use it as-is.
}
- 如果緩存占用量
超過3/4
,則需要進行兩倍擴容
以及重新開辟空間
else {//如果超出了3/4,則需要擴容(兩倍擴容)
//擴容算法: 有cap時,擴容兩倍,沒有cap就初始化為4
capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE; // 擴容兩倍 2*4 = 8
if (capacity > MAX_CACHE_SIZE) {
capacity = MAX_CACHE_SIZE;
}
// 走到這里表示 曾經有,但是已經滿了,需要重新梳理
reallocate(oldCapacity, capacity, true);
// 內存 擴容完畢
}
realloc方法:開辟空間
該方法,在第一次創建
以及兩倍擴容
時,都會使用,其源碼實現如圖所示
主要有以下幾步
allocateBuckets
方法:向系統申請開辟內存
,即開辟bucket
,此時的bucket只是一個臨時變量-
setBucketsAndMask
方法:將臨時
的bucket
存入緩存中,此時的存儲分為兩種情況:-
如果是
真機
,根據bucket和mask的位置存儲
,并將occupied
占用設置為0
image -
如果
不是真機
,正常存儲bucket和mask
,并將occupied占用設置為0image
-
-
如果有舊的buckets,需要清理之前的緩存,即調用
cache_collect_free
方法,其源碼實現如下image該方法的實現主要有以下幾步:
-
_garbage_make_room
方法:創建垃圾回收空間image如果是
第一次
,需要分配回收空間
如果
不是第一次
,則將內存段加大,即原有內存*2
記錄
存儲
這次的bucket
-
cache_collect
方法:垃圾回收,清理舊的bucketimage
-
【第三步】針對需要存儲的bucket進行內部imp和sel賦值
這部分主要是根據cache_hash
方法,即哈希算法
,計算sel-imp
存儲的哈希下標
,分為以下三種情況:
如果哈希下標的位置
未存儲sel
,即該下標位置獲取sel等于0
,此時將sel-imp存儲
進去,并將occupied
占用大小加1
如果當前哈希下標存儲的sel
等于
即將插入的sel,則直接返回如果當前哈希下標存儲的sel
不等于
即將插入的sel,則重新經過cache_next
方法 即哈希沖突算法
,重新進行哈希計算,得到新的下標,再去對比進行存儲
其中涉及的兩種哈希算法,其源碼如下
-
cache_hash
:哈希算法
static inline mask_t cache_hash(SEL sel, mask_t mask)
{
return (mask_t)(uintptr_t)sel & mask; // 通過sel & mask(mask = cap -1)
}
-
cache_next
:哈希沖突算法
#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; //(將當前的哈希下標 +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; //如果i是空,則為mask,mask = cap -1,如果不為空,則 i-1,向前插入sel-imp
}
到此,cache_t的原理基本分析完成了,然后前文提及的幾個問題,我們現在就有答案了
疑問解答
1、_mask
是什么?
_mask
是指掩碼數據
,用于在哈希算法
或者哈希沖突算法
中計算哈希下標
,其中mask 等于capacity - 1
2、_occupied
是什么?
_occupied
表示哈希表中 sel-imp
的占用大小
(即可以理解為分配的內存中已經存儲了sel-imp
的的個數
),
init
會導致occupied
變化屬性賦值
,也會隱式調用,導致occupied
變化方法調用
,導致occupied變化
3、為什么隨著方法調用的增多,其打印的occupied 和 mask會變化
?
因為在cache
初始化時,分配的空間是4
個,隨著方法調用的增多,當存儲的sel-imp個數
,即newOccupied + CACHE_END_MARKER(等于1)的和 超過 總容量的3/4
,例如有4
個時,當occupied
等于2
時,就需要對cache
的內存進行兩倍擴容
4、bucket
數據為什么會有丟失的情況
?,例如2-7中,只有say3、say4方法有函數指針
原因是在擴容
時,是將原有的內存全部清除
了,再重新申請
了內存
導致的
5、2-7中say3、say4的打印順序為什么是say4先打印,say3后打印,且還是挨著的,即 順序有問題 ?
因為sel-imp
的存儲是通過哈希算法計算下標
的,其計算的下標有可能已經存儲了sel,所以又需要通過哈希沖突算法重新計算哈希下標
,所以導致下標是隨機的,并不是固定的
6、打印的 cache_t 中的 ocupied 為什么是從 2 開始?
這里是因為LGPerson
通過alloc
創建的對象
,并對其兩個屬性賦值的原因,屬性賦值,會隱式調用set
方法,set方法的調用也會導致occupied
變化