Java JVM 內存泄露 基本概念 解析及排查處理辦法

0. 背景

本文章會一步一步的探討內存泄露的問題。

JAVA是垃圾回收語言的一種,開發者無需特意管理內存分配。但是JAVA中還是存在着許多內存泄露的可能性,如果不好好處理內存泄露,會導致APP內存單元無法釋放被浪費掉,最終導致內存全部佔據堆棧(heap)擠爆進而程序崩潰

1. 內存泄露 or 內存溢出?

說到內存泄露,就不得不提到內存溢出,這兩個比較容易混淆的概念,我們來分析一下。

  • 內存泄露程序在向系統申請分配內存空間後(new),在使用完畢後未釋放。結果導致一直佔據該內存單元,我們和程序都無法再使用該內存單元,直到程序結束,這是內存泄露。
  • 內存溢出程序向系統申請的內存空間超出了系統能給的。比如內存只能分配一個int類型,我卻要塞給他一個long類型,系統就出現oom。又比如一車最多能坐5個人,你卻非要塞下10個,車就擠爆了。

大量的內存泄露會導致內存溢出(oom)。

2. 內存

想要了解內存泄露,對內存的瞭解必不可少。
JAVA是在JVM所虛擬出的內存環境中運行的,JVM的內存可分爲三個區:堆(heap)、棧(stack)和方法區(method)。

  • 棧(stack):是簡單的數據結構,但在計算機中使用廣泛。棧最顯著的特徵是:LIFO(Last In, First Out, 後進先出)。比如我們往箱子裏面放衣服,先放入的在最下方,只有拿出後來放入的才能拿到下方的衣服。棧中只存放基本類型和對象的引用(不是對象)
  • 堆(heap)堆內存用於存放由new創建的對象和數組。在堆中分配的內存,由java虛擬機自動垃圾回收器來管理。JVM只有一個堆區(heap)被所有線程共享,堆中不存放基本類型和對象引用,只存放對象本身
  • 方法區(method):又叫靜態區,跟堆一樣,被所有的線程共享。方法區包含所有的class和static變量

內存的概念大概理解清楚後,要考慮的問題來了:
到底是哪裏的內存會讓我們造成內存泄露?

3. 內存泄露原因分析

在JAVA中JVM的棧記錄了方法的調用,每個線程擁有一個棧。在線程的運行過程當中,執行到一個新的方法調用,就在棧中增加一個內存單元,即幀(frame)。在frame中,保存有該方法調用的參數、局部變量和返回地址。然而JAVA中的局部變量只能是基本類型變量(int),或者對象的引用。所以在棧中只存放基本類型變量和對象的引用。引用的對象保存在堆中。

當某方法運行結束時,該方法對應的frame將會從棧中刪除,frame中所有局部變量和參數所佔有的空間也隨之釋放。線程回到原方法繼續執行,當所有的棧都清空的時候,程序也就隨之運行結束。

而對於堆內存,堆存放着普通變量。在JAVA中堆內存不會隨着方法的結束而清空,所以在方法中定義了局部變量,在方法結束後變量依然存活在堆中。

綜上所述,棧(stack)可以自行清除不用的內存空間。但是如果我們不停的創建新對象,堆(heap)的內存空間就會被消耗盡。所以JAVA引入了垃圾回收(garbage collection,簡稱GC)去處理堆內存的回收,但如果對象一直被引用無法被回收,造成內存的浪費,無法再被使用。所以對象無法被GC回收就是造成內存泄露的原因!

4. 垃圾回收機制

垃圾回收(garbage collection,簡稱GC)可以自動清空堆中不再使用的對象。在JAVA中對象是通過引用使用的。如果再沒有引用指向該對象,那麼該對象就無從處理或調用該對象,這樣的對象稱爲不可到達(unreachable)。垃圾回收用於釋放不可到達的對象所佔據的內存。

實現思想:我們將棧定義爲root,遍歷棧中所有的對象的引用,再遍歷一遍堆中的對象。因爲棧中的對象的引用執行完畢就刪除,所以我們就可以通過棧中的對象的引用,查找到堆中沒有被指向的對象,這些對象即爲不可到達對象,對其進行垃圾回收。

垃圾回收實現思想

如果持有對象的強引用,垃圾回收器是無法在內存中回收這個對象。

5. 引用類型

在JDK 1.2以前的版本中,若一個對象不被任何變量引用,那麼程序就無法再使用這個對象。也就是說,只有對象處於可觸及(reachable)狀態,程序才能使用它。從JDK 1.2版本開始,把對象的引用分爲4種級別,從而使程序能更加靈活地控制對象的生命週期。這4種級別由高到低依次爲:強引用、軟引用、弱引用和虛引用。

1. 強引用(Strong reference)
實際編碼中最常見的一種引用類型。

常見形式如:

A a = new A();

強引用本身存儲在棧內存中,其存儲指向對內存中對象的地址。一般情況下,當對內存中的對象不再有任何強引用指向它時,垃圾回收機器開始考慮可能要對此內存進行的垃圾回收。

如當進行編碼:a = null,此時,剛剛在堆中分配地址並新建的a對象沒有其他的任何引用,當系統進行垃圾回收時,堆內存將被垃圾回收。

2. 軟引用(Soft Reference)
軟引用的一般使用形式如下:

A a = new A();

SoftReference srA = new SoftReference(a);

軟引用所指示的對象進行垃圾回收需要滿足如下兩個條件:

  1. 當其指示的對象沒有任何強引用對象指向它;
  2. 當虛擬機內存不足時。

因此,SoftReference變相的延長了其指示對象佔據堆內存的時間,直到虛擬機內存不足時垃圾回收器纔回收此堆內存空間。

3. 弱引用(Weak Reference)
同樣的,軟引用的一般使用形式如下:

A a = new A();
WeakReference wrA = new WeakReference(a);

WeakReference不改變原有強引用對象的垃圾回收時機,一旦其指示對象沒有任何強引用對象時,此對象即進入正常的垃圾回收流程。

4. 虛引用(Phantom Reference)
與SoftReference或WeakReference相比,PhantomReference主要差別體現在如下幾點:
1.PhantomReference只有一個構造函數

PhantomReference(T referent, ReferenceQueue<? super T> q)

2.不管有無強引用指向PhantomReference的指示對象,PhantomReference的get()方法返回結果都是null。

因此,PhantomReference使用必須結合ReferenceQueue;
與WeakReference相同,PhantomReference並不會改變其指示對象的垃圾回收時機。

6. 內存泄露原因

如果持有對象的強引用,垃圾回收器是無法在內存中回收這個對象。

內存泄露的真因是:持有對象的強引用,且沒有及時釋放,進而造成內存單元一直被佔用,浪費空間,甚至可能造成內存溢出!

6.1 一般Java程序中內存泄漏場景

  1. 靜態集合類引起內存泄露
    HashMapVector等的使用最容易出現內存泄露,這些靜態變量的生命週期和應用程序一致,他們所引用的所有的對象Object也不能被釋放,因爲他們也將一直被Vector等引用着。

    Static Vector v = new Vector(10);
    for (int i = 1; i<100; i++)
    {
    Object o = new Object();
    v.add(o);
    o = null;
    }
    

    在這個例子中,循環申請Object 對象,並將所申請的對象放入一個Vector 中,如果僅僅釋放引用本身(o=null),那麼Vector 仍然引用該對象,所以這個對象對GC 來說是不可回收的。因此,如果對象加入到Vector 後,還必須從Vector 中刪除,最簡單的方法就是將Vector對象設置爲null。

  2. 集合中對象被修改後remove:
    當集合裏面的對象屬性被修改後,再調用remove()方法時不起作用

    public static void main(String[] args)
    {
    Set<Person> set = new HashSet<Person>();
    Person p1 = new Person("唐僧","pwd1",25);
    Person p2 = new Person("孫悟空","pwd2",26);
    Person p3 = new Person("豬八戒","pwd3",27);
    set.add(p1);
    set.add(p2);
    set.add(p3);
    System.out.println("總共有:"+set.size()+" 個元素!"); //結果:總共有:3 個元素!
    p3.setAge(2); //修改p3的年齡,此時p3元素對應的hashcode值發生改變
    
    set.remove(p3); //此時remove不掉,造成內存泄漏
    
    set.add(p3); //重新添加,居然添加成功
    System.out.println("總共有:"+set.size()+" 個元素!"); //結果:總共有:4 個元素!
    for (Person person : set)
    {
    System.out.println(person);
    }
    }
    
  3. 監聽器:
    在java 編程中,我們都需要和監聽器打交道,通常一個應用當中會用到很多監聽器,我們會調用一個控件的諸如addXXXListener()等方法來增加監聽器,但往往在釋放對象的時候卻沒有記住去刪除這些監聽器,從而增加了內存泄漏的機會。

  4. 各種連接:
    比如數據庫連接(dataSourse.getConnection()),網絡連接(socket)和io連接,除非其顯式的調用了其close()方法將其連接關閉,否則是不會自動被GC 回收的。對於Resultset 和Statement 對象可以不進行顯式回收,但Connection 一定要顯式回收,因爲Connection 在任何時候都無法自動回收,而Connection一旦回收,Resultset 和Statement 對象就會立即爲NULL。但是如果使用連接池,情況就不一樣了,除了要顯式地關閉連接,還必須顯式地關閉Resultset Statement 對象(關閉其中一個,另外一個也會關閉),否則就會造成大量的Statement 對象無法釋放,從而引起內存泄漏。這種情況下一般都會在try裏面去的連接,在finally裏面釋放連接。

  5. 單例模式:

    如果單例對象持有外部對象的引用,那麼這個外部對象將不能被jvm正常回收,導致內存泄露。

    不正確使用單例模式是引起內存泄露的一個常見問題,單例對象在被初始化後將在JVM的整個生命週期中存在(以靜態變量的方式),如果單例對象持有外部對象的引用,那麼這個外部對象將不能被jvm正常回收,導致內存泄露,考慮下面的例子:

    class A{
    public A(){
    B.getInstance().setA(this);
    }
    ....
    }
    //B類採用單例模式
    class B{
    private A a;
    private static B instance=new B();
    public B(){}
    public static B getInstance(){
    return instance;
    }
    public void setA(A a){
    this.a=a;
    }
    //getter...
    }
    

    顯然B採用singleton模式,它持有一個A對象的引用,而這個A類的對象將不能被回收。想象下如果A是個比較複雜的對象或者集合類型會發生什麼情況

  6. 靜態資源引用

6.2 Android中會造成內存泄露的情景:

  • 全局進程(process-global)的static變量。這個無視應用的狀態,持有Activity的強引用的怪物。
  • 活在Activity生命週期之外的線程。沒有清空對Activity的強引用。

檢查一下項目中是否有以下幾種情況:

  • Static Activities
  • Static Views
  • Inner Classes
  • Anonymous Classes
  • Handler
  • Threads
  • TimerTask
  • Sensor Manager

推薦一個可檢測app內存泄露的項目:LeakCanary(可以檢測app的內存泄露)

Ref

  1. Java/Android引用類型及其使用分析
  2. Android內存泄漏的八種可能
  3. https://blog.csdn.net/asd136912/article/details/89415884
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章