Android 記憶體洩漏分析

leavesC發表於2018-02-25

一、基礎知識

1.1、記憶體洩露、記憶體溢位:

  • 記憶體洩露(Memory Leak)指一個無用物件持續佔有記憶體或無用物件的記憶體得不到及時的釋放,從而造成記憶體空間的浪費 例如,當Activity的onDestroy()方法被呼叫以後,Activity 本身以及它涉及到的 View、Bitmap等都應該被回收。但是,如果有一個後臺執行緒持有對這個Activity的引用,那麼Activity佔據的記憶體就不能被回收,嚴重時將導致OOM,最終Crash。
  • 記憶體溢位(Out Of Memory)指一個應用在申請記憶體時,沒有足夠的記憶體空間供其使用

相同點:都會導致應用執行出現問題、效能下降或崩潰。 不同點:

  1. 記憶體洩露是導致記憶體溢位的原因之一,記憶體洩露嚴重時將導致記憶體溢位
  2. 記憶體洩露是由於軟體設計缺陷引起的,可以通過完善程式碼來避免;記憶體溢位可以通過調整配置來減少發生頻率,但無法徹底避免

1.2、Java 的記憶體分配:

  1. 靜態儲存區:在程式整個執行期間都存在,編譯時就分配好空間,主要用於存放靜態資料和常量
  2. 棧區:當一個方法被執行時會在棧區記憶體中建立方法體內部的區域性變數,方法結束後自動釋放記憶體 堆區:通常存放 new 出來的物件,由 Java 垃圾回收器回收

1.3、四種引用型別:

  1. 強引用(StrongReference):Jvm寧可丟擲 OOM (記憶體溢位)也不會讓 GC(垃圾回收) 回收具有強引用的物件
  2. 軟引用(SoftReference):只有在記憶體空間不足時才會被回收的物件
  3. 弱引用(WeakReference):在 GC 時,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體
  4. 虛引用(PhantomReference):任何時候都可以被GC回收,當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。程式可以通過判斷引用佇列中是否存在該物件的虛引用,來了解這個物件是否將要被回收。可以用來作為GC回收Object的標誌。

記憶體洩漏就是指new出來的Object(強引用)無法被GC回收

1.4、非靜態內部類和匿名類:

非靜態內部類和匿名類會隱式地持有一個外部類的引用

1.5、靜態內部類:

外部類不管有多少個例項,都是共享同一個靜態內部類,因此靜態內部類不會持有外部類的引用

二、記憶體洩漏情況分析

2.1、資源未關閉

在使用Cursor,InputStream/OutputStream,File的過程中往往都用到了緩衝,因此在不需要使用的時候就要及時關閉它們,以便及時回收記憶體。它們的緩衝不僅存在於 java虛擬機器內,也存在於java虛擬機器外,如果只是把引用設定為null而不關閉它們,往往會造成記憶體洩漏。 此外,對於需要註冊的資源也要記得解除註冊,例如:BroadcastReceiver。動畫也要在介面不再對使用者可見時停止。

2.2、Handler

在如下程式碼中

public class HandlerActivity extends AppCompatActivity {

    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_handler);
    }
    
}
複製程式碼

在宣告Handler物件後,IDE會給開發者一個提示:

This Handler class should be static or leaks might occur.
複製程式碼

意思是:Handler需要宣告為static型別的,否則可能產生記憶體洩漏

這裡來進行具體原因分析: 應用在第一次啟動時, 系統會在主執行緒建立Looper物件,Looper實現了一個簡單的訊息佇列,用來迴圈處理Message。所有主要的應用層事件(例如Activity的生命週期方法回撥、Button點選事件等)都會包含在Message裡,系統會把Message新增到Looper中,然後Looper進行訊息迴圈。主執行緒的Looper存在於整個應用的生命週期期間。 當主執行緒建立Handler物件時,會與Looepr物件繫結,被分發到訊息佇列的Message會持有對Handler的引用,以便系統在Looper處理到該Message時能呼叫Handle的handlerMessage(Message)方法。 在上述程式碼中,Handler不是靜態內部類,所以會持有外部類(HandlerActivity)的一個引用。當Handler中有延遲的的任務或者等待執行的任務佇列過長時,由於訊息持有對Handler的引用,而Handler又持有對其外部類的潛在引用,這條引用關係會一直保持到訊息得到處理為止,導致了HandlerActivity無法被垃圾回收器回收,從而導致了記憶體洩露。

比如,在如下程式碼中,在onCreate()方法中令handler每隔一秒就輸出Log日記

public class HandlerActivity extends AppCompatActivity {

    private final String TAG = "MainActivity";

    private Handler handler = new Handler();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_handler);
        handler.postDelayed(new Runnable() {
            @Override
            public void run() {
                Log.e(TAG, "Hi");
                handler.postDelayed(this, 1000);
            }
        }, 6000);
    }

}
複製程式碼

檢視Handler的原始碼可以看到,postDelayed方法其實就是在傳送一條延時的Message

	public final boolean postDelayed(Runnable r, long delayMillis){
		return sendMessageDelayed(getPostMessage(r), delayMillis);
    }
複製程式碼

首先要意識到,非靜態類和匿名內部類都會持有外部類的隱式引用。當HandlerActivity生命週期結束後,延時傳送的Message持有Handler的引用,而Handler持有外部類(HandlerActivity)的隱式引用。該引用會繼續存在直到Message被處理完成,而此處並沒有可以令Handler終止的條件語句,所以阻止了HandlerActivity的回收,最終導致記憶體洩漏。

此處使用 LeakCanary 來檢測記憶體洩露情況(該工具下邊會有介紹) 先啟動HandlerActivity後退出,等個三四秒後,可以看到LeakCanary提示我們應用記憶體洩漏了

通過文字提示可以看到問題就出在Handler身上

這裡寫圖片描述

解決辦法就是在HandlerActivity退出後,移除Handler的所有回撥和訊息

    @Override
    protected void onDestroy() {
        super.onDestroy();
       handler.removeCallbacksAndMessages(null);
    }
複製程式碼

2.3、Thread

當在開啟一個子執行緒用於執行一個耗時操作後,此時如果改變配置(例如橫豎屏切換)導致了Activity重新建立,一般來說舊Activity就將交給GC進行回收。但如果建立的執行緒被宣告為非靜態內部類或者匿名類,那麼執行緒會保持有舊Activity的隱式引用。當執行緒的run()方法還沒有執行結束時,執行緒是不會被銷燬的,因此導致所引用的舊的Activity也不會被銷燬,並且與該Activity相關的所有資原始檔也不會被回收,因此造成嚴重的記憶體洩露。

因此總結來看, 執行緒產生記憶體洩露的主要原因有兩點:

  1. 執行緒生命週期的不可控。Activity中的Thread和AsyncTask並不會因為Activity銷燬而銷燬,Thread會一直等到run()執行結束才會停止,AsyncTask的doInBackground()方法同理
  2. 非靜態的內部類和匿名類會隱式地持有一個外部類的引用

例如如下程式碼,在onCreate()方法中啟動一個執行緒,並用一個靜態變數threadIndex標記當前建立的是第幾個執行緒

public class ThreadActivity extends AppCompatActivity {

    private final String TAG = "ThreadActivity";

    private static int threadIndex;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_thread);
        threadIndex++;
        new Thread(new Runnable() {
            @Override
            public void run() {
                int j = threadIndex;
                while (true) {
                    Log.e(TAG, "Hi--" + j);
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    }

}
複製程式碼

旋轉幾次螢幕,可以看到輸出結果為:

04-04 08:15:16.373 23731-23911/com.czy.leakdemo E/ThreadActivity: Hi--2
04-04 08:15:16.374 23731-26132/com.czy.leakdemo E/ThreadActivity: Hi--4
04-04 08:15:16.374 23731-23970/com.czy.leakdemo E/ThreadActivity: Hi--3
04-04 08:15:16.374 23731-23820/com.czy.leakdemo E/ThreadActivity: Hi--1
04-04 08:15:16.852 23731-26202/com.czy.leakdemo E/ThreadActivity: Hi--5
04-04 08:15:18.374 23731-23911/com.czy.leakdemo E/ThreadActivity: Hi--2
04-04 08:15:18.374 23731-26132/com.czy.leakdemo E/ThreadActivity: Hi--4
04-04 08:15:18.376 23731-23970/com.czy.leakdemo E/ThreadActivity: Hi--3
04-04 08:15:18.376 23731-23820/com.czy.leakdemo E/ThreadActivity: Hi--1
04-04 08:15:18.852 23731-26202/com.czy.leakdemo E/ThreadActivity: Hi--5
...
複製程式碼

即使建立了新的Activity,舊的Activity中建立的執行緒依然還在執行,從而導致無法釋放Activity佔用的記憶體,從而造成嚴重的記憶體洩漏

LeakCanary的檢測結果:

這裡寫圖片描述

想要避免因為Thread造成記憶體洩漏,可以在Activity退出後主動停止Thread 例如,可以為Thread設定一個布林變數threadSwitch來控制執行緒的啟動與停止

public class ThreadActivity extends AppCompatActivity {

    private final String TAG = "ThreadActivity";

    private int threadIndex;

    private boolean threadSwitch = true;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_thread);
        threadIndex++;
        new Thread(new Runnable() {
            @Override
            public void run() {
                int j = threadIndex;
                while (threadSwitch) {
                    Log.e(TAG, "Hi--" + j);
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        threadSwitch = false;
    }

}
複製程式碼

如果想保持Thread繼續執行,可以按以下步驟來:

  1. 將執行緒改為靜態內部類,切斷Activity 對於Thread的強引用
  2. 線上程內部採用弱引用儲存Context引用,切斷Thread對於Activity 的強引用
public class ThreadActivity extends AppCompatActivity {

    private static final String TAG = "ThreadActivity";

    private static int threadIndex;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_thread);
        threadIndex++;
        new MyThread(this).start();
    }

    private static class MyThread extends Thread {

        private WeakReference<ThreadActivity> activityWeakReference;

        MyThread(ThreadActivity threadActivity) {
            activityWeakReference = new WeakReference<>(threadActivity);
        }

        @Override
        public void run() {
            if (activityWeakReference == null) {
                return;
            }
            if (activityWeakReference.get() != null) {
                int i = threadIndex;
                while (true) {
                    Log.e(TAG, "Hi--" + i);
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

}
複製程式碼

2.4、Context

在使用Toast的過程中,如果應用連續彈出多個Toast,那麼就會造成Toast重疊顯示的情況 因此,可以使用如下方法來保證當前應用任何時候只會顯示一個Toast,且Toast的文字資訊能夠得到立即更新

/**
 * 作者: 葉應是葉
 * 時間: 2017/4/4 14:05
 * 描述:
 */
public class ToastUtils {

    private static Toast toast;

    public static void showToast(Context context, String info) {
        if (toast == null) {
            toast = Toast.makeText(context, info, Toast.LENGTH_SHORT);
        }
        toast.setText(info);
        toast.show();
    }

}
複製程式碼

然後,在Activity中使用

public class ToastActivity extends AppCompatActivity {

    private static int i = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_toast);
    }

    public void showToast(View view) {
        ToastUtils.showToast(this, "顯示Toast:" + (i++));
    }

}
複製程式碼

先點選一次Button使Toast彈出後,退出ToastActivity,此時LeakCanary又會提示說造成記憶體洩漏了

這裡寫圖片描述

當中提及了 Toast.mContext,通過檢視Toast類的原始碼可以看到,Toast類內部的mContext指向傳入的Context。而ToastUtils中的toast變數是靜態型別的,其生命週期是與整個應用一樣長的,從而導致 ToastActivity 得不到釋放。因此,對Context的引用不能超過它本身的生命週期。

public Toast(Context context) {
        mContext = context;
        mTN = new TN();
        mTN.mY = context.getResources().getDimensionPixelSize(
                com.android.internal.R.dimen.toast_y_offset);
        mTN.mGravity = context.getResources().getInteger(
                com.android.internal.R.integer.config_toastDefaultGravity);
    }
複製程式碼

解決辦法是改為使用 ApplicationContext 即可,因為ApplicationContext會隨著應用的存在而存在,而不依賴於Activity的生命週期

/**
 * 作者: 葉應是葉
 * 時間: 2017/4/4 14:05
 * 描述:
 */
public class ToastUtils {

    private static Toast toast;

    public static void showToast(Context context, String info) {
        if (toast == null) {
            toast = Toast.makeText(context.getApplicationContext(), info, Toast.LENGTH_SHORT);
        }
        toast.setText(info);
        toast.show();
    }

}
複製程式碼

2.5、集合

有時候我們需要把一些物件加入到集合容器(例如ArrayList)中,當不再需要當中某些物件時,如果不把該物件的引用從集合中清理掉,也會使得GC無法回收該物件。如果集合是static型別的話,那記憶體洩漏情況就會更為嚴重。 因此,當不再需要某物件時,需要主動將之從集合中移除

三、LeakCanary

LeakCanary是Square公司開發的一個用於檢測記憶體溢位問題的開源庫,可以在 debug 包中輕鬆檢測記憶體洩露 GitHub地址:LeakCanary

要引入LeakCanary庫,只需要在專案的build.gradle檔案新增

    testCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.5'
    debugCompile 'com.squareup.leakcanary:leakcanary-android:1.5'
    releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.5'
複製程式碼

Gradle強大的可配置性,可以確保只在編譯 debug 版本時才會檢查記憶體洩露,而編譯 release 等版本的時候則會自動跳過檢查,避免影響效能

如果只是想監測Activity的記憶體洩漏,在自定義的Application中進行如下初始化即可

/**
 * 作者: 葉應是葉
 * 時間: 2017/4/4 12:41
 * 描述:
 */
public class MyApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        LeakCanary.install(this);
    }

}
複製程式碼

如果還想監測Fragmnet的記憶體洩漏情況,則在自定義的Application中進行如下初始化

/**
 * 作者: 葉應是葉
 * 時間: 2017/4/4 12:41
 * 描述:
 */
public class MyApplication extends Application {

    private RefWatcher refWatcher;

    @Override
    public void onCreate() {
        super.onCreate();
        refWatcher = LeakCanary.install(this);
    }

    public static RefWatcher getRefWatcher(Context context) {
        MyApplication application = (MyApplication) context.getApplicationContext();
        return application.refWatcher;
    }

}

複製程式碼

然後在要監測的Fragment中的onDestroy()建立監聽

public class BaseFragment extends Fragment {
    
    @Override
    public void onDestroy() {
        super.onDestroy();
        RefWatcher refWatcher = MyApplication.getRefWatcher();
        refWatcher.watch(this);
    }
	
}
複製程式碼

當在測試debug版本的過程中出現記憶體洩露時,LeakCanary將會自動展示一個通知欄顯示檢測結果

這裡寫圖片描述

這裡提供上述示例程式碼下載:Android 記憶體洩漏分析

相關文章