iOS 記憶體管理研究

即刻技術團隊發表於2018-11-14

iPhone 作為一個移動裝置,其計算和記憶體資源通常是非常有限的,而許多使用者對應用的效能卻很敏感,卡頓、應用回到前臺丟失狀態、甚至 OOM 閃退,這就給了 iOS 工程師一個很大的挑戰。

網上的絕大多數關於 iOS 記憶體管理的文章,大多是圍繞 ARC/MRC、迴圈引用的原理或者是如何找尋記憶體洩漏來展開的,而這些內容更準確的說應該是 ObjC 或者 Swift 的記憶體管理,是語言層面帶來的特性,而不是作業系統本身的記憶體管理。

如果我們需要聊聊”管理“記憶體,那麼就需要先了解一些基礎知識。

記憶體基礎概念複習

實體記憶體

一個裝置的 RAM 大小。以下是維基百科上的資料:

alt text

簡單來說,iPhone 8(不包括 plus) 和 iPhone 7(不包括 plus)及之前都是 2G 記憶體,iPhone 6 和 6 plus 及之前都是 1G 記憶體。

虛擬記憶體(VM for Virtual Memory)

每個程式都有一個自己私有的虛擬記憶體空間。對於32位裝置來說是 4GB,而64位裝置(5s以後的裝置)是 18EB(1EB = 1000PB, 1PB = 1000TB),對映到實體記憶體空間。

記憶體管理、對映中的基本單位是頁,一頁的大小是 4kb(早期裝置)或者 16kb(A7 晶片及以後)

iDzCnA.md.png

因為有頁的存在,每次申請記憶體都必須以頁為單位。然而這樣一來,如果只是申請幾個 byte,卻不得不分配一頁(16kb),是非常大的浪費。因此在使用者態我們有 “heap” 的概念。

Page In/Out

由於虛擬記憶體的空間遠遠大於實體記憶體,在任意一個時間點,虛擬記憶體中的一個頁並不一定總是在實體記憶體中,而是可能被暫時存到了磁碟上,這樣實體記憶體便可以暫時釋放這部分空間,供優先順序更高的任務使用,因此磁碟可以作為 backing store 以擴充套件實體記憶體(MacOS 中有,iOS 沒有)。另一種可能是載入一個比較大的檔案/動態庫,每次使用我們可能只需要載入其中的一部分,那麼就可以使用 mmap 對映這個檔案到虛擬記憶體空間,這樣當我們訪問其中一部分時,系統會自動把這一部分從磁碟載入到記憶體,而不載入其餘部分。

這樣把磁碟中的資料寫到記憶體/從記憶體中寫回磁碟成為 page in/out。

Wired memory

無法被 page out 的記憶體,主要為系統層所用,開發者不需要考慮這些。

VM Region

一個 VM Region 是指一段連續的記憶體頁(在虛擬地址空間裡),這些頁擁有相同的屬性(如讀寫許可權、是否是 wired,也就是是否能被 page out)。舉幾個例子:

  • mapped file,即對映到磁碟的一個檔案

  • __TEXT,r-x,多數為二進位制

  • __DATA,rw-,為可讀寫資料

  • MALLOC_(SIZE),顧名思義是 malloc 申請的記憶體

VM Object

每個 VM Region 對應一個資料結構,名為 VM Object。Object 會記錄這個 Region 記憶體的屬性

Resident Page

當前正在實體記憶體中的頁(沒有被交換出去)

與其他App共存的情況

  • app 記憶體消耗較低,同時其他 app 也非常“自律”,不會大手大腳地消耗記憶體,那麼即使切換到其他應用,我們自己的 app 依然是“活著”的,保留了使用者的使用狀態,體驗較好

  • app 記憶體消耗較低,但是其他 app 非常消耗記憶體(可能是使用不當,也可能是本身就非常消耗記憶體,比如大型遊戲),那除了當前在前臺的程式,其他 app 都會被系統回收,用來給活躍程式提供記憶體資源。這種情況我們無法控制

  • app 記憶體消耗比較大,那切換到其他 app 以後,即使其他 app 向系統申請不是特別大的記憶體,系統也會因為資源緊張,優先把消耗記憶體較多的 app 回收掉。使用者會發現只要 app 一旦退到後臺,過會再開啟時就會重新載入

  • app 記憶體消耗特別大,在前臺執行時就有可能被系統 kill 掉,引起閃退

在 iOS 上管理殺程式釋放資源策略模組叫做 Jetsam,這裡推薦五子棋的文章,其中有詳細的介紹。

OOM的判定

蘋果官方關於 OOM 的文件和介面非常少,以至於 facebook 在判斷應用是否上次因為 OOM 而閃退時,需要經過一個漫長的邏輯判斷,當不滿足所有條件時才能判定為 OOM(想象一下如果系統能提供一個介面,告訴開發者上次的退出原因,會方便多少!)

alt text

我們會好奇,當一個普通 app 啟動時,記憶體消耗究竟有多少?

如何檢視記憶體佔用量

我們剛討論到記憶體的不同類別,那麼應該選用哪個值作為記憶體佔用量的標準呢?

Memory Footprint

在 WWDC13 704 中,蘋果推薦用 footprint 命令來檢視一個程式的記憶體佔用。

關於什麼是 footprint,在官方文件 Minimizing your app’s Memory Footprint 裡有說明:

Refers to the total current amount of system memory that is allocated to your app.
複製程式碼

由於該命令只能在 MacOS 上執行,並且 iOS 上也沒有 Activity Monitor,我們新建一個 Mac app,然後用不同手段測量記憶體佔用

  • Instruments 中的 All Heap & Anonymous VM: 8.32MB

  • Xcode: 47.4MB

  • 系統 Activity Monitor: 47.4MB

  • 使用 footprint 命令(可以通過 man footprint 檢視文件): 47MB

  • task_vm_info.phys_footprint: 47.4MB

  • task_info.resident_size: 80MB

可以看到,Xcode、系統、footprint 工具和 phys_footprint 得到的資料是一致的,而既然官方推薦了 footprint,因此我們以這幾個方法得到的結果作為標準。猜測 footprint 比 Instruments 資料更大的原因是存在一些”非程式碼執行開銷“,如把系統和應用二進位制載入進記憶體。iOS 中雖然不能使用系統 Activity Monitor 和 footprint 命令,也能在 Xcode 中和 phys_footprint 得到同樣的結果。

至此我們可以得到一個結論: Instruments 中顯示的部分,其實也只是整個應用程式裡記憶體的一部分。但是由於我們能夠控制的只有這一部分,因此應該把精力投入到 Instruments 的分析中去。

使用 Instruments 分析

應用的詳細效能分析總是需要依賴 Instruments 的強大功能。從 Allocations 角度來看,總的記憶體佔用 = All Heap Allocations + All Anonymous VM:

iDmgqf.png

  • All Heap Allocations,幾乎所有類例項,包括 UIViewController、UIView、UIImage、Foundation 和我們程式碼裡的各種類/結構例項。一般和我們的程式碼直接相關。

  • All Anonymous VM,可以看到都是由”VM:”開頭的

iDzwH1.png

主要包含一些系統模組的記憶體佔用。有些部分雖然看起來離我們的業務邏輯比較遠,但其實是保證我們程式碼正常執行不可或缺的部分,也是我們常常忽視的部分。一般包括:

  • CG raster data(光柵化資料,也就是畫素資料。注意不一定是圖片,一塊顯示快取裡也可能是文字或者其他內容。通常每畫素消耗 4 個位元組)

  • Image IO(圖片編解碼快取)

  • Stack(每個執行緒都會需要500KB左右的棧空間)

  • CoreAnimation

  • SQLite

  • network

  • 等等

我們平時最經常會做的 debug 之一,就是查詢迴圈引用。而迴圈引用造成的 leak 資料通常是 UIKit 或我們自己的一些資料結構,會被歸類到 heap。這些是我們相對熟悉的,網上也有非常多的文章,這裡不再討論。而就 VM 這塊來說,因為不受我們直接控制,文件也較少,所以相對神祕一些,往往容易被忽視。

對於 VM 中的執行緒棧開銷、網路 buffer 等,我們其實沒有太大的控制能力,通常這些也不會是記憶體開銷的主要原因(除非有成百上千的執行緒和頻繁大量的網路請求)。而對於即刻和絕大多數 app 來說,尤其是採用了 AsyncDisplayKit(用空間換時間)的情況下,渲染開銷是絕對不可忽視的一塊。

我一直認為,移動裝置上不管是 CPU、GPU 還是記憶體,最大的效能殺手一定是佈局和渲染。佈局資料和一般資料結構類似,單個記憶體開銷最多以 KB 計,而渲染快取很容易就用“兆”來計算,更容易影響到整體開銷。

任意開啟一個 app,可以看到渲染無非就是兩大部分:圖片和文字。

圖片渲染開銷

我們知道,解壓後的圖片是由無數畫素資料組成。每個畫素點通常包括紅、綠、藍和 alpha 資料,每個值都是 8 位(0–255),因此一個畫素通常會佔用 4 個位元組(32 bit per pixel。少數專業的 app 可能會用更大的空間來表示色深,消耗的記憶體會相應線性增加)。

下面我們來計算一些通常的圖片開銷:

  • 普通圖片大小,如 500 * 600 * 32bpp = 1MB

  • 跟 iPhone X 螢幕一樣大的:1125 * 2436 * 32bpp = 10MB

  • 即刻中允許最大的圖片,總畫素不超過1500w:15000000 * 32bpp = 57MB

有了大致的概念,以後看到一張圖能簡單預估,大概會吃掉多少記憶體。

縮放

  • 記憶體開銷多少與圖片檔案的大小(解壓前的大小)沒有直接關係,而是跟圖片解析度有關。舉個例子:同樣是 100 * 100,jpeg 和 png 兩張圖,檔案大小可能差幾倍,但是渲染後的記憶體開銷是完全一樣的——解壓後的圖片 buffer 大小與圖片尺寸成正比,有多少畫素就有多少資料。

  • 通常我們下載的圖片和最終展示在介面上的尺寸是不同的,有時可能需要將一張巨型圖片展示在一個很小的 view 裡。如果不做縮放,那麼原圖就會被整個解壓,消耗大量記憶體,而很多畫素點會在展示前被壓縮掉,完全浪費了。所以把圖片縮放到實際顯示大小非常重要,而且解碼量變少的話,速度也會提高很多。

  • 如果在網上搜尋圖片縮放方案的話,一般都會找到類似“新建一個 context ,把圖畫在裡面,再從 context 裡讀取圖片”的答案。此時如果原圖很大,那麼即使縮放後的圖片很小,解壓原圖的過程仍然需要佔用大量記憶體資源,一不小心就會 OOM。但是如果換用 ImageIO 情況就會好很多,整個過程最多隻會佔用縮放後的圖片所需的記憶體(通常只有原圖的幾分之一),大大減少了記憶體壓力。

irGfqH.md.png

解碼

圖片解碼是每個開發者都繞不過去的話題。圖片從壓縮的格式化資料變成畫素資料需要經過解碼,而解碼對 CPU 和記憶體的開銷都比較大,同時解碼後的資料如何管理,如何顯示都是需要我們注意的。

  • 通常我們把一張圖片設定到 UIImageView 上,系統會自動處理解碼過程,但這樣會在主執行緒上佔用一定 CPU 資源,引起卡頓。使用 ImageIO 解碼 + 後臺執行緒執行是 WWDC(18 session 219) 推薦的做法。

  • ImageIO 功能很強大,但是不支援 webp

  • AsyncDisplayKit 的一大思想是拿空間換時間,換取流暢的效能,但是記憶體開銷會比 UIKit 高。同樣用一個全屏的 UIImageView 測試,直接用UIImage(named:)來設定圖片,雖然不可避免要在主執行緒上做解壓,但是消耗的記憶體反而較小,只有4MB(正常需要10MB)。猜測神祕的 IOSurface 對圖片資料做了某些優化。蘋果有這麼一段話描述 IOSurface:

Share hardware-accelerated buffer data (framebuffers and textures) across multiple processes. Manage image memory more efficiently.
複製程式碼

渲染

網上關於渲染的資料很多,但是很多都是人云亦云,我們來說一些比較少討論的點:

  • 我們經常會需要預先渲染文字/圖片以提高效能,此時需要儘可能保證這塊 context 的大小與螢幕上的實際尺寸一致,避免浪費記憶體。可以通過 View Hierarchy 除錯工具,列印一個 layer 的 contents 屬性來檢視其中的 CGImage(backing image)以及其大小

作為 backing image 的 CGImage
作為 backing image 的 CGImage

  • 一旦涉及到 offscreen rendering,就可能會需要多消耗一塊記憶體/視訊記憶體。那到底什麼是離屏渲染?不管是 CPU 還是 GPU,只要不能直接在 frame buffer 上畫,都屬於offscreen rendering。在 Core Animation: Advanced Techniques 書裡有 offscreen rendering 的一段說明:

    Offscreen rendering is invoked whenever the combination of layer properties that have been specified mean that the layer cannot be drawn directly to the screen without pre- compositing. Offscreen rendering does not necessarily imply software drawing, but it means that the layer must first be rendered (either by the CPU or GPU) into an offscreen context before being displayed.

  • layer mask 會造成離屏渲染,猜想可能是由於涉及到”根據 mask 去掉一些畫素“,無法直接在 frame buffer 中做

  • 圓角要慎用,但不是說完全不能用— — 只有圓角和 clipsToBounds 結合的時候,才會造成離屏渲染。猜想這兩者結合起來也會造成類似 mask 的效果,用來切除圓角以外的部分

  • backgroundColor 可以直接在 frame buffer 上畫,因此並不需要額外記憶體

文字渲染的CPU和記憶體開銷

關於文字渲染的文件資料並不是很多,因此我們需要做一些實驗來判斷。 新建一個專案,新增一個全屏的 label,不停切換文字,得到 cpu 佔用率穩定在 15%,gpu佔用率 0%。並且 Time Profiling 顯示

alt text

排名第一的方法主要是在呼叫 render_glyphs,說明主要是 CPU 參與了文字渲染。

  • 文字渲染中,主要記憶體開銷呼叫棧:

iOS 記憶體管理研究

  • 雖然文字比較多,但是隻佔用了 2.75MB(2883584 byte,可以看到這邊蘋果仍然是用1024KB = 1MB的換算)的記憶體。那麼問題來了,我們上面提到一塊跟螢幕一樣大的顯示區域佔用空間大約是 10MB,為什麼文字佔用這麼少呢?

alt text

理論上 iPhone X 全屏有 1125 * 2436 = 2740500 個畫素,距離實際佔用記憶體非常接近,只多了 143084 byte(139.73kb),說明差不多正好是一個畫素對應一個位元組。這印證了 WWDC(WWDC18 219和416)上的結論,即黑白的點陣圖只佔用 1 個位元組,比 4 位元組節省 75% 的空間。當然實際使用過程中很難限制文字只採用黑白兩種顏色,但是還是應該瞭解蘋果的優化過程。

alt text

在以上測試基礎上,如果我們嘗試把第一個字元加上紅色屬性,或者新增 emoji,那麼渲染結果就不再是黑白的了,而是一張彩色圖片,類似普通圖片那樣每個畫素需要 4 個位元組來描述。因此理論上所消耗的記憶體會變成 2.75MB * 4 = 10MB 多一點。測試得到:

alt text

結果佔用了 11468800 bytes,是原來 2740500 的 3.97 倍,與理論值 4 非常接近(可能記憶體中還存在一些附屬的其他後設資料,而這些不會如同畫素資料一樣線性放大,因此不完全是精確 4 倍關係)。比較好的印證了之前的結論。

整整一屏的文字,在 3x 裝置上,只佔用了 2MB 多一點的記憶體,可以說是非常省了。

總結

iOS 的記憶體管理有以下幾個特點:

  • 文件較少,系統提供的介面也較少,因此大家自己生產的輪子較多,需要多做實驗才能得到可靠的結論。多利用 Instruments 也會發現一些之前忽略的點

  • 記憶體問題的暴露有一定延時性,OOM 在本地很難復現,需要投入大量時間測試,同時配套相應的監控系統

  • 技術變化較慢,作業系統這一層的知識在過去和未來的很長一段時間都不太會改變,或只是微調,值得花時間來研究

  • 經典的時空取捨問題,在資源有限的裝置上,如何平衡 CPU/GPU 和記憶體的開銷,來達到效能最大化

  • 能夠幫助我們瞭解一些文字和圖片渲染的本質,更好的了渲染系統的工作原理,畢竟這是客戶端工程師不可替代的職責之一

推薦閱讀

Core Animation: Advanced Techniques

MacOS and *OS Internals

About the Virtual Memory System

Minimizing your app’s Memory Footprint

WWDC 13 704: Building Efficient OS X Apps

WWDC 18 219: Images and Graphics Best Practices

WWDC 18 416: iOS Memory Deep Dive


PS: 如果你認同即刻的工程師文化,又喜歡即刻的產品,那就一起來吧!有相當多的職位等著你。連結:即刻 - 加入即刻

相關文章