Android深度性能優(yōu)化--內(nèi)存優(yōu)化
一、背景
在內(nèi)存管理上,JVM擁有垃圾內(nèi)存回收的機(jī)制,自身會在虛擬機(jī)層面自動分配和釋放內(nèi)存,因此不需要像使用C/C++一樣在代碼中分配和釋放某一塊內(nèi)存。Android系統(tǒng)的內(nèi)存管理類似于JVM,通過new關(guān)鍵字來為對象分配內(nèi)存,內(nèi)存的釋放由GC來回收。并且Android系統(tǒng)在內(nèi)存管理上有一個(gè)Generational Heap Memory模型,當(dāng)內(nèi)存達(dá)到某一個(gè)閾值時(shí),系統(tǒng)會根據(jù)不同的規(guī)則自動釋放可以釋放的內(nèi)存。即便有了內(nèi)存管理機(jī)制,但是,如果不合理地使用內(nèi)存,也會造成一系列的性能問題,比如內(nèi)存泄漏、內(nèi)存抖動、短時(shí)間內(nèi)分配大量的內(nèi)存對象等等。
二、優(yōu)化工具
2.1 Memory Profiler
Memory profiler是Android Studio自帶的一個(gè)內(nèi)存檢測工具,通過實(shí)時(shí)圖表的方式展示內(nèi)存信息,具有可以識別內(nèi)存泄露,內(nèi)存抖動等現(xiàn)象,并可以將捕獲到的內(nèi)存信息進(jìn)行堆轉(zhuǎn)儲、強(qiáng)制GC以及跟蹤內(nèi)存分配的能力。
Android Studio打開Profiler工具

觀察Memory曲線,比較平緩即為內(nèi)存分配正常,如果出現(xiàn)大的波動有可能發(fā)生了內(nèi)存泄露。
GC:可手動觸發(fā)GC
Dump:Dump出當(dāng)前Java Heap信息
Record:記錄一段時(shí)間內(nèi)的內(nèi)存信息
點(diǎn)擊Dump后

可查看當(dāng)前內(nèi)存分配對象
Allocations:分配對象個(gè)數(shù)
Native Size:Native內(nèi)存大小
Shallow Size:對象本身占用內(nèi)存的大小,不包含其引用的對象
Retained Size: 對象的Retained Size = 對象本身的Shallow Size + 對象能直接或間接訪問到的對象的Shallow Size,也就是說 Retained Size 就是該對象被 Gc 之后所能回收內(nèi)存的總和
點(diǎn)擊Bitmap Preview可以進(jìn)行預(yù)覽圖片,對查看圖片占用內(nèi)存情況比較有幫助
點(diǎn)擊Record后

可以記錄一段時(shí)間內(nèi)內(nèi)存分配情況,可查看各對象分配大小及調(diào)用棧、對象生成位置
2.2 Memory Analyzer(MAT)
比Memory Profiler更強(qiáng)大的Java Heap分析工具,可以準(zhǔn)確查找內(nèi)存泄露以及內(nèi)存占用情況,還可以生成整體報(bào)告,用來分析問題等。
MAT一般用來線下結(jié)合Memory Profiler分析問題使用,Memory Profiler可以直觀看出內(nèi)存抖動,然后生成的hdprof文件,通過MAT深入分析及定位內(nèi)存泄露問題。
具體使用下面會結(jié)合實(shí)例講解一下
2.3 LeakCannary
Leak Cannary是一個(gè)能自動監(jiān)測內(nèi)存泄露的線下監(jiān)測工具,具體原理可自行了解下。
github鏈接:https://github.com/square/leakcanary
三、內(nèi)存管理
3.1 內(nèi)存區(qū)域
Java內(nèi)存劃分為方法區(qū)、堆、程序計(jì)數(shù)器、本地方法棧、虛擬機(jī)棧五個(gè)區(qū)域;
線程維度分為線程共享區(qū)和線程隔離區(qū),方法區(qū)和堆是線程共享的,程序計(jì)數(shù)器、本地方法棧、虛擬機(jī)棧是線程隔離的,如下圖

方法區(qū)
- 線程共享區(qū)域,用于存儲類信息、靜態(tài)變量、常量、即時(shí)編譯器編譯出來的代碼數(shù)據(jù)
- 無法滿足內(nèi)存分配需求時(shí)會發(fā)生OOM
堆
- 線程共享區(qū)域,是JAVA虛擬機(jī)管理的內(nèi)存中最大的一塊,在虛擬機(jī)啟動時(shí)創(chuàng)建
- 存放對象實(shí)例,幾乎所有的對象實(shí)例都在堆上分配,GC管理的主要區(qū)域
虛擬機(jī)棧
- 線程私有區(qū)域,每個(gè)java方法在執(zhí)行的時(shí)候會創(chuàng)建一個(gè)棧幀用于存儲局部變量表、操作數(shù)棧、動態(tài)鏈接、方法出口等信息。方法從執(zhí)行開始到結(jié)束過程就是棧幀在虛擬機(jī)棧中入棧出棧過程
- 局部變量表存放編譯期可知的基本數(shù)據(jù)類型、對象引用、returnAddress類型。所需的內(nèi)存空間會在編譯期間完成分配,進(jìn)入一個(gè)方法時(shí)在幀中局部變量表的空間是完全確定的,不需要運(yùn)行時(shí)改變
- 若線程申請的棧深度大于虛擬機(jī)允許的最大深度,會拋出SatckOverFlowError錯(cuò)誤
- 虛擬機(jī)動態(tài)擴(kuò)展時(shí),若無法申請到足夠內(nèi)存,會拋出OutOfMemoryError錯(cuò)誤
本地方法棧
- 為虛擬機(jī)中Native方法服務(wù),對本地方法棧中使用的語言、數(shù)據(jù)結(jié)構(gòu)、使用方式?jīng)]有強(qiáng)制規(guī)定,虛擬機(jī)可自有實(shí)現(xiàn)
- 占用的內(nèi)存區(qū)大小是不固定的,可根據(jù)需要?jiǎng)討B(tài)擴(kuò)展
程序計(jì)數(shù)器
- 一塊較小的內(nèi)存空間,線程私有,存儲當(dāng)前線程執(zhí)行的字節(jié)碼行號指示器
- 字節(jié)碼解釋器通過改變這個(gè)計(jì)數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令:分支、循環(huán)、跳轉(zhuǎn)等
- 每個(gè)線程都有一個(gè)獨(dú)立的程序計(jì)數(shù)器
- 唯一一個(gè)在java虛擬機(jī)中不會OOM的區(qū)域
3.2 對象存活判斷
引用計(jì)數(shù)法
- 給對象添加引用計(jì)數(shù)器,每當(dāng)一個(gè)地方引用時(shí),計(jì)數(shù)器加1,引用失效時(shí)計(jì)數(shù)器減1;當(dāng)引用計(jì)數(shù)器為0時(shí)即為對象不可用
- 實(shí)現(xiàn)簡單,效率高,但是無法解決相互引用問題,主流虛擬機(jī)一般不使用此方法判斷對象是否存活
可達(dá)性分析法
- 從一些稱為"GC Roots"的對象作為起點(diǎn),向下搜索,搜索走過的路徑稱為引用鏈,當(dāng)一個(gè)對象到GC Roots沒有任何引用鏈時(shí)即為對象不可用,可被回收的
- 可被稱為GC Roots的對象:虛擬機(jī)棧中引用的對象、方法區(qū)中類靜態(tài)屬性引用的對象、方法區(qū)中常量引用的對象、本地方法棧中引用的對象
GC Root有以下幾種:
- Class-由系統(tǒng)ClassLoader加載的對象
- Thread-活著的線程
- Stack Local-Java方法的local變量或參數(shù)
- JNI Local - JNI方法的local變量或參數(shù)
- JNI Global - 全局JNI引用
- Monitor Used - 用于同步的監(jiān)控對象
3.3 垃圾回收算法
標(biāo)記清除算法
標(biāo)記清除算法有兩個(gè)階段,首先標(biāo)記出需要回收的對象,在標(biāo)記完成后統(tǒng)一回收所有標(biāo)記的對象;
缺點(diǎn):
- 效率問題:標(biāo)記和清除兩個(gè)過程效率都不高
- 空間問題:標(biāo)記清除之后會導(dǎo)致很多不連續(xù)的內(nèi)存碎片,會導(dǎo)致需要分配大對象時(shí)無法找到足夠的連續(xù)空間而不得不觸發(fā)GC的問題
復(fù)制算法
將可用內(nèi)存按空間分為大小相同的兩小塊,每次只使用其中的一塊,等這塊內(nèi)存使用完了將還存活的對象復(fù)制到另一塊內(nèi)存上,然后將這塊內(nèi)存區(qū)域?qū)ο笳w清除掉。每次對整個(gè)半?yún)^(qū)進(jìn)行內(nèi)存回收,不會導(dǎo)致碎片問題,實(shí)現(xiàn)簡單高效。
缺點(diǎn):
- 需要將內(nèi)存縮小為原來的一半,空間代價(jià)太高
標(biāo)記整理算法
標(biāo)記整理算法標(biāo)記過程和標(biāo)記清除算法一樣,但清除過程并不是對可回收對象直接清理,而是將所有存活對象像一端移動,然后集中清理到端邊界以外的內(nèi)存。
分代收集算法
當(dāng)代虛擬機(jī)垃圾回收算法都采用分代收集算法來收集,根據(jù)對象存活周期不同將內(nèi)存劃分為新生代和老年代,再根據(jù)每個(gè)年代的特點(diǎn)采用最合適的算法。
- 新生代存活對象較少,每次垃圾回收都有大量對象死去,一般采用復(fù)制算法,只需要付出復(fù)制少量存活對象的成本就可以實(shí)現(xiàn)垃圾回收;
- 老年代存活對象較多,沒有額外空間進(jìn)行分配擔(dān)保,就必須采用標(biāo)記清除算法和標(biāo)記整理算法進(jìn)行回收;
四、內(nèi)存抖動
內(nèi)存頻繁分配和回收導(dǎo)致內(nèi)存不穩(wěn)定
- 頻繁GC,內(nèi)存曲線呈現(xiàn)鋸齒狀,會導(dǎo)致卡頓
- 頻繁的創(chuàng)建對象會導(dǎo)致內(nèi)存不足及碎片
- 不連續(xù)的內(nèi)存碎片無法被釋放,導(dǎo)致OOM
4.1 模擬內(nèi)存抖動
執(zhí)行此段代碼
private static Handler mShakeHandler = new Handler() {
@Override public void handleMessage(Message msg) {
super.handleMessage(msg);
// 頻繁創(chuàng)建對象,模擬內(nèi)存抖動
for(int index = 0;index <= 100;index ++) {
String strArray[] = new String[100000];
}
mShakeHandler.sendEmptyMessageDelayed(0,30);
}
};
4.2 分析并定位
利用Memory Profiler工具查看內(nèi)存信息

發(fā)現(xiàn)內(nèi)存曲線由原來的平穩(wěn)曲線變成鋸齒狀

點(diǎn)擊record記錄內(nèi)存信息,查找發(fā)生內(nèi)存抖動位置,發(fā)現(xiàn)String對象ShallowSize非常異常,可直接通過Jump to Source定位到代碼位置

五、內(nèi)存泄露
定義:內(nèi)存中存在已經(jīng)沒有用確無法回收的對象
現(xiàn)象:會導(dǎo)致內(nèi)存抖動,可用內(nèi)存減少,進(jìn)而導(dǎo)致GC頻繁、卡頓、OOM
5.1 模擬內(nèi)存泄露
模擬內(nèi)存泄露代碼,反復(fù)進(jìn)入退出該Activity
/**
* 模擬內(nèi)存泄露的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);
// 添加靜態(tài)類引用
CallBackManager.addCallBack(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
// CallBackManager.removeCallBack(this);
}
@Override
public void dpOperate() {
// do sth
}
5.2 分析并定位
通過Memory Profiler工具查看內(nèi)存曲線,發(fā)現(xiàn)內(nèi)存在不斷的上升

如果想分析定位具體發(fā)生內(nèi)存泄露位置需要借助MAT工具
首先生成hprof文件
點(diǎn)擊dump將當(dāng)前內(nèi)存信息轉(zhuǎn)成hprof文件,需要對生成的文件轉(zhuǎn)換成MAT可讀取文件
執(zhí)行一下轉(zhuǎn)換命令(Android/sdk/platorm-tools路徑下)
hprof-conv 剛剛生成的hprof文件 memory-mat.hprof
使用mat打開剛剛轉(zhuǎn)換的hprof文件

點(diǎn)擊Historygram,搜索MemoryLeakActivity

可以看到有8個(gè)MemoryLeakActivity未釋放

查看所有引用對象

查看到GC Roots的引用鏈

可以看到GC Roots是CallBackManager

解決問題,當(dāng)Activity銷毀時(shí)將當(dāng)前引用移除
@Override
protected void onDestroy() {
super.onDestroy();
CallBackManager.removeCallBack(this);
}
六、MAT分析工具
Overview
當(dāng)前內(nèi)存整體信息
[圖片上傳失敗...(image-c0bad7-1632471126749)]
Histogram
列舉對象所有的實(shí)例及實(shí)例所占大小,可按package排序
[圖片上傳失敗...(image-83fb18-1632471126749)]
可以查看應(yīng)用包名下Activity存在實(shí)例個(gè)數(shù),可以查看是否存在內(nèi)存泄露,這里發(fā)現(xiàn)內(nèi)存中有8個(gè)Activity實(shí)例未釋放
[圖片上傳失敗...(image-d501f9-1632471126749)]
查看未被釋放的Activity的引用鏈
[圖片上傳失敗...(image-4347e3-1632471126749)]
Dominator_tree
當(dāng)前所有實(shí)例的支配樹,和Histogram區(qū)別時(shí)Histogram是類維度,dominator_tree是實(shí)例維度,可以查看所有實(shí)例的所占百分比和引用鏈
[圖片上傳失敗...(image-34cb1a-1632471126749)]
SQL
通過sql語句查詢相關(guān)類信息
[圖片上傳失敗...(image-c55ac1-1632471126749)]
Thread_overview
查看當(dāng)前所有線程信息
[圖片上傳失敗...(image-f62dea-1632471126749)]
Top Consumers
通過圖形方式展示占用內(nèi)存較高的對象,對降低內(nèi)存棧優(yōu)化可用內(nèi)存比較有幫助
[圖片上傳失敗...(image-d91270-1632471126749)]
[圖片上傳失敗...(image-7c14ca-1632471126749)]
Leak Suspects
內(nèi)存泄露分析頁面
[圖片上傳失敗...(image-e1c2e4-1632471126749)]
直接定位到內(nèi)存泄露位置
[圖片上傳失敗...(image-90bec3-1632471126749)]
七、通過ARTHook檢測不合理圖片
7.1 獲取Bitmap占用內(nèi)存
- 通過getByteCount方法,但是需要在運(yùn)行時(shí)獲取
- width * height * 一個(gè)像素所占內(nèi)存 * 圖片所在資源目錄壓縮比
7.2 檢測大圖
當(dāng)圖片控件load圖片大小超過控件自身大小時(shí)會造成內(nèi)存浪費(fèi),所以檢測出不合理圖片對內(nèi)存優(yōu)化是很重要的。
ARTHook方式檢測不合理圖片
通過ARTHook方法可以優(yōu)雅的獲取不合理圖片,侵入性低,但是因?yàn)榧嫒菪詥栴}一般在線下使用。
引入epic開源庫
implementation 'me.weishu:epic:0.3.6'
實(shí)現(xiàn)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初始化時(shí)注入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());
}
});
八、線上內(nèi)存監(jiān)控
8.1 常規(guī)方案
常規(guī)方案一
在特定場景中獲取當(dāng)前占用內(nèi)存大小,如果當(dāng)前內(nèi)存大小超過系統(tǒng)最大內(nèi)存80%,對當(dāng)前內(nèi)存進(jìn)行一次Dump(Debug.dumpHprofData()),選擇合適時(shí)間將hprof文件進(jìn)行上傳,然后通過MAT工具手動分析該文件。
缺點(diǎn):
- Dump文件比較大,和用戶使用時(shí)間、對象樹正相關(guān)
- 文件較大導(dǎo)致上傳失敗率較高,分析困難
常規(guī)方案二
將LeakCannary帶到線上,添加預(yù)設(shè)懷疑點(diǎn),對懷疑點(diǎn)進(jìn)行內(nèi)存泄露監(jiān)控,發(fā)現(xiàn)內(nèi)存泄露回傳到server。
缺點(diǎn):
- 通用性較低,需要預(yù)設(shè)懷疑點(diǎn),對沒有預(yù)設(shè)懷疑點(diǎn)的地方監(jiān)控不到
- LeakCanary分析比較耗時(shí)、耗內(nèi)存,有可能會發(fā)生OOM
8.2 LeakCannary定制改造
- 將需要預(yù)設(shè)懷疑點(diǎn)改為自動尋找懷疑點(diǎn),自動將前內(nèi)存中所占內(nèi)存較大的對象類中設(shè)置懷疑點(diǎn)。
- LeakCanary分析泄露鏈路比較慢,改造為只分析Retain size大的對象。
- 分析過程會OOM,是因?yàn)長eakCannary分析時(shí)會將分析對象全部加載到內(nèi)存當(dāng)中,我們可以記錄下分析對象的個(gè)數(shù)和占用大小,對分析對象進(jìn)行裁剪,不全部加載到內(nèi)存當(dāng)中。
8.3 完整方案
- 監(jiān)控常規(guī)指標(biāo):待機(jī)內(nèi)存、重點(diǎn)模塊占用內(nèi)存、OOM率
- 監(jiān)控APP一個(gè)生命周期內(nèi)和重點(diǎn)模塊界面的生命周期內(nèi)的GC次數(shù)、GC時(shí)間等
- 將定制的LeakCanary帶到線上,自動化分析線上的內(nèi)存泄露