Android深度性能優化--內存優化(一篇就夠)

本文整理自網絡課程

 

一、背景

在內存管理上,JVM擁有垃圾內存回收的機制,自身會在虛擬機層面自動分配和釋放內存,因此不需要像使用C/C++一樣在代碼中分配和釋放某一塊內存。Android系統的內存管理類似於JVM,通過new關鍵字來爲對象分配內存,內存的釋放由GC來回收。並且Android系統在內存管理上有一個Generational Heap Memory模型,當內存達到某一個閾值時,系統會根據不同的規則自動釋放可以釋放的內存。即便有了內存管理機制,但是,如果不合理地使用內存,也會造成一系列的性能問題,比如內存泄漏、內存抖動、短時間內分配大量的內存對象等等。

 

二、優化工具

2.1 Memory Profiler

Memory profiler是Android Studio自帶的一個內存檢測工具,通過實時圖表的方式展示內存信息,具有可以識別內存泄露,內存抖動等現象,並可以將捕獲到的內存信息進行堆轉儲、強制GC以及跟蹤內存分配的能力。

 

Android Studio打開Profiler工具

 

觀察Memory曲線,比較平緩即爲內存分配正常,如果出現大的波動有可能發生了內存泄露。

GC:可手動觸發GC

Dump:Dump出當前Java Heap信息

Record:記錄一段時間內的內存信息

 

點擊Dump後

可查看當前內存分配對象

Allocations:分配對象個數

Native Size:Native內存大小

Shallow Size:對象本身佔用內存的大小,不包含其引用的對象

Retained Size: 對象的Retained Size = 對象本身的Shallow Size + 對象能直接或間接訪問到的對象的Shallow Size,也就是說 Retained Size 就是該對象被 Gc 之後所能回收內存的總和

點擊Bitmap Preview可以進行預覽圖片,對查看圖片佔用內存情況比較有幫助

 

點擊Record後

可以記錄一段時間內內存分配情況,可查看各對象分配大小及調用棧、對象生成位置

 

2.2 Memory Analyzer(MAT)

比Memory Profiler更強大的Java Heap分析工具,可以準確查找內存泄露以及內存佔用情況,還可以生成整體報告,用來分析問題等。

MAT一般用來線下結合Memory Profiler分析問題使用,Memory Profiler可以直觀看出內存抖動,然後生成的hdprof文件,通過MAT深入分析及定位內存泄露問題。

 

具體使用下面會結合實例講解一下

 

2.3 LeakCannary

Leak Cannary是一個能自動監測內存泄露的線下監測工具,具體原理可自行了解下。

github鏈接:https://github.com/square/leakcanary

 

三、內存管理

 

3.1 內存區域

Java內存劃分爲方法區、堆、程序計數器、本地方法棧、虛擬機棧五個區域;

線程維度分爲線程共享區和線程隔離區,方法區和堆是線程共享的,程序計數器、本地方法棧、虛擬機棧是線程隔離的,如下圖

 

 

方法區

  • 線程共享區域,用於存儲類信息、靜態變量、常量、即時編譯器編譯出來的代碼數據
  • 無法滿足內存分配需求時會發生OOM

 

  • 線程共享區域,是JAVA虛擬機管理的內存中最大的一塊,在虛擬機啓動時創建
  • 存放對象實例,幾乎所有的對象實例都在堆上分配,GC管理的主要區域

 

虛擬機棧

  • 線程私有區域,每個java方法在執行的時候會創建一個棧幀用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。方法從執行開始到結束過程就是棧幀在虛擬機棧中入棧出棧過程
  • 局部變量表存放編譯期可知的基本數據類型、對象引用、returnAddress類型。所需的內存空間會在編譯期間完成分配,進入一個方法時在幀中局部變量表的空間是完全確定的,不需要運行時改變
  • 若線程申請的棧深度大於虛擬機允許的最大深度,會拋出SatckOverFlowError錯誤
  • 虛擬機動態擴展時,若無法申請到足夠內存,會拋出OutOfMemoryError錯誤

 

本地方法棧

  • 爲虛擬機中Native方法服務,對本地方法棧中使用的語言、數據結構、使用方式沒有強制規定,虛擬機可自有實現
  • 佔用的內存區大小是不固定的,可根據需要動態擴展

 

程序計數器

  • 一塊較小的內存空間,線程私有,存儲當前線程執行的字節碼行號指示器
  • 字節碼解釋器通過改變這個計數器的值來選取下一條需要執行的字節碼指令:分支、循環、跳轉等
  • 每個線程都有一個獨立的程序計數器
  • 唯一一個在java虛擬機中不會OOM的區域

 

3.2 對象存活判斷

引用計數法

  • 給對象添加引用計數器,每當一個地方引用時,計數器加1,引用失效時計數器減1;當引用計數器爲0時即爲對象不可用
  • 實現簡單,效率高,但是無法解決相互引用問題,主流虛擬機一般不使用此方法判斷對象是否存活

 

可達性分析法

  • 從一些稱爲"GC Roots"的對象作爲起點,向下搜索,搜索走過的路徑稱爲引用鏈,當一個對象到GC Roots沒有任何引用鏈時即爲對象不可用,可被回收的
  • 可被稱爲GC Roots的對象:虛擬機棧中引用的對象、方法區中類靜態屬性引用的對象、方法區中常量引用的對象、本地方法棧中引用的對象

 

GC Root有以下幾種:

  • Class-由系統ClassLoader加載的對象
  • Thread-活着的線程
  • Stack Local-Java方法的local變量或參數
  • JNI Local - JNI方法的local變量或參數
  • JNI Global - 全局JNI引用
  •  Monitor Used - 用於同步的監控對象

 

3.3 垃圾回收算法

標記清除算法

標記清除算法有兩個階段,首先標記出需要回收的對象,在標記完成後統一回收所有標記的對象;

缺點:

  • 效率問題:標記和清除兩個過程效率都不高
  • 空間問題:標記清除之後會導致很多不連續的內存碎片,會導致需要分配大對象時無法找到足夠的連續空間而不得不觸發GC的問題

 

複製算法

將可用內存按空間分爲大小相同的兩小塊,每次只使用其中的一塊,等這塊內存使用完了將還存活的對象複製到另一塊內存上,然後將這塊內存區域對象整體清除掉。每次對整個半區進行內存回收,不會導致碎片問題,實現簡單高效。

缺點:

  • 需要將內存縮小爲原來的一半,空間代價太高

 

標記整理算法

標記整理算法標記過程和標記清除算法一樣,但清除過程並不是對可回收對象直接清理,而是將所有存活對象像一端移動,然後集中清理到端邊界以外的內存。

 

 

分代收集算法

當代虛擬機垃圾回收算法都採用分代收集算法來收集,根據對象存活週期不同將內存劃分爲新生代和老年代,再根據每個年代的特點採用最合適的算法。

  • 新生代存活對象較少,每次垃圾回收都有大量對象死去,一般採用複製算法,只需要付出複製少量存活對象的成本就可以實現垃圾回收;
  • 老年代存活對象較多,沒有額外空間進行分配擔保,就必須採用標記清除算法和標記整理算法進行回收;

 

 

四、內存抖動

內存頻繁分配和回收導致內存不穩定

  • 頻繁GC,內存曲線呈現鋸齒狀,會導致卡頓
  • 頻繁的創建對象會導致內存不足及碎片
  • 不連續的內存碎片無法被釋放,導致OOM

 

4.1 模擬內存抖動

執行此段代碼

private static Handler mShakeHandler = new Handler() {
    @Override public void handleMessage(Message msg) {
        super.handleMessage(msg);
        // 頻繁創建對象,模擬內存抖動
        for(int index = 0;index <= 100;index ++) {
            String strArray[] = new String[100000];
        }


        mShakeHandler.sendEmptyMessageDelayed(0,30);
    }
};

 

4.2 分析並定位

利用Memory Profiler工具查看內存信息

 

發現內存曲線由原來的平穩曲線變成鋸齒狀

 

點擊record記錄內存信息,查找發生內存抖動位置,發現String對象ShallowSize非常異常,可直接通過Jump to Source定位到代碼位置

 

 

五、內存泄露

定義:內存中存在已經沒有用確無法回收的對象

現象:會導致內存抖動,可用內存減少,進而導致GC頻繁、卡頓、OOM

 

5.1 模擬內存泄露

模擬內存泄露代碼,反覆進入退出該Activity

/**
 * 模擬內存泄露的Activity
 */
public class MemoryLeakActivity extends AppCompatActivity implements CallBack{


    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_memoryleak);
        ImageView imageView = findViewById(R.id.iv_memoryleak);
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.splash);
        imageView.setImageBitmap(bitmap);
        
        // 添加靜態類引用
        CallBackManager.addCallBack(this);
    }


    @Override
    protected void onDestroy() {
        super.onDestroy();
//        CallBackManager.removeCallBack(this);
    }


    @Override
    public void dpOperate() {
        // do sth
    }

 

 

5.2 分析並定位

通過Memory Profiler工具查看內存曲線,發現內存在不斷的上升

 

如果想分析定位具體發生內存泄露位置需要藉助MAT工具

首先生成hprof文件

點擊dump將當前內存信息轉成hprof文件,需要對生成的文件轉換成MAT可讀取文件

執行一下轉換命令(Android/sdk/platorm-tools路徑下)

hprof-conv 剛剛生成的hprof文件 memory-mat.hprof

 

使用mat打開剛剛轉換的hprof文件

 

點擊Historygram,搜索MemoryLeakActivity

 

可以看到有8個MemoryLeakActivity未釋放

 

查看所有引用對象

 

查看到GC Roots的引用鏈

 

可以看到GC Roots是CallBackManager

 

解決問題,當Activity銷燬時將當前引用移除

@Override
protected void onDestroy() {
    super.onDestroy();
    CallBackManager.removeCallBack(this);
}

 

六、MAT分析工具

Overview

當前內存整體信息

 

 

Histogram

列舉對象所有的實例及實例所佔大小,可按package排序

 

可以查看應用包名下Activity存在實例個數,可以查看是否存在內存泄露,這裏發現內存中有8個Activity實例未釋放

 

查看未被釋放的Activity的引用鏈

 

 

Dominator_tree

當前所有實例的支配樹,和Histogram區別時Histogram是類維度,dominator_tree是實例維度,可以查看所有實例的所佔百分比和引用鏈

 

 

SQL

通過sql語句查詢相關類信息

 

 

Thread_overview

查看當前所有線程信息

 

 

Top Consumers

通過圖形方式展示佔用內存較高的對象,對降低內存棧優化可用內存比較有幫助

 

 

Leak Suspects

內存泄露分析頁面

直接定位到內存泄露位置

 

七、通過ARTHook檢測不合理圖片

7.1 獲取Bitmap佔用內存

  • 通過getByteCount方法,但是需要在運行時獲取
  • width * height * 一個像素所佔內存 * 圖片所在資源目錄壓縮比

 

7.2 檢測大圖

當圖片控件load圖片大小超過控件自身大小時會造成內存浪費,所以檢測出不合理圖片對內存優化是很重要的。

 

ARTHook方式檢測不合理圖片

通過ARTHook方法可以優雅的獲取不合理圖片,侵入性低,但是因爲兼容性問題一般在線下使用。

 

引入epic開源庫

implementation 'me.weishu:epic:0.3.6'

 

實現Hook方法

public class CheckBitmapHook extends XC_MethodHook {


    @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);


        ImageView imageView = (ImageView)param.thisObject;
        checkBitmap(imageView,imageView.getDrawable());
    }


    private static void checkBitmap(Object o,Drawable drawable) {
        if(drawable instanceof BitmapDrawable && o instanceof View) {
            final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
            if(bitmap != null) {
                final View view = (View)o;
                int width = view.getWidth();
                int height = view.getHeight();
                if(width > 0 && height > 0) {
                    if(bitmap.getWidth() > (width <<1) && bitmap.getHeight() > (height << 1)) {
                        warn(bitmap.getWidth(),bitmap.getHeight(),width,height,
                                new RuntimeException("Bitmap size is too large"));
                    }
                } else {
                    final Throwable stacktrace = new RuntimeException();
                    view.getViewTreeObserver().addOnPreDrawListener(
                            new ViewTreeObserver.OnPreDrawListener() {
                                @Override public boolean onPreDraw() {
                                    int w = view.getWidth();
                                    int h = view.getHeight();
                                    if(w > 0 && h > 0) {
                                        if (bitmap.getWidth() >= (w << 1)
                                                && bitmap.getHeight() >= (h << 1)) {
                                            warn(bitmap.getWidth(), bitmap.getHeight(), w, h, stacktrace);
                                        }
                                        view.getViewTreeObserver().removeOnPreDrawListener(this);
                                    }
                                    return true;
                                }
                            });
                }
            }
        }
    }




    private static void warn(int bitmapWidth, int bitmapHeight, int viewWidth, int viewHeight, Throwable t) {
        String warnInfo = new StringBuilder("Bitmap size too large: ")
                .append("\n real size: (").append(bitmapWidth).append(',').append(bitmapHeight).append(')')
                .append("\n desired size: (").append(viewWidth).append(',').append(viewHeight).append(')')
                .append("\n call stack trace: \n").append(Log.getStackTraceString(t)).append('\n')
                .toString();


        LogUtils.i(warnInfo);
   

 

 

Application初始化時注入Hook

DexposedBridge.hookAllConstructors(ImageView.class, new XC_MethodHook() {
    @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        DexposedBridge.findAndHookMethod(ImageView.class,"setImageBitmap", Bitmap.class,
                new CheckBitmapHook());
    }
});

 

 

八、線上內存監控

8.1 常規方案

常規方案一

在特定場景中獲取當前佔用內存大小,如果當前內存大小超過系統最大內存80%,對當前內存進行一次Dump(Debug.dumpHprofData()),選擇合適時間將hprof文件進行上傳,然後通過MAT工具手動分析該文件。

缺點:

  • Dump文件比較大,和用戶使用時間、對象樹正相關
  • 文件較大導致上傳失敗率較高,分析困難

 

常規方案二

將LeakCannary帶到線上,添加預設懷疑點,對懷疑點進行內存泄露監控,發現內存泄露回傳到server。

缺點:

  • 通用性較低,需要預設懷疑點,對沒有預設懷疑點的地方監控不到
  • LeakCanary分析比較耗時、耗內存,有可能會發生OOM

 

8.2 LeakCannary定製改造

  1. 將需要預設懷疑點改爲自動尋找懷疑點,自動將前內存中所佔內存較大的對象類中設置懷疑點。
  2. LeakCanary分析泄露鏈路比較慢,改造爲只分析Retain size大的對象。
  3. 分析過程會OOM,是因爲LeakCannary分析時會將分析對象全部加載到內存當中,我們可以記錄下分析對象的個數和佔用大小,對分析對象進行裁剪,不全部加載到內存當中。

 

8.3 完整方案

  1. 監控常規指標:待機內存、重點模塊佔用內存、OOM率
  2. 監控APP一個生命週期內和重點模塊界面的生命週期內的GC次數、GC時間等
  3. 將定製的LeakCanary帶到線上,自動化分析線上的內存泄露

 


 

 

系列筆記

《Android深度性能優化--APP啓動優化》

《Android深度性能優化--APP內存優化》

《Android深度性能優化--APP佈局優化》

《Android深度性能優化--APP卡頓優化》

《Android深度性能優化--APP線程優化》

《Android深度性能優化--APP網絡優化》

《Android深度性能優化--APP電量優化》

《Android深度性能優化--APP Crash優化》

 

持續關注添加微信公衆號:玉祥筆記

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章