當一個應用同時運行越來越多的任務以及復雜的業務,Android系統的記憶體管理機制已經無法滿足記憶體的釋放與回收,為了應用的穩定性與性能,去控制記憶體的創建和回收就成為了一個重要的命題,
本篇文章主要涉及內容如下:
- 物件的創建與回收;
- 分配記憶體的方式,物件在JVM中的生命周期;
- 判斷物件是否需要被回收,垃圾回收演算法;
- 記憶體抖動、記憶體泄漏的監控;
- Bitmap的大小、重復監控方案;
- 設備分級方案,
一、物件的創建和回收
1.1、物件的創建
在java中物件的創建基本上就是一個new,但new的背后在記憶體中做了些什么?并且物件對記憶體有哪些影響?又是如何被回收的?…先了解這些基本知識點對后面記憶體性能優化有很大的幫助,
物件的創建基本上有以下幾點:
-
判斷物件對應的類是否加載、鏈接和初始化;
例如我們利用new來創建一個User物件時,JVM虛擬機在收到new的指令時,會去方法區查詢該User類是否被參考,并檢查User類是否已經被加載,鏈接和初始化過,如果沒有,就需要先去執行類的加載程序, -
為物件分配記憶體;
在Java堆中劃分一塊記憶體分配給物件,分配記憶體會根據Java堆是否規整,分為兩種方式,指標碰撞和空閑串列,-
指標碰撞
使用指標碰撞的前提是Java堆的記憶體屬于規整模型,所謂指標碰撞,指的是利用一個指標將記憶體空間分為
已被占用記憶體和空閑記憶體,當為一個物件進行記憶體分配時,指標就向空閑記憶體一側移動,移動的距離與物件大小相等,如下圖所示:

-
空閑串列
空閑串列是在Java堆記憶體不完整的情況下使用的方式,已使用記憶體與空閑記憶體無規則,并且JVM另外維護了一張空閑記憶體的表,當有新物件需要分配記憶體時,就從空閑串列中查找一塊足夠該物件的記憶體,

-
-
處理并發安全問題;
當物件創建很頻繁時,就需要去解決并發的問題,也就是執行緒安全,比如程式中多執行緒創建m和n兩個物件,給m物件分配記憶體的同時也會給n物件分配,如果這時候兩個物件分配的是同一塊記憶體,必然就出現了沖突, 為了解決這個并發的問題,JVM提供了兩種方式,-
CAS演算法+失敗重試方式
CAS是項樂觀鎖技術,當多個執行緒嘗試同時更新一個變數時,只有其中一個執行緒能夠更新變數的值,而其他的執行緒都是失敗的,但失敗的執行緒都不會被掛起,可以再次嘗試,直到成功為止,
-
本地執行緒分配快取區-TLAB
所謂本地執行緒分配快取區,就是當執行緒開啟時,就為每個執行緒在Eden區分配一塊記憶體,然后當執行緒內部創建物件時,就從自己的記憶體空間分配,若自己的記憶體不足或者用盡時,就開始從堆記憶體中分配,這個時候就是采用CAS的方式,
-
-
初始化記憶體空間;
將分配到的記憶體,除物件頭以外都初始化為零值,這也是為什么物件的實體在Java代碼中不賦初始值就可以直接使用的原因,訪問的都是物件的零值, -
設定物件的物件頭;
將物件的所屬類,物件的HashCode以及物件的GC分代等資料存盤到物件的物件頭中, -
執行init方法進行初始化,
執行init方法,初始化物件的成員變數,呼叫類的構造方法,到這里,一個物件就被創建了,
1.2、物件在JVM中的生命周期
- 創建階段,上面已經詳細給出物件的創建程序;
- 應用階段,當物件創建完成后,并分配給變數復制,狀態切換到應用階段;
- 不可見階段,在程式中找不到物件的任何強參考;
- 不可達階段,在程式中找不到物件的任何強參考,并且垃圾收集器發現物件不可達;
- 收集階段,垃圾收集器發現物件不可達,并且垃圾收集器已經準備好對該物件的記憶體空間進行重新進行分配;
- 終結階段,垃圾收集器回收該物件的空間,
1.3、物件的回收
1.3.1、判斷物件是否需要被回收
在對物件進行回收前,需要知道該物件是否是垃圾,而判斷一個物件是否需要被回收,有兩種方式,參考計數法和可達性分析演算法:
-
參考計數法
所謂參考計數法,指的是物件會維護一個參考計數器,計算被參考的次數,如果被參考一次,計數器就+1,如果不在參考,則-1,知道計數器為0時,就說明該物件可以被回收了,
如果堆中有兩個物件相互參考,那他們的計數器都為1,就不會被回收,會造成記憶體泄漏,這也是參考計數法的缺點, -
可達性分析演算法
該演算法基本思路就是GC Roots作為起點,從這個節點開始向下掃描,掃描到的物件即存活物件,未被掃描到的即需要被回收,
GC Roots可以理解為由堆外指向堆內的參考, 一般而言,GC Roots包括(但不限于)以下幾種:- Java 方法堆疊楨中的區域變數;
- 已加載類的靜態變數;
- JNI handles;
- 已啟動且未停止的 Java 執行緒,
了解了物件是否可回收,接下來就開始了解垃圾的回收演算法,
1.3.2、垃圾回收演算法
-
標記清除演算法
對存活的物件進行標記,在最后掃描整個空間時,沒有被標記的物件就會被回收,整個程序如下圖所示:
該演算法的缺點從上圖就可看到,清除垃圾物件后,會產生不連續的記憶體碎片,當后面需要分配較大的物件時,會因為無法找到足夠的連續記憶體空間,而觸發垃圾回收,如果記憶體還是不足,則會例外,
而標記壓縮演算法就解決了這個問題, -
標記壓縮演算法
對存活的物件進行標記,在最后掃描整個空間時,沒有被標記的物件就會被回收,并且進行記憶體碎片整理,整個程序如下圖所示:
雖然標記壓縮演算法解決了標記清除演算法記憶體不規整的問題,但又存在新的問題,比如說,最后對記憶體空間的整理需要花費時間,且指標也需要不斷的重新移動,時間消耗會隨堆記憶體越來越大,
-
復制演算法
復制演算法是為了解決對碎片的垃圾回收,該演算法一開始把堆一分為二,分為物件面和空閑面,程式在物件面為物件分配空間,當物件面滿了,就將每個存活物件復制到空閑面,這樣空閑面變成了物件面,物件面變成了空閑面,
該演算法的執行效率比標記整理和標記清除的效率都高,但是每次只能利用50%的記憶體空間, -
分代垃圾回收演算法
分代垃圾回識訓制是根據不同物件的不同生命周期,采用不同的回收方法,提高回收效率,
主要分為年輕代和老年代,
年輕代: 用于存放新創建的物件,存活率較低的物件,經過多次GC后,該物件仍然存活,那么就會放入老年代,常用復制演算法,
老年代: 用于存放存活時間長的物件,常用標記清除或標記整理演算法,演算法細節:
- 物件新建,將存放在新生代的Eden區域,注意Suvivor區又分為兩塊區域,FromSuv和ToSuv;
- 當年輕代Eden滿時,將會觸發Minor GC,如果物件仍然存活,物件將會被移動到Fromsuvivor空間,物件還是在新生代;
- 再次發生minor GC,物件還存活,那么將會采用復制演算法,將物件移動到ToSuv區域,此時物件的年齡+1;
- 再次發生minor GC,物件仍然存活,此時Survivor中跟物件Object同齡的物件還沒有達到Surivivor區的一半,所以還是會繼續采用復制演算法,將fromSuv和ToSuv的區域進行互換;
- 當多次發生monorGC后,物件實體仍然存活,且此時,此時Survivor中跟物件Object同齡的物件達到Surivivor區的一半,那么物件實體將會移動到老年代區域,或者物件經過多次的回收,年齡達到了15歲,那么也會遷移到老年代,
磨刀不誤砍柴工,首先基本上了解了一些記憶體方面的知識點,那接下來就開始記憶體優化實踐,
二、記憶體優化實踐
記憶體優化主要分為幾個大方向,Bitmap優化、記憶體泄漏、記憶體抖動和設備分級等,
2.1、記憶體抖動
記憶體抖動指的是記憶體頻繁分配和回收導致記憶體不穩定,頻繁GC,會導致卡頓,甚至會OOM,至于為什么會造成卡頓?是因為在GC時,會觸發STW(stop the world)機制,也就是在執行垃圾收集演算法時,應用程式的其他所有執行緒都被掛起(除了垃圾收集器之外),這個時候也就不會處理用戶的操作事件,從而出現卡頓,
下面將模擬一個記憶體抖動情況,并使用Memory profile進行記憶體抖動的分析, 這里自定義了一個小球加載中的影片效果:

在開始小球影片后,我們打開打開AS自帶的Memory,從下圖可以看到,記憶體的走勢是在上下起伏,并且記憶體也在不斷的增加,這就代表著記憶體在不斷的分配和回收,這就是記憶體抖動,

那記憶體抖動的具體問題出現在哪里?
為了分析記憶體抖動的問題所在,我們先選取一段記憶體抖動的地方,可以看到在我們選取的這段時間里,AS的profile 都顯示了每個物件的記憶體分配情況,如下所示,

從上面圖片中的紅色框里就可以了解到,App產生記憶體抖動的原因主要就是app heap的前幾項,而要匹配到專案中的代碼就需要一個一個查看占用記憶體多的模塊,

我們先選擇其中一個,在Allocation Call Stack模塊中清楚的看到具體所分配的堆疊,同時也找到了造成堆中實體物件多的源代碼,

頻繁創建物件實體的原代碼在com.fuusy.fuperformance.memory.view.WaveView的136行,

哦~~ ,原來是自定義View時,在onDraw中頻繁創建Paint所致,解決的辦法就是將paint作為全域變數,在外部創建,
這個案例只是記憶體抖動中一個小小的縮影,當專案越來越大時,排查的作業難度也隨之增加,這就要我們在平時開發時,就需要注意代碼細節問題,盡可能在coding的程序中就減少記憶體問題,
記憶體抖動的注意事項:
- 避免在回圈和頻繁呼叫的方法中創建物件;
- 使用物件池,如Handler、Glide中的物件池,
2.2、記憶體泄漏
記憶體泄漏指的是程式中已分配的記憶體由于某種原因未釋放或者無法釋放,造成系統記憶體的浪費,
造成記憶體泄漏的原因有很多,比如:
- 長生命周期物件持有短生命周期物件的強參考,從而導致短生命周期物件無法被回收;
- 異步執行緒持有短生命周期物件,如Handler、網路請求或者其他作業執行緒持有短生命周期物件;
- 資源未及時關閉,如BroadcastReceiver、File、Cursor等;
- 大量使用靜態變數;
- …
當然,在實際專案中查找記憶體泄漏的原因的方式也有很多,比如主流工具LeakCanary、 MAT等,
2.3、Bitmap優化
Bitmap作為程式中記憶體占用的大戶,是必須優化的物件,之前寫過一篇關于Bitmap優化的文章「性能優化系列」不使用第三方庫,Bitmap的優化策略,可參考查看,
Bitmap除了基本優化外,其實還需要在coding的程序中,就將Bitmap記憶體問題扼殺在搖籃里,本篇文章就將從圖片大小監控,重復圖片監控兩個方向進行闡述,
2.3.1、Bitmap大小監控方案
Bitmap有一種從其尺寸上優化的手段,即當裝載圖片的容器例如ImageView只有100 * 100,而圖片的解析度為1800 * 800,這個時候將圖片直接放置在容器上,很容易OOM,同時也是對圖片和記憶體資源的一種浪費,當容器的寬高都很小于圖片的寬高,其實就需要對圖片進行尺寸上的壓縮.
而比較重要的點就是如何判斷Bitmap的尺寸符合圖片容器?
想到的第一種方法就是可以自定義一個ImageView,在View中去判斷圖片以及容器的大小,如果圖片太大,則進行尺寸上的壓碩訓優化,這種方式簡單實用,確實也解決了我們的問題,但在實際開發中,除了代碼侵入性強外,如果想要開發團隊中的每個人加載ImageView時都使用這個控制元件,也是一件很難展開的事情,
為了更加解耦性和減少代碼侵入性,這里介紹一種Bitmap大小的監控方案-ARTHook,
ARTHook通俗來講就是統一添加代碼修改原有邏輯,基于ARTHook的框架有很多,這里介紹一個常用框架-Epic, Epic就是ART上的 Dexposed(支持 Android 5.0 ~ 11),它可以攔截本行程內部幾乎任意的 Java 方法呼叫,可用于實作 AOP 編程、運行時插樁、性能分析等,
下面就基于Epic框架進行Bitmap大小監控的撰寫,
添加依賴
dependencies {
implementation 'me.weishu:epic:0.11.0'
}
新建一個Hook類用來撰寫圖片大小讀取,比較,繼承XC_MethodHook,并實作其afterHookedMethod,在其方法內實作需要監控的物件,具體監控的方法呼叫后將會呼叫該方法,
class BitmapARTHook : XC_MethodHook() {
@Throws(Throwable::class)
override fun afterHookedMethod(param: MethodHookParam) {
super.afterHookedMethod(param)
val imageView = param.thisObject as ImageView
checkBitmap(imageView, (param.thisObject as ImageView).drawable)
}
}
在afterHookedMethod方法里實作我們需要的邏輯,這里需要判斷圖片大小并給出警示,那就加上圖片大小的監測方法,
if (bitmap.width >= width shl 1 && bitmap.height >= height shl 1) {
warn(
bitmap.width,
bitmap.height,
width,
height,
RuntimeException("Bitmap size too large")
)
}
詳細代碼請參考fuusy/FuPerformance
寫個測驗來看看最終的效果,在xml中新建一個ImageView,寬高都設定為100dp,在Activity中將一張解析度為1300* 500的圖片設定到ImageView中,
val imageView = findViewById<ImageView>(R.id.iv_bitmap)
BitmapFactory.decodeResource(resources, R.mipmap.bitmap1).apply {
imageView.setImageBitmap(this)
}
運行后在終端可以看到圖片大小的提示資訊,


2.3.2、重復圖片監控
張邵文在高手課中提及重復圖片指的是 Bitmap 的像素資料完全一致,但是有多個不同的物件存在,給出的方案是使用HAHA 庫快速判斷記憶體中是否存在重復的圖片,并且將這些重復圖片的 PNG、堆疊等資訊輸出,
需要注意的是需要使用8.0以下的機器,因為8.0以后Bitmap中的buffer已經放到native記憶體中, 核心代碼與思路如下:
//打開hprof檔案
HprofBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);
HprofParser parser = new HprofParser(buffer);
//決議獲得快照
com.squareup.haha.perflib.Snapshot snapshot = parser.parse();
snapshot.computeDominators();
//獲得Bitmap Class
Collection<ClassObj> bitmapClasses = snapshot.findClasses("android.graphics.Bitmap");
//獲取堆資料,這里包括專案app、系統、default heap的資訊,需要進行過濾
Collection<Heap> heaps = snapshot.getHeaps();
long startTime = System.currentTimeMillis();
Tools.print("---------------------- 開始 ----------------------- ");
for (Heap heap : heaps) {
// 只需要分析app和default heap即可
if (!heap.getName().equals("app") && !heap.getName().equals("default")) {
continue;
}
Tools.print("HeapName:" + heap.getName());
Map<Integer, List<AnalyzerResult>> map = new HashMap<>();
for (ClassObj clazz : bitmapClasses) {
//從heap中獲得所有的Bitmap實體
List<Instance> instances = clazz.getHeapInstances(heap.getId());
for (int i = 0; i < instances.size(); i++) {
//從GcRoot開始遍歷搜索,Integer.MAX_VALUE代表無法被搜索到,說明物件沒被參考可以被回收
if (instances.get(i).getDistanceToGcRoot() == Integer.MAX_VALUE) {
continue;
}
List<AnalyzerResult> analyzerResults;
int curHashCode = Tools.getHashCodeByInstance(instances.get(i));
AnalyzerResult result = Tools.getAnalyzerResult(instances.get(i));
result.setInstance(instances.get(i));
if (map.get(curHashCode) == null){
analyzerResults = new ArrayList<>();
}else {
analyzerResults = map.get(curHashCode);
}
analyzerResults.add(result);
map.put(curHashCode, analyzerResults);
}
}
if (map.isEmpty()){
Tools.print("當前head暫無bitmap物件");
}
for (Map.Entry<Integer, List<AnalyzerResult>> entry : map.entrySet()){
List<AnalyzerResult> analyzerResults = entry.getValue();
//去除size小于2的,剩余的為重復圖片,
if (analyzerResults.size() < 2){
continue;
}
}
}
2.4、設備分級
所謂設備分級,指的是根據不同設備環境來考慮不同的記憶體優化策略,目前市場上手機層出不窮,幾乎每一年都會對手機性能進行提升,但是對于性能較差的手機,app應用的運行狀況就會較差,
對于低端機用戶可以關閉復雜的影片,或者是某些功能;使用 565 格式的圖片,使用更小的快取記憶體等,在現實環境下,不是每個用戶的設備都跟我們的測驗機一樣高端,在開發程序我們要學會思考功能要不要對低端機開啟、在系統資源吃緊的時候能不能做降級,- 張邵文
那如何進行設備分級?
Facebook其實開發了一個 設備年份類別庫,它使用簡單的演算法將設備的 RAM、CPU 內核和時鐘速度與這些特性被認為是高端的年份相匹配,使得我們能夠根據手機的硬體功能撰寫不同的邏輯,
| RAM | condition | Year Class |
|---|---|---|
| 768MB | 1 core | 2009 |
| 2+ cores | 2010 | |
| 1GB | <1.3GHz | 2011 |
| 1.3GHz+ | 2012 | |
| 1.5GB | <1.8GHz | 2012 |
| 1.8GHz+ | 2013 | |
| 2GB | 2013 | |
| 3GB | 2014 | |
| 5GB | 2015 | |
| more | 2016 |
而針對設備性能,我們能做的優化就如上面張邵文所說,
- 是否關閉影片;
- 圖片質量分級;
- 為低性能設備設計簡版應用,
設備分級實踐
添加設備年份庫的依賴
implementation 'com.facebook.device.yearclass:yearclass:2.1.0'
獲取設備年限以及進行設備分級,
val year = YearClass.get(applicationContext)
Log.d(TAG, "Year: $year")
when {
year >= 2013 -> {
// Do advanced animation
}
year > 2010 -> {
// Do simple animation
}
else -> {
// Phone too slow, don't do any animations
}
}

三、總結
記憶體優化是一個很大的命題,從記憶體在虛擬機中的創建與回收,到LeakCanary、MAT分析工具的使用,再到記憶體泄漏、記憶體抖動以及Bitmap的監控和優化,再分細一點,線上線下的監控方案、優化手段以及如何一直保持記憶體的穩定,這些都是記憶體優化需要關注的問題,
感謝閱讀,這是一個性能優化系列,請持續關注,
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/297168.html
標籤:其他
