iOS Memory 記憶體詳解 (長文)

RickeyBoy發表於2019-07-29

本文以 iOS Memory 的相關內容作為主題,主要從一般作業系統的記憶體管理、iOS 系統記憶體、app 記憶體管理等三個層面進行了介紹,主要內容的目錄如下:

1

iOS 是基於 BSD 發展而來,所以先理解一般的桌面作業系統的記憶體機制是非常有必要的。在此基礎之上,本文會進一步在 iOS 系統層面進行分析,包括 iOS 整體的記憶體機制,以及 iOS 系統執行時的記憶體佔用的情況。最後會將粒度縮小到 iOS 中的單個 app,講到單個 app 的記憶體管理策略。

作業系統的記憶體機制

為了從根本上更好地理解和分析 iOS 系統上的記憶體特性,我們首先需要正確理解一般作業系統通用的記憶體機制。

馮·諾依曼結構

2

馮·諾依曼結構(Von Neumann architecture)在 1945 年就已經被提出了, 這個概念當時十分新穎,它第一次將儲存器和運算器分離,導致了以儲存器為核心的現代計算機的誕生。

在馮·諾依曼結構中,儲存器有著重要地位,它存放著程式的指令以及資料,在程式執行時,根據需要提供給 CPU 使用。可以想象,一個理想的儲存器,應該是兼顧讀寫速度快、容量大、價格便宜等特點的,但是魚和熊掌不可兼得,讀寫速度越快的儲存器也更貴、容量更小。

但馮·諾依曼結構存在一個難以克服的問題,被稱為馮·諾依曼瓶頸 —— 在目前的科技水平之下,CPU 與儲存器之間的讀寫速率遠遠小於 CPU 的工作效率。簡單來說就是 CPU 太快了,儲存器讀寫速度不夠快,造成了 CPU 效能的浪費。

既然現在我們沒辦法獲得完美的儲存器,那我們如何儘量突破馮·諾依曼結構的瓶頸呢?現行的解決方式就是採用多級儲存,來平衡儲存器的讀寫速率、容量、價格。

儲存器的層次結構

3

儲存器主要分為兩類:易失性儲存器速度更快,斷電之後資料會丟失;非易失性儲存器容量更大、價格更低,斷電也不會丟失資料。隨機訪問儲存器 RAM 也分為兩類,其中 SRAM 速度更快,所以用作快取記憶體,DRAM 用作主存。只讀儲存器 ROM 實際上只有最開始的時候是隻讀的,後來隨著發展也能夠進行讀寫了,只是沿用了之前的名字。

4

上圖就是多層儲存器的具體情況,我們平時常說的記憶體,實際上就是指的 L4 主存。而 L1-L3 快取記憶體和主存相比,速度更快,並且它們都已經整合在 CPU 晶片內部了。其中 L0 暫存器本身就是 CPU 的組成部分之一,讀寫速度最快,操作耗費 0 個時鐘週期。

簡單來說,儲存器的分級實際上就是一種快取思想。金字塔底部的部分容量大,更便宜,主要是為了發揮其儲存屬性;而金字塔尖的快取記憶體部分讀寫速度快,負責將高頻使用的部分快取起來,一定程度上優化整體的讀寫效率。

為什麼採用快取就能夠提高效率呢?邏輯上理解起來其實很簡單,具體來說就是因為存在區域性性原理(Principle of locality) —— 被使用過的儲存器內容在未來可能會被多次使用,以及它附近的內容也大概率被使用。當我們把這些內容放在快取記憶體中,那麼就可以在部分情況下節約訪問儲存器的時間。

CPU 定址方式

那麼,CPU 是如何訪問記憶體的呢?記憶體可以被看作一個陣列,陣列元素是一個位元組大小的空間,而陣列索引則是所謂的實體地址(Physical Address)。最簡單最直接的方式,就是 CPU 直接通過實體地址去訪問對應的記憶體,這樣也被叫做物理定址。

物理定址後來也擴充套件支援了分段機制,通過在 CPU 中增加段暫存器,將實體地址變成了 "段地址":"段內偏移量" 的形式,增加了物理定址的定址範圍。

不過支援了分段機制的物理定址,仍然有一些問題,最嚴重的問題之一就是地址空間缺乏保護。簡單來說,因為直接暴露的是實體地址,所以程式可以訪問到任何實體地址,使用者程式想幹嘛就幹嘛,這是非常危險的。

5

現代處理器使用的是虛擬定址的方式,CPU 通過訪問虛擬地址(Virtual Address),經過翻譯獲得實體地址,才能訪問記憶體。這個翻譯過程由 CPU 中的記憶體管理單元(Memory Management Unit,縮寫為 MMU)完成。

具體流程如上圖所示:首先會在 TLB(Translation Lookaside Buffer)中進行查詢,它表位於 CPU 內部,查詢速度最快;如果沒有命中,那麼接下來會在頁表(Page Table)中進行查詢,頁表位於實體記憶體中,所以查詢速度較慢;最後如果發現目標頁並不在實體記憶體中,稱為缺頁,此時會去磁碟中找。當然,如果頁表中還找不到,那就是出錯了。

翻譯過程實際上和前文講到的儲存器分級類似,都體現了快取思想:TLB 的速度最快,但是容量也最小,之後是頁表,最慢的是硬碟。

虛擬記憶體

剛才提到,直接使用物理定址,會有地址空間缺乏保護的嚴重問題。那麼如何解決呢?實際上在使用了虛擬定址之後,由於每次都會進行一個翻譯過程,所以可以在翻譯中增加一些額外的許可權判定,對地址空間進行保護。所以,對於每個程式來說,作業系統可以為其提供一個獨立的、私有的、連續的地址空間,這就是所謂的虛擬記憶體。

6

虛擬記憶體最大的意義就是保護了程式的地址空間,使得程式之間不能夠越權進行互相地干擾。對於每個程式來說,作業系統通過虛擬記憶體進行"欺騙",程式只能夠操作被分配的虛擬記憶體的部分。與此同時,程式可見的虛擬記憶體是一個連續的地址空間,這樣也方便了程式設計師對記憶體進行管理。

7

對於程式來說,它的可見部分只有分配給它的虛擬記憶體,而虛擬記憶體實際上可能對映到實體記憶體以及硬碟的任何區域。由於硬碟讀寫速度並不如記憶體快,所以作業系統會優先使用實體記憶體空間,但是當實體記憶體空間不夠時,就會將部分記憶體資料交換到硬碟上去儲存,這就是所謂的 Swap 記憶體交換機制。有了記憶體交換機制以後,相比起物理定址,虛擬記憶體實際上利用硬碟空間擴充了記憶體空間。

總結起來,虛擬記憶體有下面幾個意義:保護了每個程式的地址空間、簡化了記憶體管理、利用硬碟空間擴充了記憶體空間。

記憶體分頁

基於前文的思路,虛擬記憶體和實體記憶體建立了對映的關係。為了方便對映和管理,虛擬記憶體和實體記憶體都被分割成相同大小的單位,實體記憶體的最小單位被稱為幀(Frame),而虛擬記憶體的最小單位被稱為頁(Page)。

注意頁和幀大小相同,有著類似函式的對映關係,前文提到的藉助 TLB、頁表進行的翻譯過程,實際上和函式的對映非常類似。

記憶體分頁最大的意義在於,支援了實體記憶體的離散使用。由於存在對映過程,所以虛擬記憶體對應的實體記憶體可以任意存放,這樣就方便了作業系統對實體記憶體的管理,也能夠可以最大化利用實體記憶體。同時,也可以採用一些頁面排程(Paging)演算法,利用翻譯過程中也存在的區域性性原理,將大概率被使用的幀地址加入到 TLB 或者頁表之中,提高翻譯的效率。

iOS 的記憶體機制

根據官方文件 Memory Usage Performance Guidelines(現在已經不更新了)我們能知道 iOS 的記憶體機制有下面幾個特點:

使用虛擬記憶體

iOS 和大多數桌面作業系統一樣,使用了虛擬記憶體機制。

記憶體有限,但單應用可用記憶體大

對於移動裝置來說,受限於客觀條件,實體記憶體容量本身就小,而 iPhone 的 RAM 本身也是偏小的,最新的 iPhone XS Max 也才有 4GB,橫向對比小米 9 可達 8GB,華為 P30 也是 8GB。根據 List of iPhones 可以檢視歷代 iPhone 的記憶體大小。

但是與其他手機不同的是,iOS 系統給每個程式分配的虛擬記憶體空間非常大。據官方文件的說法,iOS 為每個 32 位的程式都會提供高達 4GB 的可定址空間,這已經算非常大的了。

沒有記憶體交換機制

虛擬記憶體遠大於實體記憶體,那如果實體記憶體不夠用了該怎麼辦呢?之前我們講到,其他桌面作業系統(比如 OS X)有記憶體交換機制,在需要時能將實體記憶體中的一部分內容交換到硬碟上去,利用硬碟空間擴充記憶體空間,這也是使用虛擬記憶體帶來的優勢之一。

然而 iOS 並不支援記憶體交換機制,大多數移動裝置都不支援記憶體交換機制。移動裝置上的大容量儲存器通常是快閃記憶體(Flash),它的讀寫速度遠遠小於電腦所使用的硬碟,這就導致了在移動裝置就算使用記憶體交換機制,也並不能提升效能。其次,移動裝置的容量本身就經常短缺、快閃記憶體的讀寫壽命也是有限的,所以這種情況下還拿快閃記憶體來做記憶體交換,就有點太過奢侈了。

需要注意的是,網上有少數文章說 iOS 沒有虛擬記憶體機制,實際上應該指的是 iOS 沒有記憶體交換機制,因為在 Windows 系統下,虛擬記憶體有時指的是硬碟提供給記憶體交換的大小。

記憶體警告

那麼當記憶體不夠用時,iOS 的處理是會發出記憶體警告,告知程式去清理自己的記憶體。iOS 上一個程式就對應一個 app。程式碼中的 didReceiveMemoryWarning() 方法就是在記憶體警告發生時被觸發,app 應該去清理一些不必要的記憶體,來釋放一定的空間。

OOM 崩潰

如果 app 在發生了記憶體警告,並進行了清理之後,實體記憶體還是不夠用了,那麼就會發生 OOM 崩潰,也就是 Out of Memory Crash。

在 stack overflow 上,有人對單個 app 能夠使用的最大記憶體做了統計:iOS app max memory budget。以 iPhone XS Max 為例,總共的可用記憶體是 3735 MB(比硬體大小小一些,因為系統本身也會消耗一部分記憶體),而單個 app 可用記憶體達到 2039 MB,達到了 55%。當 app 使用的記憶體超過這個臨界值,就會發生 OOM 崩潰。可以看出,單個 app 的可用實體記憶體實際上還是很大的,要發生 OOM 崩潰,絕大多數情況下都是程式本身出了問題。

iOS 系統記憶體佔用

分析了 iOS 記憶體機制的特點之後,我們能夠意識到合理控制 app 使用的記憶體是非常重要的一件事。那麼具體來說,我們需要減少的是哪些部分呢?實際上這就是所謂的 iOS 記憶體佔用(Memory Footprint)的部分。

上文講到記憶體分頁,實際上記憶體頁也有分類,一般來說分為 clean memory 和 dirty memory 兩種,iOS 中也有 compressed memory 的概念。

Clean memory & dirty memory

對於一般的桌面作業系統,clean memory 可以認為是能夠進行 Page Out 的部分。Page Out 指的是將優先順序低的記憶體資料交換到磁碟上的操作,但 iOS 並沒有記憶體交換機制,所以對 iOS 這樣的定義是不嚴謹的。那麼對於 iOS 來說,clean memory 指的是能被重新建立的記憶體,它主要包含下面幾類:

  • app 的二進位制可執行檔案

  • framework 中的 _DATA_CONST 段

  • 檔案對映的記憶體

  • 未寫入資料的記憶體

記憶體對映的檔案指的是當 app 訪問一個檔案時,系統會將檔案對映載入到記憶體中,如果檔案只讀,那麼這部分記憶體就屬於 clean memory。另外需要注意的是,連結的 framework 中 _DATA_CONST 並不絕對屬於 clean memory,當 app 使用到 framework 時,就會變成 dirty memory。

未寫入資料的記憶體也屬於 clean memory,比如下面這段程式碼,只有寫入了的部分才屬於 dirty memory。

int *array = malloc(20000 * sizeof(int));
array[0] = 32
array[19999] = 64
複製程式碼

8

所有不屬於 clean memory 的記憶體都是 dirty memory。這部分記憶體並不能被系統重新建立,所以 dirty memory 會始終佔據實體記憶體,直到實體記憶體不夠用之後,系統便會開始清理。

Compressed memory

當實體記憶體不夠用時,iOS 會將部分實體記憶體壓縮,在需要讀寫時再解壓,以達到節約記憶體的目的。而壓縮之後的記憶體,就是所謂的 compressed memory。蘋果最開始只是在 OS X 上使用這項技術,後來也在 iOS 系統上使用。

實際上,隨著虛擬記憶體技術的發展,很多桌面作業系統早已經應用了記憶體壓縮技術,比如 Windows 中的 memory combining 技術。這本質上來說和記憶體交換機制類似,都是是一種用 CPU 時間換記憶體空間的方式,只不過記憶體壓縮技術消耗的時間更少,但佔用 CPU 更高。不過在文章最開始,我們就已經談到由於 CPU 算力過剩,在大多數場景下,實體記憶體的空間相比起 CPU 算力來說顯然更為重要,所以記憶體壓縮技術非常有用。

根據 OS X Mavericks Core Technology Overview 官方文件來看,使用 compressed memory 能在記憶體緊張時,將目標記憶體壓縮至原有的一半以下,同時壓縮和解壓消耗的時間都非常小。對於 OS X,compressed memory 也能和記憶體交換技術共用,提高記憶體交換的效率,畢竟壓縮後再進行交換效率明顯更高,只是 iOS 沒有記憶體交換,也就不存在這方面的好處了。

本質上來講,compressed memory 也屬於 dirty memory。

記憶體佔用組成

9

對於 app 來說,我們主要關心的記憶體是 dirty memory,當然其中也包含 compressed memory。而對於 clean memory,作為開發者通常可以不必關心。

當記憶體佔用的部分過大,就會發生前文所說的記憶體警告以及 OOM 崩潰等情況,所以我們應該儘可能的減少記憶體佔用,並對記憶體警告以及 OOM 崩潰做好防範。減少記憶體佔用也能側面提升啟動速度,要載入的記憶體少了,自然啟動速度會變快。

按照正常的思路,app 監聽到記憶體警告時應該主動清理釋放掉一些優先順序低的記憶體,這本質上是沒錯的。不過由於 compressed memory 的特殊性,所以導致記憶體佔用的實際大小考慮起來會有些複雜。

10

比如上面這種情況,當我們收到記憶體警告時,我們嘗試將 Dictionary 中的部分內容釋放掉,但由於之前的 Dictionary 由於未使用,所以正處於被壓縮狀態;而解壓、釋放部分內容之後,Dictionary 處於未壓縮狀態,可能並沒有減少實體記憶體,甚至可能反而讓實體記憶體更大了。

所以,進行快取更推薦使用 NSCache 而不是 NSDictionary,就是因為 NSCache 不僅執行緒安全,而且對存在 compressed memory 情況下的記憶體警告也做了優化,可以由系統自動釋放記憶體。

iOS app 記憶體管理

前文講了 iOS 系統層面上的記憶體機制,在系統層面上的記憶體管理大多數情況下都已經由作業系統自動完成了。iOS 中一個 app 就是一個程式,所以開發者平時經常討論的記憶體管理,比如 MRC、ARC 等等,實際上屬於程式內部的記憶體管理,或者說是語言層面上的記憶體管理。這部分記憶體管理語言本身、作業系統均會有一些管理策略,但是作為開發者來說,很多時候還是需要從語言層面直接進行操作的。

iOS app 地址空間

前文我們說過,每個程式都有獨立的虛擬記憶體地址空間,也就是所謂的程式地址空間。現在我們稍微簡化一下,一個 iOS app 對應的程式地址空間大概如下圖所示:

11

每個區域實際上都儲存相應的內容,其中程式碼區、常量區、靜態區這三個區域都是自動載入,並且在程式結束之後被系統釋放,開發者並不需要進行關注。

棧區一般存放區域性變數、臨時變數,由編譯器自動分配和釋放,每個執行緒執行時都對應一個棧。而堆區用於動態記憶體的申請,由程式設計師分配和釋放。一般來說,棧區由於被系統自動管理,速度更快,但是使用起來並不如堆區靈活。

對於 Swift 來說,值型別存於棧區,引用型別存於堆區。值型別典型的有 struct、enum 以及 tuple 都是值型別。而比如 Int、Double、Array,Dictionary 等其實都是用結構體實現的,也是值型別。而 class、closure 都是引用型別,也就是說 Swift 中我們如果遇到類和閉包,就要留個心眼,考慮一下他們的引用情況。

引用計數

堆區需要程式設計師進行管理,如何管理、記錄、回收就是一個很值得思考的問題。iOS 採用的是引用計數(Reference Counting)的方式,將資源被引用的次數儲存起來,當被引用次數變為零時就將其空間釋放回收。

對於早期 iOS 來說,使用的是 MRC(Mannul Reference Counting)手動管理引用計數,通過插入 retainrelease 等方法來管理物件的生命週期。但由於 MRC 維護起來實在是太麻煩了,2011 年的 WWDC 大會上提出了 ARC(Automatic Reference Counting)自動管理引用計數,通過編譯器的靜態分析,自動插入引入計數的管理邏輯,從而避免繁雜的手動管理。

引用計數只是垃圾回收中的一種,除此之外還有標記-清除演算法(Mark Sweep GC)、可達性演算法(Tracing GC)等。相比之下,引用計數由於只記錄了物件的被引用次數,實際上只是一個區域性的資訊,而缺乏全域性資訊,因此可能產生迴圈引用的問題,於是在程式碼層面就需要格外注意。

那麼為什麼 iOS 還要採用引用計數呢?首先使用引用計數,物件生命週期結束時,可以立刻被回收,而不需要等到全域性遍歷之後再回首。其次,在記憶體不充裕的情況下,tracing GC 演算法的延遲更大,效率反而更低,由於 iPhone 整體記憶體偏小,所以引用計數算是一種更為合理的選擇。

迴圈引用

記憶體洩漏指的是沒能釋放不能使用的記憶體,會浪費大量記憶體,很可能導致應用崩潰。ARC 可能導致的迴圈引用就是其中一種,並且也是 iOS 上最常發生的。什麼情況下會發生迴圈引用,大家可能都比較熟悉了,swift 中比較典型的是在使用閉包的時候:

class viewController: UIViewController {
    var a = 10
    var b = 20
    var someClosure: (() -> Int)?
    
    func anotherFunction(closure: @escaping () -> Int) {
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) {
            print(closure)
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        someClosure = {
            return self.a + self.b
        }
        anotherFunction(closure: someClosure!)
    }
}
複製程式碼

上面這段程式碼中,viewController 會持有 someClosure,而 someClosure 也因為需要使用 self.a + self.b 而持有了 viewController,這就導致了迴圈引用。注意,閉包和類相似,都是引用型別,當把閉包賦值給類的屬性時,實際上是把閉包的引用賦值給了這個屬性。

12

解決方法也很簡單,利用 Swift 提供的閉包捕獲列表,將迴圈引用中的一個強引用關係改為弱引用就好了。實際上,Swift 要求在閉包中使用到了 self 的成員都必須不能省略 self. 的關鍵詞,就是為了提醒這種情況下可能發生迴圈引用問題。

someClosure = { [weak self] in
    guard let self = self else { return 0 }
    return self.a + self.b
}
複製程式碼

weak 和 unowned

weak 關鍵字能將迴圈引用中的一個強引用替換為弱引用,以此來破解迴圈引用。而還有另一個關鍵字 unowned,通過將強引用替換為無主引用,也能破解迴圈引用,不過二者有什麼區別呢?弱引用物件可以為 nil,而無主引用物件不能,會發生執行時錯誤。

比如上面的例子我們使用了 weak,那麼就需要額外使用 guard let 進行一步解包。而如果使用 unowned,就可以省略解包的一步:

someClosure = { [unowned self] in
    return self.a + self.b
}
複製程式碼

weak 在底層新增了附加層,間接地把 unowned 引用包裹到了一個可選容器裡面,雖然這樣做會更加清晰,但是在效能方面帶來了一些影響,所以 unowned 會更快一些。

但是無主引用有可能導致 crash,就是無主引用的物件為 nil 時,比如上面這個例子中,anotherFunction 我們會延遲 5s 呼叫 someClosure,但是如果 5s 內我們已經 pop 了這個 viewController,那麼 unowned self 在呼叫時就會發現 self 已經被釋放了,此時就會發生崩潰。

Fatal error: Attempted to read an unowned reference but the object was already deallocated

如果簡單類比,使用 weak 的引用物件就類似於一個可選型別,使用時需要考慮解包;而使用 unowned 的引用物件就類似於已經進行強制解包了,不需要再解包,但是如果物件是 nil,那麼就會直接 crash。

13

到底什麼情況下可以使用 unowned 呢?根據官方文件 Automatic Reference Counting 所說,無主引用在其他例項有相同或者更長的生命週期時使用。

Unlike a weak reference, however, an unowned reference is used when the other instance has the same lifetime or a longer lifetime.

一種情況,如果兩個互相持有的物件,一個可能為 nil 而另一個不會為 nil,那麼就可以使用 unowned。比如官方文件中的這個例子,每張信用卡必然有它的主人,CreditCard 必然對應一個 Customer,所以這裡使用了 unowned

class Customer {
    let name: String
    var card: CreditCard?
    init(name: String) {
        self.name = name
    }
    deinit { print("\(name) is being deinitialized") }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
    deinit { print("Card #\(number) is being deinitialized") }
}
複製程式碼

而另一種情況,對於閉包,在閉包和捕獲的例項總是相互引用並且同時銷燬時,可以將閉包的捕獲定義為 unowned。如果被捕獲的引用絕對不會變為 nil,應該使用 unowned,而不是 weak

If the captured reference will never become nil, it should always be captured as an unowned reference, rather than a weak reference.

比如下面這個例子中的閉包,首先 asHTML 被宣告為 lazy,那麼一定是 self 先被初始化;同時內部也沒有使用 asHTML 屬性,所以 self 一旦被銷燬,閉包也不存在了。這種情況下就應該使用 unowned

class HTMLElement {

    let name: String
    let text: String?

    lazy var asHTML: () -> String = {
        [unowned self] in
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }

    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
    }

}
複製程式碼

總的來說,最關鍵的點在於 weakunowned 更加安全,能夠避免意外的 crash,這對於工程來說是非常有益的。所以大多數時候,就像我們通過 if let 以及 guard let 來避免使用 ! 強制解析一樣,我們也通常直接使用 weak

不會導致迴圈引用的情形

由於閉包經常產生迴圈引用的問題,而且加上 weak 以及 guard let 之後也不會出現錯誤,所以很多時候我們遇到閉包就直接無腦使用 weak,這實際上就太過粗糙了。

比如,如果在 viewController 中使用了類似下面的閉包,就不會發生迴圈引用,因為 DispatchQueue 並不會被持有:

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    self.execute()
}
複製程式碼

更典型的比如使用 static functions 的時候:

class APIClass {
    // static 函式
    static func getData(params: String, completion:@escaping (String) -> Void) {
        request(method: .get, parameters: params) { (response) in
            completion(response)
        }
    }
}
class viewController {

		var params = "something"
		var value = ""

    override func viewDidLoad() {
        super.viewDidLoad()
        getData(params: self.params) { (value) in
            self.value = value
        }
    }
}
複製程式碼

此時並不會產生迴圈引用,因為 self 並不會持有 static class,因此也不會產生記憶體洩漏:

14

OOM 崩潰

Jetsam 機制

iOS 是一個從 BSD 衍生而來的系統,其核心是 Mach。其中記憶體警告,以及 OOM 崩潰的處理機制就是 Jetsam 機制,也被稱為 Memorystatus。Jetsam 會始終監控記憶體整體使用情況,當記憶體不足時會根據優先順序、記憶體佔用大小殺掉一些程式,並記錄成 JetsamEvent

根據 apple 開源的核心程式碼 apple/darwin-xnu,我們可以看到,Jetsam 維護了一個優先順序佇列,具體的優先順序內容可以在 bsd/kern/kern_memorystatus.c 檔案中找到:

static const char *
memorystatus_priority_band_name(int32_t priority)
{
	switch (priority) {
	case JETSAM_PRIORITY_FOREGROUND:
		return "FOREGROUND";
	case JETSAM_PRIORITY_AUDIO_AND_ACCESSORY:
		return "AUDIO_AND_ACCESSORY";
	case JETSAM_PRIORITY_CONDUCTOR:
		return "CONDUCTOR";
	case JETSAM_PRIORITY_HOME:
		return "HOME";
	case JETSAM_PRIORITY_EXECUTIVE:
		return "EXECUTIVE";
	case JETSAM_PRIORITY_IMPORTANT:
		return "IMPORTANT";
	case JETSAM_PRIORITY_CRITICAL:
		return "CRITICAL";
	}

	return ("?");
}
複製程式碼

而如何監控記憶體警告,以及處理 Jetsam 事件呢?首先,核心會調起一個核心優先順序最高(95 /* MAXPRI_KERNEL */ 已經是核心能給執行緒分配的最高優先順序了)的執行緒:

// 同樣在 bsd/kern/kern_memorystatus.c 檔案中
result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread);
複製程式碼

這個執行緒會維護兩個列表,一個是基於優先順序的程式列表,另一個是每個程式消耗的記憶體頁的列表。與此同時,它會監聽核心 pageout 執行緒對整體記憶體使用情況的通知,在記憶體告急時向每個程式轉發記憶體警告,也就是觸發 didReceiveMemoryWarning 方法。

而殺掉應用,觸發 OOM,主要是通過 memorystatus_kill_on_VM_page_shortage,有同步和非同步兩種方式。同步方式會立刻殺掉程式,先根據優先順序,殺掉優先順序低的程式;同一優先順序再根據記憶體大小,殺掉記憶體佔用大的程式。而非同步方式只會標記當前程式,通過專門的記憶體管理執行緒去殺死。

如何檢測 OOM

OOM 分為兩大類,Foreground OOM / Background OOM,簡寫為 FOOM 以及 BOOM。而其中 FOOM 是指 app 在前臺時由於消耗記憶體過大,而被系統殺死,直接表現為 crash。

而 Facebook 開源的 FBAllocationTracker,原理是 hook 了 malloc/free 等方法,以此在執行時記錄所有例項的分配資訊,從而發現一些例項的記憶體異常情況,有點類似於在 app 內執行、效能更好的 Allocation。但是這個庫只能監控 Objective-C 物件,所以侷限性非常大,同時因為沒辦法拿到物件的堆疊資訊,所以更難定位 OOM 的具體原因。

而騰訊開源的 OOMDetector,通過 malloc/free 的更底層介面 malloc_logger_t 記錄當前存活物件的記憶體分配資訊,同時也根據系統的 backtrace_symbols 回溯了堆疊資訊。之後再根據伸展樹(Splay Tree)等做資料儲存分析,具體方式參看這篇文章:iOS微信記憶體監控

OOM 常見原因

記憶體洩漏

最常見的原因之一就是記憶體洩漏。

UIWebview 缺陷

無論是開啟網頁,還是執行一段簡單的 js 程式碼,UIWebView 都會佔用大量記憶體,同時舊版本的 css 動畫也會導致大量問題,所以最好使用 WKWebView

大圖片、大檢視

縮放、繪製解析度高的大圖片,播放 gif 圖,以及渲染本身 size 過大的檢視(例如超長的 TextView)等,都會佔用大量記憶體,輕則造成卡頓,重則可能在解析、渲染的過程中發生 OOM。

記憶體分析

關於記憶體佔用情況、記憶體洩漏,我們都有一系列方法進行分析檢測。

  • Xcode memory gauge:在 Xcode 的 Debug navigator 中,可以粗略檢視記憶體佔用的情況。
  • Instrument - Allocations:可以檢視虛擬記憶體佔用、堆資訊、物件資訊、呼叫棧資訊,VM Regions 資訊等。可以利用這個工具分析記憶體,並針對地進行優化。
  • Instrument - Leaks:用於檢測記憶體洩漏。
  • MLeaksFinder:通過判斷 UIViewController 被銷燬後其子 view 是否也都被銷燬,可以在不入侵程式碼的情況下檢測記憶體洩漏。
  • Instrument - VM Tracker:可以檢視記憶體佔用資訊,檢視各型別記憶體的佔用情況,比如 dirty memory 的大小等等,可以輔助分析記憶體過大、記憶體洩漏等原因。
  • Instrument - Virtual Memory Trace:有記憶體分頁的具體資訊,具體可以參考 WWDC 2016 - Syetem Trace in Depth
  • Memory Resource Exceptions:從 Xcode 10 開始,記憶體佔用過大時,偵錯程式能捕獲到 EXC_RESOURCE RESOURCE_TYPE_MEMORY 異常,並斷點在觸發異常丟擲的地方。
  • Xcode Memory Debugger:Xcode 中可以直接檢視所有物件間的相互依賴關係,可以非常方便的查詢迴圈引用的問題。同時,還可以將這些資訊匯出為 memgraph 檔案。
  • memgraph + 命令列指令:結合上一步輸出的 memgraph 檔案,可以通過一些指令來分析記憶體情況。vmmap 可以列印出程式資訊,以及 VMRegions 的資訊等,結合 grep 可以檢視指定 VMRegion 的資訊。leaks 可追蹤堆中的物件,從而檢視記憶體洩漏、堆疊資訊等。heap 會列印出堆中所有資訊,方便追蹤記憶體佔用較大的物件。malloc_history 可以檢視 heap 指令得到的物件的堆疊資訊,從而方便地發現問題。總結:malloc_history ===> Creation;leaks ===> Reference;heap & vmmap ===> Size。

Github 文章連結

參考資料

  1. 什麼是記憶體 - eleven_yw
  2. 機器之心 - 馮諾依曼結構
  3. 虛擬記憶體那點事 - SylvanasSun
  4. stack overflow - Why don't most Android devices have swap area as typical OS does?
  5. stack overflow - What is resident and dirty memory of iOS?
  6. OS X Mavericks 中的記憶體壓縮技術到底有多強大? - rlei的回答 - 知乎
  7. WWDC 2018:iOS 記憶體深入研究
  8. WWDC 2018 - 深入解析 iOS 記憶體 iOS Memory Deep Dive
  9. 垃圾回收機制中,引用計數法是如何維護所有物件引用的? - RednaxelaFX的回答 - 知乎
  10. 垃圾回收演算法:引用計數法 - good speed
  11. All About Memory Leaks in iOS
  12. Unowned or Weak? Lifetime and Performance
  13. How Swift Implements Unowned and Weak References
  14. 《The Swift Programming Language》in Chinese - Swift GG
  15. iOS記憶體abort(Jetsam) 原理探究
  16. OOM探究:XNU 記憶體狀態管理
  17. iOS微信記憶體監控

相關文章