iOS 記憶體優化之工具介紹
0x1 前言
本文將介紹如何使用Xcode檢測和診斷記憶體問題。首先需要了解記憶體構成,記憶體佔用對app的影響、以及一些常見的記憶體問題。最後將介紹leaks、vmmap、malloc_history等工具來分析定位記憶體問題。
系統的記憶體是有限,更低的、合理的使用記憶體能使app獲得更好的體驗: 1. 更快的應用程式啟用(提高熱啟動概率,避免進入後臺後,因佔據較大記憶體被系統回收程序) 2. 更快速的響應(減少卡頓) 3. 處理更復雜的功能(載入影片、動畫) 4. 更夠在更多的裝置上執行(低記憶體裝置)
0x2 記憶體構成
記憶體是由系統管理,一般以頁為單位來劃分。在iOS上,每一頁包含16KB的空間。一段資料可能會佔用多頁記憶體,所佔用頁總數乘以每頁空間得到的就是這段資料使用的總記憶體。 app的記憶體佔用可分為以下三類: 1. 髒記憶體 2. 壓縮記憶體 3. 乾淨的記憶體
1.髒記憶體
髒記憶體是已經被app寫入的記憶體,如下: - 所有堆上的記憶體 - 圖片解碼的緩衝區 - Frameworks中的__DATA和__DATA_DIRTY部分
2.壓縮記憶體
當記憶體不足的時候,系統會按照一定策略來騰出更多空間供使用,比較常見的做法是將一部分低優先順序的資料挪到磁碟上,之後當再次訪問到這塊資料的時候,系統會負責將它重新搬回記憶體空間中。然後對於移動裝置而言,頻繁對磁碟進行IO操作會降低儲存裝置的壽命。所以從iOS7開始,系統開始採用壓縮記憶體的方式來釋放記憶體空間。
在iOS中當記憶體緊張時能夠將最近未使用過的髒記憶體佔用壓縮至原有大小的一半以下,並且能夠在需要時解壓複用。在節省記憶體的同時提高系統的響應速度,有以下特點: - 減少了不活躍的記憶體佔用 - 改善電源效率,通過壓縮減少磁碟IO帶來的損耗 - 壓縮/解壓十分迅速,能夠儘可能減少CPU的時間開銷 - 支援多核操作
iOS在記憶體緊張時使用的是記憶體壓縮技術,而MacOS在記憶體緊張時使用記憶體壓縮和磁碟交換技術
3.乾淨的記憶體
還沒有被寫入的記憶體或可以被系統清除且在需要時能重新載入的記憶體(記憶體是按頁分配的,只有整頁的資料被清除才可以被系統重新分配,只被清除部分資料,導致系統無法重新分配該頁) - 記憶體對映檔案 - 可以被整頁釋放的記憶體 - Frameworks中的__DATA_CONST部分 - 應用的二進位制可執行檔案
4.小結:
- 應用記憶體佔用大小 = 髒記憶體大小 + 壓縮記憶體大小
- 減少應用的記憶體佔用 = 減少髒記憶體大小 = 減少堆上記憶體佔用 + 圖片解碼緩衝區大小
0x2 記憶體洩露
應用程式申請記憶體後,無法釋放已申請的記憶體空間,一次記憶體洩露的危害可以忽略,但記憶體洩露堆積的後果很嚴重,無論多少記憶體,遲早會被佔光。 根據記憶體洩露原因,可以分為以下兩類: - 無主記憶體(沒有指標指向的記憶體,已經無法被釋放) - 迴圈引用
0x3 堆大小問題
堆是程序地址空間的一部分,用來儲存動態生成的物件。堆上容易出現以下問題: 1. 堆分配迴歸 2. 碎片化
堆分配迴歸的治理策略: - 移除無用記憶體分配 - 減少過大記憶體的分配 - 不再使用的記憶體需要釋放 - 需要時才去分配記憶體
什麼是碎片化? page(記憶體頁)是系統授予程序的固定大小、不可分割的最小記憶體塊。因為page是不可分割的,當程序寫入page的任意部分,整個page都會被認為是dirty(髒記憶體)並且程序將會管理它,即使page的大部分沒有被使用到。
當程序的dirty page沒有被100%佔用時,就會產生碎片化。 舉個例子: 假如有有一個髒記憶體頁,該頁被使用了一半的記憶體(8KB),此時建立了一個需要16KB大小的物件,則該頁無法放下,所以需要使用一個新的記憶體頁進行放入該物件。假如有n個類似的髒記憶體頁(未被100%使用),即使它們未被使用的總記憶體大於新物件所需要的記憶體,系統也無法進行分配至這些頁中,導致記憶體利用率低。這種現象就是記憶體碎片化
降低記憶體碎片化的方法就是建立記憶體相鄰,生命週期相似的物件。這能確保這些物件會被一起釋放,這樣程序就會得到一大塊連續的空閒記憶體進行物件分配。
0x4 定位記憶體問題
下面以MemoryGraphDemo為例,分別介紹Xcode 記憶體圖與命令列工具的使用方式,來講述如何定位迴圈引用、無主記憶體和記憶體追溯。
迴圈引用場景:
兩個物件相互強引用的場景
1.開啟專案(demo已設定)按步驟設定 Edit Scheme -> Run -> Diagnostics -> Malloc Stack Logging -> Live Allocations Only
開啟該配置後,記憶體圖會記錄malloc的分配堆疊日誌,發現記憶體問題後,可以通過記錄的堆疊回溯找到存在問題的程式碼。但是會給app增加額外記憶體佔用,所以僅在除錯時使用該配置。
2.執行demo,點選迴圈引用場景,製造一個洩露點,然後開啟記憶體圖,點選步驟如圖所示
3.然後過濾出洩露物件.記憶體圖左邊欄可以檢視總的洩露物件個數、型別,中間的圖表明該洩露是一個迴圈引用導致的,右邊Object欄可以檢視物件的具體資訊,包含型別、大小、地址資訊。Bactrace則是產生洩露點的堆疊,該堆疊只有打開了Malloc Stack Logging後才會有。通過點選堆疊後面的小箭頭,可以直接跳轉到程式碼位置。
4.leaks可以使用程序名來執行,以demo為例:
leaks MemoryGraphDemo
控制檯輸出對應資訊,下圖為部分關鍵資訊:
- 頭部:展示記憶體洩露的概覽,產生了2個洩露物件,浪費了共96KB
- STACK:展示了產生洩露的相關堆疊
- ROOT CYCLE:代表是迴圈引用導致洩露
leaks也可以通過模糊匹配程序名的方式使用,如leaks Memory
也是有效的,
想了解更多的使用方式,可以使用man leaks
命令檢視leaks的使用手冊。
無主記憶體場景:
記憶體無法被釋放或未呼叫相關的釋放函式的場景
1.重新執行專案(避免迴圈引用場景干擾),點選No Active References
場景
2.同樣的步驟開啟Xcode記憶體圖
從圖中可以看到,沒有任何物件引用這個陣列,因此它也就不可能被呼叫釋放函式,釋放這塊記憶體。
3.使用相同的命令leaks MemoryGraphDemo
,輸出結果如下:
ROOT LEAK:代表該洩露問題是由於沒有任何指標指向該物件導致的洩露
間接持有場景
假設有一個物件A,A持有一個可變集合B,集合B裡存放都是C物件,C物件強持有A。
A -> Set, Set add C, C -> A
1.再次重啟demo,點選Indirect Retain Cycles
,開啟Xcode記憶體圖
從圖中可以看到四個物件之間產生了相互引用的關係,導致無法釋放記憶體。
2.使用leaks工具檢視
從輸出結果紅框裡可以分析出,迴圈引用導致的洩露(ROOT CYCLE), 一個SomeItem物件強持有(__strong)_helper,_helper物件強持有(__strong)_items, _items內持有了一個SomeItem物件。
隱式間接持有場景
該場景基本和間接持有場景基本一致,區別在於集合的持有方式。本場景使用分類的方式為helper新增一個集合物件(分類新增屬性的方式objc_setAssociatedObject)。
A -> Dynamic Set, Dynamic Set add C, C -> A
這種場景記憶體圖和leaks工具都不能直接過濾出來,需要結合程式碼上下文和記憶體圖進行分析。
1.再次重啟demo,點選Dynamic Indirect Retain Cycles
, 然後開啟Xcode記憶體圖
2.同樣,使用leaks命令(leaks MemoryGraphDemo
)的結果如下
從輸出結果來看,本場景沒有發生迴圈引用和無主記憶體,但是在過濾框中搜索someItem,會發現該物件和helper物件依然存在記憶體中。
3.過濾出app建立的物件,可以看到物件仍然在記憶體中,並且可以看出helper通過objc_setAssociatedObject方式新增的陣列物件,並不會被helper直接持有。而是被objc_setAssociatedObject函式建立的一塊記憶體持有著該陣列。從Xcode右邊欄Object區獲取helper的Address,使用leaks MemoryGraphDemo --traceTree=Address
命令可以更清晰的看出其引用關係
從圖中可以看出helper被someItem持有,且someItem被NSMutableArray物件持有,NSMutableArray物件由objc_setAssociatedObject建立的物件持有,最終儲存在objc::AssociationManager::_mapStorage中。可以通過objc的原始碼分析為什麼這種引用方式造成物件不會被釋放。
參照objc4-866.9對於objc_setAssociatedObject的實現 首先objc::AssociationManager::_mapStorage中是個靜態變數,初始化後一直存在,所以關聯的陣列物件不會被釋放,因為被_mapStorage這個靜態變數所持有。
從程式碼中可以看出,呼叫_object_set_associative_reference時,獲取靜態變數_mapStorage,然後根據物件指標建立一個object-key,根據該key獲取/建立一個hashMap,該hashMap以外部傳入的key為鍵,以包含value的一個物件為值進行儲存關聯。
簡單的說,就是helper物件通過objc_setAssociatedObject記錄的陣列,最終是被_mapStorage儲存,helper通過key的方式進行訪問陣列,運算元組。由於helper被陣列元素物件強持有了,所以最終也是被_mapStorage引用, 當helper物件沒有被其他物件引用時,_mapStorage是否移除關聯物件決定了helper是否能被釋放。
那按照這個邏輯看的話,豈不是所有物件分類新增的屬性都不會被釋放?從理論上來說,這是不可能發生的,因為如果隨便寫一個分類,併為其新增屬性的話,都會導致該分類物件無法釋放,最終必然會導致大量記憶體洩露問題。那麼,_mapStorage什麼時候釋放掉關聯的物件?
全域性搜尋_object_remove_associations
函式,有兩處呼叫,一處是外部呼叫的介面,一處是在物件進行dealloc呼叫的時候。
通過objc_destructInstance
的實現邏輯可以知道,當物件呼叫dealloc時,如果物件有繫結關聯物件,則會進行呼叫_object_remove_associations
方法釋放_mapStorage對該物件的關聯記錄。
所以這種場景下,除非手動呼叫objc_removeAssociatedObjects
函式進行釋放helper的關聯物件,否則只能等helper物件的dealloc執行進行自動釋放關聯物件。但是helper被someItem強持有,someItem被陣列持有,陣列最終被_mapStorage持有。所以helper並不會呼叫dealloc方法,而_mapStorage釋放陣列依賴於helper的dealloc呼叫,這樣就造成了一個隱式的間接持有關係。
小結
所以定位這種問題,需要從業務場景,程式碼上下文中進行分析,從而推斷該物件未釋放是否是正常情況。比如: - 銷燬了某個ViewController,但是該vc中的某些物件依然存在記憶體中 - GCD延遲block持有的物件
記憶體追溯場景
當專案隨著迭代越發龐大時,對於某些場景的記憶體增長的原因難以通過檢視程式碼的方式瞭解。本場景就是講述如何通過使用工具的方式在龐大的原始碼中定位到記憶體增長的程式碼。
假設某個迭代的版本發現記憶體突然增加,但是不知道是哪塊程式碼引發的問題。比如SDWebImage載入高清圖片
1.重新執行demo,點選Large Buffers
2.可以看到模擬器記憶體由30M+激增到300M+,真機由13M+增長到70M+ (iOS 15以上)
3.使用vmmap -summary MemoryGraphDemo
命令,檢視demo程序的記憶體分佈情況。
在iOS中SWAPPED SIZE就是壓縮記憶體大小,從輸出的結果來看,CG Image和CoreAnimation這兩塊區域佔據大量記憶體(共330M左右)。所以排查的目標放在這兩個區域。
4.使用vmmap -v MemoryGraphDemo | grep "CG image\|CoreAnimation"
檢視這兩塊記憶體區的詳細資訊。其中會包含相應的佔用記憶體地址範圍和大小。
可以對比圖中髒記憶體和壓縮記憶體的大小來鎖定大記憶體塊的起始地址和結束地址。
5.使用malloc_history MemoryGraphDemo -fullStacks 0x288000000
命令通過傳入記憶體塊的起始地址,可以輸出該記憶體塊被建立時的一個呼叫堆疊。
從輸出的結果中可以發現堆疊包含一個SDImageCoderHelper類的呼叫,找到該類,並定位到31行。
從程式碼中可以看出這裡只針對iOS 15以上版本呼叫了系統函式
imageByPreparingForDisplay
,從malloc_history命令的輸出結果和斷點的方式(該函式前後斷點)測試,可以確定是該函式導致應用的記憶體激增。
6.那麼如何解決這個問題?
針對可能出現大圖的場景設定options
[imageView sd_setImageWithURL:url placeholderImage:nil options:SDWebImageAvoidDecodeImage];
小結
當需要檢視記憶體的分佈是否合理時,儘量覆蓋業務場景(該方法的缺陷),然後通過以下步驟定位記憶體佔用
1. vmmap -summary process
:檢視記憶體的一個整體分佈
2. vmmap -v process | grep "xxx"
:檢視懷疑區的詳細資訊,獲得地址
3. malloc_history process -fullStacks 地址
:檢視該地址記憶體的建立堆疊
4. 找到對應業務程式碼分析
0x5 總結
本篇文章主要介紹了Xcode記憶體圖和leaks工具的使用,以及排查記憶體問題的流程與思路: 1. 執行專案,測試覆蓋場景 2. 使用記憶體圖/leaks檢視記憶體洩露情況 3. 針對場景檢查是否有隱式間接持有場景 4. 根據情況修復問題 5. 迴歸
這套流程足夠一般中小專案進行排查記憶體問題,但是對於大型的、複雜的專案,該流程有明顯的缺點,就是手動操作成本比較高,使用起來並不是非常方便,且測試場景的覆蓋率直接影響排查問題的準確率。
這套流程的最佳實踐應該是利用UITest測試將記憶體圖檔案匯出來,並結合leaks、vmmap、malloc_history工具對記憶體圖檔案進行分析,實現自動化輸出視覺化結果的一套流程。