Android記憶體優化雜談

發表於2015-12-17
      Android記憶體優化是我們效能優化工作中比較重要的一環,這裡其實主要包括兩方面的工作:
  1. 優化RAM,即降低執行時記憶體。這裡的目的是防止程式發生OOM異常,以及降低程式由於記憶體過大被LMK機制殺死的概率。另一方面,不合理的記憶體使用會使GC大大增多,從而導致程式變卡。
  2. 優化ROM,即降低程式佔ROM的體積。這裡主要是為了降低程式佔用的空間,防止由於ROM空間不足導致程式無法安裝。

本文的著重點為第一點,總結概述降低應用執行記憶體的技巧。在這裡我們不再細述PSS、USS等概念與Android應用的記憶體管理,如對這部分內容感興趣,可自行閱讀文末的參考文章。

記憶體洩露的檢測與修改

記憶體洩露:簡單來說物件由於編碼錯誤或系統原因,仍然存在著對其直接或間接的引用,導致系統無法進行回收。記憶體洩露,容易留下邏輯隱患,同時增加了應用記憶體峰值與發生OOM的概率。它屬於bug issue,是我們一定要修改的。

下面是造成記憶體洩露的一些常見原因,但是如何建立一套發現記憶體洩露、解決記憶體洩露的閉環方案,才是我們工作的重點。

一. 記憶體洩露的監控方案

Square的開源庫leakcanry是一個非常不錯的選擇,它通過弱引用方式偵查Activity或物件的生命週期,若發現記憶體洩露自動dump Hprof檔案,通過HAHA庫得到洩露的最短路徑,最後通過notification展示。

記憶體洩露判斷與處理的流程如下圖 ,各自執行的程式空間(主程式通過idlehandler,HAHA分析使用的是單獨的程式):

微信在leakcanry推出之前已經有了自己的記憶體洩露監控體系,與leakcanry大致有以下的區別:

  1. 在微信中,對於4.0以上的機型也是採用通過註冊ActivityLifecycleCallbacks介面,對於4.0以下的機型我們會嘗試反射ActivityThread中的mInstrumentation物件。當然,現在微信也改成只支援android-15以上,美美噠。
  2. leakcanry儘管使用了idlehandler與分程式,但是dumphprof依然會造成應用明顯的卡頓(SuspendAll Thread)。而在三星等一些手機,系統會快取最後一個Activity,所以在微信,我們採取了更嚴格的檢測模式,即洩露三次確認以及經過5個新建的Activity,確保不是由於系統快取的原因造成。
  3. 在微信中,當發現疑似記憶體洩露時會彈出對話方塊,當我們主動點選時才會去做dumpHprof以及上傳Hprof快照的操作,而是否誤報、洩露鏈等分析工作也是放於伺服器端。

事實上,通過對leakcanry做簡單的定製,我們就可以實現以下一個記憶體洩露監控閉環。

二. 對系統記憶體洩露的Hack Fix

AndroidExcludedRefs列出了一些由於系統原因導致引用無法釋放的例子,同時對於大多數的例子,都會提供建議如何通過hack的建議去修復。在微信中,對TextLine、InputMethodManager、AudioManger、android.os.Message也採用了類似Hack的方式(詳細可看參考資料)。

三. 通過兜底回收記憶體

Activity洩漏會導致該Activity引用到的Bitmap、DrawingCache等無法釋放,對記憶體造成大的壓力,兜底回收是指對於已洩漏Activity,嘗試回收其持有的資源,洩漏的僅僅是一個Activity空殼,從而降低對記憶體的壓力。

做法也非常簡單,在Activity onDestory時候從view的rootview開始,遞迴釋放所有子view涉及的圖片,背景,DrawingCache,監聽器等等資源,讓Activity成為一個不佔資源的空殼,洩露了也不會導致圖片資源被持有。

總的來說,我們不是隻懂得一些記憶體洩露解決方法就可以,更重要的是通過日常測試與監控,得到記憶體洩露檢測與修改的一整套閉環體系。

降低執行時記憶體的一些方法

當我們能確保應用中不會出現記憶體洩露時,我們需要一些其他的方法來降低執行時的記憶體。更多的時候,我們其實只希望降低應用發生OOM的概率。

Android OOM:

  • Android 2.x系統,當dalvik allocated + external allocated + 新分配的大小 >= dalvik heap 最大值時候就會發生OOM。其中bitmap是放於external中 。
  • Android 4.x系統,廢除了external的計數器,類似bitmap的分配改到dalvik的java heap中申請,只要allocated + 新分配的記憶體 >= dalvik heap 最大值的時候就會發生OOM(art執行環境的統計規則還是和dalvik保持一致)

一. 減少bitmap佔用的記憶體

說到記憶體,bitmap必然是這裡的大頭。對於bitmap記憶體佔用,想說的有以下幾點:

  1. 防止bitmap佔用資源多大導致OOM
    Android 2.x 系統 BitmapFactory.Options 裡面隱藏的的inNativeAlloc反射開啟後,申請的bitmap就不會算在external中。對於Android 4.x系統,可採用facebook的fresco庫,即可把圖片資源放於native中。
  2. 圖片按需載入
    即圖片的大小不應該超過view的大小。在把圖片載入記憶體之前,我們需要先計算出一個合適的inSampleSize縮放比例,避免不必要的大圖載入。對此,我們可以過載drawable與ImageView,例如在Activity ondestroy時,檢測圖片大小與View的大小,若超過,可以上報或提示。
  3. 統一的bitmap載入器
    Picasso、Fresco都是比較出名的載入庫,同樣微信也有自己的庫ImageLoader。載入庫的好處在於將版本差異、大小處理對使用者不感知。有了統一的bitmap載入器,我們可以在載入bitmap時,若發生OOM(try catch方式),可以通過清除cache,降低bitmap format(ARGB8888/RBG565/ARGB4444/ALPHA8)等方式,重新嘗試。
  4. 圖片存在畫素浪費
    對於.9圖,美工可能在出圖時在拉伸與非拉伸區域都有大量的畫素重複。通過獲取圖片的畫素ARGB值,計算連續相同的畫素區域,自定義演算法判定這些區域是否可以縮放。關鍵也是需要將這些工作做到系統化,可及時發現問題,解決問題。

一個好的imageLoader,可以將2.X、4.X或5.X對圖片載入的處理對使用者隱藏,同時也可以將自適應大小、質量等放於框架中。

二. 自身記憶體佔用監控

對於系統函式onLowMemory等函式是針對整個系統而已的,對於本程式來說,其dalvik記憶體距離OOM的差值並沒有體現,也沒有回撥函式供我們及時釋放記憶體。假若能有那麼一套機制,可以實時監控程式的堆記憶體使用率,達到設定值即關於通知相關模組進行記憶體釋放,這會大大的降低OOM。

  • 實現原理
    這個其實比較簡單,通過Runtime獲得maxMemory,而totalMemory-freeMemory即為當前真正使用的dalvik記憶體。

  • 操作方式
    我們可以定期(前臺每隔3分鐘)去得到這個值,當我們這個值達到危險值時(例如80%),我們應當主要去釋放我們的各種cache資源(bitmap的cache為大頭),同時顯示的去Trim應用的memory,加速記憶體收集。

三. 使用多程式

對於webview,相簿等,由於存在記憶體系統洩露或者佔用記憶體過多的問題,我們可以採用單獨的程式。微信當前也會把它們放在單獨的tools程式中

四. 上報OOM詳細資訊

當系統發生OOM的crash時,我們應當上傳更加詳細的記憶體相關資訊,方便我們定位當時記憶體的具體情況。

其他例如使用large heap、inBitmap、SparseArray、Protobuf等不再一一細述,對程式碼採用優化--埋坑--優化--埋坑的方式並不推薦。我們應該著力於建立一套合理的框架與監控體系,能及時的發現諸如bitmap過大、畫素浪費、記憶體佔用過大、應用OOM等問題。

GC優化

Java擁有GC的機制,不同的系統版本GC的實現可能有比較大的差異。但是無論哪種版本,大量的GC操作則會顯著佔用幀間隔時間(16ms)。如果在幀間隔時間裡面做了過多的GC操作,那麼自然其他類似計算,渲染等操作的可用時間就變得少了。

一. GC的型別

GC的型別有以下幾種,其中GC_FOR_ALLOC是同步方式進行,對應用幀率的影響最大。

  1. GC_FOR_ALLOC
    當堆記憶體不夠的時候容易被觸發,尤其是new一個物件的時候,很容易被觸發到,所以如果要加速啟動,可以提高dalvik.vm.heapstartsize的值,這樣在啟動過程中可以減少GC_FOR_ALLOC的次數。注意這個觸發是以同步的方式進行的。如果GC後仍然沒有空間,則堆進行擴張
  2. GC_EXPLICIT
    這個gc是被可以呼叫的,比如system.gc, 一般gc執行緒的優先順序比較低,所以這個垃圾回收的過程不一定會馬上觸發, 千萬不要認為呼叫了system.gc,記憶體的情況就能有所好轉
  3. GC_CONCURRENT
    當分配的物件大小超過384K時觸發,注意這是以非同步的方式進行回收的.如果發現大量反覆的Concurrent GC出現,說明系統中可能一直有大於384K的物件被分配,而這些往往是一些臨時物件,被反覆觸發了。給到我們的暗示是:物件的複用不夠。
  4. GC_EXTERNAL_ALLOC (在3.0系統之後被廢了)
    Native層的記憶體分配失敗了,這類GC就會被觸發。如果GPU的紋理、bitmap、或者java.nio.ByteBuffers的使用沒有釋放,這種型別的GC往往會被頻繁觸發。

二. 記憶體抖動現象

Memory Churn記憶體抖動,記憶體抖動是因為在短時間內大量的物件被建立又馬上被釋放。瞬間產生大量的物件會嚴重佔用記憶體區域,當達到閥值,剩餘空間不夠的時候,會觸發GC從而導致剛產生的物件又很快被回收。即使每次分配的物件佔用了很少的記憶體,但是他們疊加在一起會增加Heap的壓力,從而觸發更多其他型別的GC。這個操作有可能會影響到幀率,並使得使用者感知到效能問題。

通過Memory Monitor,我們可以跟蹤整個app的記憶體變化情況。若短時間發生了多次記憶體的漲跌,這意味著很有可能發生了記憶體抖動。

三. GC優化

通過Heap Viewer,我們可以檢視當前記憶體快照,便於對比分析哪些物件有可能發生了洩漏。更重要的工具是Allocation Tracker,追蹤記憶體物件的型別、堆疊、大小等。手Q有做一個統計工具,對Allocation Tracker的原始資料,按照(型別&堆疊)的組合(堆疊取棧頂的5層)統計某一種物件分配的大小、次數。同時按照次數、大小的排序,從多/大到少/小結合程式碼分析,並自頂向下的逐輪進行優化。

這樣,我們就可以快速知道發生記憶體抖動時,是因為哪些變數的建立造成頻繁GC。一般來說我們需要注意以下幾個方面:

  1. 字串拼接優化
    減少字串使用加號拼接,改為使用StringBuilder。減少StringBuilder.enlarge,初始化時設定capacity;這裡需要注意的是,若開啟Looper中Printer回撥,也會存在較多的字串拼接。

  1. 讀檔案優化 讀檔案使用ByteArrayPool,初始設定capacity,減少expand
  2. 資源重用
    建立全球快取池,對頻繁申請、釋放的物件型別重用
  3. 減少不必要或不合理的物件
    例如在ondraw、getview中應減少物件申請,儘量重用。更多是一些邏輯上的東西,例如迴圈中不斷申請區域性變數等
  4. 選用合理的資料格式 使用SparseArray, SparseBooleanArray, and LongSparseArray來代替Hashmap

總結

我們並不能將記憶體優化中用到的所有技巧都一一說明,而且隨著Android版本的更替,可能很多方法都會變的過時。我在想更重要的是我們能持續的發現問題,精細化的監控,而不是一直處於”哪個有坑填哪裡的”的窘況。在這裡給大家的建議有:

  1. 率先考慮採用已有的工具;中國人喜歡重複造輪子,我們更推薦花精力去優化已有工具,為廣大碼農做貢獻。生活已不易,碼農何為為難碼農!
  2. 不拘泥於點,更重要在於如何建立合理的框架避免發生問題,或者是能及時的發現問題。

當前微信記憶體監控體系中也存在一些不盡人意的地方,在未來的日子裡也同樣需要努力去優化。

參考文章

  1. Android記憶體管理(http://developer.android.com/intl/zh-cn/training/articles/memory.html)
  2. leakcanary(https://github.com/square/leakcanary)
  3. AndroidExcludedRefs(https://github.com/square/leakcanary/blob/master/leakcanary-android/src/main/java/com/squareup/leakcanary/AndroidExcludedRefs.java)
  4. fresco(https://github.com/facebook/fresco)
  5. 優化安卓應用記憶體的神祕方法以及背後的原理(http://bugly.qq.com/blog/?p=621)
  6. Android效能優化之記憶體篇(http://hukai.me/android-performance-memory/)

相關文章