objc原始碼解析-ObjectiveC物件結構

battle_field發表於2019-04-09

概要

本文將從原始碼角度分析 Objective-C 物件的資料結構,閱讀本文需要對 Objective-C 語言有基本瞭解。本文的原始碼來自objc4-706,可在該頁面下載原始碼。另附一份可執行的Runtime原始碼。

一、Objective-C 物件定義

Objective-C 是一種物件導向的語言,NSObject 是所有類的基類。我們可以開啟 NSObject.h 檔案檢視到 NSObject 的類定義如下:

@interface NSObject <NSObject> {
    Class isa  OBJC_ISA_AVAILABILITY;
}
複製程式碼

這裡表示一個 NSObject 擁有一個 Class 型別的成員變數,那麼這個 Class 是什麼意思?我們可以在 objc4-706 原始碼的 objc-private.h 中看到如下兩個定義:

typedef struct objc_class *Class;
typedef struct objc_object *id;
複製程式碼

從第一個定義中可以看出,Class 其實就是 C 語言定義的結構體型別(struct objc_class)的指標,這個宣告說明 Objective-C 的類實際上就是 struct objc_class。

第二個定義中出現了我們經常遇到的 id 型別,這裡可以看出 id 型別是 C 語言定義的結構體型別(struct objc_object)的指標,我們知道我們可以用 id 來宣告一個物件,所以這也說明了 Objective-C 的物件實際上就是 struct objc_object。

在 objc4-680 原始碼中我們跳轉到 objc_class 的定義:

// note:這裡沒有列出結構體中定義的方法
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
};
複製程式碼

從上面可以看到 objc_class 是繼承自 objc_object 的,所以 Objective-C 中的類自身也是一個物件,只是除了 objc_object 中定義的成員變數外,還有另外三個成員變數:superclass、cache 和 bits。

所以,Objective-C 中最基本的資料結構就是:struct objc_object,objc_object 結構體定義如下:

// note:這裡沒有列出結構體中定義的方法
struct objc_object {
private:
    isa_t isa;
};

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;

#if __arm64__
#   define ISA_MASK        0x0000000ffffffff8ULL
#   define ISA_MAGIC_MASK  0x000003f000000001ULL
#   define ISA_MAGIC_VALUE 0x000001a000000001ULL
    struct {
        uintptr_t nonpointer        : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 19;
#       define RC_ONE   (1ULL<<45)
#       define RC_HALF  (1ULL<<18)
    };
#elif __x86_64__
    // Definition for x86_64, not listed, check source code in objc-private.h.
# else
#   error unknown architecture for packed isa
#endif
複製程式碼

Note: 上面對原始碼進行了簡化,原始碼中有多個條件編譯指令。為了解除閱讀原始碼時對上述簡化程式碼的疑惑,這裡簡單介紹下原始碼中幾個條件編譯巨集(如果未閱讀原始碼,可不必關心下面的解釋):

  1. SUPPORT_PACKED_ISA:表示平臺是否支援在 isa 指標中插入除 Class 之外的資訊。如果支援就會將 Class 資訊放入 isa_t 定義的 struct 內,並附上一些其他資訊,例如上面的 nonpointer 等等;如果不支援,那麼不會使用 isa_t 內定義的 struct,這時 isa_t 只使用 cls(Class 指標)。在 iOS 以及 MacOSX 上,SUPPORT_PACKED_ISA 定義為 1。
  2. __arm64__、__x86_64__ 表示 CPU 架構,例如電腦一般是 __x86_64__ 架構,手機一般是 arm 結構,這裡 64 代表是 64 位 CPU。上面只列出了 __arm64__ 架構的定義。
  3. 對於 SUPPORT_PACKED_ISA(見第一點)的 isa 指標,SUPPORT_INDEXED_ISA 表示 isa_t 中存放的 Class 資訊是 Class 的地址,還是一個索引(根據該索引可在類資訊表中查詢該類結構地址)。經測試,iOS 裝置上 SUPPORT_INDEXED_ISA 是 0。
  4. 除了上述的條件編譯巨集,這裡提一下 Union 結構,如果對 C 語言 union 不瞭解的可以參考 C/C++中 union 用法總結Why we need C Unions?

本節通過原始碼檢視了 Objective-C 語言中的類、物件以及相關資料結構的定義,可以看出 isa_t 結構非常關鍵,下面來分析一下 isa_t 結構。

二、深入理解 isa_t

上面已經給出了 isa_t 在 __arm64__ 架構下的定義,這裡繼續以 __arm64__ 架構下的定義分析(__x86_64__架構下非常類似)。下圖給出了 __arm64__ 架構下 isa_t 結構的記憶體佈局:

Screen Shot 2017-03-23 at 21.58.11.png

isa_t 結構中的 struct 的成員變數都繪製在了上圖中,下面逐個分析各個欄位的含義:

nonpointer: 表示是否對 isa 指標開啟指標優化

在說明 nonpointer 意義前,先簡單介紹一下蘋果為 64 位裝置提出的節省記憶體和提高執行效率的一種優化方案:Tagged Pointer。

設想在 32 位和 64 位裝置上分別儲存一個 NSNumber 物件,其值是一個 NSInteger 整數。

首先,分析一下記憶體佔用情況:

  1. 讀寫 NSNumber 物件的指標。在 32 位裝置上,一個指標需要 4byte。在 64 位裝置上,一個指標需要 8byte。

  2. 儲存 NSNumber 物件值的記憶體。在 32 位裝置上,NSInteger 佔用 4byte。在 64 位裝置上,NSInteger 佔用 8byte。

  3. Objective-C 記憶體管理採用引用計數的方式,我們需要使用額外的空間來儲存引用計數,如果引用計數使用 NSInteger,那麼 64 位裝置會比 32 位裝置多用 4byte。

此外,從效率上講,引用計數、生命週期標識等儲存在其他地方,也有不少處理邏輯(例如為引用計數動態分配記憶體等)。

一般來說,32 位已經足夠儲存我們通常遇到的整數和指標地址了,那麼在 64 位裝置上,就有 32 位地址空間浪費掉了,儲存一個值為 NSInteger 的 NSNumber 物件就浪費了 8byte 的空間(4byte 指標和 4byte value)。

為了節省記憶體以及提高程式執行效率,蘋果提出了 Tagged Pointer,Tagged Pointer 簡單來說就是使用儲存指標的記憶體空間儲存實際的資料。

例如,NSNumber 指標在 64 位裝置上佔用 8byte 記憶體空間,指標優化可以將 NSNumber 的值通過某種規則放入到儲存 NSNumber 指標地址的 8byte 中,這樣就減少了 NSInteger 所需的 8byte 記憶體空間,從而節省了記憶體。

另外,Tagged Pointer 已經不再是物件指標,它裡面存放著實際資料,只是一個普通變數,所以它的記憶體無需在堆上 calloc/free,從而提高了記憶體讀取效率。但是由於 Tagged Pointer 不是合法的物件指標,所以我們無法通過 Tagged Pointer 獲取 isa 資訊。關於 Tagged Pointer 更詳細的介紹可參考:深入理解 Tagged Pointer-唐巧,這裡不做深入介紹。

瞭解 Tagged Pointer 的概念後,再來看 nonpointer 變數。nonpointer 變數佔用 1bit 記憶體空間,可以有兩個值:0 和 1,分別代表不同的 isa_t 的型別:

  1. 0 表示 isa_t 沒有開啟指標優化,不使用 isa_t 中定義的結構體。訪問 objc_object 的 isa 會直接返回 isa_t 結構中的 cls 變數,cls 變數會指向物件所屬的類的結構,在 64 位裝置上會佔用 8byte。

  2. 1 表示 isa_t 開啟了指標優化,不能直接訪問 objc_object 的 isa 成員變數(因為 isa 已經不是一個合法的記憶體指標了,見 Tagged Pointer 的介紹),從其名字 nonpointer 也可獲知這個 isa 已經不是一個指標了。但是 isa 中包含了類資訊、物件的引用計數等資訊,在 64 位裝置上充分利用了記憶體空間。

對於 nonpointer 為 1 的 isa_t 的結構就是 isa_t 內部定義的結構體,該結構體中包含了物件的所屬類資訊、引用計數等。從這裡也可以看出,指標優化減少了記憶體使用,並且引用計數等物件關聯資訊都存放在 isa_t 中,也減少了很多獲取物件資訊的邏輯,提高了執行效率。

shiftcls:

儲存類指標的值。開啟指標優化的情況下,在 arm64 架構中有 33 位用來儲存類指標。

其餘變數

其他幾個變數很容易理解,這裡不再做太多介紹。

  1. has_assoc 該變數與物件的關聯引用有關,當物件有關聯引用時,釋放物件時需要做額外的邏輯。關聯引用就是我們通常用 objc_setAssociatedObject 方法設定給物件的,這裡對於關聯引用不做過多分析,如果後續有時間寫關聯引用實現時再深入分析關聯引用有關的程式碼。

  2. has_cxx_dtor 表示該物件是否有 C++ 或者 Objc 的析構器,如果有解構函式,則需要做析構邏輯,如果沒有,則可以更快的釋放物件。

  3. magic 用於判斷物件是否已經完成了初始化,在 arm64 中 0x16 是偵錯程式判斷當前物件是真的物件還是沒有初始化的空間(在 x86_64 中該值為 0x3b)。

  4. weakly_referenced 標誌物件是否被指向或者曾經指向一個 ARC 的弱變數,沒有弱引用的物件可以更快釋放。

  5. deallocating 標誌物件是否正在釋放記憶體。

  6. extra_rc 表示該物件的引用計數值,實際上是引用計數值減 1,例如,如果物件的引用計數為 10,那麼 extra_rc 為 9。如果引用計數大於 10,則需要使用到下面的 has_sidetable_rc。

  7. has_sidetable_rc 當物件引用技術大於 10 時,則需要借用該變數儲存進位(類似於加減法運算中的進位借位)。

  8. ISA_MAGIC_MASK 通過掩碼方式獲取 magic 值。

  9. ISA_MASK 通過掩碼方式獲取 isa 的類指標值。

  10. RC_ONE 和 RC_HALF 用於引用計數的相關計算。

struct objc_object 中的方法

通過原始碼檢視 struct objc_object,我們可以看到其中定義了很多方法。例如 isa_t 中的與類指標相關的兩個方法:

Class ISA();
Class getIsa();
複製程式碼

為什麼需要定義方法來操作 isa 指標?這裡只因為對 isa_t 的變數操作封裝了方法是因為前面介紹了開啟了指標優化的 isa 已經不是一個合法的指標了,我們無法直接操作物件的 isa 指標,只有通過方法來進行相應操作。

三、物件、類、元類

第一部分討論了 NSObject、objc_object、objc_class 的定義,可以看出 Class 其實就是 C 語言定義的 objc_class 結構體,而 objc_class 繼承自 objc_object,所以 Objective-C 中 Class 也是一個物件。第二部分深入解析了 objc_object 中唯一的成員變數的型別:isa_t,這部分討論了 isa_t 中存放著物件所屬的類的指標。第三部分我們來討論在 Objective-C 的物件、類和元類(meta-class,後面會介紹)的關係。

剛剛提到,objc_class 繼承自 objc_object,所以 objc_class 也是有 isa_t 型別的 isa 成員變數的,那麼 objc_class 的 isa_t 中的 shiftcls 表示了什麼意思呢?這裡引入一個新的概念:元類。objc_class 的 isa_t 中的 shiftcls 就指向了 objc_object 的元類。什麼是元類?

先來看一下 objc_class 除了繼承自 objc_object 的成員變數 isa_t 外的三個成員變數:

  1. Class superclass:該變數指向父類的 objc_class;
  2. cache_t cache:該變數存放著例項方法的快取,為了提高每次執行;
  3. class_data_bits_t bits:存放著例項的所有方法。

關於 Objective-C 的 Runtime 的方法查詢這裡不進深入討論。不過,通過上面三個屬性的解釋,我們可以窺探出一個物件可以呼叫的方法列表是儲存在物件的類結構中的。其實不儲存在物件中的原因也很好理解,如果方法列表存放在物件結構中,那每建立一個物件,就要增加一個例項方法列表,資源消耗過大,所以儲存在了類結構中。但是,除了例項方法外,一般還會法就存放在了上面提到的元類裡。元類也是一個 objc_class 結構,結構中有 isa 和 superclass 指標,下圖是 Objective-C Runtime 講解中最經典的一張圖:

meta_class.png

注意:上圖中 isa 的箭頭,其實並不是 isa 指標直接指向了相應結構,而是 isa_t 中的 shiftcls 指向了相應結構。

這裡根據上述分析以及上圖給出幾個總結點:

  1. 每個類都有其對應的元類;

  2. 一個類的類結構中儲存著該類所有例項方法,物件通過 isa 去類結構中獲取例項方法實現,若該類結構中沒有所需的例項方法,則通過 superclass 指標去父類結構查詢,直到 Root class(class)。

  3. 一個類的元類中儲存著該類所有類方法,類物件通過 isa 去元類結構中獲取類方法實現,若元類結構中沒有所需的類方法,則通過 superclass 指標去父類元類結構查詢,直到 Root class(class)。

  4. 在 Objective-C 中,Root class(class)其實就是 NSObject,NSObject 的 superclass 指向 nil。

  5. 在 Objective-C 中,所有的物件(包含 instance、class、meta-class)都可以呼叫 NSObject 的例項方法。

  6. 在 Objective-C 中,所有的 class 以及 meta-class 都可以呼叫 NSObject 的類方法。

如果對於元類(meta-class)還不理解,推薦閱讀 what is meta class in objective-c?,此文對 meta-class 的解釋通俗易懂,建議閱讀。

四、物件初始化過程

第四部分將從原始碼角度解析一個 NSObject 物件建立的過程。

我們知道建立一個 NSObject 物件的程式碼為:[[NSObject alloc] init];(還有一種方式是使用 [NSObject new],檢視原始碼可以看到內部其實與第一種方式完全相同)。

+ (id)alloc {
    return _objc_rootAlloc(self);
}

_objc_rootAlloc(Class cls) {
    return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}

static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false) {
    // means if (checkNil && !cls)) return nil;
    // besides, if checkNil && !cls probably to be false, "return nil" is optimized.
    if (slowpath(checkNil && !cls)) return nil; // 檢查 cls 資訊是否為 nil,如果為 nil,則無法建立新物件,返回 nil。

#if __OBJC2__ // If Objective-C 2.0 or later.
    if (fastpath(!cls->ISA()->hasCustomAWZ())) { // 檢查類是否有預設的 alloc/allocWithZone 實現
        // No alloc/allocWithZone implementation. Go straight to the allocator.
        // fixme store hasCustomAWZ in the non-meta class and
        // add it to canAllocFast's summary
        if (fastpath(cls->canAllocFast())) { // 是否可以快速分配記憶體(這裡跟蹤原始碼可看到返回了 false)
            // No ctors, raw isa, etc. Go straight to the metal.
            bool dtor = cls->hasCxxDtor();
            id obj = (id)calloc(1, cls->bits.fastInstanceSize());
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            obj->initInstanceIsa(cls, dtor);
            return obj;
        }
        else {
            // Has ctor or raw isa or something. Use the slower path.
            id obj = class_createInstance(cls, 0); // 這裡是建立物件的關鍵函式呼叫
            if (slowpath(!obj)) return callBadAllocHandler(cls); // 檢查新建的物件是否合法
            return obj;
        }
    }
#endif

    // No shortcuts available.
    if (allocWithZone) return [cls allocWithZone:nil]; // 這裡 cls 的 allocWithZone 方法裡也是呼叫了 class_createInstance。
    return [cls alloc];
}
複製程式碼

可以看到,新建一個物件主要是 callAlloc 函式。在該函式中有一個 slowpath,我們來看下定義:

#define fastpath(x) (__builtin_expect(bool(x), 1))
#define slowpath(x) (__builtin_expect(bool(x), 0))
複製程式碼

其中 __builtin_expect(EXP, N) 表示 EXP == N 編譯器優化的 gcc 內建函式。通過這種方式,編譯器在編譯過程中會把可能性更大的 if 分支程式碼緊跟前面的程式碼,從而減少指令跳轉帶來的效能的下降。所以從邏輯上講 if(slowpath(x)) 與 if(x) 的含義相同,只不過是多了編譯器優化的內容。

上述程式碼中已經對一些語句進行了註釋,該方法中大部分語句都是處理新建物件使用的 zone。我們這裡重點分析 class_createInstance 函式,下面是原始碼中 class_createInstance 的實現:

id class_createInstance(Class cls, size_t extraBytes) {
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}

static __attribute__((always_inline))
id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
                              bool cxxConstruct = true,
                              size_t *outAllocatedSize = nil) {
    if (!cls) return nil; // 檢查 cls 是否合法

    assert(cls->isRealized());

    // Read class's info bits all at once for performance
    bool hasCxxCtor = cls->hasCxxCtor(); // 是否有建構函式
    bool hasCxxDtor = cls->hasCxxDtor(); // 是否有解構函式
    bool fast = cls->canAllocNonpointer(); // 是否使用原始 isa 格式(見第二部分對 isa 的介紹)

    size_t size = cls->instanceSize(extraBytes); // 需要分配的空間大小,開啟 instanceSize 實現可以知道物件是按照 16bytes 對齊的
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    if (!zone  &&  fast) {
        obj = (id)calloc(1, size);
        if (!obj) return nil;
        obj->initInstanceIsa(cls, hasCxxDtor); // 這一步是關鍵,用於初始化建立的記憶體空間
    }
    else {
        if (zone) {
            obj = (id)malloc_zone_calloc ((malloc_zone_t *)zone, 1, size);
        } else {
            obj = (id)calloc(1, size);
        }
        if (!obj) return nil;

        // Use raw pointer isa on the assumption that they might be
        // doing something weird with the zone or RR.
        obj->initIsa(cls);
    }

    if (cxxConstruct && hasCxxCtor) {
        obj = _objc_constructOrFree(obj, cls);
    }

    return obj;
}
複製程式碼

上述函式實現中,也有了關鍵位置的註釋,這裡我們直接分析最重要部分: obj->initInstanceIsa(cls, hasCxxDtor); 該部分程式碼可以根據第二部分進行簡化,簡化後如下:

inline void objc_object::initInstanceIsa(Class cls, bool hasCxxDtor) {
    assert(!cls->instancesRequireRawIsa());
    assert(hasCxxDtor == cls->hasCxxDtor());
    initIsa(cls, true, hasCxxDtor);
}

inline void objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor) {
    assert(!isTaggedPointer());
    if (!nonpointer) {
        isa.cls = cls;
    } else {
        assert(!DisableNonpointerIsa);
        assert(!cls->instancesRequireRawIsa());

        isa_t newisa(0);
        newisa.bits = ISA_MAGIC_VALUE;
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.shiftcls = (uintptr_t)cls >> 3;
    }
}
複製程式碼

上述程式碼中,newisa.bits = ISA_MAGIC_VALUE; 是為了對 isa 結構賦值一個初始值,ISA_MAGIC_VALUE 的值為 0x001d800000000001ULL,通過第二部分對 isa_t 的結構分析,我們可以知道此次賦值只是對 nonpointer 和 magic 部分進行了賦值。

newisa.shiftcls = (uintptr_t)cls >> 3; 是將類的地址儲存在物件的 isa 結構中, 這裡右移三位的主要原因是用於將 Class 指標中無用的後三位清除減小記憶體的消耗,因為類的指標要按照位元組(8 bits)對齊記憶體,其指標後三位都是沒有意義的 0。關於類指標對齊的詳細解析可參考:從 NSObject 的初始化了解 isa

初始化 isa 之後,[NSObject alloc] 的工作算是做完了,下面就是 init 相關邏輯:

- (id)init {
    return _objc_rootInit(self);
}

id _objc_rootInit(id obj) {
    return obj;
}
複製程式碼

可以看到 init 其實只是返回了新建的物件指標,沒有其他多餘邏輯。

到這裡新建一個物件的所有邏輯就結束了。

五、參考資料

1. 神經病院 Objective-C Runtime 入院第一天— isa 和 Class

2.從 NSObject 的初始化了解 isa

3.深入理解 Tagged Pointer

4.ObjC runtime 原始碼 閱讀筆記(一)

5.What is a meta-class in Objective-C?

Note: 文中內容不代表權威,有任何問題都可以進行交流。轉載請註明原文地址。

相關文章