微信 Android 終端記憶體優化實踐
前言
記憶體問題是軟體領域的經典問題,平時藏得很深,在出現問題之前沒太多徵兆。而一旦爆發問題,問題來源的多樣、不易重現、現場資訊少、難以定位等困難,就會讓人頭疼不已。
微信在過去 N 多的版本迭代中,經歷了各式各樣的記憶體問題,這些問題包括但不限於 Activity 的洩漏、Cursor 未關閉、執行緒的過度使用、無節制的建立快取、以及某個 so 庫悄無聲息一點點的洩漏記憶體,等等。有些問題甚至曾倒逼著我們改變了微信的架構(2.x 時代 webview 核心洩露催生了微信多程序架構的改變)。時至今日微信依然偶爾會受到記憶體問題的挑戰,在持續不斷的版本迭代中,總會有新的問題被引入並潛藏著。
在解決各種問題的過程中,我們積累了一些相對有效和多面的優化手段及工具,從監控上報到開發階段的測試檢查,為預防和解決問題提供幫助,並還在不斷的持續改進。本文打算介紹一下這些工程上的優化實踐經驗,希望對大家有一些參考價值。
Activity 洩露檢測
Activity 洩漏,即因為各種原因導致 Activity 被生命週期遠比該 Activity 長的物件直接或間接以強引用持有,導致在此期間 Activity 無法被 GC 機制回收的問題。與其他物件洩漏相比,Android 上的 Activity 一方面提供了與系統互動的 Context,另一方面也是使用者與 App 互動的承載者,因此非常容易意外被系統或其他業務邏輯作為一個普通的工具物件長期持有,而且一旦發生洩漏,被牽連導致同樣被洩漏的物件也會非常多。此外,由於 這類問題在大量爆發之前除了 App 記憶體佔用變大之外並沒有 crash 之類的明顯徵兆 ,因此在測試階段主動檢測、排查 Activity 洩漏,避免線上出現 OOM 或其他問題就顯得非常必要了。
早期我們曾通過自動化測試階段在記憶體佔用達到閾值後自動觸發 Hprof Dump,將得到的 Hprof 存檔後由人工通過 MAT 進行分析。在新程式碼提交速度還不太快的時候,這樣做確實也能湊合著解決問題,但隨著微信新業務程式碼越來越多,人工排查後反饋給各 Activity 的負責人,各負責人修復之後再人工確認一遍是否已經修復,這個過程需要反覆的情況也越來越多,人工解決的方案已力不從心。
後來我們嘗試了 LeakCanary。這款工具除了能給出可讀性非常好的檢測結果外,對於排查出的問題,還會展示開源社群維護的解決方案,在 Activity 洩漏檢測、分析上完全可以代替人力。唯一美中不足的是 LeakCanary 把檢測和分析報告都放到了一起,流程上更符合開發和測試是同一人的情況,對批量自動化測試和事後分析就不太友好了。
為此我們在 LeakCanary 的基礎上研發了一套 Activity 洩漏的檢測分析方案 —— ResourceCanary,作為我們內部質量監控平臺 Matrix 的一部分參與到每天的自動化測試流程中。與 LeakCanary 相比 ResourceCanary 做了以下改進:
-
分離檢測和分析兩部分邏輯。
事實上這兩部分本來就可以獨立運作,檢測部分負責檢測和產生 Hprof 及一些必要的附加資訊,分析部分處理這些產物即可得到引發洩漏的強引用鏈。這樣一來檢測部分就不再和分析、故障解決相耦合,自動化測試由測試平臺進行,分析則由監控平臺的服務端離線完成,再通知相關開發同學解決問題。三者互不打斷對方程序,保證了自動化流程的連貫性。
-
裁剪 Hprof 檔案,降低後臺存檔 Hprof 的開銷。
就 Activity 洩漏分析而言,我們只需要 Hprof 中類和物件的描述和這些描述所需的字串資訊,其他資料都可以在客戶端就地裁剪。由於 Hprof 中這些資料比重很低, 這樣處理之後能把 Hprof 的大小降至原來的 1/10 左右 ,極大降低了傳輸和儲存開銷。
實際執行中通過 ResourceCanary,我們排查了一些非常典型的洩漏場景,部分列舉如下:
-
匿名內部類隱式持有外部類的引用導致的洩漏
public class ChattingUI extends MMActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_chatting_ui); EventCenter.addEventListener(new IListener<IEvent>() { // 這個 IListener 內部類裡有個隱藏成員 this$ 持有了外部的 ChattingUI @Override public void onEvent() { // ... } }); } } public class EventCenter { // 此 ArrayList 例項的生命週期為 App 的生命週期 private static List<IListener> sListeners = new ArrayList(); public void addEventListener(IListener cb) { // ArrayList 物件持有 cb,cb.this$ 持有 ChattingUI,導致 ChattingUI 洩漏 sListeners.add(cb); } }
-
各種原因導致的反註冊函式未按預期被呼叫導致的Activity洩漏
-
系統元件導致的 Activity 洩漏,如 LeakCanary 中提到的 SensorManager 和 InputMethodManager 導致的洩漏。
還有特別耗時的 Runnable 持有 Activity,或者此 Runnable 本身並不耗時,但在它前面有個耗時的 Runnable 堵塞了執行執行緒導致此 Runnable 一直沒機會從等待佇列裡移除,也會引發 Activity 洩漏等等。從來源上這類例子是舉不完的,總之任何能構造長期持有 Activity 的強引用的場景都能洩漏掉 Activity,從而洩漏 Activity 持有的大量 View 和其他物件。
事實上,ResourceCanary 將檢測與分析分離和大幅裁剪了 Hprof 檔案體積的改進是相當重要的,這使我們將 Activity 檢查做成自動化變得更容易。我們將 ResourceCanary 的 sdk 植入在微信中,通過每日常規的自動化測試,將發現的問題上報到微信的 Matrix 平臺,自動進行統計、棧提取、歸責、建單,然後系統會自動通知相關開發同學進行修復,並可以持續跟進修復情況。對有效解決問題的意義非常大。
除了開發同學每天根據 Matrix 平臺的報告進行確認修復外,對於這些洩漏, 微信客戶端還會採取一些主動措施規避掉無法立即解決的洩漏 ,大致包括:
-
主動切斷 Activity 對 View 的引用、回收 View 中的 Drawable,降低 Activity 洩漏帶來的影響
-
儘量用 Application Context 獲取某些系統服務例項,規避系統帶來的記憶體洩漏
-
對於已知的無法通過上面兩步解決的來自系統的記憶體洩漏,參考 LeakCanary 給出的建議進行規避
Bitmap 分配及回收追蹤
Bitmap 一直以來都是 Android App 的記憶體消耗大戶,很多 Java 甚至 native 記憶體問題的背後都是不當持有了大量大小很大的 Bitmap。
與此同時,Bitmap 有幾個特點方便我們對它們進行監控:
-
建立場景較為單一。Bitmap 通常通過在 Java 層呼叫 Bitmap.create 直接建立,或者通過 BitmapFactory 從檔案或網路流解碼。正好,我們有一層對 Bitmap 建立介面呼叫的封裝,基本囊括微信內建立 Bitmap 的全部場景(包括呼叫外部庫產生 Bitmap 也封裝在這層介面內)。這層統一介面有利於我們在建立 Bitmap 時進行統一監控,而不需要進行插樁或 hook 等較為 hack 的方法。
-
建立頻率較低。Bitmap 建立的行為不如 malloc 等通用記憶體分配頻繁,本身往往也伴隨著耗時較長的解碼或處理,因此在建立 Bitmap 時加入監控邏輯,其效能要求不會特別高。即使是獲取完整的 Java 堆疊甚至做一些篩選,其耗時相比起解碼或者其他影象處理也是微不足道,我們可以執行稍微複雜的邏輯。
-
Java 物件的生命週期。Bitmap 物件的生命週期和普通 Java 物件一樣服從 JVM 的 GC,因此我們可以通過 WeakReference 等手段來跟蹤 Bitmap 的銷燬,而不用像建立一樣對銷燬也一併跟蹤。
針對上述特點,我們加入了一個針對 Bitmap 的高性價比監控: 在介面層中將所有被創建出來的 Bitmap 加入一個 WeakHashMap ,同時記錄建立 Bitmap 的時間、堆疊等資訊,然後在適當的時候檢視這個 WeakHashMap 看看哪些 Bitmap 仍然存活來判斷是否出現 Bitmap 濫用或洩漏。
這個監控對效能消耗非常低,可以在釋出版進行。判斷是否洩漏則需要耗費一點效能,且目前還需要人工處理。收集洩漏的時機包括:
-
如果是測試環境,比如 Monkey Test 過程中,則使用 “ 激進模式 ”,即每次進行 Bitmap 建立的數秒後都檢查一次 Java 堆的情況,Java 記憶體佔用超過某個閾值即觸發收集邏輯,將所有存活的 Bitmap 資訊輸出到檔案,另外還輸出 hprof 輔助查詢別的記憶體洩漏。
-
釋出版則採用 “ 保守模式 ”,只有在出現 OOM 了之後,才將記憶體佔用 1 MB 以上的 Bitmap 資訊輸出到 xlog,避免 xlog 過大。
激進模式中閾值目前定為 200 MB,這是因為我們支援的 Android 裝置中,最容易出現 OOM 的一批手機的 large heap 限制為 256 MB,一旦 Heap 峰值達到 200 MB 以上且回收不及時,在一些需要類似解碼大圖的場景下就會出現無法臨時分配數十 MB 的記憶體供圖片顯示而導致 OOM,因此在 Monkey Test 時認為 Java Heap 佔用超過 200 MB 即為異常。
Bitmap 追蹤嘗試投入到 Monkey Test 後,發現問題最多最突出的,是快取的濫用問題,最為典型的是 使用 static LRUCache 來快取大尺寸 Bitmap 。
private static LruCache<String, Bitmap> sBitmapCache = new LruCache<>(20); public static Bitmap getBitmap(String path) { Bitmap bitmap = sBitmapCache.get(path); if (bitmap != null) { return bitmap; } bitmap = decodeBitmapFromFile(path); sBitmapCache.put(path, bitmap); return bitmap; }
比如上面的程式碼,作用是快取一些重複使用的 Bitmap 避免重複解碼損失效能,但由於 sBitmapCache 是靜態的且沒有清理邏輯, 快取在其中的圖片將永遠無法釋放 ,除非 20 個的配額用盡或圖片被替換。 LruCache 對快取物件的 個數 進行了限制,但沒有對物件的 總大小 進行限制(Java的物件模型也不支援直接獲取物件佔用記憶體大小),因此如果快取裡面存放了數個大圖或者長圖,將長期佔用大量記憶體。此外,不同業務之間不太可能提前考慮快取可能造成的相互擠壓,進一步加劇問題。也正因如此我們還開始推動了內部使用統一的快取管理元件,從整體上,控制使用快取的策略和數量。
Native 記憶體洩漏檢測
Native 層記憶體洩漏通常是指各種原因導致的已分配記憶體未得到有效釋放,導致可用記憶體越來越少直到 crash 的問題。由於Native 層沒有 GC 機制,記憶體管理行為非常可控,檢測起來確實也簡單許多——直接攔截記憶體分配和釋放相關的函式看一下是否配對即可。
我們首先在單個 so 上嘗試了一些成熟的方案:
-
valgrind
App 明顯變得卡頓,檢測結果沒有太大幫助,而且 valgrind 在 Android 上的部署太麻煩了,要在幾百臺測試機器上部署是個很大的問題。
-
asan
跟文件描述得差不多,檢測階段開銷確實比 valgrind 少,但是 App 還是變卡了,自動化測試時容易 ANR。回溯堆疊階段容易 crash。另外我們的一些歷史悠久的 so 按 asan 的要求用 clang 編譯之後可能存在 bug,這點也成為了採用此方案的阻礙。
對上述結果我們的猜想是這些工具除了本身開銷之外,大而全的功能,諸如雙重釋放,地址合法性檢測,越界訪問檢測也增加了執行時開銷。按此思路我們又改用系統自帶的 malloc_debug 進行檢測,但 malloc_debug 在堆疊回溯階段會產生一個必現的 crash,按照網上資料和廠商的反饋的說法,應該是它依賴 stl 庫裡的 __Unwind 系列函式需要的資料結構在不同的 stl 庫裡定義不同導致的,然而由於一些原因,被檢測的 so 裡有些已經不具備換 stl 庫重編的條件了。這樣的狀況迫使我們自研一套方案解決問題。
根據之前的嘗試,實際上我們需要研發兩個方案組合使用。對於不方便重編的庫,我們採用一個不需要重編的方案捨棄一些資訊以換取對洩漏的定位能力;對於易於重編的庫,我們採用一個不需要 clang 環境的方案保證能在不引入 bug 的情況下拿到 asan 能拿到的洩漏記憶體分配位置的堆疊資訊。當然,兩個方案都要足夠輕,保證不會產生 ANR 中斷自動化測試過程。
限於篇幅,這裡不再展開介紹方案原理,只大概說明兩個方案的思路:
-
無法重編的情況:PLT hook 攔截被測庫的記憶體分配函式,重定向到我們自己的實現後記錄分配的記憶體地址、大小、來源 so 庫路徑等資訊,定期掃描分配與釋放是否配對,對於不配對的分配輸出我們記錄的資訊。
-
可重編的情況:通過 gcc 的 -finstrument-functions 引數給所有函式插樁,樁中模擬呼叫棧入棧出棧操作;通過 ld 的 --wrap 引數攔截記憶體分配和釋放函式,重定向到我們自己的實現後記錄分配的記憶體地址、大小、來源 so 以及插樁記錄的呼叫棧此刻的內容 ,定期掃描分配與釋放是否配對,對於不配對的分配輸出我們記錄的資訊。
實測中這兩個方案為每次記憶體分配帶來的額外開銷小於 10ns,總體開銷的變化幾乎可忽略不計。 我們通過這兩套方案 組合 除了發現一個棘手的新問題外,還順便檢測了使用多年的基礎網路協議 so 庫,併成功找出隱藏多年的十多處小記憶體洩漏點,降低記憶體地址的碎片化 。
執行緒監控
常見的 OOM 情況大多數是因為記憶體洩漏或申請大量記憶體造成的,比較少見的有下面這種跟執行緒相關情況,但在我們 crash 系統上有時能發現一些這樣的問題。
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory
原因分析
OutOfMemoryError 這種異常根本原因在於申請不到足夠的記憶體造成的,直接的原因是在建立執行緒時初始 stack size 的時候,分配不到記憶體導致的。這個異常是在 /art/runtime/thread.cc 中執行緒初始化的時候 throw 出來的。
void Thread::CreateNativeThread( JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) { ... int pthread_create_result = pthread_create( &new_pthread, &attr, Thread::CreateCallback, child_thread); if (pthread_create_result != 0) { env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer, 0); { std::string msg(StringPrintf("pthread_create (%s stack) failed: %s", PrettySize(stack_size).c_str(), strerror(pthread_create_result))); ScopedObjectAccess soa(env); soa.Self()->ThrowOutOfMemoryError(msg.c_str()); } } }
呼叫這個 pthread_create 的方法去 clone 一個執行緒,如果返回 pthread_create_result 不為 0,則代表初始化失敗。什麼情況下會初始化失敗,pthread_create 的具體邏輯是在 /bionic/libc/bionic/pthread_create.cpp 中完成:
int pthread_create(pthread_t* thread_out, pthread_attr_t const* attr, void* (*start_routine)(void*), void* arg) { ... pthread_internal_t* thread = NULL; void* child_stack = NULL; int result = __allocate_thread(&thread_attr, &thread, &child_stack); if (result != 0) { return result; } ... } static int __allocate_thread(pthread_attr_t* attr, pthread_internal_t** threadp, void** child_stack) { size_t mmap_size; uint8_t* stack_top; ... attr->stack_base = __create_thread_mapped_space(mmap_size, attr->guard_size); if (attr->stack_base == NULL) { return EAGAIN; // EAGAIN != 0 } ... return 0; }
可以看到每個執行緒初始化都需要 mmap 一定的 stack size,在預設的情況下一般初始化一個執行緒需要 mmap 1M 左右的記憶體空間,在 32bit 的應用中有 4g 的 vmsize,實際能使用的有 3g+,按這種估算,一個程序最大能建立的執行緒數可達 3000+,當然這是理想的情況,在 linux 中對每個程序可建立的執行緒數也有一定的限制(/proc/pid/limits)而實際測試中,我們也發現不同廠商對這個限制也有所不同,而且當超過系統程序執行緒數限制時,同樣會丟擲這個型別的 OOM。
可見對執行緒數量的限制,可以一定程度避免 OOM 的發生。所以我們也開始對微信的執行緒數進行了監控統計。
監控上報
我們在灰度版本中通過一個定時器 10 分鐘 dump 出應用所有的執行緒,當執行緒數超過一定閾值時,將當前的執行緒上報並預警,通過對這種異常情況的捕捉,我們發現微信在某些特殊場景下,確實存線上程洩漏以及短時間內執行緒暴增,導致執行緒數過大(500+)的情況,這種情況下再建立執行緒往往容易出現 OOM。
在定位並解決這幾個問題後,我們的 crash 系統和廠商的反饋中這種型別 OOM 確實降低了不少。所以監控執行緒數,收斂執行緒也成為我們降低 OOM 的有效手段之一。
記憶體監控
Android 系統中,需要關注兩類記憶體的使用情況,實體記憶體和虛擬記憶體。通常我們使用 Memory Profiler 的方式檢視 APP 的記憶體使用情況。
在預設檢視中,我們可以檢視程序總記憶體佔用、JavaHeap、NativeHeap,以及 Graphics、Stack、Code 等細分型別的記憶體分配情況。當系統記憶體不足時,會觸發 onLowMemory。在 API Level 14及以上,則有更細分的 onTrimMemory。實際測試中,我們發現 onTrimMemory 的 ComponentCallbacks2.TRIM_MEMORY_COMPLETE 並不等價於 onLowMemory,因此推薦仍然要監聽 onLowMemory 回撥。
除了舊有的大盤粗粒度記憶體上報,我們正在建設相對精細的記憶體使用情況監控並整合到 Matrix 平臺上。進行監控方案前,我們需要執行時獲得各項記憶體使用資料的能力。通過 ActivityManager 的 getProcessMemoryInfo,我們獲得微信程序的 Debug.MemoryInfo 資料(注意:這個介面在低端機型中可能耗時較久,不能在主執行緒中呼叫,且監控呼叫耗時,在耗時過大的機型上,遮蔽記憶體監控模組)。通過 hook Debug.MemoryInfo 的 getMemoryStat 方法(需要 23 版本及以上),我們可以獲得等價於 Memory Profiler 預設檢視中的多項資料,從而獲得細分記憶體使用情況。此外,通過 Runtime 可獲得 DalvikHeap;通過 Debug.getNativeHeapAllocatedSize 可獲得 NativeHeap。至此,我們可以獲得低記憶體發生時,微信的虛擬記憶體、實體記憶體的各項資料,從而實現監控。
記憶體監控將分為常規監控和低記憶體監控兩個場景。
-
常規記憶體監控 —— 微信使用過程中,記憶體監控模組會根據斐波那契數列的特性,每隔一段時間(最長30分鐘)獲取記憶體的使用情況,從而獲得微信隨使用時間而變化的記憶體曲線。
-
低記憶體監控 —— 通過 onLowMemory 的回撥,或者通過 onTrimMemory 回撥且不同的標記位,結合 ActivityManager.MemoryInfo 的 lowMemory 標記,我們可以獲得低記憶體的發生時機。這裡需要注意,只有實體記憶體不足時,才會引起 onLowMemory 回撥。超過虛擬記憶體的大小限制則直接觸發 OOM 異常。因此我們也監聽虛擬記憶體的佔用情況,當虛擬記憶體佔用超過最大限制的 90% 時,觸發為低記憶體告警。低記憶體監控將監控低記憶體的發生頻率、發生時各項記憶體使用情況監控、發生時微信的當前場景等。
兜底保護
除了上面的各種問題及解決手段外,面對各種未知的、難以及時發現問題,目前我們也提出了一個兜底保護策略進行嘗試。
從大盤統計的資料上看,我們發現微信主程序存活的時間超過一天的使用者達千萬級別,佔比 1.5%+,倘若應用本身或系統底層存在細微的記憶體洩漏,短時間上不會造成 OOM,但在長時間的使用中,會使得應用佔用記憶體越積越大,最終也會造成 OOM 情況發生。在這種情況下,我們也在思考,如果可以提前知道記憶體的佔用情況,以及使用者當前的使用場景,那麼我們可以將這種異常的情況進行兜底保護,來避免不可控的且容易讓使用者感知到的 OOM 現象。
如何兜底
OOM 會使得程序被殺,實際上也是系統處理異常所丟擲來的訊號及處理方式。如果應用本身也充當起這個角色,相比系統而言,我們可以根據具體場景,更加靈活的提前處理這種異常情況。其中最大的好處在於,可以在使用者無感知的情況下,在接近觸發系統異常前,選擇合適的場景殺死程序並將其重啟,使得應用的記憶體佔用回到正常情況,這不為是一種好的兜底方式。
這裡我們主要考慮了幾種條件:
-
微信是否在主介面退到後臺 且 位於後臺的時間超過 30 分鐘
-
當前時間為凌晨 2~5 點
-
不存在前臺服務(存在通知欄,音樂播放欄等情況)
-
java heap 必須大於當前程序最大可分配的 85% || native 記憶體大於 800M || vmsize 超過了 4G(微信 32bit)的 85%
-
非大量的流量消耗(每分鐘不超過 1M) && 程序無大量 CPU 排程情況
在滿足以上幾種條件下,殺死當前微信主程序並通過 push 程序重新拉起及初始化,來進行兜底保護。在使用者角度,當用戶將微信切回前臺時,不會看到初始化介面,還是位於主介面中,所以也不會感到突兀。從本地測試及灰度的結果上看,應用上該兜底策略,可以有效的減少使用者出現 OOM 的情況,在灰度的 5w 使用者中,有 3、4 個是命中了這個兜底策略,但具體兜底的策略是否合理,還需要經過更嚴格的測試才能確認上線。
總結
通過上面的文章,我們儘可能多的介紹了多個方面的記憶體問題優化手段和工程實踐。因為篇幅有限,一些不那麼顯著的問題和不少細節無法詳細展開。總的來說,我們優化實踐的思路是在研發階段不斷實現更多的工具和元件,系統性的並逐步提升自動化程度從而提升發現問題的效率。
當然不得不提的是,即使做了這麼多努力,記憶體問題仍沒有徹底消滅,仍有問題會因為缺少資訊而難以定位原因、或因為測試路徑無法覆蓋而無法提前發現,還有相容性的問題、引入的外部元件有洩漏等等,以及我們還需要更多的系統化和自動化,這是我們還在不斷優化和改進的方向。
希望已有的這些經驗能對大家有所幫助,優化沒有盡頭,微信還在努力。