iOS 底層 - 類的本質與方法緩存

前言

想要學好一個方向的編程語言,底層基礎必定是個必不可少的前提。而在 Objective-C 這個篇章中,類與對象更是基礎中的基礎,它是能讓咱們串聯起萬物的基石。算法

所以,本篇文章就來好好探索一下 類的本質類的結構類的懶加載概念 以及 從編譯時到運行時 到底作了什麼事情,來完全的瞭解一下它 。編程

源碼準備

前導知識

什麼是類

Objective-C 是一門面向對象的編程語言。每一個對象都是其 的實例 , 被稱爲實例對象 . 每個對象都有一個名爲 isa 的指針,指向該對象的類。多線程

而類自己也是一個對象。爲何這麼說呢?架構

來看下源碼app

typedef struct objc_object *id;
typedef struct objc_class *Class;

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
    /**/
}

struct objc_object {
private:
    isa_t isa;
public:
    // ISA() assumes this is NOT a tagged pointer object
    Class ISA();

    // getIsa() allows this to be a tagged pointer object
    Class getIsa();
    /*...*/
}
複製代碼

首先,對象是一個 id 類型 , 也就是一個指向 objc_object 結構體的指針。而咱們看到 Class 是一個指向 objc_class 結構體指針,而 objc_class 繼承於 objc_object , 所以,咱們說 類也是一個對象

其實 對象是類的實例,而類是由元類對象的實例,實例方法存儲在類中,一樣,類方法則存儲在元類中。一樣咱們能夠得出結論:Objective-C 對象都是 C 語言結構體實現的

總結

  • Objective-C 中,每一個對象 ( 其根本是一個 objc_object 結構體指針 ) 都有一個名爲 isa 的指針,指向該對象的類 ( 其根本是一個 objc_class 結構體 )

  • 每個類實際上也是一個對象 ( 由於 objc_class 繼承與 objc_object),所以每個類也能夠接受消息,即調用類方法,而接受者就是類對象 isa 所指向的元類。

  • NSObjectNSProxy 是兩個基類,他們都遵循了 <NSObject> 協議,以此爲其繼承的子類提供了公共接口和能力。

類的結構

提示 :

OBJC2 中 如下 objc_class 已經被棄用 .

struct objc_class {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;

#if !__OBJC2__
    Class _Nullable super_class                              OBJC2_UNAVAILABLE;
    const char * _Nonnull name                               OBJC2_UNAVAILABLE;
    long version                                             OBJC2_UNAVAILABLE;
    long info                                                OBJC2_UNAVAILABLE;
    long instance_size                                       OBJC2_UNAVAILABLE;
    struct objc_ivar_list * _Nullable ivars OBJC2_UNAVAILABLE;
    struct objc_method_list * _Nullable * _Nullable methodLists OBJC2_UNAVAILABLE;
    struct objc_cache * _Nonnull cache OBJC2_UNAVAILABLE;
    struct objc_protocol_list * _Nullable protocols OBJC2_UNAVAILABLE;
#endif
} OBJC2_UNAVAILABLE;
複製代碼

類的結構體源碼以下 :

typedef struct objc_class *Class;

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
    /**/
}
複製代碼

首先看到類的本質是一個結構體 , 這也是爲何咱們都說 OC 對象的本質其實是個結構體指針的緣由 .

結構體內部結構以下 :

  • Class ISA :指向關聯類 , 繼承自 objc_object . 參考 isa 的前世此生
  • Class superclass:父類指針 , 一樣參考上述文章中有詳細指向探索 .
  • cache_t cache , 方法緩存存儲數據結構 .
  • class_data_bits_t bits , bits 中存儲了屬性,方法等類的源數據。

接下來咱們就來一一探索一下 cache_tclass_data_bits_t .

一、class_data_bits_t - 類信息數據源

struct class_data_bits_t {
    uintptr_t bits;
    
    class_rw_t* data() {
        return (class_rw_t *)(bits & FAST_DATA_MASK);
    }
    /*.其餘一些方法省略..*/
}

typedef unsigned long           uintptr_t;

// data pointer
#define FAST_DATA_MASK 0x00007ffffffffff8UL
複製代碼

點進去咱們看到 , 其實這個 bits , 跟咱們講述 isa ( isa 的前世此生 ) 時開啓了 isa 優化的狀況下是大體相同的 .

也就是說 , bits 使用了 8 個字節總共 64 個二進制位來存儲更多內容 , 讀取時經過 mask 進行位運算獲取所存儲指針數據 .

例如 :

class_rw_t* data() {
    return (class_rw_t *)(bits & FAST_DATA_MASK);
}
複製代碼

經過 bits & FAST_DATA_MASK 獲取其中固定二進制位下的數據 , 轉化爲 class_rw_t 類型的指針 . ( 其實 objc 源碼中大多數 bits 字樣的寫法都是這種處理方法 , 也是一種優化措施 , 被稱爲 享元設計模式 ) .

1.1 class_rw_t

class_rw_t 數據結構以下 :

struct class_rw_t {
    uint32_t flags;
    uint32_t version;
    const class_ro_t *ro;

    method_array_t methods;
    property_array_t properties;
    protocol_array_t protocols;

    Class firstSubclass;
    Class nextSiblingClass;
    char *demangledName;

#if SUPPORT_INDEXED_ISA
    uint32_t index;
#endif
    /*...*/
}
複製代碼

這個就是咱們所熟悉的 方法列表 , 屬性列表 , 協議列表等等數據信息了 .

其中方法列表須要注意的是 : (類對象存放對象方法,元類對象存放類方法)

還有一個值得提的就是 const class_ro_t *ro; . 其源碼以下 :

1.2 class_ro_t

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;
    const char * name;
    method_list_t * baseMethodList;
    protocol_list_t * baseProtocols;
    const ivar_list_t * ivars;

    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;
}
複製代碼

能夠看到 ro 中一樣存儲了 baseMethodList , baseProtocols , ivars 等數據 , 可是 ro 是被 const 修飾的 , 也就是不可變 .

其結構以下圖 :

那麼 rwro 是什麼關係 , 爲何須要重複存儲呢 ?

1.3 rw 與 ro 的關係

先說結論 .

  • 其實如名稱同樣 , rwread write , roread only . OC 爲了動態的特性 , 在編譯器肯定並保存了一份 類的結構數據在 ro 中 , 另外存儲一份在運行時加載到 rw 中 , 供 runtime 動態修改使用 .

  • ro 是不可變的 , 而 rwmethods , properties 以及 protocols 內存空間是可變的 . 這也是 已有類 爲何能夠動態添加方法 , 確不能動態添加屬性的緣由 ( 添加屬性會一樣添加成員變量 , 也就是 ivar . 而 ivar 是存儲在 ro 中的 ) .

  • 一樣分類不能添加屬性的緣由也是如此 ( 關聯屬性是單獨存儲在 ObjectAssociationMap 中的 , 跟類的原理並不同 )

1.4 rw 與 ro 關係驗證

首先在 從頭梳理 dyld 加載流程 中咱們提到過 , libobjc 的初始化是從 _objc_init 開始的 , 而這個函數中調用了 map_images , load_images , 以及 unmap_image 這三個函數 .

  • 什麼意思呢 ?

其實也就是 dyld 負責將應用由磁盤加載到運行內存中 , 而也是在此時註冊的類及元類的數據和內存結構 . 也就是在 map_images 中 .

提示:

  • 1️⃣、 當 dyld 加載到開始連接主程序的時候 , 遞歸調用 recursiveInitialization 函數 .

  • 2️⃣、 這個函數第一次執行 , 進行 libsystem 的初始化 . 會走到 doInitialization -> doModInitFunctions -> libSystemInitialized .

  • 3️⃣、 Libsystem 的初始化 , 它會調用起 libdispatch_init , libdispatchinit 會調用 _os_object_init , 這個函數裏面調用了 _objc_init .

  • 4️⃣、_objc_init 中註冊並保存了 map_images , load_images , unmap_image 函數地址.

  • 5️⃣ : 註冊完畢繼續回到 recursiveInitialization 遞歸下一次調用 , 例如 libobjc , 當 libobjc 來到 recursiveInitialization 調用時 , 會觸發 libsystem 調用到 _objc_init 裏註冊好的回調函數進行調用 . 就來到了 libobjc , 調用 map_images.

那咱們來從源碼看下 .
void map_images(unsigned count, const char * const paths[], const struct mach_header * const mhdrs[]) {
    mutex_locker_t lock(runtimeLock);
    return map_images_nolock(count, paths, mhdrs);
}

void map_images_nolock(unsigned mhCount, const char * const mhPaths[], const struct mach_header * const mhdrs[]) {
    if (hCount > 0) {
        _read_images(hList, hCount, totalClasses, unoptimizedTotalClasses);
    }
}
複製代碼

↓↓

void _read_images(header_info **hList, uint32_t hCount, int totalClasses, int unoptimizedTotalClasses)
{
    for (EACH_HEADER) {
        classref_t *classlist = _getObjc2ClassList(hi, &count);
        for (i = 0; i < count; i++) {
            Class cls = (Class)classlist[i];
            Class newCls = readClass(cls, headerIsBundle, headerIsPreoptimized);
        }
    }
}

Class readClass(Class cls, bool headerIsBundle, bool headerIsPreoptimized) {
    Class replacing = nil;
    if (Class newCls = popFutureNamedClass(mangledName)) {
        class_rw_t *rw = newCls->data();
        const class_ro_t *old_ro = rw->ro;
        memcpy(newCls, cls, sizeof(objc_class));
        rw->ro = (class_ro_t *)newCls->data();
        newCls->setData(rw);
        freeIfMutable((char *)old_ro->name);
        free((void *)old_ro);
        
        addRemappedClass(cls, newCls);
        replacing = cls;
        cls = newCls;
    }
}
複製代碼

結論 :

  • 能夠看到 , 在 dyld 加載類過程當中 , 將 ro 中數據拷貝到 rw 中 , 另外在 realizeClassWithoutSwift 中也有體現 , 這裏就不貼出來了 .

  • 而在這以前 , ro 的數據已是處理完畢的 , 也就是說類的結構體在編譯期 ro 的數據已經處理完畢 .

那麼首先編譯期 ro 數據肯定咱們如何驗證 ?

答案是明顯的 , clang + MachoView

1.5 編譯期 ro 數據驗證

1. clang 驗證

新建一個類 , clang readwrite 一下 , 打開 main.cpp . 查看到以下 :

static struct _class_ro_t _OBJC_METACLASS_RO_$_LBPerson __attribute__ ((used, section ("__DATA,__objc_const"))) = {
	1, sizeof(struct _class_t), sizeof(struct _class_t), 
	(unsigned int)0, 
	0, 
	"LBPerson",
	0, 
	0, 
	0, 
	0, 
	0, 
};

static struct _class_ro_t _OBJC_CLASS_RO_$_LBPerson __attribute__ ((used, section ("__DATA,__objc_const"))) = {
	0, __OFFSETOFIVAR__(struct LBPerson, name), sizeof(struct LBPerson_IMPL), 
	(unsigned int)0, 
	0, 
	"LBPerson",
	(const struct _method_list_t *)&_OBJC_$_INSTANCE_METHODS_LBPerson,
	0, 
	(const struct _ivar_list_t *)&_OBJC_$_INSTANCE_VARIABLES_LBPerson,
	0, 
	(const struct _prop_list_t *)&_OBJC_$_PROP_LIST_LBPerson,
};
複製代碼
2. macho 查看驗證

由上述 clang 中咱們獲得 , ro 是存儲在 __DATA_objc_const 節中 , 使用 MachOView 打開 macho 文件看到以下 :

獲得驗證 , ro 中數據在編譯期肯定並存儲完畢 , 運行時沒法修改 .

那麼接下來 , 咱們就使用 lldb 來調試一下 , 來實際看下 類中數據的內存佈局 .

1.6 內存佈局調試探索

代碼準備

@interface LBPerson : NSObject{
    @public
    NSString *ivarName;
}
@property (nonatomic, copy) NSString *propertyName;
+ (void)testClassMethod;
- (void)testInstanceMethod;
@end

@implementation LBPerson
+ (void)testClassMethod{
    NSLog(@"%s",__func__);
}
- (void)testInstanceMethod{
    NSLog(@"%s",__func__);
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        LBPerson * person = [[LBPerson alloc] init];
        person->ivarName = @"ivar";
        person.propertyName = @"property";
        [person propertyName];
        [person testInstanceMethod];
        NSLog(@"123");
    }
    return 0;
}
複製代碼

添加斷點到 NSLog 處 , 運行工程 .

lldb 輸入指令 p/x LBPerson.class . 打印以下 :

爲何是首地址偏移 0x20 個字節就是 bits 呢 ?

  • 由於類的結構體 , isa 聯合體 8 字節 , superclass 指針 8 字節 , cache_t 結構體 16 字節.
struct objc_class : objc_object {
    // Class ISA; // 8
    Class superclass;   // 8
    cache_t cache;      // 16
    class_data_bits_t bits; 
}

struct cache_t {
    struct bucket_t *_buckets;  // 8
    mask_t _mask;       // 4
    mask_t _occupied;   // 4
}
複製代碼

另外注意 : 要在 objc 可編譯源碼進行上述 lldb 調試 , 不然沒法強轉 class_data_bits_t .

找到 rw 咱們就分別來看下 成員變量 , 屬性 , 方法的存儲位置 .

成員變量

首先在 rw 中咱們看到並無 ivar . 所以來到 ro 中 .

這裏咱們跟 rw 比較一下發現 , rwroprotocols , propertiesmethods 內存地址是如出一轍的 . 說明了運行時 ro 讀取到 rw 中的時候 , 這三個列表是淺拷貝 .

繼續獲取 ivar .

能夠看到不只有咱們本身定義的 ivarName , 還有自動生成的 _propertyName . 定義屬性時自動會生成 _ + 名稱 的實例變量 .

屬性

方法

  • 能夠看到實例變量並無生成 gettersetter , 這也是屬性跟成員變量的區別 .
  • 結合實例變量中的結果能夠得出 : 屬性 = 成員變量 + gettersetter
  • 另外類方法也並無在類的方法列表中 , 實際上是在元類裏 , 感興趣的同窗能夠繼續去測試一下 .

以上就是類中主要存儲數據的內容了 . 探索完 bits . 下面咱們來看看 cache_t , 順便了解了解方法緩存的原理 .

二、cache_t - 方法緩存數據源

cache_t 做爲存儲方法緩存的數據結構 , 咱們就來探索一下方法緩存的原理 .

struct cache_t {
    struct bucket_t *_buckets; // 緩存數組,即哈希桶
    mask_t _mask;   // 緩存數組的容量臨界值,其實是爲了 capacity 服務
    mask_t _occupied;   // 緩存數組中已緩存方法數量
    /* ... */
}

#if __LP64__
typedef uint32_t mask_t;  // x86_64 & arm64 asm are less efficient with 16-bits
#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
}
複製代碼

從源碼得知 , cache_t64 位下佔用 16 個字節 , 而 impsel 就是存儲在 bucket_t 結構中的.

接下來 , 咱們就使用 lldb 來實際探索一下 方法緩存的原理 .

三、方法緩存原理探索

3.1 代碼準備

@interface LBObj : NSObject
- (void)testFunc1;
- (void)testFunc2;
- (void)testFunc3;
@end

@implementation LBObj
- (void)testFunc1{
    NSLog(@"%s",__FUNCTION__);
}
- (void)testFunc2{
    NSLog(@"%s",__FUNCTION__);
}
- (void)testFunc3{
    NSLog(@"%s",__FUNCTION__);
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        LBObj * obj = [[LBObj alloc] init];
        [obj testFunc1];
        [obj testFunc2];
        [obj testFunc3];
    }
    return 0;
}
複製代碼

3.2 開始探索

提示 : 在 objc 源碼中跑項目 , 不然 lldb 強轉會提示找不到 cache_t .

在對象建立前加上斷點 , 先來看下緩存桶中數據 .

過掉建立對象斷點 , 來到調用方法處 , 再次查看 .

  • 看到這裏 , 首先 initsel 以及 imp 都已經緩存到了桶中 , 已佔用 _occupied 變爲 1 , _mask 變爲 3 .

另外咱們有兩個疑問 .

1️⃣ : alloc 方法爲何沒有緩存 ?

2️⃣ : 爲何 init 方法不在 _buckets 第一個位置 ?

答 :

  • alloc 爲何在類的 cache_t 中沒有緩存呢 ? 答案很明顯 , 類方法以及類方法的緩存都是存儲在元類中的 . 有興趣的同窗能夠去驗證一下 .

  • 爲何不是按順序存儲呢 , 這個涉及到哈希表的結構設計 , 本篇文章很少作講述了 . 熟悉的同窗應該清楚 , 另外還有 key 值編碼 , 哈希表擴容 , 以及哈希衝突的處理 , 是一個比較經典的題目 , iOS 中使用的也比較多 .

過掉斷點執行 testFunc1 . 繼續查看 .

  • init 以及 testFunc1sel 以及 imp 都已經緩存到了桶中 , 已佔用 _occupied 變爲 2 , _mask3 .

過掉斷點執行 testFunc2 . 繼續查看 .

  • inittestFunc1testFunc2sel 以及 imp 都已經緩存到了桶中 , 已佔用 _occupied 變爲 3 , _mask3 .

過掉斷點執行 testFunc3 . 繼續查看 .

這裏就比較有意思了 , 以前存儲的方法緩存已被清理 , testFunc3sel 以及 imp 緩存到了桶中 . 已佔用 _occupied 變爲 1 , _mask7 , 爲何會這樣呢 ? 下面咱們會詳細探索 .

3.3 關於緩存容量

實際上緩存容量並不取 _mask 屬性 , 而是調用 cache_t 結構體中的 mask_t capacity(); 方法 .

mask_t cache_t::capacity() 
{
    return mask() ? mask()+1 : 0; 
}
mask_t cache_t::mask() 
{
    return _mask; 
}
複製代碼

當沒有調用過方法 , _mask0 , 容量 capacity 也爲 0 , 當 _mask 有值時 , 實際容量爲 _mask + 1 .

當咱們對 cache_t 的內存結構有了深刻了解以後 , 就能夠來看下 OC 調用方法時 , 究竟是如何查找緩存的 . 當緩存到達臨界值時 , 又是如何擴容 , 或者說緩存的淘汰策略又是什麼樣的呢 ?

探索OC方法的本質OC方法查找與消息轉發 這兩篇文章中咱們詳細探索了方法的本質和方法查找的完整流程 . 其中第一部分就是 objc_MsgSend 的彙編查找緩存部分 .

3.4 彙編查找緩存

arm64 爲例 ( 不一樣架構下對應着不一樣的彙編指令集 ) .

ENTRY _objc_msgSend
	UNWIND _objc_msgSend, NoFrame

	cmp p0, #0			// nil check and tagged pointer check
#if SUPPORT_TAGGED_POINTERS
	b.le LNilOrTagged		//  (MSB tagged pointer looks negative)
#else
	b.eq LReturnZero
#endif
	ldr p13, [x0]		// p13 = isa
	GetClassFromIsa_p16 p13		// p16 = class
LGetIsaDone:
	CacheLookup NORMAL		// calls imp or objc_msgSend_uncached

#if SUPPORT_TAGGED_POINTERS
LNilOrTagged:
	b.eq LReturnZero		// nil check

	// tagged
	adrp x10, _objc_debug_taggedpointer_classes@PAGE
	add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
	ubfx x11, x0, #60, #4
	ldr x16, [x10, x11, LSL #3]
	adrp x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGE
	add x10, x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGEOFF
	cmp x10, x16
	b.ne LGetIsaDone

	// ext tagged
	adrp x10, _objc_debug_taggedpointer_ext_classes@PAGE
	add x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF
	ubfx x11, x0, #52, #8
	ldr x16, [x10, x11, LSL #3]
	b LGetIsaDone
// SUPPORT_TAGGED_POINTERS
#endif
 LReturnZero:
	// x0 is already zero
	mov x1, #0
	movi d0, #0
	movi d1, #0
	movi d2, #0
	movi d3, #0
	ret

	END_ENTRY _objc_msgSend
複製代碼
  • 在有了對 isa 的詳細瞭解之後 , 看這些彙編指令仍是比較清楚的 . 不太清楚 isa 的同窗能夠去查閱查閱 isa 的前世此生 .

  • 對於彙編指令不太熟悉的同窗能夠藉助 HopperIDA , 他們上面是有彙編語言還原高級僞代碼的功能 , 幫助查看 .

在整個 _objc_msgSend 彙編部分 , 其實就是查找緩存的流程 , 也就是咱們所說的快速查找流程 ( 緩存 ) . 這個流程具體步驟以下 :

1️⃣、對象空值判斷
ENTRY _objc_msgSend
UNWIND _objc_msgSend, NoFrame

cmp p0, #0

#if SUPPORT_TAGGED_POINTERS
LNilOrTagged:
	b.eq LReturnZero		// nil check
複製代碼

方法調用者便是咱們 OC 隱藏參數的第一個 , 在 arm 彙編中 , 方法的第一個參數以及返回值會存儲在 x0 寄存器中 .

所以 , 此處就是對 x0 寄存器中的消息接收者是否爲空的判斷 , 爲空就直接返回了 , 這也是爲何向空對象發送消息 , 不會崩潰也不會調用的緣由 .

2️⃣、根據消息調用者獲取isa
#if SUPPORT_TAGGED_POINTERS
	b.le LNilOrTagged		//  (MSB tagged pointer looks negative)
#else
	b.eq LReturnZero
#endif
	ldr p13, [x0]		// p13 = isa
	GetClassFromIsa_p16 p13		// p16 = class
複製代碼

因爲對象的根本是 objc_object , 所以每一個對象都有一個 isa 指針 , 而經過對象獲取 isa 的時候 , 因爲 isa 優化 ( 是否爲 nonpointer_isa 以及是不是 taggedPoint ) 須要不一樣處理 .

若是這部分不太熟悉的同窗請閱讀 isa 的前世此生 , 裏面有很是詳細的講述 .

彙編代碼具體獲取 isa 以下 :

.macro GetClassFromIsa_p16 /* src */

#if SUPPORT_INDEXED_ISA
	// Indexed isa
	mov	p16, $0			// optimistically set dst = src
	tbz	p16, #ISA_INDEX_IS_NPI_BIT, 1f	// done if not non-pointer isa
	// isa in p16 is indexed
	adrp	x10, _objc_indexed_classes@PAGE
	add	x10, x10, _objc_indexed_classes@PAGEOFF
	ubfx	p16, p16, #ISA_INDEX_SHIFT, #ISA_INDEX_BITS  // extract index
	ldr	p16, [x10, p16, UXTP #PTRSHIFT]	// load class from array
1:

#elif __LP64__
	// 64-bit packed isa
	and	p16, $0, #ISA_MASK
#else
	// 32-bit raw isa
	mov	p16, $0
#endif
.endmacro
複製代碼

這裏就獲取到了 isa , 放在了 p16 寄存器中 .
其實跟 objc 源碼中 getIsa 中的基本差很少 , 只不過一個是用匯編 , 一個是用 c 來實現的 .

inline Class objc_object::getIsa() 
{
    if (!isTaggedPointer()) return ISA();
    uintptr_t ptr = (uintptr_t)this;
    if (isExtTaggedPointer()) {
        uintptr_t slot = 
            (ptr >> _OBJC_TAG_EXT_SLOT_SHIFT) & _OBJC_TAG_EXT_SLOT_MASK;
        return objc_tag_ext_classes[slot];
    } else {
        uintptr_t slot = 
            (ptr >> _OBJC_TAG_SLOT_SHIFT) & _OBJC_TAG_SLOT_MASK;
        return objc_tag_classes[slot];
    }
}
複製代碼
3️⃣、遍歷緩存哈希桶並查找緩存中的方法實現

彙編源碼 : CacheLookup

.macro CacheLookup
	// p1 = SEL, p16 = isa
	ldp	p10, p11, [x16, #CACHE]	// p10 = buckets, p11 = occupied|mask
#if !__LP64__
	and	w11, w11, 0xffff	// p11 = mask
#endif
	and	w12, w1, w11		// x12 = _cmd & mask
	add	p12, p10, p12, LSL #(1+PTRSHIFT)
		             // p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))

	ldp	p17, p9, [x12]		// {imp, sel} = *bucket
1:	cmp	p9, p1			// if (bucket->sel != _cmd)
	b.ne	2f			//     scan more
	CacheHit $0			// call or return imp
	
2:	// not hit: p12 = not-hit bucket
	CheckMiss $0			// miss if bucket->sel == 0
	cmp	p12, p10		// wrap if bucket == buckets
	b.eq	3f
	ldp	p17, p9, [x12, #-BUCKET_SIZE]!	// {imp, sel} = *--bucket
	b	1b			// loop

3:	// wrap: p12 = first bucket, w11 = mask
	add	p12, p12, w11, UXTW #(1+PTRSHIFT)
		                        // p12 = buckets + (mask << 1+PTRSHIFT)
	ldp	p17, p9, [x12]		// {imp, sel} = *bucket
1:	cmp	p9, p1			// if (bucket->sel != _cmd)
	b.ne	2f			//     scan more
	CacheHit $0			// call or return imp
	
2:	// not hit: p12 = not-hit bucket
	CheckMiss $0			// miss if bucket->sel == 0
	cmp	p12, p10		// wrap if bucket == buckets
	b.eq	3f
	ldp	p17, p9, [x12, #-BUCKET_SIZE]!	// {imp, sel} = *--bucket
	b	1b			// loop

3:	// double wrap
	JumpMiss $0
.endmacro
複製代碼

CacheLookup 有三種狀況 , NORMAL , GETIMP , LOOKUP . 咱們這裏先走的是 NORMAL 狀況 .

根據咱們前面提到 類的結構 , 8 個字節 isa + 8 個字節 superClass , 而後就是 cache_t 了 .

ldp	p10, p11, [x16, #CACHE]	// p10 = buckets, p11 = occupied|mask
and	w12, w1, w11		// x12 = _cmd & mask
add	p12, p10, p12, LSL
複製代碼
  • 3.1 、 這裏就是偏移 16 字節 , 獲取到 cache_t 中的 bucketsoccupied 放到 p10, p11 中 . 而後將方法名強轉成 cache_key_tmask 進行與運算 ( and 指令便是 & )

    • 這裏稍微描述一下 , 首先咱們已經知道 mask 的值是緩存中桶的數量減 1,一個類初始緩存中的桶的數量是 4 ,每次桶數量擴容時都乘 2

    • 也就是說 mask 的值的二進制的全部 bit 位數全都是 1,那麼方法名轉成 cache_key_tmask 進行與操做時也就是取其中低 mask 位數來命中哈希桶中的元素。

    • 所以這個哈希算法所獲得的 index 索引值必定是小於緩存中桶的數量而不會出現越界的狀況。

    • 方法查找時是這個流程 , 那麼存的時候 也必定是這樣 . 下文咱們會繼續講述 .

  • 3.2 、 當經過哈希算法獲得對應的索引值後 , 循環遍歷比較所要查找的 方法名與桶中所存儲方法名 , 每次查找索引值 index-- .

    • 找到則 CacheHit $0 , 直接 call or return imp
    • 沒有找到 keyif $0 == NORMAL cbz p9, __objc_msgSend_uncached.
    • 找到符合的 key , 可是沒有對應 imp , 哈希衝突 , index-- 繼續查找 .

4️⃣、 緩存沒有查找到 調用 : __objc_msgSend_uncached .

STATIC_ENTRY __objc_msgSend_uncached
    UNWIND __objc_msgSend_uncached, FrameWithNoSaves
    MethodTableLookup
    TailCallFunctionPointer x17
END_ENTRY __objc_msgSend_uncached
複製代碼

MethodTableLookup 源碼以下 :

.macro MethodTableLookup
    SignLR
    stp	fp, lr, [sp, #-16]!
    mov fp, sp

    /*省略*/
    // receiver and selector already in x0 and x1
    mov x2, x16
    bl __class_lookupMethodAndLoadCache3
.endmacro
複製代碼

class_lookupMethodAndLoadCache3 就來到了 C 函數的流程 , 也就是咱們所說的 類和父類消息查找以及轉發流程 .

對這部分感興趣的同窗能夠閱讀 探索OC方法的本質OC方法查找與消息轉發

這兩篇文章中咱們詳細探索了方法的本質和方法查找的完整流程 .

3.5 存儲緩存

在知道了方法調用時 , 如何查找緩存 , 咱們來看下當沒有找到緩存 ( 也就是結束彙編查找來到了 C 函數的部分 ) 時, 對方法如何進行存儲到緩存的 . 達到臨界值時又是如何擴容以及存儲的 .

OC方法查找與消息轉發 中咱們說過 , 查找完本身的類以及父類的方法列表後 .

  • 若是找到 imp , 會調用到 log_and_fill_cache .
  • 若是沒找到 imp , 首先會有一次動態方法解析的機會 , 最後會來到 獲取到 resolveInstanceMethod 中返回的 imp 而且進行 cache_fill .

源碼以下 : ( 截取片斷 )

/**查找本類 */
{
    Method meth = getMethodNoSuper_nolock(cls, sel);
    if (meth) {
        log_and_fill_cache(cls, meth->imp, sel, inst, cls);
        imp = meth->imp;
        goto done;
    }
}

/**遍歷查找父類 */
unsigned attempts = unreasonableClassCount();
for (Class curClass = cls->superclass;
     curClass != nil;
     curClass = curClass->superclass)
{
    if (imp) {
        if (imp != (IMP)_objc_msgForward_impcache) {
            // Found the method in a superclass. Cache it in this class.
            log_and_fill_cache(cls, imp, sel, inst, curClass);
            goto done;
        }
}

/* 動態方法解析後 */
imp = (IMP)_objc_msgForward_impcache;
cache_fill(cls, sel, imp, inst);
複製代碼

log_and_fill_cache 內部也是調用的 cache_fill , cache_fill 內部調用 cache_fill_nolock , 所以咱們直接來到這個方法實現 .

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) {
    cacheUpdateLock.assertLocked();

    if (!cls->isInitialized()) return;

    // 再查一次 , 確保沒有其餘線程恰好已經將該方法存儲了
    if (cache_getImp(cls, sel)) return;
    // 取到 cls 類/元類的 cache_t 
    cache_t *cache = getCache(cls);

    // 新的佔用數 爲舊的 + 1
    mask_t newOccupied = cache->occupied() + 1;
    mask_t capacity = cache->capacity();
    if (cache->isConstantEmptyCache()) {
        // 檢查是不是類第一次緩存 occupied == 0 && buckets() == emptyBuckets
        // cache_t 默認 爲 read-only , 替換掉 .
        cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
    }
    else if (newOccupied <= capacity / 4 * 3) {
        // 加上本次後新的佔用數若是不超過總容量的 四分之三 不做處理
    }
    else {
        // 加上本次後新的佔用數超過總容量的四分之三 , 進行擴容
        cache->expand();
    }

    // 經過本sel查找緩存 , 找到則佔用數無須增長,直接更新該 sel 對應的 imp 
    //沒找到則佔用數 + 1 , 並把 sel 與 imp 設置到這個 bucket 中
    bucket_t *bucket = cache->find(sel, receiver);
    if (bucket->sel() == 0) cache->incrementOccupied();
    bucket->set<Atomic>(sel, imp);
}
複製代碼

緩存方法的填充在上述源碼註釋咱們已經解釋的很是清楚了 .

咱們來看下是如何擴容的 , 以瞭解咱們上述探索中 , 調用完 testFunc3_occupied 變爲 1 , _mask7 的緣由 .

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) {
        newCapacity = oldCapacity;
    }

    reallocate(oldCapacity, newCapacity);
}

enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};
複製代碼

能夠看到 新容量有如下幾個條件 :

  • 當第一次開闢 ( mask 爲 0 時 capacity 也爲 0 ) , 容量爲 INIT_CACHE_SIZE , 也就是 1 << 2 , 就是 4 .
  • oldCapacity 不爲 0 , 則開闢老的容量的兩倍 .
  • 當新容量超過 4 字節 , 新開闢容量從新置爲老容量 .

容量處理完以後 , reallocate , 重置哈希桶 , 這也是咱們上面調用完爲何 testFunc3 以前存儲的緩存沒了的緣由 .

3.6 疑問

🔐 1 、爲何每次擴容都要重置哈希桶 ?

🔑答 :

  • 1️⃣ : 因爲哈希表的特性 -- 地址映射 , 當每次總表擴容時 , 全部元素的映射都會失效 , 由於總容量變了 , 下標哈希結果也會改變 .

  • 2️⃣ : 若是須要以前全部所緩存的方法都從新存儲 , 消耗與花費有點過於大了 .

  • 3️⃣ : 所以知足最新的方法會緩存的前提下 , 老的方法若是每次擴容須要從新存儲 , 對於設計緩存自己的意義 ( 爲了提高查找方法的效率 ) 就衝突了 .

🔐 2 、爲何緩存是在佔用了 四分之三 就進行擴容呢 ? iOS 中不少處淘汰策略都採用了 3/4 處理措施 .

🔑答 :

  • 1️⃣ : 擴容並不必定會成功 , 這樣就算不成功仍是能保證有空間存儲 , 若是用完再擴容 , 擴容失敗那麼本次方法緩存就會存儲失敗了 , 基於最多見的 LRU ( 最近最少訪問原則 ) 來看 , 顯然最新的方法不該該存儲失敗 .

  • 2️⃣ : 綜合 空間時間 的利用率以及 哈希衝突問題來看 , 3/4 顯然是最佳處理策略 .

  • 3️⃣ : 當爲系統老是會將空桶的數量保證有 1/4 的空閒的狀況下 ,循環遍歷查找緩存時必定會出現命中緩存或者會出現 key == NULL 的狀況而退出循環 , 以此來避免死循環的狀況發生 。

3.7 多線程下緩存的讀寫安全

本部分知識文章參考

3.7.1 多線程讀取緩存

首先 , 讀取緩存並不會進行寫入操做 , 所以多個線程都是讀取緩存的狀況下 , 是不會有資源多線程安全問題的 , 所以 爲了效率考量 , 在 _objc_msgSend 彙編讀取緩存部分爲了效率沒有加鎖的處理 .

3.7.2 多線程寫緩存

在上述 expand 擴容 , 以及 cache_fill_nolock 中第一步都有一個

cacheUpdateLock.assertLocked();

/*********************************************************************** * Lock management **********************************************************************/
mutex_t runtimeLock;
mutex_t selLock;
mutex_t cacheUpdateLock;
recursive_mutex_t loadMethodLock;
複製代碼

系統使用了一個全局的互斥鎖 , 在填充緩存時 咱們說到了寫入前會 再查一次 , 確保沒有其餘線程恰好已經將該方法存儲了 , 所以多線程寫入緩存安全問題得以確保 .

3.7.3 多線程讀寫緩存

首先咱們來思考一下 , 多線程讀寫緩存會發生什麼問題 .

  • 🔐 一、刪 : 當其中一條線程寫入緩存達到臨界值觸發擴容時 , 會丟棄掉老的方法 , 只保留本地調用的方法 . 那麼其餘線程讀取時可能就會出現問題 .
  • 🔐 二、改 : 當其中一條線程修改了 bucketsel 對應的 imp , 其餘線程讀取一樣會出現問題 .
  • 🔐 三、改 : 當一個線程擴容了 buckets , mask 變大 , 而其餘線程讀取到了舊的 buckets 和新的 mask , 就會出現越界狀況 .

那麼帶着上述問題咱們來探索下 libobjc 是如何保證線程安全的同時最大化不影響性能的需求的 .

問題 1 :

答 : 🔑 爲了保證擴容清零不對其餘線程讀取有影響 爲了解決這個問題系統將全部會訪問到 Class 對象中的 cache 數據的 6API 函數的開始地址和結束地址保存到了兩個全局的數組中:

extern "C" uintptr_t objc_entryPoints[];
extern "C"  uintptr_t objc_exitPoints[];
複製代碼
  • 當某個寫線程對 Class 對象 cache 中的哈希桶進行擴充時,會先將已經分配的老的須要銷燬的哈希桶內存塊地址,保存到一個全局的垃圾回收數組變量 garbage_refs 中.

  • 而後再遍歷當前進程中的全部線程,並查看線程狀態中的當前 PC 寄存器中的值是否在 objc_entryPointsobjc_exitPoints 這個範圍內。

  • 也就是說查看是否有線程正在執行 objc_entryPoints 列表中的函數 .

    • 若是沒有則代表此時沒有任何函數會訪問 Class 對象中的 cache 數據,這時候就能夠放心的將全局垃圾回收數組變量 garbage_refs 中的全部待銷燬的哈希桶內存塊執行真正的銷燬操做;
    • 而若是有任何一個線程正在執行 objc_entryPoints 列表中的函數則不作處理,而等待下次再檢查並在適當的時候進行銷燬。
  • 這樣也就保證了讀線程在訪問 Class 對象中的 cache 中的 buckets 時不會產生內存訪問異常。

問題 2 / 3 :

答 : 🔑在上述 _objc_msgSend 彙編查找緩存部分 , 咱們看到

ldp x10, x11, [x16, #CACHE]	// x10 = buckets, x11 = occupied|mask
複製代碼
  • 在整個查找緩存方法中 , 將 bucketsmask ( occupied 就是 mask + 1 , mask 爲 0 時 occupied 也爲 0 , 無須讀取 ) 讀取到了 x10x11 寄存器中 , 後續處理都是使用的這兩個寄存器裏的值 .

  • 而彙編指令因爲自己就是最小執行單位 . ( 原子(atom)指化學反應不可再分的基本微粒,原子在化學反應中不可分割 ) , 所以絕大多數狀況下 , 彙編指令能夠保證原子性 .

    • 爲何說絕大多數狀況下 , 系統仍然能夠停止正在執行的命令,這時候普通匯編指令仍然是可能被意外修改。在彙編語言層面上,提供了 lock 指令前綴來保護指令執行過程層中的數據安全 .
    • 加了 lock 修飾的單條編譯指令以及這些特殊的安全指令纔算是真正的原子操做
    • 例 : lock addl $0x1 %r8d
  • 因此 只要保證讀取 _buckets_maskx10 , x11 寄存器 這條指令讀取到的是互相匹配的(即要麼同時是擴容前的數據,要麼同時是擴容後的數據),那麼即便在這個過程當中該 bucket 被刪掉了 , 也不會影響本次調用 , 由於寄存器中已經存儲了 。

那麼如何保證 這條指令讀取到的 _buckets_mask 是匹配的 , 不會被編譯器優化所影響呢 ?

編譯內存屏障

首先咱們知道 _mask 在每次擴容永遠是新值有可能會大於舊值 .

//設置更新緩存的哈希桶內存和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;
}
複製代碼

上述代碼中註釋已經寫的很清楚了 , 利用編譯內存屏障僅僅是保證 _buckets 的賦值會優先於 _mask 的賦值,也就是說,指令

ldp	x10, x11, [x16, #CACHE]
複製代碼

執行後,要麼是舊的 _mask 和 舊的 buckets, 要麼是新的 buckets 和舊的 mask , 都不會出現越界的狀況 , 綜上這些手段來保證了多線程讀寫緩存的安全 .

至此 , 方法的緩存查找以及緩存的讀取咱們已經探索完畢了 .

四、 方法緩存原理總結

  • 1️⃣ : OC 方法能夠進行緩存 , 類方法實例方法 分別存儲在 元類 的結構體的 cache_tbuckets 哈希桶中 . 消息發送時會先查找緩存 , 找到則不會繼續方法查找和轉發的流程 .
  • : cache_t 使用 capacity ( mask = 0 ? mask + 1 : 0 )來記錄當前最大容量 .
  • cache_t 使用 occupied 來記錄當前已經使用容量 .
  • 當使用容量到達總容量的 3/4 時 , 哈希桶會進行擴容 , 擴容容量爲當前容量的兩倍 ( 當使用超過 4 字節不擴容 ) . 擴容時會清空歷史緩存 , 只保留最新 selimp .
  • 緩存的讀取是線程安全的 .

關於類的基礎知識 , 所涉及的知識點咱們基本上都講述完畢了 , 接下來會繼續帶來 iOS 相關底層知識文章 , 敬請關注 .

相關文章
相關標籤/搜索