OC對象的本質及底層探究

Objective-C中的對象,簡稱OC對象,主要分為3種:

  • instance對象(實例對象)
  • class對象(類對象)
  • meta-class對象(元類對象)

instance對象

instance對象就是通過類對象alloc或者new操作所創(chuàng)建的,每次調用alloc都會產(chǎn)生新的instance對象。在這個過程中會拷貝實例所屬類的成員變量,但不拷貝類對象中定義的方法。
instance對象在內(nèi)存中存儲的信息包括:


instance對象內(nèi)存中存儲信息

因為OC的對象結構都是通過基礎C\C++的結構實現(xiàn)的,所以我們通過創(chuàng)建OC文件及對象,并將OC文件轉化為C++文件來探究OC對象的本質。

創(chuàng)建一個命令行項目,OC代碼如下:

@interface Person : NSObject
{
    @public
    int _age;
    int _no;
}
@end

@implementation Person


@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *p1 = [[Person alloc] init];
        p1->_age = 3;
        
        Person *p2 = [[Person alloc] init];
        p2->_age = 4;
    }
    return 0;
}

我們通過命令行將OC的main.m文件轉化為C++文件

clang -rewrite-objc main.m -o main.cpp // 這種方式?jīng)]有指定架構例如arm64架構 其中cpp代表(c plus plus)
生成 main.cpp

我們可以指定架構模式的命令行,使用xcode工具 xcrun

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp 
生成 main-arm64.cpp 

通過反編譯oc代碼為c++代碼,可以發(fā)現(xiàn)instance對象實際結構為:

struct NSObject_IMPL {
    Class isa;
};
struct Person_IMPL {
    struct NSObject_IMPL NSObject_IVARS;
    int _age;
    int _no;
};

相當于

struct Person_IMPL {
    Class isa;
    int _age;
    int _no;
};

class對象

class對象在內(nèi)存中存儲的信息主要包括
1.isa指針
2.superclass指針
3.成員變量信息(ivar)
4.屬性信息(@property)
5.對象方法信息(instance method)
6.方法緩存(cache)
7.協(xié)議信息(protocol)
......


class對象內(nèi)存中存儲信息

成員變量(ivar)的值是儲存在instance對象中的,因為只有當我們創(chuàng)建實例對象的時候才為成員變量賦值。但是成員變量叫什么名字,是什么類型,只需要一份就可以了。所以存儲在class對象中。

屬性(property)和成員變量是不同的,屬性是添加了存取方法的成員變量。

class是一個objc_class結構體指針,通過查看runtime源碼,搜索struct objc_class,可以得知其主要結構如下:

struct objc_class

元類對象(meta-class)

meta-class對象和class對象的內(nèi)存結構是一樣的,但是用途不一樣,在內(nèi)存中存儲的主要信息包括
1.isa指針
2.superclass指針
3.類方法信息(class method)
......


meta-class內(nèi)存中存儲信息

meta-class對象和class對象的內(nèi)存結構是一樣的,所以meta-class中也有類的屬性信息,類的對象方法信息等成員變量,但其中的值是空的。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        //instance對象,實例對象
        NSObject *object1 = [[NSObject alloc] init];
        NSObject *object2 = [[NSObject alloc] init];
        
        //class對象,類對象
        //class方法返回的一直是class對象,類對象
        Class objectClass1 = [object1 class];
        Class objectClass2 = [object2 class];
        Class objectClass3 = object_getClass(object1);
        Class objectClass4 = object_getClass(object2);
        Class objectClass5 = [NSObject class];
        
        // meta-class對象,元類對象
        // 將類對象當做參數(shù)傳入,獲得元類對象
        Class objectMetaClass = object_getClass(objectClass5);
        
        NSLog(@"instance - %p %p",
              object1,
              object2);
        
        NSLog(@"class - %p %p %p %p %p %d",
              objectClass1,
              objectClass2,
              objectClass3,
              objectClass4,
              objectClass5,
              class_isMetaClass(objectClass3));
        
        NSLog(@"objectMetaClass - %p %d", objectMetaClass, class_isMetaClass(objectMetaClass));

    }
    return 0;
}
2019-06-11 20:43:35.089627+0800 OC對象本質[29156:6815927] instance - 0x100602640 0x100602270
2019-06-11 20:43:35.090136+0800 OC對象本質[29156:6815927] class - 0x7fff9bec1140 0x7fff9bec1140 0x7fff9bec1140 0x7fff9bec1140 0x7fff9bec1140 0
2019-06-11 20:43:35.090148+0800 OC對象本質[29156:6815927] objectMetaClass - 0x7fff9bec10f0 1

程序中只會存在一個class對象,也只有一個meta-class對象,但是class對象通過alloc或者new可以創(chuàng)建多個instance對象。

isa指針和superclass指針

結合文章后面的面試問題詳細講解(見文章下面面試題3)

以上為我的總結,現(xiàn)在我們用三個面試問題來探究底層的原理。

1.一個NSObject對象占用多少內(nèi)存?

思考:一個OC對象在系統(tǒng)中是如何分配內(nèi)存的。
NSObject對象的底層,通過將OC代碼編譯成C++代碼(編譯方法如文章最開始所說),其實就是一個結構體

struct NSObject_IMPL {
    Class isa;
};

我們發(fā)現(xiàn)這個結構體只有一個成員isa指針,而指針在64位架構中占8個字節(jié)。也就是說一個NSObject對象所需的內(nèi)存是8個字節(jié)。但是實際情況系統(tǒng)具體在內(nèi)存中分配了多少內(nèi)存呢?
OC代碼

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <malloc/malloc.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
        // 獲得NSObject實例對象的成員變量所占用的大小 >> 8
        NSLog(@"%zd", class_getInstanceSize([NSObject class]));
        // 獲得obj指針所指向內(nèi)存的大小 >> 16
        NSLog(@"%zd", malloc_size((__bridge const void *)obj));
    }
    return 0;
}

運行結果

2019-06-12 14:44:51.903416+0800 OC對象內(nèi)存分布[32699:8399663] 8
2019-06-12 14:44:51.906796+0800 OC對象內(nèi)存分布[32699:8399663] 16

NSObject對象大小是8,但是系統(tǒng)分配的內(nèi)存是16。這樣這個面試題就有了答案。

但是為什么結果會是16而不是8呢?
繼續(xù)舉一個例子,Person類繼承NSObject,看其內(nèi)存分配情況

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <malloc/malloc.h>

@interface Person : NSObject
{
    int _age;
    int _no;
    NSString *_name;
}

@end

@implementation Person

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *p = [[Person alloc] init];
        NSLog(@"%zd", class_getInstanceSize([Person class]));
        NSLog(@"%zd", malloc_size((__bridge const void *)p));
    }
    return 0;
}

運行結果

2019-06-12 14:46:32.696414+0800 OC對象內(nèi)存分布[32713:8405320] 24
2019-06-12 14:46:32.696602+0800 OC對象內(nèi)存分布[32713:8405320] 32

編譯代碼成c++,發(fā)現(xiàn)Person_IMPL結構體為

struct Person_IMPL {
    Class isa;          // 8個字節(jié)
    int _age;           //4個字節(jié)
    int _no;            //4個字節(jié)
    NSString *_name;    //8字節(jié)
};

同樣Person對象大小是24,但是系統(tǒng)分配內(nèi)存為32。

窺探內(nèi)存結構

實時查看內(nèi)存數(shù)據(jù)

方式一:通過打斷點
Debug Workflow -> viewMemory address中輸入對象p的地址

p內(nèi)存地址

從上圖我們可以發(fā)現(xiàn),截圖中分別標出了每個成員變量對應的值,讀取數(shù)據(jù)從高位開始讀,isa值為0x001d800100001215(8字節(jié)),_age值為0x00000005(10進制=5)(4字節(jié)),_no值為0x00000006(10進制=6)(4字節(jié)),_name值為0x0000000100001048(8字節(jié))

方式二:通過lldb指令,xcode自帶的調試器

memory read 0x100507e40

// 增加讀取條件
// memory read/數(shù)量格式字節(jié)數(shù)  內(nèi)存地址
// 簡寫 x/數(shù)量格式字節(jié)數(shù)  內(nèi)存地址
// 格式 x是16進制,f是浮點,d是10進制
// 字節(jié)大小   b:byte 1字節(jié),h:half word 2字節(jié),w:word 4字節(jié),g:giant word 8字節(jié)

示例:x/4xw    //   /后面表示如何讀取數(shù)據(jù) w表示4個字節(jié)4個字節(jié)讀取,x表示以16進制的方式讀取數(shù)據(jù),4則表示讀取4次

同時也可以通過lldb修改內(nèi)存中的值,在不清楚實際情況時慎用

memory write 0x100507e48 6
將_no的值改為了6,因為第一個成員變量isa的地址為0x100507e40,并且isa占用內(nèi)存為8字節(jié),所以第二個成員變量的地址為0x100507e48
(lldb) memory read 0x100507e40
0x100507e40: 15 12 00 00 01 80 1d 00 05 00 00 00 06 00 00 00  ................
0x100507e50: 48 10 00 00 01 00 00 00 00 00 00 00 00 00 00 00  H...............
(lldb) x/4xw 0x100507e40
0x100507e40: 0x00001215 0x001d8001 0x00000005 0x00000006
(lldb) x/2xg 0x100507e40
0x100507e40: 0x001d800100001215 0x0000000600000005
(lldb) x/4dw 0x100507e40
0x100507e40: 4629
0x100507e44: 1933313
0x100507e48: 5
0x100507e4c: 6
(lldb) memory write 0x100507e48 6
(lldb) po p->_age
6

(lldb) po p->_no
6

(lldb) 

上面提出的問題,為什么對象本身大小和系統(tǒng)分配的大小不是完全相等,其實是因為系統(tǒng)在分配內(nèi)存之前會先進行計算,這個計算要符合內(nèi)存對齊原則。內(nèi)存未對齊的情況下會大大降低CPU的讀取性能。
以下通過查看runtime源碼,來驗證這個說法。

當類對象調用alloc之后,系統(tǒng)底層會調用class_createInstances這個方法,順著這個方法往下走,會進入instanceSize方法計算大小,而計算方法就是word_align,內(nèi)存對齊。當計算值小于16時,強制等于16。

class_createInstances(Class cls, size_t extraBytes, 
                      id *results, unsigned num_requested)
{
    return _class_createInstancesFromZone(cls, extraBytes, nil, 
                                          results, num_requested);
}

_class_createInstancesFromZone(Class cls, size_t extraBytes, void *zone, 
                               id *results, unsigned num_requested)
{
    unsigned num_allocated;
    if (!cls) return 0;

    size_t size = cls->instanceSize(extraBytes);
}

size_t instanceSize(size_t extraBytes) {
        size_t size = alignedInstanceSize() + extraBytes;
        // CF requires all objects be at least 16 bytes.
        if (size < 16) size = 16;
        return size;
    }

// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() {
        return word_align(unalignedInstanceSize());
    } 

#   define WORD_MASK 7UL
static inline uint32_t word_align(uint32_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
}

編譯器在給結構體開辟空間時,首先找到結構體中最寬的基本數(shù)據(jù)類型,然后尋找內(nèi)存地址能是該基本數(shù)據(jù)類型的整倍的位置,作為結構體的首地址。將這個最寬的基本數(shù)據(jù)類型的大小作為對齊模數(shù)。
為結構體的一個成員開辟空間之前,編譯器首先檢查預開辟空間的首地址相對于結構體首地址的偏移是否是本成員的整數(shù)倍,若是,則存放本成員,反之,則在本成員和上一個成員之間填充一定的字節(jié),以達到整數(shù)倍的要求,也就是將預開辟空間的首地址后移幾個字節(jié)。
總結內(nèi)存對齊兩大原則
原則1:前面的地址必須是后面的地址正數(shù)倍,不是就補齊。
原則2:整個Struct的地址必須是最大字節(jié)的整數(shù)倍。

ios系統(tǒng)在64bit環(huán)境下的內(nèi)存分配規(guī)則
1.分配最小內(nèi)存為16字節(jié)
2.分配字節(jié)總大小是16的正整數(shù)倍

通過以上原則我們可以知道為什么NSObject對象大小為8實際分配大小為16,也知道Person對象大小為24實際分配大小為32了。

2.OC的類信息存放在哪里?

查看runtime源碼,Class類型其實是一個objc_class指針

/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;

網(wǎng)上很多人的文章是用這個代碼來講解的,目前其實已經(jīng)過時OBJC2_UNAVAILABLE,但是類主要的信息還是類似的,具有一定的參考意義

struct objc_class {
    Class isa  OBJC_ISA_AVAILABILITY;
#if !__OBJC2__
    Class super_class                                        OBJC2_UNAVAILABLE;
    const char *name                                         OBJC2_UNAVAILABLE;
    long version                                             OBJC2_UNAVAILABLE;
    long info                                                OBJC2_UNAVAILABLE;
    long instance_size                                       OBJC2_UNAVAILABLE;
    struct objc_ivar_list *ivars                             OBJC2_UNAVAILABLE;
    struct objc_method_list **methodLists                    OBJC2_UNAVAILABLE;
    struct objc_cache *cache                                 OBJC2_UNAVAILABLE;
    struct objc_protocol_list *protocols                     OBJC2_UNAVAILABLE;
#endif

} OBJC2_UNAVAILABLE;

最新的底層代碼結構是這樣的,以下代碼有刪減,只展示主要信息

struct objc_class{
    Class isa;
    Class superclass;
    cache_t cache;             // 方法緩存
    class_data_bits_t bits;    // 用于獲取具體類信息

    class_rw_t *data() {       //可讀可寫信息
        return bits.data();
    }
}
bits數(shù)據(jù)進行&FAST_DATA_MASK運算得到下面數(shù)據(jù)
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;     //協(xié)議列表

    Class firstSubclass;
    Class nextSiblingClass;

    char *demangledName;
}

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;          //instance對象占用的內(nèi)存空間
#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;

    method_list_t *baseMethods() const {
        return baseMethodList;
    }
};

通過源碼可以得出,Class類型包含isa指針、superclass指針、成員變量列表、屬性列表、方法列表、協(xié)議列表、已調用方法緩存、類名、實例變量大小等信息

class對象和meta-class對象同為Class類型,但是兩者也有區(qū)別。對象方法保存在class中,類方法保存在meta-class中;isa指針和superclass指針的指向也分別不同。

如何證明對象方法是保存在class中?如何證明類方法時保存在meta-class中?以及方法的調用本質及流程是如何?這些問題我將在下一篇文章中講解

3.isa指針和superclass指針到底指向哪里?

1.當instance調用對象方法的時候,從上面講到,對象方法是存儲在class對象中的,那么要找到實例方法,就必須找到class對象,那么此時isa的作用就來了。

  • instance的isa指向class,當調用對象方法時,通過instance的isa找到class,最后找到對象方法的實現(xiàn)進行調用。

2.當class對象調用類方法的時候,同上,類方法是存儲在meta-class對象中的。那么要找到類方法,就需要找到meta-class對象。

  • class的isa指向meta-class,當調用類方法時,通過class的isa找到meta-class,最后找到類方法的實現(xiàn)進行調用。
    isa指針指向圖

3.當instance調用其父類對象方法的時候,又是怎么找到父類對象方法的呢?此時就需要使用到class類對象superclass指針。

  • 當Student的instance對象要調用父類的對象方法時,會先通過isa找到Student的class,然后通過superclass找到Person的class,最后找到對象方法的實現(xiàn)進行調用。
  • 如果Person發(fā)現(xiàn)自己沒有響應的對象方法,就會通過superclass隨著繼承鏈一直向上到父類中尋找,直到找到基類的class,一直找到基類的class都沒有找到就會報錯。
    class對象的superclass指針指向圖

4.當class對象調用父類的類方法時,此時既需要使用isa指針又需要使用superclass指針。

  • 當Student的class要調用父類的類方法時,會先通過isa找到Student的meta-class,然后通過superclass找到Person的meta-class,最后找到類方法的實現(xiàn)進行調用。
  • 如果Person的meta-class沒有響應的類方法時,就會通過superclass隨著繼承鏈一直向上到父類中尋找,直到找到基類的meta-class,此時沒有找到響應方法,不會報錯。
  • 接下來會來到基類的class中去找,只要基類的class中有相同方法名的方法就會調用(無論對象方法或者類方法),此時還沒找到,才會報錯。
    meta-class對象的superclass指針指向圖

最后請看這張經(jīng)典的isa、superclass指向圖,一切就更加明了


官方isa、superclass指向圖

isa,superclass總結
isa指針

  • instance的isa指向所屬class
  • class的isa指向所屬meta-class
  • meta-class的isa指向所屬基類的meta-class

superclass

  • class的superclass指向父類的class,如果該類已經(jīng)是基類,則superclass為nil
  • meta-class的superclass指向父類的meta-class,如果該類是基類的meta-class,則superclass指向基類的class

instance對象調用對象方法的軌跡:isa找到class,方法不存在,就通過superclass找父類
class調用類方法的軌跡:isa找meta-class,方法不存在,就通過superclass找父類

如何證明isa指針和superclass的指針真如上所說呢?

64bit環(huán)境,instance的isa指向class,以及class的isa指向meta-class都是經(jīng)過一次位運算的(isa & ISA_MASK)。通過runtime源碼,找到以下內(nèi)容

# if __arm64__  //iOS程序
#   define ISA_MASK        0x0000000ffffffff8ULL
# elif __x86_64__  //MAC程序
#   define ISA_MASK        0x00007ffffffffff8ULL
#endif

首先證明instance的isa是指向class的

#import <Foundation/Foundation.h>
#import <objc/runtime.h>

@interface Person : NSObject

@end

@implementation Person

@end

@interface Student : Person

@end

@implementation Student

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *person = [[Person alloc] init];

        Class personClass = [Person class];

        Class personMetaClass = object_getClass(personClass);

        NSLog(@"%p %p %p", person, personClass, personMetaClass);
    }
    return 0;
}

程序運行時打斷點,在控制臺進行l(wèi)ldb操作,由下圖結果得證。


isa指向驗證1.png

再證明class的isa指針是指向meta-class的

因為在外部不能直接打印出class的isa地址,所以我仿照系統(tǒng)自定義一個class結構體,暴露出isa指針,然后將personClass強轉為我定義的類型,從而拿到class的isa地址。
同樣,程序運行時打斷點,在控制臺進行l(wèi)ldb操作,由下圖結果得證。


isa指向驗證2

接下來證明superclass是指向其父類的
因為在外部也不能直接打印出class的superclass地址,用以上同樣方法自定義class結構體。

superclass指向驗證

面試題總結

  1. 一個NSObject對象占用多少內(nèi)存?
    答:一個指針變量所占用的大小(64bit占8個字節(jié),32bit占4個字節(jié)),iOS系統(tǒng)中占用16個字節(jié)。
  2. OC的類信息存放在哪里?
    答:成員變量的具體值存放在instance對象。對象方法,協(xié)議,屬性,成員變量信息存放在class對象。類方法信息存放在meta-class對象。
  3. isa指針和superclass指針到底指向哪里?
    答:1.instance的isa指向所屬class,class的isa指向所屬meta-class,meta-class的isa指向所屬基類的meta-class。2.class的superclass指向父類的class,如果該類已經(jīng)是基類,則superclass為nil;meta-class的superclass指向父類的meta-class,如果該類是基類的meta-class,則superclass指向基類的class。

文中如果有不對的地方歡迎指出
本文有參考http://www.lxweimin.com/p/aa7ccadeca88

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容