1. 程式人生 > >Android-記憶體優化之OOM

Android-記憶體優化之OOM

Android的記憶體優化是效能優化中很重要的一部分,而避免OOM又是記憶體優化中比較核心的一點,這是一篇關於記憶體優化中如何避免OOM的總結性概要文章,內容大多都是和OOM有關的實踐總結概要。理解錯誤或是偏差的地方,還請多包涵指正,謝謝!

(一)Android的記憶體管理機制

Google在Android的官網上有這樣一篇文章,初步介紹了Android是如何管理應用的程序與記憶體分配:http://developer.android.com/training/articles/memory.html。 Android系統的Dalvik虛擬機器扮演了常規的記憶體垃圾自動回收的角色,Android系統沒有為記憶體提供交換區,它使用

pagingmemory-mapping(mmapping)的機制來管理記憶體,下面簡要概述一些Android系統中重要的記憶體管理基礎概念。

1)共享記憶體

Android系統通過下面幾種方式來實現共享記憶體:

  • Android應用的程序都是從一個叫做Zygote的程序fork出來的。Zygote程序在系統啟動並且載入通用的framework的程式碼與資源之後開始啟動。為了啟動一個新的程式程序,系統會fork Zygote程序生成一個新的程序,然後在新的程序中載入並執行應用程式的程式碼。這使得大多數的RAM pages被用來分配給framework的程式碼,同時使得RAM資源能夠在應用的所有程序之間進行共享。
  • 大多數static的資料被mmapped到一個程序中。這不僅僅使得同樣的資料能夠在程序間進行共享,而且使得它能夠在需要的時候被paged out。常見的static資料包括Dalvik Code,app resources,so檔案等。
  • 大多數情況下,Android通過顯式的分配共享記憶體區域(例如ashmem或者gralloc)來實現動態RAM區域能夠在不同程序之間進行共享的機制。例如,Window Surface在App與Screen Compositor之間使用共享的記憶體,Cursor Buffers在Content Provider與Clients之間共享記憶體。

2)分配與回收記憶體

  • 每一個程序的Dalvik heap都反映了使用記憶體的佔用範圍。這就是通常邏輯意義上提到的Dalvik Heap Size,它可以隨著需要進行增長,但是增長行為會有一個系統為它設定的上限。
  • 邏輯上講的Heap Size和實際物理意義上使用的記憶體大小是不對等的,Proportional Set Size(PSS)記錄了應用程式自身佔用以及和其他程序進行共享的記憶體。
  • Android系統並不會對Heap中空閒記憶體區域做碎片整理。系統僅僅會在新的記憶體分配之前判斷Heap的尾端剩餘空間是否足夠,如果空間不夠會觸發gc操作,從而騰出更多空閒的記憶體空間。在Android的高階系統版本里面針對Heap空間有一個Generational Heap Memory的模型,最近分配的物件會存放在Young Generation區域,當這個物件在這個區域停留的時間達到一定程度,它會被移動到Old Generation,最後累積一定時間再移動到Permanent Generation區域。系統會根據記憶體中不同的記憶體資料型別分別執行不同的gc操作。例如,剛分配到Young Generation區域的物件通常更容易被銷燬回收,同時在Young Generation區域的gc操作速度會比Old Generation區域的gc操作速度更快。如下圖所示:

memory_mode_generation android_memory_gc_mode

每一個Generation的記憶體區域都有固定的大小,隨著新的物件陸續被分配到此區域,當這些物件總的大小快達到這一級別記憶體區域的閥值時,會觸發GC的操作,以便騰出空間來存放其他新的物件。如下圖所示:

gc_threshold

通常情況下,GC發生的時候,所有的執行緒都是會被暫停的。執行GC所佔用的時間和它發生在哪一個Generation也有關係,Young Generation中的每次GC操作時間是最短的,Old Generation其次,Permanent Generation最長。執行時間的長短也和當前Generation中的物件數量有關,遍歷樹結構查詢20000個物件比起遍歷50個物件自然是要慢很多的。

3)限制應用的記憶體

  • 為了整個Android系統的記憶體控制需要,Android系統為每一個應用程式都設定了一個硬性的Dalvik Heap Size最大限制閾值,這個閾值在不同的裝置上會因為RAM大小不同而各有差異。如果你的應用佔用記憶體空間已經接近這個閾值,此時再嘗試分配記憶體的話,很容易引起OutOfMemoryError的錯誤。
  • ActivityManager.getMemoryClass()可以用來查詢當前應用的Heap Size閾值,這個方法會返回一個整數,表明你的應用的Heap Size閾值是多少Mb(megabates)。

4)應用切換操作

  • Android系統並不會在使用者切換應用的時候做交換記憶體的操作。Android會把那些不包含Foreground元件的應用程序放到LRU Cache中。例如,當用戶開始啟動了一個應用,系統會為它建立了一個程序,但是當用戶離開這個應用,此程序並不會立即被銷燬,而是會被放到系統的Cache當中,如果使用者後來再切換回到這個應用,此程序就能夠被馬上完整的恢復,從而實現應用的快速切換。
  • 如果你的應用中有一個被快取的程序,這個程序會佔用一定的記憶體空間,它會對系統的整體效能有影響。因此當系統開始進入Low Memory的狀態時,它會由系統根據LRU的規則與應用的優先順序,記憶體佔用情況以及其他因素的影響綜合評估之後決定是否被殺掉。
  • 對於那些非foreground的程序,Android系統是如何判斷Kill掉哪些程序的問題,請參考Processes and Threads

(二)OOM(OutOfMemory)

前面我們提到過使用getMemoryClass()的方法可以得到Dalvik Heap的閾值。簡要的獲取某個應用的記憶體佔用情況可以參考下面的示例( 關於更多記憶體檢視的知識,可以參考這篇官方教程:Investigating Your RAM Usage )

1)檢視記憶體使用情況

  • 通過命令列檢視記憶體詳細佔用情況:

android_perf_oom_dumpsys_meminfo.png

  • 通過Android Studio的Memory Monitor檢視記憶體中Dalvik Heap的實時變化

android_perf_oom_studio_mem_monitormemory_monitor_free_allocation memory_monitor_gc_event

2)發生OOM的條件

關於Native Heap,Dalvik Heap,Pss等記憶體管理機制比較複雜,這裡不展開描述。簡單的說,通過不同的記憶體分配方式(malloc/mmap/JNIEnv/etc)對不同的物件(bitmap,etc)進行操作會因為Android系統版本的差異而產生不同的行為,對Native Heap與Dalvik Heap以及OOM的判斷條件都會有所影響。在2.x的系統上,我們常常可以看到Heap Size的total值明顯超過了通過getMemoryClass()獲取到的閾值而不會發生OOM的情況,那麼針對2.x與4.x的Android系統,到底是如何判斷會發生OOM呢?

  • Android 2.x系統 GC LOG中的dalvik allocated + external allocated + 新分配的大小 >= getMemoryClass()值的時候就會發生OOM。 例如,假設有這麼一段Dalvik輸出的GC LOG:GC_FOR_MALLOC free 2K, 13% free 32586K/37455K, external 8989K/10356K, paused 20ms,那麼32586+8989+(新分配23975)=65550>64M時,就會發生OOM。

  • Android 4.x系統 Android 4.x的系統廢除了external的計數器,類似bitmap的分配改到dalvik的java heap中申請,只要allocated + 新分配的記憶體 >= getMemoryClass()的時候就會發生OOM,如下圖所示(雖然圖示演示的是art執行環境,但是統計規則還是和dalvik保持一致)

android_perf_oom_gc_log.png

(三)如何避免OOM總結

前面介紹了一些基礎的記憶體管理機制以及OOM的基礎知識,那麼在實踐操作當中,有哪些指導性的規則可以參考呢?歸納下來,可以從四個方面著手,首先是減小物件的記憶體佔用,其次是記憶體物件的重複利用,然後是避免物件的記憶體洩露,最後是記憶體使用策略優化。

減小物件的記憶體佔用

避免OOM的第一步就是要儘量減少新分配出來的物件佔用記憶體的大小,儘量使用更加輕量的物件。

1)使用更加輕量的資料結構

例如,我們可以考慮使用ArrayMap/SparseArray而不是HashMap等傳統資料結構,下圖演示了HashMap的簡要工作原理,相比起Android系統專門為移動作業系統編寫的ArrayMap容器,在大多數情況下,都顯示效率低下,更佔記憶體。通常的HashMap的實現方式更加消耗記憶體,因為它需要一個額外的例項物件來記錄Mapping操作。另外,SparseArray更加高效在於他們避免了對key與value的autobox自動裝箱,並且避免了裝箱後的解箱。

android_perf_3_arraymap_key_value

2)避免在Android裡面使用Enum

Android官方培訓課程提到過“Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.”,具體原理請參考http://hukai.me/android-performance-patterns-season-3/,所以請避免在Android裡面使用到列舉。

3)減小Bitmap物件的記憶體佔用

Bitmap是一個極容易消耗記憶體的大胖子,減小創建出來的Bitmap的記憶體佔用是很重要的,通常來說有下面2個措施:

  • inSampleSize:縮放比例,在把圖片載入記憶體之前,我們需要先計算出一個合適的縮放比例,避免不必要的大圖載入。
  • decode format:解碼格式,選擇ARGB_8888/RBG_565/ARGB_4444/ALPHA_8,存在很大差異。

4)使用更小的圖片

在設計給到資源圖片的時候,我們需要特別留意這張圖片是否存在可以壓縮的空間,是否可以使用一張更小的圖片。儘量使用更小的圖片不僅僅可以減少記憶體的使用,還可以避免出現大量的InflationException。假設有一張很大的圖片被XML檔案直接引用,很有可能在初始化檢視的時候就會因為記憶體不足而發生InflationException,這個問題的根本原因其實是發生了OOM。

記憶體物件的重複利用

大多數物件的複用,最終實施的方案都是利用物件池技術,要麼是在編寫程式碼的時候顯式的在程式裡面去建立物件池,然後處理好複用的實現邏輯,要麼就是利用系統框架既有的某些複用特性達到減少物件的重複建立,從而減少記憶體的分配與回收。

android_perf_2_object_pool

在Android上面最常用的一個快取演算法是LRU(Least Recently Use),簡要操作原理如下圖所示:

android_perf_2_lru_mode

1)複用系統自帶的資源

Android系統本身內建了很多的資源,例如字串/顏色/圖片/動畫/樣式以及簡單佈局等等,這些資源都可以在應用程式中直接引用。這樣做不僅僅可以減少應用程式的自身負重,減小APK的大小,另外還可以一定程度上減少記憶體的開銷,複用性更好。但是也有必要留意Android系統的版本差異性,對那些不同系統版本上表現存在很大差異,不符合需求的情況,還是需要應用程式自身內建進去。

2)注意在ListView/GridView等出現大量重複子元件的視圖裡面對ConvertView的複用

android_perf_oom_listview_recycle

3)Bitmap物件的複用

  • 在ListView與GridView等顯示大量圖片的控制元件裡面需要使用LRU的機制來快取處理好的Bitmap。

android_perf_2_inbitmap_old

  • 利用inBitmap的高階特性提高Android系統在Bitmap分配與釋放執行效率上的提升(3.0以及4.4以後存在一些使用限制上的差異)。使用inBitmap屬性可以告知Bitmap解碼器去嘗試使用已經存在的記憶體區域,新解碼的bitmap會嘗試去使用之前那張bitmap在heap中所佔據的pixel data記憶體區域,而不是去問記憶體重新申請一塊區域來存放bitmap。利用這種特性,即使是上千張的圖片,也只會僅僅只需要佔用螢幕所能夠顯示的圖片數量的記憶體大小。

android_perf_2_inbitmap_new

使用inBitmap需要注意幾個限制條件:

  • 在SDK 11 -> 18之間,重用的bitmap大小必須是一致的,例如給inBitmap賦值的圖片大小為100-100,那麼新申請的bitmap必須也為100-100才能夠被重用。從SDK 19開始,新申請的bitmap大小必須小於或者等於已經賦值過的bitmap大小。
  • 新申請的bitmap與舊的bitmap必須有相同的解碼格式,例如大家都是8888的,如果前面的bitmap是8888,那麼就不能支援4444與565格式的bitmap了。 我們可以建立一個包含多種典型可重用bitmap的物件池,這樣後續的bitmap建立都能夠找到合適的“模板”去進行重用。如下圖所示:

android_perf_2_inbitmap_pool

另外提一點:在2.x的系統上,儘管bitmap是分配在native層,但是還是無法避免被計算到OOM的引用計數器裡面。這裡提示一下,不少應用會通過反射BitmapFactory.Options裡面的inNativeAlloc來達到擴大使用記憶體的目的,但是如果大家都這麼做,對系統整體會造成一定的負面影響,建議謹慎採納。

4)避免在onDraw方法裡面執行物件的建立

類似onDraw等頻繁呼叫的方法,一定需要注意避免在這裡做建立物件的操作,因為他會迅速增加記憶體的使用,而且很容易引起頻繁的gc,甚至是記憶體抖動。

5)StringBuilder

在有些時候,程式碼中會需要使用到大量的字串拼接的操作,這種時候有必要考慮使用StringBuilder來替代頻繁的“+”。

避免物件的記憶體洩露

記憶體物件的洩漏,會導致一些不再使用的物件無法及時釋放,這樣一方面佔用了寶貴的記憶體空間,很容易導致後續需要分配記憶體的時候,空閒空間不足而出現OOM。顯然,這還使得每級Generation的記憶體區域可用空間變小,gc就會更容易被觸發,容易出現記憶體抖動,從而引起效能問題。

android_perf_3_leak

1)注意Activity的洩漏

通常來說,Activity的洩漏是記憶體洩漏裡面最嚴重的問題,它佔用的記憶體多,影響面廣,我們需要特別注意以下兩種情況導致的Activity洩漏:

  • 內部類引用導致Activity的洩漏

最典型的場景是Handler導致的Activity洩漏,如果Handler中有延遲的任務或者是等待執行的任務佇列過長,都有可能因為Handler繼續執行而導致Activity發生洩漏。此時的引用關係鏈是Looper -> MessageQueue -> Message -> Handler -> Activity。為了解決這個問題,可以在UI退出之前,執行remove Handler訊息佇列中的訊息與runnable物件。或者是使用Static + WeakReference的方式來達到斷開Handler與Activity之間存在引用關係的目的。

  • Activity Context被傳遞到其他例項中,這可能導致自身被引用而發生洩漏。

內部類引起的洩漏不僅僅會發生在Activity上,其他任何內部類出現的地方,都需要特別留意!我們可以考慮儘量使用static型別的內部類,同時使用WeakReference的機制來避免因為互相引用而出現的洩露。

2)考慮使用Application Context而不是Activity Context

對於大部分非必須使用Activity Context的情況(Dialog的Context就必須是Activity Context),我們都可以考慮使用Application Context而不是Activity的Context,這樣可以避免不經意的Activity洩露。

3)注意臨時Bitmap物件的及時回收

雖然在大多數情況下,我們會對Bitmap增加快取機制,但是在某些時候,部分Bitmap是需要及時回收的。例如臨時建立的某個相對比較大的bitmap物件,在經過變換得到新的bitmap物件之後,應該儘快回收原始的bitmap,這樣能夠更快釋放原始bitmap所佔用的空間。

需要特別留意的是Bitmap類裡面提供的createBitmap()方法:

android_perf_oom_create_bitmap.png

這個函式返回的bitmap有可能和source bitmap是同一個,在回收的時候,需要特別檢查source bitmap與return bitmap的引用是否相同,只有在不等的情況下,才能夠執行source bitmap的recycle方法。

4)注意監聽器的登出

在Android程式裡面存在很多需要register與unregister的監聽器,我們需要確保在合適的時候及時unregister那些監聽器。自己手動add的listener,需要記得及時remove這個listener。

5)注意快取容器中的物件洩漏

有時候,我們為了提高物件的複用性把某些物件放到快取容器中,可是如果這些物件沒有及時從容器中清除,也是有可能導致記憶體洩漏的。例如,針對2.3的系統,如果把drawable新增到快取容器,因為drawable與View的強應用,很容易導致activity發生洩漏。而從4.0開始,就不存在這個問題。解決這個問題,需要對2.3系統上的快取drawable做特殊封裝,處理引用解綁的問題,避免洩漏的情況。

6)注意WebView的洩漏

Android中的WebView存在很大的相容性問題,不僅僅是Android系統版本的不同對WebView產生很大的差異,另外不同的廠商出貨的ROM裡面WebView也存在著很大的差異。更嚴重的是標準的WebView存在記憶體洩露的問題,看這裡WebView causes memory leak - leaks the parent Activity。所以通常根治這個問題的辦法是為WebView開啟另外一個程序,通過AIDL與主程序進行通訊,WebView所在的程序可以根據業務的需要選擇合適的時機進行銷燬,從而達到記憶體的完整釋放。

7)注意Cursor物件是否及時關閉

在程式中我們經常會進行查詢資料庫的操作,但時常會存在不小心使用Cursor之後沒有及時關閉的情況。這些Cursor的洩露,反覆多次出現的話會對記憶體管理產生很大的負面影響,我們需要謹記對Cursor物件的及時關閉。

記憶體使用策略優化

1)謹慎使用large heap

正如前面提到的,Android裝置根據硬體與軟體的設定差異而存在不同大小的記憶體空間,他們為應用程式設定了不同大小的Heap限制閾值。你可以通過呼叫getMemoryClass()來獲取應用的可用Heap大小。在一些特殊的情景下,你可以通過在manifestapplication標籤下新增largeHeap=true的屬性來為應用宣告一個更大的heap空間。然後,你可以通過getLargeMemoryClass()來獲取到這個更大的heap size閾值。然而,宣告得到更大Heap閾值的本意是為了一小部分會消耗大量RAM的應用(例如一個大圖片的編輯應用)。不要輕易的因為你需要使用更多的記憶體而去請求一個大的Heap Size。只有當你清楚的知道哪裡會使用大量的記憶體並且知道為什麼這些記憶體必須被保留時才去使用large heap。因此請謹慎使用large heap屬性。使用額外的記憶體空間會影響系統整體的使用者體驗,並且會使得每次gc的執行時間更長。在任務切換時,系統的效能會大打折扣。另外, large heap並不一定能夠獲取到更大的heap。在某些有嚴格限制的機器上,large heap的大小和通常的heap size是一樣的。因此即使你申請了large heap,你還是應該通過執行getMemoryClass()來檢查實際獲取到的heap大小。

2)綜合考慮裝置記憶體閾值與其他因素設計合適的快取大小

例如,在設計ListView或者GridView的Bitmap LRU快取的時候,需要考慮的點有:

  • 應用程式剩下了多少可用的記憶體空間?
  • 有多少圖片會被一次呈現到螢幕上?有多少圖片需要事先快取好以便快速滑動時能夠立即顯示到螢幕?
  • 裝置的螢幕大小與密度是多少? 一個xhdpi的裝置會比hdpi需要一個更大的Cache來hold住同樣數量的圖片。
  • 不同的頁面針對Bitmap的設計的尺寸與配置是什麼,大概會花費多少記憶體?
  • 頁面圖片被訪問的頻率?是否存在其中的一部分比其他的圖片具有更高的訪問頻繁?如果是,也許你想要儲存那些最常訪問的到記憶體中,或者為不同組別的點陣圖(按訪問頻率分組)設定多個LruCache容器。

3)onLowMemory()與onTrimMemory()

Android使用者可以隨意在不同的應用之間進行快速切換。為了讓background的應用能夠迅速的切換到forground,每一個background的應用都會佔用一定的記憶體。Android系統會根據當前的系統的記憶體使用情況,決定回收部分background的應用記憶體。如果background的應用從暫停狀態直接被恢復到forground,能夠獲得較快的恢復體驗,如果background應用是從Kill的狀態進行恢復,相比之下就顯得稍微有點慢。

android_perf_3_memory_bg_2_for

  • onLowMemory():Android系統提供了一些回撥來通知當前應用的記憶體使用情況,通常來說,當所有的background應用都被kill掉的時候,forground應用會收到onLowMemory()的回撥。在這種情況下,需要儘快釋放當前應用的非必須的記憶體資源,從而確保系統能夠繼續穩定執行。
  • onTrimMemory(int):Android系統從4.0開始還提供了onTrimMemory()的回撥,當系統記憶體達到某些條件的時候,所有正在執行的應用都會收到這個回撥,同時在這個回撥裡面會傳遞以下的引數,代表不同的記憶體使用情況,收到onTrimMemory()回撥的時候,需要根據傳遞的引數型別進行判斷,合理的選擇釋放自身的一些記憶體佔用,一方面可以提高系統的整體執行流暢度,另外也可以避免自己被系統判斷為優先需要殺掉的應用。下圖介紹了各種不同的回撥引數:

  • TRIM_MEMORY_UI_HIDDEN:你的應用程式的所有UI介面被隱藏了,即使用者點選了Home鍵或者Back鍵退出應用,導致應用的UI介面完全不可見。這個時候應該釋放一些不可見的時候非必須的資源

當程式正在前臺執行的時候,可能會接收到從onTrimMemory()中返回的下面的值之一:

  • TRIM_MEMORY_RUNNING_MODERATE:你的應用正在執行並且不會被列為可殺死的。但是裝置此時正運行於低記憶體狀態下,系統開始觸發殺死LRU Cache中的Process的機制。
  • TRIM_MEMORY_RUNNING_LOW:你的應用正在執行且沒有被列為可殺死的。但是裝置正運行於更低記憶體的狀態下,你應該釋放不用的資源用來提升系統性能。
  • TRIM_MEMORY_RUNNING_CRITICAL:你的應用仍在執行,但是系統已經把LRU Cache中的大多數程序都已經殺死,因此你應該立即釋放所有非必須的資源。如果系統不能回收到足夠的RAM數量,系統將會清除所有的LRU快取中的程序,並且開始殺死那些之前被認為不應該殺死的程序,例如那個包含了一個執行態Service的程序。

當應用程序退到後臺正在被Cached的時候,可能會接收到從onTrimMemory()中返回的下面的值之一:

  • TRIM_MEMORY_BACKGROUND: 系統正運行於低記憶體狀態並且你的程序正處於LRU快取名單中最不容易殺掉的位置。儘管你的應用程序並不是處於被殺掉的高危險狀態,系統可能已經開始殺掉LRU快取中的其他程序了。你應該釋放那些容易恢復的資源,以便於你的程序可以保留下來,這樣當用戶回退到你的應用的時候才能夠迅速恢復。
  • TRIM_MEMORY_MODERATE: 系統正運行於低記憶體狀態並且你的程序已經已經接近LRU名單的中部位置。如果系統開始變得更加記憶體緊張,你的程序是有可能被殺死的。
  • TRIM_MEMORY_COMPLETE: 系統正運行於低記憶體的狀態並且你的程序正處於LRU名單中最容易被殺掉的位置。你應該釋放任何不影響你的應用恢復狀態的資源。

android_perf_3_memory_ontrimmemory

  • 因為onTrimMemory()的回撥是在API 14才被加進來的,對於老的版本,你可以使用onLowMemory)回撥來進行相容。onLowMemory相當與TRIM_MEMORY_COMPLETE。
  • 請注意:當系統開始清除LRU快取中的程序時,雖然它首先按照LRU的順序來執行操作,但是它同樣會考慮程序的記憶體使用量以及其他因素。佔用越少的程序越容易被留下來。

4)資原始檔需要選擇合適的資料夾進行存放

我們知道hdpi/xhdpi/xxhdpi等等