微信 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?{@Overrideprotected?void?onCreate(Bundle?savedInstanceState)?{super.onCreate(savedInstanceState);setContentView(R.layout.activity_chatting_ui);EventCenter.addEventListener(new?IListener<IEvent>()?{//?這個?IListener?內部類里有個隱藏成員?this$?持有了外部的?ChattingUI@Overridepublic?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 個是命中了這個兜底策略,但具體兜底的策略是否合理,還需要經過更嚴格的測試才能確認上線。
?
總結
通過上面的文章,我們盡可能多的介紹了多個方面的內存問題優化手段和工程實踐。因為篇幅有限,一些不那么顯著的問題和不少細節無法詳細展開。總的來說,我們優化實踐的思路是在研發階段不斷實現更多的工具和組件,系統性的并逐步提升自動化程度從而提升發現問題的效率。
?
當然不得不提的是,即使做了這么多努力,內存問題仍沒有徹底消滅,仍有問題會因為缺少信息而難以定位原因、或因為測試路徑無法覆蓋而無法提前發現,還有兼容性的問題、引入的外部組件有泄漏等等,以及我們還需要更多的系統化和自動化,這是我們還在不斷優化和改進的方向。
?
希望已有的這些經驗能對大家有所幫助,優化沒有盡頭。
現在加Android高級開發群;701740775,可免費領取一份最新Android高級架構技術體系大綱和進階視頻資料,以及這些年年積累整理的所有面試資源筆記。加群請備注csdn領取xx資料
總結
以上是生活随笔為你收集整理的微信 Android 终端内存优化实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 程序人生丨如何体现测试工程师的价值
- 下一篇: 令人心动的HTTP知识点大全