摘要:參考Apple的 WWDC18 Session 416 —— iOS Memory Deep Dive,處理圖片縮放時,直接使用UIImage會在解碼時讀取文件佔用一部分內存,還會生成中間位圖bitmap消耗大量內存,而ImageIO不存在上述兩種內存消耗,只會佔用最終圖片大小的內存。在memorystatus_act_on_hiwat_processes(),通過memorystatus_kill_hiwat_proc(),在上文提到的memstat_bucket中查找優先級最低的進程,如果進程的內存小於閾值(footprint_in_bytes <= memlimit_in_bytes),則繼續尋找下一個優先級次低的進程,直到找到佔用內存超過閾值的,並將其殺掉,函數返回。

我們都知道手機的物理內存是有限的,App的內存優化不僅能使其自身更少出現內存耗盡(OOM,Out-Of-Memory)崩潰,同時也能讓系統後臺“保留”更多應用(包括自己的App),以便更快地被喚起,提升用戶移動設備的整體使用體驗。

爲此,我們對京東iOS App整體進行了一次內存使用優化,使京東App的OOM發生概率下降了50%左右,現在將前期調研學習到的一些知識和具體的優化方案分享出來。

OOM崩潰從何而來

在Linux系統中,交換空間(swap space)可以用來存放內存中不常用的臨時數據。而iOS因爲閃存容量和讀寫壽命的原因並沒有引入交換空間,取而代之的是Compressed memory技術,既當內存緊張時壓縮一些內存內容,並在需要時解壓。但這樣會造成較高的CPU佔用甚至卡頓,手機耗電量也會隨之增加。因此iOS的內存空間顯得更爲珍貴。

爲此蘋果設計了Jetsam機制。

其實之前不太瞭解這個名詞的同學,可以在我們手機的“設置->隱私->分析->分析數據”中,看到一些"JetsamEvent-"開頭的日誌,這些都是由於OOM問題而上報的。打開一份日誌,點擊屏幕右上角分享按鈕,將日誌分享到電腦中,搜索"reason"可以看到:

{

"uuid" : "7682d50d-b09e-38ac-bd5c-a99c8fde1a8c",

"states" : [

"frontmost"

],

"killDelta" : 2941,

"genCount" : 0,

"age" : 4630423870208,

"purgeable" : 1,

"fds" : 100,

"coalition" : 70,

"rpages" : 17928,

"reason" : "highwater",

"pid" : 57,

"cpuTime" : 2622.5295529999999,

"name" : "SpringBoard",

"lifetimeMax" : 17929

}

筆者手機這次OOM是大名鼎鼎的SpringBoard(相當於Windows的“桌面”進程)被殺掉,而對應的原因"highwater"會在後文中進行介紹。

在蘋果官方文檔中,關於Low Memory Reports有下面一段介紹:

"

When a low-memory condition is detected, the virtual memory system in iOS relies on the cooperation of applications to release memory. Low-memory notifications are sent to all running applications and processes as a request to free up memory, hoping to reduce the amount of memory in use. If memory pressure still exists, the system may terminate background processes to ease memory pressure. If enough memory can be freed up, your application will continue to run. If not, your application will be terminated by iOS because there isn't enough memory to satisfy the application's demands, and a low memory report will be generated and stored on the device. 

"

大意是當內存不足時,系統會先通知各個運行中的App去釋放內存(既 - (void)applicationDidReceiveMemoryWarning:(UIApplication *)application;方法和UIApplicationDidReceiveMemoryWarningNotification通知),如果內存壓力依然存在,將會終止一些後臺進程。最終內存還不夠的話,就會殺掉當前App並且上報日誌。

我們也可以在開源的iOS XNU內核源碼中,窺探蘋果Jetsam的具體實現。

內存資源監測相關主要在 kern_memorystatus.h   和 kern_memorystatus.c中,這裏只貼幾處較爲關鍵的部分,感興趣的同學可以去整體閱讀一下源碼。

OS X和iOS實現了一個低內存情形的處理機制,稱爲Jetsam,或者叫做Memorystatus。Jetsam的名字來源於殺掉消耗內存最多的進程並且拋棄(jettison)這些進程佔用的內存頁面的過程。它在內核中維護了一個快照,這個快照包含系統中所有進程的狀態以及消耗的內存頁面數。同時還維護了一個優先級數組,用以在內存不足時按順序結束進程,數組每一項是一個包含進程鏈表list的結構體。結構體定義如下:

#define MEMSTAT_BUCKET_COUNT (JETSAM_PRIORITY_MAX + 1)


typedef struct memstat_bucket {

TAILQ_HEAD(, proc) list;

int count;

} memstat_bucket_t;


memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT];

在kern_memorystatus.h文件中,我們可以找到這個數組長度JETSAM_PRIORITY_MAX和進程優先級相關的定義:

#define JETSAM_PRIORITY_IDLE_HEAD -2

/* The value -1 is an alias to JETSAM_PRIORITY_DEFAULT */

#define JETSAM_PRIORITY_IDLE 0

#define JETSAM_PRIORITY_IDLE_DEFERRED 1 /* Keeping this around till all xnu_quick_tests can be moved away from it.*/

#define JETSAM_PRIORITY_AGING_BAND1 JETSAM_PRIORITY_IDLE_DEFERRED

#define JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC 2

#define JETSAM_PRIORITY_AGING_BAND2 JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC

#define JETSAM_PRIORITY_BACKGROUND 3

#define JETSAM_PRIORITY_ELEVATED_INACTIVE JETSAM_PRIORITY_BACKGROUND

#define JETSAM_PRIORITY_MAIL 4

#define JETSAM_PRIORITY_PHONE 5

#define JETSAM_PRIORITY_UI_SUPPORT 8

#define JETSAM_PRIORITY_FOREGROUND_SUPPORT 9

#define JETSAM_PRIORITY_FOREGROUND 10

#define JETSAM_PRIORITY_AUDIO_AND_ACCESSORY 12

#define JETSAM_PRIORITY_CONDUCTOR 13

#define JETSAM_PRIORITY_HOME 16

#define JETSAM_PRIORITY_EXECUTIVE 17

#define JETSAM_PRIORITY_IMPORTANT 18

#define JETSAM_PRIORITY_CRITICAL 19


#define JETSAM_PRIORITY_MAX 21

其中數值越大,優先級越高。 可以關注: 後臺應用程序優先級JETSAM_PRIORITY_BACKGROUND 是3,低於前臺應用程序優先級JETSAM_PRIORITY_FOREGROUND 10,而SpringBoard位於JETSAM_PRIORITY_HOME 16。

在kern_memorystatus.c文件開頭,能看到所有OOM日誌中可能出現的原因:

/* For logging clarity */

static const char *memorystatus_kill_cause_name[] = {

"" , /* kMemorystatusInvalid */

"jettisoned" , /* kMemorystatusKilled */

"highwater" , /* kMemorystatusKilledHiwat */

"vnode-limit" , /* kMemorystatusKilledVnodes */

"vm-pageshortage" , /* kMemorystatusKilledVMPageShortage */

"proc-thrashing" , /* kMemorystatusKilledProcThrashing */

"fc-thrashing" , /* kMemorystatusKilledFCThrashing */

"per-process-limit" , /* kMemorystatusKilledPerProcessLimit */

"disk-space-shortage" , /* kMemorystatusKilledDiskSpaceShortage */

"idle-exit" , /* kMemorystatusKilledIdleExit */

"zone-map-exhaustion" , /* kMemorystatusKilledZoneMapExhaustion */

"vm-compressor-thrashing" , /* kMemorystatusKilledVMCompressorThrashing */

"vm-compressor-space-shortage" , /* kMemorystatusKilledVMCompressorSpaceShortage */

};

不過這裏不要被這裏的"disk-space-shortage"迷惑,只有MacOS的OOM纔有可能上報這種類型(iOS沒有交換空間)。 還有一些是虛擬內存導致的OOM,篇幅限制這裏就不展開討論了。

接下來讓我們看看memorystatus_init這個函數里面初始化JETSAM線程的關鍵部分代碼:

__private_extern__ void

memorystatus_init(void)

{

...

/* Initialize all the jetsam threads */

for (i = 0; i < max_jetsam_threads; i++) {

result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread);

if (result == KERN_SUCCESS) {

jetsam_threads[i].inited = FALSE;

jetsam_threads[i].index = i;

thread_deallocate(jetsam_threads[i].thread);

} else {

panic("Could not create memorystatus_thread %d", i);

}

}

}

①首先,在這裏會根據內核啓動參數和設備性能,開啓max_jetsam_threads個(一般爲1。 特殊情況開啓fast jetsam且設備允許時,可能爲3個)jetsam線程,這些線程的優先級是內核所能分配的最高級(95 /* MAXPRI_KERNEL */)。 在版本老一些的源碼中,這裏只創建了一個名爲"VM_memorystatus"的線程,而現在每個線程名後面加上了它被創建的次序。 (注意:前文的-2~19是進程優先級區間,而這裏的95是線程優先級,XNU的線程優先級範圍是0~127)。

②繼續看memorystatus_thread這個線程啓動的初始化函數:

static void

memorystatus_thread(void *param __unused, wait_result_t wr __unused)

{

...

while (memorystatus_action_needed()) {

cause = kill_under_pressure_cause;

switch (cause) {

...

}

...

}

可以看到memorystatus_init開啓了一個memorystatus_action_needed控制的while循環持續釋放內存:

static boolean_t

memorystatus_action_needed(void)

{

...

return (is_reason_thrashing(kill_under_pressure_cause) ||

is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||

memorystatus_available_pages <= memorystatus_available_pages_pressure);

...

}

這裏通過接受vm_pageout守護程序(實際上是一個線程)發送的內存壓力來判斷當前內存資源是否緊張。 內存緊張的情況可能是: 操作系統的抖動(Thrashing,頻繁的內存頁面(page)換進換出並佔用CPU過度),虛擬內存耗盡(比如有人從硬盤向ZFS池中拷貝1TB的數據),或者內存可用頁低於閾值memorystatus_available_pages_pressure。

③如果內存緊張,將先觸發High-water類型的OOM。這種類型的OOM會在某個進程使用內存超過了其最高內存使用限制"high water mark"(HWM)時發生。在memorystatus_act_on_hiwat_processes(),通過memorystatus_kill_hiwat_proc(),在上文提到的memstat_bucket中查找優先級最低的進程,如果進程的內存小於閾值(footprint_in_bytes <= memlimit_in_bytes),則繼續尋找下一個優先級次低的進程,直到找到佔用內存超過閾值的,並將其殺掉,函數返回。

...

/* Highwater */

boolean_t is_critical = TRUE;

if (memorystatus_act_on_hiwat_processes(&errors, &hwm_kill, &post_snapshot, &is_critical)) {

if (is_critical == FALSE) {

break;

} else {

goto done;

}

}

...

④不過High-water的閾值較高,一般不容易觸發。 如果通過其不能結束任何進程,將走入memorystatus_act_aggressive()函數里,也就是大部分OOM發生的地方。

...

if (memorystatus_act_aggressive(cause, jetsam_reason, &jld_idle_kills, &corpse_list_purged, &post_snapshot)) {

goto done;

}

...

在這裏,將分三步釋放內存:

1.先回收優先級極低的進程和一些正常情況下隨時可回收的進程。

2.如果內存壓力依然存在,繼續殺掉後臺進程。

3.最終走投無路,就會發生FOOM(Foreground Out-Of-Memory),即前臺進程被系統結束(memorystatus_kill_top_process_aggressive())。

瞭解OOM發生的原理後其實可以發現,優化自身App內存不僅能降低OOM崩潰率,同時當大家的App都非常“自律”地使用內存時,即使應用間互相切換,App們依然能在後臺保留用戶的使用狀態,隨時“熱啓動”,體驗較好。

我們能做些什麼

* 圖片內存使用優化

1.使用適當尺寸的圖片

我們知道,解壓後的圖片是由一個個像素點組成的。每個像素點一般有R、G、B、A(紅綠藍透明度)四個通道,每個通道是8位,因此一個像素通常佔用4字節。對於一張圖片,如果同樣是300*300分辨率的jpeg和png兩張圖,文件大小可能差幾倍,但是渲染後的內存開銷是完全一樣的。

而由於機型的不同,下載的圖片經常與最終展示在界面上的尺寸不同。如果我們將一張矩形圖片展示在很小的view裏,原圖解壓會消耗大量內存,但最終大部分像素最終都被丟掉浪費了。或者將圖片手動縮放成合適大小,處理過程中仍然可能會多佔用一部分內存。

因此,我們與服務端共同制定了一套方案,在服務端將圖片裁剪成控件的精確尺寸(記得乘上屏幕縮放比例[UIScreen mainScreen].scale)下發到不同機型,從根本上將內存使用降低。

2.及時回收圖片

單張圖片佔用內存不多,累計起來卻非常可觀。因此,當頁面pop掉時,有必要清理頁面內圖片的內存緩存。其次,列表類的頁面在滑動時,可以及時清理那些滑出屏幕圖片的內存緩存。

3.注意圖片縮放方式

參考Apple的 WWDC18 Session 416 —— iOS Memory Deep Dive,處理圖片縮放時,直接使用UIImage會在解碼時讀取文件佔用一部分內存,還會生成中間位圖bitmap消耗大量內存,而ImageIO不存在上述兩種內存消耗,只會佔用最終圖片大小的內存。(此處可以參考Linux的mmap內存映射)

常見的UIimage縮放寫法: 

- (UIImage *)scaleImage:(UIImage *)image newSize:(CGSize)newSize{

UIGraphicsBeginImageContextWithOptions(newSize, NO, 0);

[image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)];

UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();

UIGraphicsEndImageContext();

 

return newImage;

}

節約內存的ImageIO縮放寫法:

+ (UIImage *)scaledImageWithData:(NSData *)data withSize:(CGSize)size scale:(CGFloat)scale orientation:(UIImageOrientation)orientation{

CGFloat maxPixelSize = MAX(size.width, size.height);

CGImageSourceRef sourceRef = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil);

NSDictionary *options = @{(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : (__bridge id)kCFBooleanTrue,

(__bridge id)kCGImageSourceThumbnailMaxPixelSize : [NSNumber numberWithFloat:maxPixelSize]};

CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (__bridge CFDictionaryRef)options);

UIImage *resultImage = [UIImage imageWithCGImage:imageRef scale:scale orientation:orientation];

CGImageRelease(imageRef);

CFRelease(sourceRef);

 

return resultImage;

}

* 合理使用自動釋放池

自動釋放池(autoreleasePool)相信大家一定很熟悉,它的實現可以稱得上蘋果“代碼的藝術”,網上資料也非常多。但其實在實際開發中,可能並沒有引起我們的足夠重視,在一些該加的地方沒有加。通常autoreleased對象在runloop結束時才釋放。如果在一些體循環中,或者很複雜的邏輯中產生大量autoreleased對象,內存峯值會猛漲,容易觸發OOM。

其實,自動釋放池的效果非常顯著,能夠讓對象更及時釋放,降低內存峯值,我們的線上數據也證明其能非常有效地降低OOM發生。當然了,這與APP的類型、代碼實際處理的業務也密切相關。 

* 對象按需創建

頁面內除最主要的展示外,其他控件儘量採用懶加載的方式,待數據返回後或者需要展示時再進行加載。需要注意的還有包含元素過多、size過大的界面,會消耗大量內存,同時也會造成卡頓,應儘量優化其結構,降低頁面複雜度。還可以排查代碼的邏輯,將不必要的單例對象改爲懶加載的普通對象,使用完也能及時釋放掉。

* 避免內存泄露

每個版本發佈前,我們都會對基礎頁面和有新增改動的頁面進行內存泄漏檢測。開發和Review時也應注意是否存在循環引用,CF類型內存是否釋放,UIGraphicsBeginImageContext和UIGraphicsEndImageContext是否成對出現等問題。

最後

App的發展和手機的進化,其實是一個動態博弈的過程。用戶想要更流暢順滑的操作體驗,手機可用資源也在增加,但總歸有限。CPU與內存在某一方資源不足時會去補足,某一方資源充足時又可以分擔對方的負擔,也就是常說的是去“時間換空間”還是“空間換時間”。我們每一個開發工程師就是在這樣的平衡之間“跳舞”,爭取用現成的最少的資源,實現最佳的效果。

參考資料

1.《深入解析Mac OS X & iOS操作系統》 Jonathan 清華大學出版社

2.《No pressure,Mon! —— Handling low memory conditions in iOS and Mavericks》 Jonathan Levin

3.《Understanding and Analyzing Application Crash Reports》 Apple

4.WWDC18 Session 416 —— iOS Memory Deep Dive Apple

5.《iOS內存abort(jetsam)原理探究》 SatanWoo

6.《iOS內存管理研究》 即刻技術團隊

7.《iOS微信內存監控》 楊津 

相關文章