iOS的內存分布探究

前言

最近遇到一些內存相關crash,排查問題過程中產生對進程內整個地址空間分布的疑惑。搜查了一番資料,網上關于Linux進程地址空間分布的介紹比較詳細,但是iOS實際運行效果的比較少。
本文基于網上相關文章,進行實際測試,探究App實際運行過程中的地址分布。

正文

32位的分布情況

32位的機器,每個進程會有4G虛擬地址空間,較高的1G是從0xC0000000到0xFFFFFFFF的內核空間(Kernel Space ),較低的3G是從0x00000000到0xBFFFFFFF用戶空間(User Space )。 內核空間中存放的是內核代碼和數據,用戶空間中存放的是App進程的代碼和數據。這里地址指的都是虛擬地址空間,由操作系統負責映射為物理地址。
把最常用的幾個概念堆、棧、數據段、代碼段做一個地址從大到小的排序:

  • 棧:在函數調用過程中,每個函數都會有一個相關的區域來存儲函數的參數和局部變量,每次進行函數調用的時候系統都會往棧壓入一個新的棧幀,在函數返回時清除。入棧和出棧的操作非常快,這個過程會用到兩個寄存器:fp和sp寄存器。

  • 堆:在進程運行過程中,用于存儲局部變量之外的變量。工作中常用的malloc函數、new操作符等可以從堆中申請內存。上面的棧很像數據結構中的棧,但這里的堆并不像數據結構的堆,其分配的方式是鏈表式,用brk()函數從操作系統批發內存,再零售給用戶。

  • 數據段:通常指的段和data段,bss段內是未被初始化的靜態變量,data段是在代碼中已經初始化的靜態變量。data段變大會導致啟動速度變慢,bss段變大幾乎不影響。因為bss段只需要預留位置,并沒有真正的copy操作。相比data段增加的是具體的數據,bss段增加的只是數據描述信息。

  • 代碼段:程序運行的機器指令,由代碼編譯產生。

64位的實際分布

對于一個iOS開發來說,目前大部分手機都是64位機器,還是需要對實際運行結果進行一些測試。
以下真機測試的機型是iPhone XS Max + iOS 14.5。

64位機器,進程內存地址從高到低分別是:
0xFFFF FFFF FFFF FFFF ??
內核空間
用戶空間-保留區域
擴展使用區域
系統共享庫
棧空間
內存映射區域(mmap)
堆空間
BSS段
DATA段
Text段
0x0000 0000 0000 0000

常見概念-堆、棧、數據段、代碼段

堆和棧

用一段簡單的代碼,分別從堆和棧上面創建一塊內存:

  char stack_address;
  UIView *heap_view_address = [[UIView alloc] init];
  NSLog(@"0x%016lx => stack 0x%016lx => heap", (long)&stack_address, (long)heap_view_address);

輸出 0x16f4c5af7 => stack 0x100e0d8a0 => heap,可以大概知道棧和堆所在區域,0x16F4...是棧地址的開始,0x100E...是堆地址的開始。

數據段

bss段內是未被初始化的靜態變量,data段是在代碼中已經初始化的靜態變量。

// 函數外-靜態變量
static int vcStaticInt = 1024;
static int vcStaticNotInit;

// 函數內
NSLog(@"0x%lx => data 0x%lx => bss", (long)&vcStaticInt, (long)&vcStaticNotInit);

vcStaticNotInit代表bss段,最終的地址是0x100945788
vcStaticInt代表data段,最終的地址是0x1009455f8

代碼段

代碼段是代碼編譯后的機器指令,可以用一個類來定位:

NSLog(@"class_address: 0x%lx\n", (long)[ViewController class]);

最終輸出的class_address是0x100945500。

將這幾個地址的大小進行排序,可以看到有:
0x16F4C 5AF7(棧地址)
0x100E0 D8A0(堆地址)
0x10094 5788(bss段)
0x10094 55F8(data段)
0x10094 5500(Text段)

系統共享庫

下面是兩個不同App(bundle id不一樣)在同手機上的運行crash日志,對比可以發現:在dyld之前的系統庫地址不一樣,在dyld之后的地址都是一樣的。

App中存在很多系統動態庫,在啟動時依賴dyld加載系統動態庫到內存中。App依賴的具體系統動態庫可能不同,但是都是iOS系統提供的。自然可以采用一種優化App啟動速度方法:將所有的的系統依賴庫按照固定的地址寫在某個固定區域,這樣只需保證App運行時這塊內存不被使用,就能保證所有App啟動時候不需要去裝載所有的動態庫。

內存映射區域

在棧空間的下方和堆空間的上方,有一塊區域是內存映射區域。系統可以將文件的內容直接映射到內存,App可以通過mmap()方法請求將磁盤上文件的地址信息與進程用的虛擬邏輯地址進行映射。相比普通的讀寫文件,當App讀取一個文件時有兩步:先將文件從磁盤讀取到物理內存,再從內核空間拷貝到用戶空間。內存映射則可以減少操作系統的地址轉換帶來的消耗。

可以寫一段mmap的代碼來觀察生成的地址

- (void)testMmap {
  NSString *imagePathStr = [[NSBundle mainBundle] pathForResource:@"abc" ofType:@"png"];
  size_t dataLength;
  void *dataPtr;
  // MapFile是自己寫的mmap方法
  int errorCode = MapFile([imagePathStr cStringUsingEncoding:NSUTF8StringEncoding], &dataPtr, &dataLength);
  NSLog(@"mmapData:0x%lx, bytes_address:0x%lx, size:%d, error:%d", (long)dataPtr, (long)dataPtr, (long)dataLength, errorCode);
}

最終輸出的dataLength地址是0x1026b8000,size是18432,注意到這個地址是在上面的堆和棧之間。

用戶空間-保留區域

這一塊沒有查到相關信息,如有資料求分享。以下是實際運行的分析。

@interface TestOCObject : NSObject
@property (nonatomic, readonly, assign) char *name_buffer;
@end
@implementation TestOCObject {
  char name[102400];
}
- (char *)name_buffer {
  return name;
}
@end

- (void)testHeapSize:(int)count {
  NSMutableArray<TestOCObject *> *arr = [NSMutableArray new];
  while (true) {
    char stackSize;
    TestOCObject *obj = [[TestOCObject alloc] init];
    ++count;
    if (obj) {
      NSLog(@"%05d stack_address => 0x%lx heap_address => 0x%lx chars => 0x%lx", count, (long)&stackSize, (long)obj, (long)obj.name_buffer);
      [arr addObject:obj];
    }
    else {
      break;
    }
  }
}

當進程不斷從堆空間申請內存,剛開始的時候從堆空間分配的地址是小于棧空間地址,但是隨著內存不斷被使用,在14700次左右的時候,堆空間分配的地址就會超過棧空間的地址。

14703 stack_address => 0x16d751aef heap_address => 0x16d630000
14704 stack_address => 0x16d751aef heap_address => 0x16db28000

然后在17000次左右的時候,出現了一次大的地址變動:從0x1變成了0x2a開始。0x2a的地址空間是在系統共享庫地址(0x1a)上方。

之所以有這樣的現象,個人理解是為了兼容32位的情況。因為不管是系統共享庫,還是堆、棧地址空間的大小,初始地址都是在32位的地址空間內。而后面地址從0x2a0000000開始,就已經超過了32位的地址空間,屬于64位機器的地址空間。最終運行到達到63000次左右,一次是100KB,可以計算得到63000*100KB/1024/1024=6G左右的空間。

這時候產生了一個疑問:為什么32位的情況下,堆空間只有1G多空間大小?為什么64位的情況下,堆空間也只有6G多空間大小?(可以先暫停閱讀,思考后見最下面分析)

思維發散

經過上面的分析,再來解析一下以前的問題:

普通對象和靜態變量有哪些區別?

對象存儲區域不同,普通對象一般是在棧、堆上,但是靜態變量會存儲在數據段,地址會有較大的差別。

對象實例和對象方法的關系?

一個OC對象的實例,其實就是一塊存儲數據的內存。內存中有指針,可以指向對象的類地址(代碼段);訪問一個對象方法其實是通過內存中的指針找到類地址,然后將對象的內存地址和調用的方法名作為參數傳遞。也可以用一種形象但可能不太恰當的比喻:執行一個方法就像帶著原料跑到加工廠進行流水線的處理,原料就是對象的內存地址和其他傳入方法的內存地址,流水線編譯生成的固定機器指令。

棧空間地址從高到低增長?

前面已經提到,在函數調用過程中,會往棧壓入一個新的棧幀,在函數返回時清除。
那么只需要構造一個遞歸調用,觀察每個函數局部變量的地址即可觀察到棧空間的地址變化:

- (void)testStackSize:(int)count {
  char stackSize[1024];
  NSLog(@"%05d stack_address => 0x%lx ", count, (long)&stackSize);
  if (count < 1000) {
    ++count;
    [self testStackSize:count];
  }
  else {
    NSLog(@"end");
  }

需要注意,同一個函數內,先后申請兩個局部變量A和B,觀察A和B的地址,并不能看出棧空間的地址變化。因為同一個函數內的局部變量可能會受到編譯器的優化,導致不符合預期。所以觀察不同棧幀間的局部變量地址變化更為準確。
通過上面的代碼可以知道,棧空間地址確實是從高到低增長,隨著遞歸函數的不斷調用,局部變量的地址也在不斷變小。在真機測試的情況下,兩次運行的stackSize分別為 0x16ce86868和0x16ce86408,地址差為0x000000460, 轉換成二進制4(16^2)+616=1024+96, 其中1024是申請的char數組,96則是函數遞歸調用的其他開銷。這段遞歸代碼運行994次會報錯,由此可以計算主線程的棧空間有1MB左右。(此部分為實際運行效果推算,不同環境下可能結果各異)

堆空間地址從低到高增長?

堆空間的內存分配方式與棧空間不同,如果先后從堆上創建兩個對象A和B,再對比兩個對象的內存地址,那么A和B的大小應該沒有直接關系。因為堆空間存在對象的創建和銷毀,當對象A和B創建時,都有可能用到前面某些對象銷毀時被回收的內存地址。
常說的堆空間地址從低到高增長,是Linux系統堆空間初始分配之后,擴大堆空間大小的時候,會往高地址增長。iOS實際運行過程中,有可能先申請到一個很大的內存地址,比如說下面這代碼:

NSObject *oc_object = [[NSObject alloc] init];
TestOCObject *oc_big_object = [[TestOCObject alloc] init];
NSLog(@"oc_object_address => 0x%lx oc_big_object_address => 0x%lx", (long)oc_object, (long)oc_big_object);

TestOCObject是上文用到一個自定義OC類,當代碼實際運行的時候,可以會看到輸出
oc_object_address => 0x283d84cb0 oc_big_object_address => 0x1026b8000
其中oc_object的地址是0x283d84cb0,而oc_big_object的地址是0x1026b8000
0x28開頭的地址也會被用于分配內存,一般用于內存較小的情況,而內存比較大的時候仍然會從正常的堆地址空間開始。(這個不同地址取決于libsystem_malloc.dylib對申請內存大小的不同處理)

為什么32位的情況下,堆空間只有1G多空間大小?為什么64位的情況下,堆空間也只有6G多空間大小?

操作系統內存是段頁式管理,App先分段再分頁,頁是內存管理的基本單位。(32位是4096B=4KB,64位是16KB)
當App訪問虛擬內存時,操作系統會檢查虛擬內存對應物理內存是否存在,如果不存在則觸發一次缺頁中斷(Page Fault),將數據從磁盤加載到物理內存中,并建立物理內存和虛擬內存的映射。
32位機器的虛擬空間最多只有4G,其中1G還要留給內核空間,堆和棧之間能留下來的空間并不寬裕,即使加上棧空間到系統共享庫之間的區域,總共也只有1G多空間。而64位的機器用于充足的虛擬地址空間,虛擬內存占用超過1G多之后,會從0x2a開始申請虛擬地址。但是由于有物理內存的限制,過大的虛擬內存占用會導致物理內存快速消耗,當物理內存被消耗完成后,就需要釋放現有的內存頁。所以App并不需要有非常大的虛擬內存,因為瓶頸往往出現在物理內存上面。
另外這里為什么可以創建6G的虛擬內存,這是因為測試代碼申請的內存頁大都沒有寫入操作,當內存有壓力的時候,會被系統進行壓縮成Compressed Memory。如果增加一個簡單的寫入操作,那么這個內存頁就變成了臟內存,進程在1G多占用的時候就會被操作系統kill。

- (void)testHeapSize:(int)count {
  NSMutableArray<TestOCObject *> *arr = [NSMutableArray new];
  while (true) {
    char stackSize;
    TestOCObject *obj = [[TestOCObject alloc] init];
    ++count;
    if (obj) {
      NSLog(@"%05d stack_address => 0x%lx heap_address => 0x%lx chars => 0x%lx", count, (long)&stackSize, (long)obj, (long)obj.name_buffer);
      // 增加write操作
      for (int i = 0; i < 100; ++i) {
        memcpy(obj.name_buffer + (i * 1024), "hello", 6);
      }
      [arr addObject:obj];
    }
    else {
      break;
    }
  }
}

輔助工具

objdump指令可以得到二進制分布,比如說下面的objdump -d LearnMemoryAddress

總結

本文為實際運行結果的分析,測試機型-iPhone XS Max + iOS 14.5。
實際運行結果的解析部分可能存在錯誤,如果發現請幫忙糾正。
知道各個地址空間的分布,能幫助我們更好理解iOS系統。在面對內存相關crash的時候,看到地址就能大概判斷是屬于哪一個區域,也能更加清晰具體去解析錯誤。

參考資料-Memory Usage Performance Guidelines

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

推薦閱讀更多精彩內容

  • 前言 最近遇到一些內存相關crash,排查問題過程中產生對進程內整個地址空間分布的疑惑。搜查了一番資料,網上關于L...
    落影loyinglin閱讀 2,417評論 0 14
  • 在iOS中,內存主要分為棧區、堆區、全局區、常量區、代碼區五大區域。如下圖所示 下面分別介紹這五大區 棧區(Sta...
    輝輝歲月閱讀 975評論 0 1
  • 前言: 在iOS開發中,平常大家都會說,堆區,棧區,都是存在虛擬內存。 虛擬內存五大區:堆區、棧區、全局區、常量區...
    淺墨入畫閱讀 523評論 0 2
  • iOS內存問題: IBOutlet為啥是weak的?因為subview添加到view上時,view會“擁有”sub...
    davidxwwang閱讀 1,552評論 0 1
  • iOS中,內存主要分為棧區、堆區、全局區、常量區、代碼區五大區域 1. 棧區 定義 棧是系統數據結構,其對應的進程...
    北京_小海閱讀 2,703評論 0 6