Android備忘錄《記憶體洩漏》

Ansong發表於2019-02-22

Java記憶體分配策略

Java 程式執行時的記憶體分配策略有三種

  • 【靜態分配】靜態儲存區(方法區):主要存放靜態資料、全域性 static 資料和常量。這塊記憶體在程式編譯時就已經分配好,並且在程式整個執行期間都存在。

  • 【棧式分配】棧區:當方法被執行時,方法體內的區域性變數(其中包括基礎資料型別、物件的引用)都在棧上建立,並在方法執行結束時這些區域性變數所持有的記憶體將會自動被釋放。因為棧記憶體分配運算內建於處理器的指令集中,效率很高,但是分配的記憶體容量有限

  • 【堆式分配】堆區:又稱動態記憶體分配,通常就是指在程式執行時直接 new 出來的記憶體,也就是物件的例項。這部分記憶體在不使用時將會由 Java 垃圾回收器來負責回收。

棧與堆的區別

在方法體內定義的(區域性變數)一些基本型別的變數和物件的引用變數都是在方法的棧記憶體中分配的。當在一段方法塊中定義一個變數時,Java就會在棧中為該變數分配記憶體空間,當超過該變數的作用域後,該變數也就無效了,分配給它的記憶體空間也將被釋放掉,該記憶體空間可以被重新使用。

堆記憶體用來存放所有由new建立的物件(包括該物件其中的所有成員變數)和陣列。在堆中分配的記憶體,將由Java垃圾回收器來自動管理。在堆中產生了一個陣列或者物件後,還可以在棧中定義一個特殊的變數,這個變數的取值等於陣列或者物件在堆記憶體中的首地址,這個特殊的變數就是我們上面說的引用變數。我們可以通過這個引用變數來訪問堆中的物件或者陣列。

public class Sample {
    int s1 = 0;				
    Sample mSample1 = new Sample();
    public void method() {
        int s2 = 1;
        Sample mSample2 = new Sample();
    }
}
Sample mSample3 = new Sample();

Sample 類的區域性變數 s2 和引用變數 mSample2 都是存在於棧中,但 mSample2 指向的物件是存在於堆上的。
mSample3 指向的物件實體存放在堆上,包括這個物件的所有成員變數 s1 和 mSample1,而它自己存在於棧中。

複製程式碼

結論:

區域性變數的基本資料型別和引用儲存於棧中,引用的物件實體儲存於堆中。—— 因為它們屬於方法中的變數,生命週期隨方法而結束。

成員變數全部儲存與堆中(包括基本資料型別,引用和引用的物件實體)—— 因為它們屬於類,類物件終究是要被new出來使用的。

Java是如何管理記憶體

Java的記憶體管理就是物件的分配釋放問題

記憶體的分配是由程式完成的,在Java中,程式設計師需要通過關鍵字new為每個物件申請記憶體空間 (基本型別除外),所有的物件都在堆(Heap)中分配空間

物件的釋放是由 GC決定和執行的,這種收支兩條線的方法確實簡化了程式設計師的工作。但同時也加重了JVM的工作。這也是Java程式執行速度較慢的原因之一。因為,GC為了能夠正確釋放物件,GC必須監控每一個物件的執行狀態,包括物件的申請、引用、被引用、賦值等,GC都需要進行監控。監視物件狀態是為了更加準確地、及時地釋放物件,而釋放物件的根本原則就是該物件不再被引用。

為了更好理解 GC 的工作原理,我們可以將物件考慮為有向圖的頂點,將引用關係考慮為圖的有向邊,有向邊從引用者指向被引物件。另外,每個執行緒物件可以作為一個圖的起始頂點,例如大多程式從main程式開始執行,那麼該圖就是以 main 程式頂點開始的一棵根樹。在這個有向圖中,根頂點可達的物件都是有效物件,GC將不回收這些物件。如果某個物件(連通子圖)與這個根頂點不可達(注意,該圖為有向圖),那麼我們認為這個(這些)物件不再被引用,可以被 GC回收。以下,我們舉一個例子說明如何用有向圖表示記憶體管理。對於程式的每一個時刻,我們都有一個有向圖表示JVM的記憶體分配情況。

Android備忘錄《記憶體洩漏》
Java使用有向圖的方式進行記憶體管理,可以消除引用迴圈的問題,例如有三個物件,相互引用,只要它們和根程式不可達的,那麼GC也是可以回收它們的。這種方式的優點是管理記憶體的精度很高,但是效率較低。另外一種常用的記憶體管理技術是使用計數器,例如COM模型採用計數器方式管理構件,它與有向圖相比,精度行低(很難處理迴圈引用的問題),但執行效率很高。

什麼是Java中的記憶體洩露

在Java中,記憶體洩漏就是存在一些被分配的物件,這些物件有下面兩個特點,首先,這些物件是可達的,即在有向圖中,存在通路可以與其相連;其次,這些物件是無用的,即程式以後不會再使用這些物件。如果物件滿足這兩個條件,這些物件就可以判定為Java中的記憶體洩漏,這些物件不會被GC所回收,然而它卻佔用記憶體。

在C++中,記憶體洩漏的範圍更大一些。有些物件被分配了記憶體空間,然後卻不可達,由於C++中沒有GC,這些記憶體將永遠收不回來。在Java中,這些不可達的物件都由GC負責回收,因此程式設計師不需要考慮這部分的記憶體洩露。

通過分析,我們得知,對於C++,程式設計師需要自己管理邊和頂點,而對於Java程式設計師只需要管理邊就可以了(不需要管理頂點的釋放)。通過這種方式,Java提高了程式設計的效率。

Android備忘錄《記憶體洩漏》
因此,通過以上分析,我們知道在Java中也有記憶體洩漏,但範圍比C++要小一些。因為Java從語言上保證,任何物件都是可達的,所有的不可達物件都由GC管理。

對於程式設計師來說,GC基本是透明的,不可見的。雖然,我們只有幾個函式可以訪問GC,例如執行GC的函式System.gc(),但是根據Java語言規範定義, 該函式不保證JVM的垃圾收集器一定會執行。因為,不同的JVM實現者可能使用不同的演算法管理GC。通常,GC的執行緒的優先順序別較低。JVM呼叫GC的策略也有很多種,有的是記憶體使用到達一定程度時,GC才開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。但通常來說,我們不需要關心這些。除非在一些特定的場合,GC的執行影響應用程式的效能,例如對於基於Web的實時系統,如網路遊戲等,使用者不希望GC突然中斷應用程式執行而進行垃圾回收,那麼我們需要調整GC的引數,讓GC能夠通過平緩的方式釋放記憶體,例如將垃圾回收分解為一系列的小步驟執行,Sun提供的HotSpot JVM就支援這一特性。

一個 Java 記憶體洩漏的典型例子,

Vector v = new Vector(10);
for (int i = 1; i < 100; i++) {
    Object o = new Object();
    v.add(o);
    o = null;    
}
複製程式碼

在這個例子中,我們迴圈申請Object物件,並將所申請的物件放入一個 Vector 中,如果我們僅僅釋放引用本身,那麼Vector仍然引用該物件,所以這個物件對GC來說是不可回收的。因此,如果物件加入到Vector後,還必須從 Vector 中刪除,最簡單的方法就是將 Vector 物件設定為 null。

Android中常見的記憶體洩漏彙總

  • 集合類洩漏

集合類如果僅僅有新增元素的方法,而沒有相應的刪除機制,導致記憶體被佔用。如果這個集合類是全域性性的變數 (比如類中的靜態屬性,全域性性的 map 等即有靜態引用或 final一直指向它),那麼沒有相應的刪除機制,很可能導致集合所佔用的記憶體只增不減。比如上面的典型例子就是其中一種情況,當然實際上我們在專案中肯定不會寫這麼2B的程式碼,但稍不注意還是很容易出現這種情況,比如我們都喜歡通過HashMap做一些快取之類的事,這種情況就要多留一些心眼。

  • 單例造成的記憶體洩漏

由於單例的靜態特性使得其生命週期跟應用的生命週期一樣長,所以如果使用不恰當的話,很容易造成記憶體洩漏。比如下面一個典型的例子

public class AppManager {
    private static AppManager instance;
    private Context context;
    private AppManager(Context context) {
        this.context = context;
    }
    public static AppManager getInstance(Context context) {
        if (instance == null) {
            instance = new AppManager(context);
        }
        return instance;
    }
}
複製程式碼

這是一個普通的單例模式,當建立這個單例的時候,由於需要傳入一個Context,所以這個Context的生命週期的長短至關重要:

1、如果此時傳入的是 Application 的 Context,因為 Application 的生命週期就是整個應用的生命週期,所以這將沒有任何問題。

2、如果此時傳入的是 Activity 的 Context,當這個 Context 所對應的 Activity 退出時,由於該 Context 的引用被單例物件所持有,其生命週期等於整個應用程式的生命週期,所以當前 Activity 退出時它的記憶體並不會被回收,這就造成洩漏了。

正確的方式應該改為下面這種方式:

public class AppManager {
    private static AppManager instance;
    private Context context;
    private AppManager(Context context) {
        this.context = context.getApplicationContext();// 使用Application 的context
    }
    public static AppManager getInstance(Context context) {
        if (instance == null) {
            instance = new AppManager(context);
        }
        return instance;
    }
}
複製程式碼

或者這樣寫,連 Context 都不用傳進來了:

在你的 Application 中新增一個靜態方法,getContext() 返回 Application 的 context,
context = getApplicationContext();
   /**
     * 獲取全域性的context
     * @return 返回全域性context物件
     */
    public static Context getContext(){
        return context;
    }

public class AppManager {
    private static AppManager instance;
    private Context context;
    private AppManager() {
        this.context = MyApplication.getContext();// 使用Application 的context
    }
    public static AppManager getInstance() {
        if (instance == null) {
            instance = new AppManager();
        }
        return instance;
    }
}
複製程式碼
  • 靜態變數導致記憶體洩露

靜態變數儲存在方法區,它的生命週期從類載入開始,到整個程式結束。一旦靜態變數初始化後,它所持有的引用只有等到程式結束才會釋放。比如下面這樣的情況,在Activity中為了避免重複的建立info,將sInfo作為靜態變數:

public class MainActivity extends AppCompatActivity {
    private static Info sInfo;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        if (sInfo != null) {
            sInfo = new Info(this);
        }
    }
}
class Info {
    public Info(Activity activity) {
    }
}
複製程式碼

Info作為Activity的靜態成員,並且持有Activity的引用,但是sInfo作為靜態變數,生命週期肯定比Activity長。所以當Activity退出後,sInfo仍然引用了Activity,Activity不能被回收,這就導致了記憶體洩露。

在Android開發中,靜態持有很多時候都有可能因為其使用的生命週期不一致而導致記憶體洩露,所以我們在新建靜態持有的變數的時候需要多考慮一下各個成員之間的引用關係,並且儘量少地使用靜態持有的變數,以避免發生記憶體洩露。當然,我們也可以在適當的時候講靜態量重置為null,使其不再持有引用,這樣也可以避免記憶體洩露。

  • 非靜態內部類導致記憶體洩露

非靜態內部類(包括匿名內部類)預設就會持有外部類的引用,當非靜態內部類物件的生命週期比外部類物件的生命週期長時,就會導致記憶體洩露。非靜態內部類導致的記憶體洩露在Android開發中有一種典型的場景就是使用Handler,很多開發者在使用Handler是這樣寫的:

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        start();
    }
    private void start() {
        Message msg = Message.obtain();
        msg.what = 1;
        mHandler.sendMessage(msg);
    }
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            if (msg.what == 1) {
                // 做相應邏輯
            }
        }
    };
}
複製程式碼

也許有人會說,mHandler並未作為靜態變數持有Activity引用,生命週期可能不會比Activity長,應該不一定會導致記憶體洩露呢,顯然不是這樣的!

熟悉Handler訊息機制的都知道,mHandler會作為成員變數儲存在傳送的訊息msg中,即msg持有mHandler的引用,而mHandler是Activity的非靜態內部類例項,即mHandler持有Activity的引用,那麼我們就可以理解為msg間接持有Activity的引用。msg被髮送後先放到訊息佇列MessageQueue中,然後等待Looper的輪詢處理(MessageQueue和Looper都是與執行緒相關聯的,MessageQueue是Looper引用的成員變數,而Looper是儲存在ThreadLocal中的)。那麼當Activity退出後,msg可能仍然存在於訊息對列MessageQueue中未處理或者正在處理,那麼這樣就會導致Activity無法被回收,以致發生Activity的記憶體洩露。通常在Android開發中如果要使用內部類,但又要規避記憶體洩露,一般都會採用靜態內部類+弱引用的方式。

public class MainActivity extends AppCompatActivity {
    private Handler mHandler;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mHandler = new MyHandler(this);
        start();
    }
    private void start() {
        Message msg = Message.obtain();
        msg.what = 1;
        mHandler.sendMessage(msg);
    }
    private static class MyHandler extends Handler {
        private WeakReference<MainActivity> activityWeakReference;
        public MyHandler(MainActivity activity) {
            activityWeakReference = new WeakReference<>(activity);
        }
        @Override
        public void handleMessage(Message msg) {
            MainActivity activity = activityWeakReference.get();
            if (activity != null) {
                if (msg.what == 1) {
                    // 做相應邏輯
                }
            }
        }
    }
}
複製程式碼

mHandler通過弱引用的方式持有Activity,當GC執行垃圾回收時,遇到Activity就會回收並釋放所佔據的記憶體單元。這樣就不會發生記憶體洩露了。

上面的做法確實避免了Activity導致的記憶體洩露,傳送的msg不再已經沒有持有Activity的引用了,但是msg還是有可能存在訊息佇列MessageQueue中,所以更好的是在Activity銷燬時就將mHandler的回撥和傳送的訊息給移除掉。

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

非靜態內部類造成記憶體洩露還有一種情況就是使用Thread或者AsyncTask。 比如在Activity中直接new一個子執行緒Thread:

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        new Thread(new Runnable() {
            @Override
            public void run() {
                // 模擬相應耗時邏輯
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}
複製程式碼

或者直接新建AsyncTask非同步任務:

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... params) {
                // 模擬相應耗時邏輯
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                return null;
            }
        }.execute();
    }
}
複製程式碼

很多初學者都會像上面這樣新建執行緒和非同步任務,殊不知這樣的寫法非常地不友好,這種方式新建的子執行緒Thread和AsyncTask都是匿名內部類物件,預設就隱式的持有外部Activity的引用,導致Activity記憶體洩露。要避免記憶體洩露的話還是需要像上面Handler一樣使用靜態內部類+弱應用的方式(程式碼就不列了,參考上面Hanlder的正確寫法)。

  • 未取消註冊或回撥導致記憶體洩露

比如我們在Activity中註冊廣播,如果在Activity銷燬後不取消註冊,那麼這個剛播會一直存在系統中,同上面所說的非靜態內部類一樣持有Activity引用,導致記憶體洩露。因此註冊廣播後在Activity銷燬後一定要取消註冊。

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        this.registerReceiver(mReceiver, new IntentFilter());
    }
    private BroadcastReceiver mReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            // 接收到廣播需要做的邏輯
        }
    };
    @Override
    protected void onDestroy() {
        super.onDestroy();
        this.unregisterReceiver(mReceiver);
    }
}
複製程式碼

在註冊觀察則模式的時候,如果不及時取消也會造成記憶體洩露。比如使用Retrofit+RxJava註冊網路請求的觀察者回撥,同樣作為匿名內部類持有外部引用,所以需要記得在不用或者銷燬的時候取消註冊。

  • Timer和TimerTask導致記憶體洩露

Timer和TimerTask在Android中通常會被用來做一些計時或迴圈任務,比如實現無限輪播的ViewPager:

public class MainActivity extends AppCompatActivity {
    private ViewPager mViewPager;
    private PagerAdapter mAdapter;
    private Timer mTimer;
    private TimerTask mTimerTask;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        init();
        mTimer.schedule(mTimerTask, 3000, 3000);
    }
    private void init() {
        mViewPager = (ViewPager) findViewById(R.id.view_pager);
        mAdapter = new ViewPagerAdapter();
        mViewPager.setAdapter(mAdapter);
        mTimer = new Timer();
        mTimerTask = new TimerTask() {
            @Override
            public void run() {
                MainActivity.this.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        loopViewpager();
                    }
                });
            }
        };
    }
    
  private void loopViewpager() {
        if (mAdapter.getCount() > 0) {
            int curPos = mViewPager.getCurrentItem();
            curPos = (++curPos) % mAdapter.getCount();
            mViewPager.setCurrentItem(curPos);
        }
    }

    private void stopLoopViewPager() {
        if (mTimer != null) {
            mTimer.cancel();
            mTimer.purge();
            mTimer = null;
        }
        if (mTimerTask != null) {
            mTimerTask.cancel();
            mTimerTask = null;
        }
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
        stopLoopViewPager();
    }
}
複製程式碼

當我們Activity銷燬的時,有可能Timer還在繼續等待執行TimerTask,它持有Activity的引用不能被回收,因此當我們Activity銷燬的時候要立即cancel掉Timer和TimerTask,以避免發生記憶體洩漏。

  • 集合中的物件未清理造成記憶體洩露

這個比較好理解,如果一個物件放入到ArrayList、HashMap等集合中,這個集合就會持有該物件的引用。當我們不再需要這個物件時,也並沒有將它從集合中移除,這樣只要集合還在使用(而此物件已經無用了),這個物件就造成了記憶體洩露。並且如果集合被靜態引用的話,集合裡面那些沒有用的物件更會造成記憶體洩露了。所以在使用集合時要及時將不用的物件從集合remove,或者clear集合,以避免記憶體洩漏。

  • 資源未關閉或釋放導致記憶體洩露

在使用IO、File流或者Sqlite、Cursor等資源時要及時關閉。這些資源在進行讀寫操作時通常都使用了緩衝,如果及時不關閉,這些緩衝物件就會一直被佔用而得不到釋放,以致發生記憶體洩露。因此我們在不需要使用它們的時候就及時關閉,以便緩衝能及時得到釋放,從而避免記憶體洩露。

  • 屬性動畫造成記憶體洩露

動畫同樣是一個耗時任務,比如在Activity中啟動了屬性動畫(ObjectAnimator),但是在銷燬的時候,沒有呼叫cancle方法,雖然我們看不到動畫了,但是這個動畫依然會不斷地播放下去,動畫引用所在的控制元件,所在的控制元件引用Activity,這就造成Activity無法正常釋放。因此同樣要在Activity銷燬的時候cancel掉屬性動畫,避免發生記憶體洩漏。

@Override
protected void onDestroy() {
    super.onDestroy();
    mAnimator.cancel();
}
複製程式碼
  • WebView造成記憶體洩露

關於WebView的記憶體洩露,因為WebView在載入網頁後會長期佔用記憶體而不能被釋放,因此我們在Activity銷燬後要呼叫它的destory()方法來銷燬它以釋放記憶體。

另外在查閱WebView記憶體洩露相關資料時看到這種情況:

Webview下面的Callback持有Activity引用,造成Webview記憶體無法釋放,即使是呼叫了Webview.destory()等方法都無法解決問題(Android5.1之後)。

最終的解決方案是:在銷燬WebView之前需要先將WebView從父容器中移除,然後在銷燬WebView。詳細分析過程請參考這篇文章

@Override
protected void onDestroy() {
    super.onDestroy();
    // 先從父控制元件中移除WebView
    mWebViewContainer.removeView(mWebView);
    mWebView.stopLoading();
    mWebView.getSettings().setJavaScriptEnabled(false);
    mWebView.clearHistory();
    mWebView.removeAllViews();
    mWebView.destroy();
}
複製程式碼

總結

記憶體洩露在Android記憶體優化是一個比較重要的一個方面,很多時候程式中發生了記憶體洩露我們不一定就能注意到,所有在編碼的過程要養成良好的習慣。總結下來只要做到以下這幾點就能避免大多數情況的記憶體洩漏: 構造單例的時候儘量別用Activity的引用;靜態引用時注意應用物件的置空或者少用靜態引用;使用靜態內部類+軟引用代替非靜態內部類;及時取消廣播或者觀察者註冊;耗時任務、屬性動畫在Activity銷燬時記得cancel;檔案流、Cursor等資源及時關閉;Activity銷燬時WebView的移除和銷燬。

相關文章