OC對象的本質(上) —— OC對象的底層實現原理

一個NSObject對象占用多少內存?

Objective-C的本質

平時我們編寫的OC代碼,底層實現都是C/C++代碼

Objective-C --> C/C++ --> 匯編語言 --> 機器碼

所以Objective-C的面向對象都是基于C/C++的數據結構實現的,所以我們可以將Objective-C代碼轉換成C/C++代碼,來研究OC對象的本質。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
    }
    return 0;
}

我們在main函數里面定義一個簡單對象,然后通過 clang -rewrite-objc main.m -o main.cpp命令,將main.m文件進行重寫,即可轉換出對應的C/C++代碼。但是可以看到一個問題,就是轉換出來的文件過長,將近10w行。


因為不同平臺支持的代碼不同(Windows/Mac/iOS),那么同樣一句OC代碼,經過編譯,轉成C/C++代碼,以及最終的匯編碼,是不一樣的,匯編指令嚴重依賴平臺環境。
我們當前關注iOS開發,所以,我們只需要生成iOS支持的C/C++代碼。因此,可以使用如下命令

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc <OC源文件> -o <輸出的cpp文件>
-sdk:指定sdk
-arch:指定機器cpu架構(模擬器-i386、32bit、64bit-arm64 )
如果需要鏈接其他框架,使用-framework參數,比如-framework UIKit
一般我們手機都已經普及arm64,所以這里的架構參數用arm64,生成的cpp代碼如下


接下來,我們查看一下main_arm64.cpp源文件,如果熟悉這個文件,你將會發現這么一個結構體

struct NSObject_IMPL {
    Class isa;
};

我們再來對比看一下NSObject頭文件的定義

@interface NSObject <NSObject> {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-interface-ivars"
    Class isa  OBJC_ISA_AVAILABILITY;
#pragma clang diagnostic pop
}
@end

簡化一下,就是

@interface NSObject  {
    Class isa ;
}
@end

是不是猜到點什么了?沒錯,struct NSObject_IMPL其實就是NSObject的底層結構,或者說底層實現。換個角度理解,可以說C/C++的結構體類型支撐了OC的面相對象。

點進Class的定義,我們可以看到 是typedef struct objc_class *Class;

Class isa; 等價于 struct objc_class *isa;

所以NSObject對象內部就是放了一個名叫isa的指針,指向了一個結構體 struct objc_class。

總結一:一個OC對象在內存中是如何布局的?


猜想:NSObject對象的底層就是一個包含了一個指針的結構體,那么它的大小是不是就是8字節(64位下指針類型占8個字節)?
為了驗證猜想,我們需要借助runtime提供的一些工具,導入runtime頭文件,class_getInstanceSize ()方法可以計算一個類的實例對象所實際需要的的空間大小

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
        size_t size = class_getInstanceSize([NSObject class]);
        NSLog(@"NSObject對象的大?。?zd",size);
    }
    return 0;
}

結果是



完美驗證,it's over,let's go home!







等等,就這么簡單?確定嗎?答案是否定的~~~
介紹另一個庫#import <malloc/malloc.h>,其下有個方法 malloc_size(),該函數的參數是一個指針,可以計算所傳入指針 所指向內存空間的大小。我們來用一下

#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];
        size_t size = class_getInstanceSize([NSObject class]);
        NSLog(@"NSObject實例對象的大?。?zd",size);
        size_t size2 = malloc_size((__bridge const void *)(obj));
        NSLog(@"對象obj所指向的的內存空間大?。?zd",size2);
    }
    return 0;
}

結果是16,如何解釋呢?

想要真正弄清楚其中的緣由,就需要去蘋果官方的開源代碼里面去一探究竟了。蘋果的開源代請看這里。
先看一下class_getInstanceSize的實現。我們需要進到objc4/文件里面下載一份最新的源碼,我當前最新的版本是objc4-750.1.tar.gz。下載解壓之后,打開工程,就可以查看runtime的實現源碼。
搜索class_getInstanceSize找到實現代碼

size_t class_getInstanceSize(Class cls)
{
    if (!cls) return 0;
    return cls->alignedInstanceSize();
}

再點進alignedInstanceSize方法的實現

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

可以看到該方法的注釋說明Class's ivar size rounded up to a pointer-size boundary.,意思就是獲得類的成員變量的大小,其實也就是計算類所對應的底層結構體的大小,注意后面的這個rounded up to a pointer-size boundary指的是系統在為類的結構體分配內存時所進行的內存對齊,要以一個指針的長度作為對齊系數,64位系統指針長度(字長)是8個字節,那么返回的結果肯定是8的最小整數倍。為什么需要用指針長度作為對齊系數呢?因為類所對應的結構體,在頭部的肯定是一個isa指針,所以指針肯定是該結構體中最大的基本數據類型,所以根據結構體的內存對齊規則,才做此設定。如果對這里有疑惑的話,請先復習一下有關內存對齊的知識,便一目了然了。
所以class_getInstanceSize方法,可以幫我們獲取一個類的的實例對象所對應的結構體的實際大小。

我們再從alloc方法探究一下,alloc方法里面實際上是AllocWithZone方法,我們在objc源碼工程里面搜索一下,可以在Object.mm文件里面找到一個_objc_rootAllocWithZone方法。

id _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone)
{
    id obj;

#if __OBJC2__
    // allocWithZone under __OBJC2__ ignores the zone parameter
    (void)zone;
    obj = class_createInstance(cls, 0);
#else
    if (!zone) {
        obj = class_createInstance(cls, 0);
    }
    else {
        obj = class_createInstanceFromZone(cls, 0, zone);
    }
#endif

    if (slowpath(!obj)) obj = callBadAllocHandler(cls);
    return obj;
}

再點進里面的關鍵方法class_createInstance的實現看一下

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

繼續點進_class_createInstanceFromZone方法

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;

    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();

    size_t size = cls->instanceSize(extraBytes);
    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 = (id)calloc(1, size);,是在分配內存,這里的size是需要分配的內存的大小,那這句應該就是為對象開辟內存的核心代碼,再看它里面的參數size,我們能在上兩行代碼中找到size_t size = cls->instanceSize(extraBytes);,于是我們繼續點進instanceSize看看

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;
    }

翻譯一下這句注//CF requires all objects be at least 16 bytes.我們就明白了,CF作出了硬性的規定:當創建一個實例對象的時候,為其分配的空間不能小于16個字節,為什么這么規定呢,我個人目前的理解是這可能就相當于一種開發規范,或者對于CF框架內部的一些實現提供的規范。
這個size_t instanceSize(size_t extraBytes)返回的字節數,其實就是為 為一個類創建實例對象所需要分配的內存空間。這里我們的NSObject類創建一個實例對象,就分配了16個字節。
我們在點進上面代碼中的alignedInstanceSize方法

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

這不就是我們上面分析class_getInstanceSize方法里面看到的那個alignedInstanceSize嘛。

總結二:class_getInstanceSize&malloc_size的區別
  • class_getInstanceSize:獲取一個objc類的實例的實際大小,這個大小可以理解為創建這個實例對象至少需要的空間(系統實際為這個對象分配的空間可能會比這個大,這是出于系統內存對齊的原因)。
  • malloc_size:得到一個指針所指向的內存空間的大小。我們的OC對象就是一個指針,利用這個函數,我們可以得到該對象所占用的內存大小,也就是系統為這個對象(指針)所指向對象所實際分配的內存大小。
    sizeof():獲取一個類型或者變量所占用的存儲空間,這是一個運算符。
  • [NSObject alloc]之后,系統為其分配了16個字節的內存,最終obj對象(也就是struct NSObject_IMPL結構體),實際使用了其中的8個字節內存,(也就是其內部的那個isa指針所用的8個字節,這里我們是在64位系統為前提下來說的)

關于運算符和函數的一些對比理解

  • 函數在編譯完之后,是可以在程序運行階段被調用的,有調用行為的發生
  • 運算符則是在編譯按一刻,直接被替換成運算后的結果常量,跟宏定義有些類似,不存在調用的行為,所以效率非常高

更為復雜的自定義類

我們開發中會自定義各種各樣的類,基本上都是NSObject的子類。更為復雜的子類對象的內存布局又是如何的呢?我們新建一個NSObject的子類Student,并為其增加一些成員變量

@interface Student : NSObject
{
   @public
    int _age;
    int _no;
}

@end

@implementation Student

@end

使用我們之前介紹過的方法,查看一下這個類的底層實現代碼

struct NSObject_IMPL {
    Class isa;
};

struct Student_IMPL {
    struct NSObject_IMPL NSObject_IVARS;
    int _age;
    int _no;  
    
};

我們發現其實Student的底層結構里,包含了它的成員變量,還有一個NSObject_IMPL結構體變量,也就是它的父類的結構體。根據我們上面的總結,NSObject_IMPL結構體需要的空間是8字節,但是系統給NSObject對象實際分配的內存是16字節,那么這里Student的底層結構體里面的成員變量NSObject_IMPL應該會得到多少的內存分配呢?我們驗證一下。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
        //獲取`NSObject`類的實例對象的成員變量所占用的大小
        size_t size = class_getInstanceSize([NSObject class]);
        NSLog(@"NSObject實例對象的大?。?zd",size);
        //獲取obj所指向的內存空間的大小
        size_t size2 = malloc_size((__bridge const void *)(obj));
        NSLog(@"對象obj所指向的的內存空間大?。?zd",size2);
        
        Student * std = [[Student alloc]init];
        size_t size3 = class_getInstanceSize([Student class]);
        NSLog(@"Student實例對象的大小:%zd",size3);
        size_t size4 = malloc_size((__bridge const void *)(std));
        NSLog(@"對象std所指向的的內存空間大?。?zd",size4);
    }
    return 0;
}


從結果可以看出,Student類的底層結構體等同于

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

總結一下就是,一個子類的底層結構體,相當于 其父類結構體里面的所有成員變量 + 該子類自身定義的成員變量 所組成的一個結構體。
出于嚴謹,我又給Student類多加了幾個成員變量,驗證我的猜想。

@interface Student : NSObject
{
   @public
    int _age;
    int _no;
    int _grade;
}
image.png

貌似是對的了,但是為什么用malloc_size得到std所被分配的內存是32?再來一發試試

@interface Student : NSObject
{

   @public
    //父類的isa還會占用8個字節
    int _age;//4字節
    int _no;//4字節
    int _grade;//4字節
    int *p1;//8字節
    int *p2;//8字節 
}

Student結構體所有成員變量所需要的總空間為 36字節,根據內存對齊原則,最后結構體所需要的空間應該是8的倍數,那應該就是40,我們看一下結果

從結果看沒錯,但是同時也發現了一個規律,隨著std對象成員變量的增加,系統為Student對象std分配的內存空間總是以16的倍數增加(16~32~48......),我們之前分析源碼好像沒看到有做這個設定


其實上面這個方法只是可以用來計算一個結構體對象所實際需要的內存大小。 [update]其實instanceSize()-->alignedInstanceSize()只是可以用來計算一個結構體對象理論上(按照內存對其規則)所需要分配的內存大小。

真正給實例對象完成分配內存操作的是下面這個方法calloc()


這個方法位于蘋果源碼的libmalloc文件夾中。但是里面的代碼再往下深究,介于我目前的知識儲備以及專業出身(數學專業),還是困難比較大。好在從一些大神那里得到了指點。
剛才文章開始,我們討論到了結構體的內存對齊,這是針對數據結構而言的。從系統層面來說,就以蘋果系統而言,出于對內存管理和訪問效率最優化的需要,會實現在內存中規劃出很多塊,這些塊有大有小,但都是16的倍數,比如有的是32,有的是48,在libmalloc源碼的nano_zone.h里面有這么一段代碼

#define NANO_MAX_SIZE    256 /* Buckets sized {16, 32, 48, 64, 80, 96, 112, ...} */

NANO是源碼庫里面的其中一種內存分配方法,類似的還有frozen、legacymagazinepurgeable。

這些是蘋果基于各種場景優化需求而設定的對應的內存管理相關的庫,暫時不用對其過分解讀。
上面的NANO_MAX_SIZE解釋中有個詞Buckets sized,就是蘋果事先規劃好的內存塊的大小要求,針對nano,內存塊都被設定成16的倍數,并且最大值是256。舉個例子,如果一個對象結構體需要46個字節,那么系統會找一塊48字節的內存塊分配給它用,如果另一個結構體需要58個字節,那么系統會找一塊64字節的內存塊分配給它用。
到這里,應該就可以基本上解釋清楚,為什么剛才student結構需要40個字節的時候,被分配到的內存大小確實48個字節。至此,針對一個NSObject對象占用內存的問題,以及延伸出來的內存布局,以及其子類的占內存問題,應該就都可以得到解答了。

面試題解答

  • 一個NSObject對象占用多少內存?
    1)系統分配了16字節給NSObject對象(通過malloc_size函數可以獲得)
    2)NSObject對象內部只使用了8個字節的空間,用來存放isa指針變量(64位系統下,可以通過class_getInstanceSize函數獲得)
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372

推薦閱讀更多精彩內容