我們為什麼要優化記憶體

在 Android 中我們寫的 .java 檔案,最終會編譯成 .class 檔案, class 又由類裝載器加載後,在 JVM 中會形成一份描述 class 結構的元資訊對象,通過該元資訊對象可以知道 class 的結構資訊 (構造函數、屬性、方法)等。JVM 會把描述類的資料從 class 檔案加載到記憶體,Java 有一個很好的管理記憶體的機制,垃圾回收機制 GC 。為什麼 Java 都給我們提供了垃圾回收機制,程式有時還會導緻記憶體洩漏,記憶體溢出 OOM,甚至導緻程式 Crash 。接下來我們就對實際開發中出現的這些記憶體問題,來進行優化。
JAVA 虛拟機
我們先來大概了解一下 Java 虛拟機裡面運作時的資料區域有哪些,如果想深入了解 Java 虛拟機 建議可以購買
<<深入了解 Java 虛拟機>> 或者直接點選我這裡的 PDF 版本 密碼: jmnf線程獨占區
程式計數器
- 相當于一個執行代碼的訓示器,用來确認下一行執行的位址
- 每個線程都有一個
- 沒有 OOM 的區
虛拟機棧
- 我們平時說的棧就是這塊區域
- java 虛拟機規範中定義了 OutOfMemeory , stackoverflow 異常
本地方法棧
- java 虛拟機規範中定義了 OutOfMemory ,stackoverflow 異常
注意
- 在 hotspotVM 中把虛拟機棧和本地方法棧合為了一個棧區
線程共享區
方法區
- ClassLoader 加載類資訊
- 常量、靜态變量
- 編譯後的代碼
- 會出現 OOM
- 運作時常量池
- public static final
- 符号引用類、接口全名、方法名
java 堆 (本次需要優化的地方)
- 虛拟機能管理的最大的一塊記憶體 GC 主戰場
- 對象執行個體
- 資料的内容
JAVA GC 如何确定記憶體回收
随着程式的運作,記憶體中的執行個體對象、變量等占據的記憶體越來越多,如果不及時進行回收,會降低程式運作效率,甚至引發系統異常。
目前虛拟機基本都是采用可達性分析算法,為什麼不采用引用計數算法呢?下面就說說引用計數法是如果統計所有對象的引用計數的,再對比可達性分析算法是如何解決引用計數算法的不足。下面就來看下這 2 個算法:
引用計數算法
每個對象有一個引用計數器,當對象被引用一次則計數器加一,當對象引用一次失效一次則計數器減一,對于計數器為 0 的時候就意味着是垃圾了,可以被 GC 回收。
下面通過一段代碼來實際看下
public class GCTest {
private Object instace = null;
public static void onGCtest() {
//step 1
GCTest gcTest1 = new GCTest();
//step 2
GCTest gcTest2 = new GCTest();
//step 3
gcTest1.instace = gcTest2;
//step 4
gcTest2.instace = gcTest1;
//step 5
gcTest1 = null;
//step 6
gcTest2 = null;
}
public static void main(String[] arg) {
onGCtest();
}
}
分析代碼
//step 1 gcTest1 引用 + 1 = 1
//step 2 gcTest2 引用 + 1 = 1
//step 3 gcTest1 引用 + 1 = 2
//step 4 gcTest2 引用 + 1 = 2
//step 5 gcTest1 引用 - 1 = 1
//step 6 gcTest2 引用 - 1 = 1
很明顯現在 2 個對象都不能用了都為 null 了,但是 GC 确不能回收它們,因為它們本身的引用計數不為 0 。不能滿足被回收的條件,盡管調用 System.gc() 也還是不能得到回收, 這就造成了 記憶體洩漏 。當然,現在虛拟機基本上都不采用此方式。
可達性分析算法
從 GC Roots 作為起點開始搜尋,那麼整個連通圖中額對象邊都是活對象,對于 GC Roots 無法到達的對象便成了垃圾回收的對象,随時可能被 GC 回收。
可以作為 GC Roots 的對象
- 虛拟機棧正在運作使用的引用
- 靜态屬性 常量
- JNI 引用的對象
GC 是需要 2 次掃描才回收對象,是以我們可以使用 finalize 去救活丢失的引用
@Override
protected void finalize() throws Throwable {
super.finalize();
instace = this;
}
到了這裡,相信大家已經能夠弄明白這 2 個算法的差別了吧?反正對于對象之間循環引用的情況,引用計數算法無法回收這 2 個對象,而可達性是從 GC Roots 開始搜尋,是以能夠正确的回收。
不同引用類型的回收狀态
強引用
Object strongReference = new Object()
如果一個對象具有強引用,那垃圾回收器絕不會回收它,當記憶體空間不足, Java 虛拟機甯願抛出 OOM 錯誤,使程式異常 Crash ,也不會靠随意回收具有強引用的對象來解決記憶體不足的問題.如果強引用對象不再使用時,需要弱化進而使 GC 能夠回收,需要:
strongReference = null; //等 GC 來回收
還有一種情況,如果:
public void onStrongReference(){
Object strongReference = new Object()
}
在 onStrongReference() 内部有一個強引用,這個引用儲存在 java 棧 中,而真正的引用内容 (Object)儲存在 java 堆中。當這個方法運作完成後,就會退出方法棧,則引用對象的引用數為 0 ,這個對象會被回收。
但是如果 mStrongReference 引用是全局時,就需要在不用這個對象時指派為 null ,因為 強引用 不會被 GC 回收。
軟引用 (SoftReference)
如果一個對象隻具有軟引用,則記憶體空間足夠,垃圾回收器就不會回收它;如果記憶體空間不足了,就會回收這些對象的記憶體,隻要垃圾回收器沒有回收它,該對象就可以被程式使用。軟引用可用來實作記憶體敏感的高速緩存。
軟引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果軟引用所引用的對象被垃圾回收器回收, java 虛拟機就會把這個軟引用加入到與之關聯的引用隊列中。
注意: 軟引用對象是在 jvm 記憶體不夠的時候才會被回收,我們調用 System.gc() 方法隻是起通知作用, JVM 什麼時候掃描回收對象是 JVM 自己的狀态決定的。就算掃描到了 str 這個對象也不會回收,隻有記憶體不足才會回收。
弱引用 (WeakReference)
弱引用與軟引用的差別在于: 隻具有弱引用的對象擁有更短暫的生命周期。在垃圾回收器線程掃描它所管轄的記憶體區域的過程中,一旦發現了隻具有弱引用的對象,不管目前記憶體空間足夠與否,都會回收它的記憶體。不過由于垃圾回收器是一個優先級很低的線程,是以不一定會很快發現那些隻具有弱引用的對象。
弱引用可以和一個引用隊列聯合使用,如果弱引用所引用的對象被垃圾回收,Java 虛拟機就會把這個弱引用加入到與之關聯的引用隊列中。
可見 weakReference 對象的生命周期基本由 GC 決定,一旦 GC 線程發現了弱引用就标記下來,第二次掃描到就直接回收了。
注意這裡的 referenceQueuee 是裝的被回收的對象。
虛引用 (PhantomReference)
@Test
public void onPhantomReference()throws InterruptedException{
String str = new String("123456");
ReferenceQueue queue = new ReferenceQueue();
// 建立虛引用,要求必須與一個引用隊列關聯
PhantomReference pr = new PhantomReference(str, queue);
System.out.println("PhantomReference:" + pr.get());
System.out.printf("ReferenceQueue:" + queue.poll());
}
虛引用顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用并不會決定對象的生命周期。如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收器回收。
虛引用主要用來跟蹤對象被垃圾回收器回收的活動。虛引用與軟引用和弱引用的一個差別在于: 虛引用必須和引用隊列 (ReferenceQueue) 聯合使用。當垃圾回收器準備回收一個對象時,如果發現它還有虛引用,就會在回收對象的記憶體之前,把這個虛引用加入到與之關聯的引用隊列中。
總結
引用類型 | 調用方式 | GC | 是否記憶體洩漏 |
---|---|---|---|
直接調用 | 不回收 | 是 | |
軟引用 | .get() | 視記憶體情況回收 | 否 |
弱引用 | 回收 | 不可能 | |
虛引用 | null | 任何時候都可能被回收,相當于沒有引用一樣 |
分析記憶體常用工具
工具很多,掌握原理方法,工具随意挑選使用。
top/procrank
meinfo
Procstats
DDMS
MAT
Finder - Activity
LeakCanary
LeakInspector
記憶體洩漏
産生的原因: 一個長生命周期的對象持有一個短生命周期對象的引用,通俗點講就是該回收的對象,因為引用問題沒有被回收,最終會産生 OOM。
下面我們來利用 Profile 來檢查項目是否有記憶體洩漏
怎麼利用 profile 來檢視項目中是否有記憶體洩漏
- 在 AS 中項目以 profile 運作
- 在 MEMORY 界面中選擇要分析的一段記憶體,右鍵 export
**Allocations:** 動态配置設定對象個數
**Deallocation:** 解除配置設定的對象個數
**Total count:** 對象的總數
**Shalow Size:** 對象本身占用的記憶體大小
**Retained Size:** GC 回收能收走的記憶體大小
- 轉換 profile 檔案格式
- 将 export 導出的 dprof 檔案轉換為 Mat 的 dprof 檔案
- cd /d 進入到 Android sdk/platform-tools/hprof-conv.exe
//轉換指令 hprof-conv -z src des D:\Android\AndroidDeveloper-sdk\android-sdk-windows\platform-tools>hprof-conv -z D:\temp_\temp_6.hprof D:\temp_\memory6.hprof
- 下載下傳 Mat 工具
- 打開 MemoryAnalyzer.exe 點選左上角 File 菜單中的 Open Heap Dupm
- 檢視記憶體洩漏中的 GC Roots 強引用
這裡我們得知是一個 ilsLoginListener 引用了 LoginView,我們來看下代碼最後怎麼解決的。
代碼中我們找到了 LoginView 這個類,發現是一個單例中的回調引起的記憶體洩漏,下面怎麼解決勒,請看第七小點。
- 2種解決單例中的記憶體洩漏
- 将引用置為 null
/** * 銷毀監聽 */ public void unRemoveRegisterListener(){ mMessageController.unBindListener(); } public void unBindListener(){ if (listener != null){ listener = null; } }
- 将引用置為 null
2. 使用弱引用
//将監聽器放入弱引用中
WeakReference<IBinderServiceListener> listenerWeakReference = new WeakReference<>(listener);
//從弱引用中取出回調
listenerWeakReference.get();
- 通過第七小點就能完美的解決單例中回調引起的記憶體洩漏。
Android 中常見的記憶體洩漏經典案例及解決方法
-
單例
示例 :
public class AppManager { private static AppManager sInstance; private CallBack mCallBack; private Context mContext; private AppManager(Context context) { this.mContext = context; } public static AppManager getInstance(Context context) { if (sInstance == null) { sInstance = new AppManager(context); } return sInstance; } public void addCallBack(CallBack call){ mCallBack = call; } }
1. 通過上面的單列,如果 context 傳入的是 Activity , Service 的 this,那麼就會導緻記憶體洩漏。
以 Activity 為例,當 Activity 調用 getInstance 傳入 this ,那麼 sInstance 就會持有 Activity 的引用,當 Activity 需要關閉的時候需要 回收的時候,發現 sInstance 還持有 沒有用的 Activity 引用,導緻 Activity 無法被 GC 回收,就會造成記憶體洩漏
2. addCallBack(CallBack call) 這樣寫看起來是沒有毛病的。但是當這樣調用在看一下勒。
//在 Activity 中實作單例的回調
AppManager.getInstance(getAppcationContext()).addCallBack(new CallBack(){
@Override
public void onStart(){
}
});
這裡的 new CallBack() 匿名内部類 預設持有外部的引用,造成 CallBack 釋放不了,那麼怎麼解決了,請看下面解決方法
**解決方法**:
1. getInstance(Context context) context 都傳入 Appcation 級别的 Context,或者實在是需要傳入 Activity 的引用就用 WeakReference 這種形式。
2. 匿名内部類建議大家單獨寫一個檔案或者
public void addCallBack(CallBack call){
WeakReference<CallBack> mCallBack= new WeakReference<CallBack>(call);
}
-
Handler
示例:
//在 Activity 中實作 Handler class MyHandler extends Handler{ private Activity m; public MyHandler(Activity activity){ m=activity; } // class..... }
這裡的 MyHandler 持有 activity 的引用,當 Activity 銷毀的時候,導緻 GC 不會回收造成 記憶體洩漏。
**解決方法**:
1.使用靜态内部類 + 弱引用
2.在 Activity onDestoty() 中處理 removeCallbacksAndMessages()
@Override
protected void onDestroy() {
super.onDestroy();
if(null != handler){
handler.removeCallbacksAndMessages(null);
handler = null;
}
}
- 靜态變量
public class MainActivity extends AppCompatActivity { private static Police sPolice; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); if (sPolice != null) { sPolice = new Police(this); } } } class Police { public Police(Activity activity) { } }
這裡 Police 持有 activity 的引用,會造成 activity 得不到釋放,導緻記憶體洩漏。
**解決方法**:
//1\. sPolice 在 onDestory()中 sPolice = null;
//2\. 在 Police 構造函數中 将強引用 to 弱引用;
-
非靜态内部類
參考 第二點 Handler 的處理方式
- 匿名内部類
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); new Thread(){ @Override public void run() { super.run(); } }; } }
很多初學者都會像上面這樣建立線程和異步任務,殊不知這樣的寫法非常地不友好,這種方式建立的子線程`Thread`和`AsyncTask`都是匿名内部類對象,預設就隐式的持有外部`Activity`的引用,導緻`Activity`記憶體洩露。
**解決方法**:
//靜态内部類 + 弱引用
//單獨寫一個檔案 + onDestory = null;
- 未取消注冊或回調
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); registerReceiver(mReceiver, new IntentFilter()); } private BroadcastReceiver mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { // TODO ------ } }; }
在注冊觀察則模式的時候,如果不及時取消也會造成記憶體洩露。比如使用`Retrofit + RxJava`注冊網絡請求的觀察者回調,同樣作為匿名内部類持有外部引用,是以需要記得在不用或者銷毀的時候取消注冊。
**解決方法**:
//Activity 中實作 onDestory()反注冊廣播得到釋放
@Override
protected void onDestroy() {
super.onDestroy();
this.unregisterReceiver(mReceiver);
}
- 定時任務
public class MainActivity extends AppCompatActivity { /**模拟計數*/ private int mCount = 1; private Timer mTimer; private TimerTask mTimerTask; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); init(); mTimer.schedule(mTimerTask, 1000, 1000); } private void init() { mTimer = new Timer(); mTimerTask = new TimerTask() { @Override public void run() { MainActivity.this.runOnUiThread(new Runnable() { @Override public void run() { addCount(); } }); } }; } private void addCount() { mCount += 1; } }
當我們`Activity`銷毀的時,有可能`Timer`還在繼續等待執行`TimerTask`,它持有Activity 的引用不能被 GC 回收,是以當我們 Activity 銷毀的時候要立即`cancel`掉`Timer`和`TimerTask`,以避免發生記憶體洩漏。
**解決方法**:
//當 Activity 關閉的時候,停止一切正在進行中的定時任務,避免造成記憶體洩漏。
private void stopTimer() {
if (mTimer != null) {
mTimer.cancel();
mTimer = null;
}
if (mTimerTask != null) {
mTimerTask.cancel();
mTimerTask = null;
}
}
@Override
protected void onDestroy() {
super.onDestroy();
stopTimer();
}
- 資源未關閉
**示例:**
ArrayList,HashMap,IO,File,SqLite,Cursor 等資源用完一定要記得 clear remove 等關閉一系列對資源的操作。
**解決方法**:
用完即刻銷毀
- 屬性動畫
**示例:**
動畫同樣是一個耗時任務,比如在 Activity 中啟動了屬性動畫 (ObjectAnimator) ,但是在銷毀的時候,沒有調用 cancle 方法,雖然我們看不到動畫了,但是這個動畫依然會不斷地播放下去,動畫引用所在的控件,所在的控件引用 Activity ,這就造成 Activity 無法正常釋放。是以同樣要在Activity 銷毀的時候 cancel 掉屬性動畫,避免發生記憶體洩漏。
**解決方法**:
@Override
protected void onDestroy() {
super.onDestroy();
//當關閉 Activity 的時候記得關閉動畫的操作
mAnimator.cancel();
}
- Android 源碼或者第三方 SDK
**示例:**
//如果在開發調試中遇見 Android 源碼或者 第三方 SDK 持有了我們目前的 Activity 或者其它類,那麼現在怎麼辦了。
**解決方法**:
//目前是通過 Java 中的反射找到某個類或者成員,來進行手動 = null 的操作。
記憶體抖動
什麼是記憶體抖動
記憶體頻繁的配置設定與回收,(配置設定速度大于回收速度時) 最終産生 OOM 。
也許下面的錄屏更能解釋什麼是記憶體抖動
可以看出當我點選了一下 Button 記憶體就頻繁的建立并回收(注意看垃圾桶)。
那麼我們找出代碼中具體那一塊出現問題了勒,請看下面一段錄屏
mButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
imPrettySureSortingIsFree();
}
});
/**
* 排序後列印二維數組,一行行列印
*/
public void imPrettySureSortingIsFree() {
int dimension = 300;
int[][] lotsOfInts = new int[dimension][dimension];
Random randomGenerator = new Random();
for (int i = 0; i < lotsOfInts.length; i++) {
for (int j = 0; j < lotsOfInts[i].length; j++) {
lotsOfInts[i][j] = randomGenerator.nextInt();
}
}
for (int i = 0; i < lotsOfInts.length; i++) {
String rowAsStr = "";
//排序
int[] sorted = getSorted(lotsOfInts[i]);
//拼接列印
for (int j = 0; j < lotsOfInts[i].length; j++) {
rowAsStr += sorted[j];
if (j < (lotsOfInts[i].length - 1)) {
rowAsStr += ", ";
}
}
Log.i("ricky", "Row " + i + ": " + rowAsStr);
}
}
最後我們之後是 onClick 中的 imPrettySureSortingIsFree() 函數裡面的 rowAsStr += sorted[j]; 字元串拼接造成的 記憶體抖動 ,因為每次拼接一個 String 都會申請一塊新的堆記憶體,那麼怎麼解決這個頻繁開辟記憶體的問題了。其實在 Java 中有 2 個更好的 API 對 String 的操作很友好,相信應該有人猜到了吧。沒錯就是将 此處的 String 換成 StringBuffer 或者 StringBuilder,就能很完美的解決字元串拼接造成的記憶體抖動問題。
修改後
/**
* 列印二維數組,一行行列印
*/
public void imPrettySureSortingIsFree() {
int dimension = 300;
int[][] lotsOfInts = new int[dimension][dimension];
Random randomGenerator = new Random();
for(int i = 0; i < lotsOfInts.length; i++) {
for (int j = 0; j < lotsOfInts[i].length; j++) {
lotsOfInts[i][j] = randomGenerator.nextInt();
}
}
// 使用StringBuilder完成輸出,我們隻需要建立一個字元串即可, 不需要浪費過多的記憶體
StringBuilder sb = new StringBuilder();
String rowAsStr = "";
for(int i = 0; i < lotsOfInts.length; i++) {
// 清除上一行
sb.delete(0, rowAsStr.length());
//排序
int[] sorted = getSorted(lotsOfInts[i]);
//拼接列印
for (int j = 0; j < lotsOfInts[i].length; j++) {
sb.append(sorted[j]);
if(j < (lotsOfInts[i].length - 1)){
sb.append(", ");
}
}
rowAsStr = sb.toString();
Log.i("jason", "Row " + i + ": " + rowAsStr);
}
}
這裡可以看見沒有垃圾桶出現,說明記憶體抖動解決了。
注意: 實際開發中如果在 LogCat 中發現有這些 Log 說明也發生了 記憶體抖動 (Log 中出現 concurrent copying GC freed ....)
回收算法
ps:我覺得這個隻是為了應付面試,那麼可以參考這裡,我也隻了解概念這裡就不用在多寫了,點選看這個文章吧 也可以參考掘金的這一篇 GC 回收算法标記清除算法 Mark-Sweep
複制算法 Copying
标記壓縮算法 Mark-Compact
分代收集算法
總結 (隻要養成這樣的習慣,至少可以避免 90 % 以上不會造成記憶體異常)
- 資料類型: 不要使用比需求更占用空間的基本資料類型
- 循環盡量用 foreach ,少用 iterator, 自動裝箱也盡量少用
- 資料結構與算法的解度處理 (數組,連結清單,棧樹,樹,圖)
- 資料量千級以内可以使用 Sparse 數組 (Key為整數),ArrayMap (Key 為對象) 雖然性能不如 HashMap ,但節約記憶體。
-
枚舉優化
缺點:
- 每一個枚舉值都是一個單例對象,在使用它時會增加額外的記憶體消耗,是以枚舉相比與 Integer 和 String 會占用更多的記憶體
- 較多的使用 Enum 會增加 DEX 檔案的大小,會造成運作時更多的 IO 開銷,使我們的應用需要更多的空間
- 特别是分 Dex 多的大型 APP,枚舉的初始化很容易導緻 ANR
public class SHAPE { public static final int TYPE_0=0; public static final int TYPE_1=1; public static final int TYPE_2=2; public static final int TYPE_3=3; @IntDef(flag=true,value={TYPE_0,TYPE_1,TYPE_2,TYPE_3}) @Target({ElementType.PARAMETER,ElementType.METHOD,ElementType.FIELD}) @Retention(RetentionPolicy.SOURCE) public @interface Model{ } private @Model int value=TYPE_0; public void setShape(@Model int value){ this.value=value; } @Model public int getShape(){ return this.value; } }
- static , static final 的問題
- static 會由編譯器調用 clinit 方法進行初始化
- static final 不需要進行初始化工作,打包在 dex 檔案中可以直接調用,并不會在類初始化申請記憶體
- 字元串的拼接盡量少用 +=
- 重複申請記憶體問題
- 同一個方法多次調用,如遞歸函數 ,回調函數中 new 對象
- 不要在 onMeause() onLayout() ,onDraw() 中去重新整理UI(requestLayout)
- 避免 GC 回收将來要重新使用的對象 (記憶體設計模式對象池 + LRU 算法)
- Activity 元件洩漏
- 非業務需要不要把 activity 的上下文做參數傳遞,可以傳遞 application 的上下文
- 非靜态内部類和匿名内部内會持有 activity 引用(靜态内部類 或者 單獨寫檔案)
- 單例模式中回調持有 activity 引用(弱引用)
- handler.postDelayed() 問題
- 如果開啟的線程需要傳入參數,用弱引接收可解決問題
- handler 記得清除 removeCallbacksAndMessages(null)
- Service 耗時操作盡量使用 IntentService,而不是 Service
最後思維導圖做一個總結:
**推薦閱讀:[2020最新Android大廠高頻面試題解析大全(BAT TMD JD 小米)
](
https://www.jianshu.com/p/0d7808bdffec)**
2020最新BAT Android高端技術面試145題詳解 2019年鴻洋大神最新整理一線網際網路公司Android中進階面試題總結(附答案解析)**[2017-2020曆年位元組跳動Android面試真題解析(累計下載下傳1082萬次,持續更新中)
https://www.jianshu.com/p/7f9ade51232e)作者:DevYK
連結:
https://juejin.im/post/5cd82a3ee51d456e781f20ce來源:掘金