Android面試被問到記憶體洩漏了咋整?

船頭尺發表於2021-09-09

前言

記憶體洩漏即該被釋放的記憶體沒有被及時的釋放,一直被某個或某些例項所持有卻不再使用導致GC不能回收。
文末準備了一份完整系統的進階提升的技術大綱和學習資料,希望對於有一定工作經驗但是技術還需要提升的朋友提供一個方向參考,以及免去不必要的網上到處搜資料時間精力。

Java記憶體分配策略

Java程式執行時的記憶體分配策略有三種,分別是靜態分配,棧式分配,和堆式分配。對應的三種策略使用的記憶體空間是要分別是靜態儲存區(也稱方法區),棧區,和堆區。

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

  • 棧區:當方法執行時,方法內部的區域性變數都建立在棧記憶體中,並在方法結束後自動釋放分配的記憶體。因為棧記憶體分配是在處理器的指令集當中所以效率很高,但是分配的記憶體容量有限。

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

棧與堆的區別:

在方法體內定義的(區域性變數)一些基本型別的變數和物件的引用變數都在方法的棧記憶體中分配。當在一段方法塊中定義一個變數時,Java就會在棧中為其分配記憶體,當超出變數作用域時,該變數也就無效了,此時佔用的記憶體就會釋放,然後會被重新利用。

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

舉個例子:

public class Sample {
    int s1 = 0;
    Sample mSample1 = new Sample();

    public void method() {
        int s2 = 0;
        Sample mSample2 = new Sample();
    }
}
    Sample mSample3 = new Sample();
複製程式碼

如上區域性變數s2mSample2存放在棧記憶體中,mSample3所指向的物件存放在堆記憶體中,包括該物件的成員變數s1mSample1也存放在堆中,而它自己則存放在棧中。

結論:

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

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

瞭解了Java的記憶體分配之後,我們再來看看Java是怎麼管理記憶體。

Java是如何管理記憶體

由程式分配記憶體,GC來釋放記憶體。記憶體釋放的原理為該物件或者陣列不再被引用,則JVM會在適當的時候回收記憶體。

記憶體管理演算法:

  1. 引用計數法:物件內部定義引用變數,當該物件被某個引用變數引用時則計數加1,當物件的某個引用變數超出生命週期或者引用了新的變數時,計數減1。任何引用計數為0的物件例項都可以被GC。這種演算法的優點是:引用計數收集器可以很快的執行,交織在程式執行中。對程式需要不被長時間打斷的實時環境比較有利。缺點:無法檢測出迴圈引用。

引用計數無法解決的迴圈引用問題如下:

    public void method() {
        //Sample count=1
        Sample ob1 = new Sample();
        //Sample count=2
        Sample ob2 = new Sample();
        //Sample count=3
        ob1.mSample = ob2;
        //Sample count=4
        ob2.mSample = ob1;
        //Sample count=3
        ob1=null;
        //Sample count=2
        ob2=null;
        //計數為2,不能被GC
    }
複製程式碼

Java可以作為GC ROOT的物件有:虛擬機器棧中引用的物件(本地變數表),方法區中靜態屬性引用的物件,方法區中常量引用的物件,本地方法棧中引用的物件(Native物件)

  1. 標記清除法:從根節點集合進行掃描,標記存活的物件,然後再掃描整個空間,對未標記的物件進行回收。在存活物件較多的情況下,效率很高,但是會造成記憶體碎片。

  2. 標記整理演算法:同標記清除法,只不過在回收物件時,對存活的物件進行移動。雖然解決了記憶體碎片的問題但是增加了記憶體的開銷。

  3. 複製演算法:此方法為克服控制程式碼的開銷和解決堆碎片。把堆分為一個物件面和多個空閒面。把存活的物件copy到空閒面,主要空閒面就變成了物件面,原來的物件面就變成了空閒面。這樣增加了記憶體的開銷,且在交換過程中程式會暫停執行。

  4. 分代演算法:

分代垃圾回收策略,是基於:不同的物件的生命週期是不一樣的。因此,不同生命週期的物件可以採取不同的回收演算法,以便提高回收效率。

年輕代:

  1. 所有新生成的物件首先都是存放在年輕代。年輕代的目標就是儘可能快速的收集掉那些生命週期短的物件。

  2. 新生代記憶體按照8:1:1的比例分為一個eden區和兩個survivor(survivor0,survivor1)區。一個Eden區,兩個 Survivor區(一般而言)。大部分物件在Eden區中生成。回收時先將eden區存活物件複製到一個survivor0區,然後清空eden區,當這個survivor0區也存放滿了時,則將eden區和survivor0區存活物件複製到另一個survivor1區,然後清空eden和這個survivor0區,此時survivor0區是空的,然後將survivor0區和survivor1區交換,即保持survivor1區為空, 如此往復。

  3. 當survivor1區不足以存放 eden和survivor0的存活物件時,就將存活物件直接存放到老年代。若是老年代也滿了就會觸發一次Full GC,也就是新生代、老年代都進行回收

  4. 新生代發生的GC也叫做Minor GC,MinorGC發生頻率比較高(不一定等Eden區滿了才觸發)

年老代:

  1. 在年輕代中經歷了N次垃圾回收後仍然存活的物件,就會被放到年老代中。因此,可以認為年老代中存放的都是一些生命週期較長的物件。

  2. 記憶體比新生代也大很多(大概比例是1:2),當老年代記憶體滿時觸發Major GC即Full GC,Full GC發生頻率比較低,老年代物件存活時間比較長,存活率標記高。

持久代:

用於存放靜態檔案,如Java類、方法等。持久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者呼叫一些class,例如Hibernate 等,在這種時候需要設定一個比較大的持久代空間來存放這些執行過程中新增的類。

Android常見的記憶體洩漏彙總

集合類洩漏

先看一段程式碼

   List<Object> objectList = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            Object o = new Object();
            objectList.add(o);
            o = null;
        }
複製程式碼

上面的例項,雖然在迴圈中把引用o釋放了,但是它被新增到了objectList中,所以objectList也持有物件的引用,此時該物件是無法被GC的。因此物件如果新增到集合中,還必須從中刪除,最簡單的方法

  //釋放objectList
        objectList.clear();
        objectList=null;
複製程式碼

單例造成的記憶體洩漏

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

public class SingleInstanceClass {

    private static SingleInstanceClass instance;

    private Context mContext;

    private SingleInstanceClass(Context context) {
        this.mContext = context;
    }

    public SingleInstanceClass getInstance(Context context) {
        if (instance == null) {
            instance = new SingleInstanceClass(context);
        }
        return instance;
    }
}
複製程式碼

正如前面所說,靜態變數的生命週期等同於應用的生命週期,此處傳入的Context引數便是禍端。如果傳遞進去的是Activity或者Fragment,由於單例一直持有它們的引用,即便Activity或者Fragment銷燬了,也不會回收其記憶體。特別是一些龐大的Activity非常容易導致OOM。

正確的寫法應該是傳遞Application的Context,因為Application的生命週期就是整個應用的生命週期,所以沒有任何的問題。

public class SingleInstanceClass {

    private static SingleInstanceClass instance;

    private Context mContext;

    private SingleInstanceClass(Context context) {
        this.mContext = context.getApplicationContext();// 使用Application 的context
    }

    public SingleInstanceClass getInstance(Context context) {
        if (instance == null) {
            instance = new SingleInstanceClass(context);
        }
        return instance;
    }
}

or

//在Application中定義獲取全域性的context的方法
 /**
     * 獲取全域性的context
     * @return 返回全域性context物件
     */
    public static Context getContext(){
        return context;
    }

public class SingleInstanceClass {

    private static SingleInstanceClass instance;

    private Context mContext;

    private SingleInstanceClass() {
       mContext=MyApplication.getContext;
    }

    public SingleInstanceClass getInstance() {
        if (instance == null) {
            instance = new SingleInstanceClass();
        }
        return instance;
    }
}

複製程式碼

匿名內部類/非靜態內部類和非同步執行緒

  • 非靜態內部類建立靜態例項造成的記憶體洩漏
    我們都知道非靜態內部類是預設持有外部類的引用的,如果在內部類中定義單例例項,會導致外部類無法釋放。如下面程式碼:
public class TestActivity extends AppCompatActivity {
    public static InnerClass innerClass = null;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (innerClass == null)
            innerClass = new InnerClass();
    }
    private class InnerClass {
        //...
    }
}
複製程式碼

TestActivity銷燬時,因為innerClass生命週期等同於應用生命週期,但是它又持有TestActivity的引用,因此導致記憶體洩漏。

正確做法應將該內部類設為靜態內部類或將該內部類抽取出來封裝成一個單例,如果需要使用Context,請按照上面推薦的使用Application 的 Context。當然,Application 的 context 不是萬能的,所以也不能隨便亂用,對於有些地方則必須使用 Activity 的 Context,對於Application,Service,Activity三者的Context的應用場景如下:

Android面試被問到記憶體洩漏了咋整?

  • 匿名內部類
    android開發經常會繼承實現Activity/Fragment/View,此時如果你使用了匿名類,並被非同步執行緒持有了,那要小心了,如果沒有任何措施這樣一定會導致洩露。如下程式碼:
public class TestActivity extends AppCompatActivity {
  //....

    private Runnable runnable=new Runnable() {
        @Override
        public void run() {

        }
    };

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
       //......
    }

}
複製程式碼

上面的runnable所引用的匿名內部類持有TestActivity的引用,當將其傳入非同步執行緒中,執行緒與Activity生命週期不一致就會導致記憶體洩漏。

  • Handler造成的記憶體洩漏
    Handler造成記憶體洩漏的根本原因是因為,Handler的生命週期與Activity或者View的生命週期不一致。Handler屬於TLS(Thread Local Storage)生命週期同應用週期一樣。看下面的程式碼:
public class TestActivity extends AppCompatActivity {
    private Handler mHandler = new Handler() {
        @Override
        public void dispatchMessage(Message msg) {
            super.dispatchMessage(msg);
        }
    };

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mHandler.postDelayed(new Runnable() {
            @Override
            public void run() {
            //do your things
            }
        }, 60 * 1000 * 10);

        finish();
    }
}
複製程式碼

在該TestActivity中宣告瞭一個延遲10分鐘執行的訊息 MessagemHandler將其 push 進了訊息佇列 MessageQueue 裡。當該 Activity 被finish()掉時,延遲執行任務的Message還會繼續存在於主執行緒中,它持有該 Activity 的Handler引用,所以此時 finish()掉的 Activity 就不會被回收了從而造成記憶體洩漏(因 Handler 為非靜態內部類,它會持有外部類的引用,在這裡就是指TestActivity)。

修復方法:採用內部靜態類以及弱引用方案。程式碼如下:

public class TestActivity extends AppCompatActivity {
    private MyHandler mHandler;

    private static class MyHandler extends Handler {
        private final WeakReference<TestActivity> mActivity;

        public MyHandler(TestActivity activity) {
            mActivity = new WeakReference<>(activity);
        }

        @Override
        public void dispatchMessage(Message msg) {
            super.dispatchMessage(msg);
            TestActivity activity = mActivity.get();
            //do your things
        }
    }

    private static final Runnable mRunnable = new Runnable() {
        @Override
        public void run() {
            //do your things
        }
    };

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mHandler = new MyHandler(this);
        mHandler.postAtTime(mRunnable, 1000 * 60 * 10);

        finish();
    }

}

複製程式碼

需要注意的是:使用靜態內部類 + WeakReference 這種方式,每次使用前注意判空。

前面提到了 WeakReference,所以這裡就簡單的說一下 Java 物件的幾種引用型別。

Java對引用的分類有 Strong reference, SoftReference, WeakReference, PhatomReference 四種。


Android面試被問到記憶體洩漏了咋整?

ok,繼續回到主題。前面所說的,建立一個靜態Handler內部類,然後對 Handler 持有的物件使用弱引用,這樣在回收時也可以回收 Handler 持有的物件,但是這樣做雖然避免了Activity洩漏,不過Looper 執行緒的訊息佇列中還是可能會有待處理的訊息,所以我們在Activity的 Destroy 時或者 Stop 時應該移除訊息佇列 MessageQueue 中的訊息。

下面幾個方法都可以移除 Message:

public final void removeCallbacks(Runnable r);

public final void removeCallbacks(Runnable r, Object token);

public final void removeCallbacksAndMessages(Object token);

public final void removeMessages(int what);

public final void removeMessages(int what, Object object);
複製程式碼

儘量避免使用 staic 成員變數

如果成員變數被宣告為 static,那我們都知道其生命週期將與整個app程式生命週期一樣。

這會導致一系列問題,如果你的app程式設計上是長駐記憶體的,那即使app切到後臺,這部分記憶體也不會被釋放。按照現在手機app記憶體管理機制,佔記憶體較大的後臺程式將優先回收,意味著如果此app做過程式互保保活,那會造成app在後臺頻繁重啟。就會出現一夜時間手機被消耗空了電量、流量,這樣只會被使用者棄用。
這裡修復的方法是:

不要在類初始時初始化靜態成員。可以考慮lazy初始化。
架構設計上要思考是否真的有必要這樣做,儘量避免。如果架構需要這麼設計,那麼此物件的生命週期你有責任管理起來。

  • 避免 override finalize():
  1. finalize 方法被執行的時間不確定,不能依賴與它來釋放緊缺的資源。時間不確定的原因是: 虛擬機器呼叫GC的時間不確定以及Finalize daemon執行緒被排程到的時間不確定。

  2. finalize 方法只會被執行一次,即使物件被複活,如果已經執行過了 finalize 方法,再次被 GC 時也不會再執行了,原因是:含有 finalize 方法的 object 是在 new 的時候由虛擬機器生成了一個 finalize reference 在來引用到該Object的,而在 finalize 方法執行的時候,該 object 所對應的 finalize Reference 會被釋放掉,即使在這個時候把該 object 復活(即用強引用引用住該 object ),再第二次被 GC 的時候由於沒有了 finalize reference 與之對應,所以 finalize 方法不會再執行。

  3. 含有Finalize方法的object需要至少經過兩輪GC才有可能被釋放。

其它

記憶體洩漏檢測工具強烈推薦 squareup 的 LeakCannary,但需要注意Android版本是4.4+的,否則會Crash。

Android面試被問到記憶體洩漏了咋整?Android面試被問到記憶體洩漏了咋整?

+qq群:853967238。獲取以上高清技術思維圖,以及相關技術的免費視訊學習資料


相關文章