1. 程式人生 > >Java垃圾回收機制、GC

Java垃圾回收機制、GC

        說到垃圾回收(Garbage Collection,GC),很多人就會自然而然地把它和Java聯絡起來。在Java中,程式設計師不需要去關心記憶體動態分配和垃圾回收的問題,這一切都交給了JVM來處理。顧名思義,垃圾回收就是釋放垃圾佔用的空間

一、如何確定某個物件是“垃圾”?

        在這一小節我們先了解一個最基本的問題:如果確定某個物件是“垃圾”?既然垃圾收集器的任務是回收垃圾物件所佔的空間供新的物件使用,那麼垃圾收集器如何確定某個物件是“垃圾”?—即通過什麼方法判斷一個物件可以被回收了。

        在java中是通過引用來和物件進行關聯的,也就是說如果要操作物件,必須通過引用來進行。那麼很顯然一個簡單的辦法就是通過引用計數來判斷一個物件是否可以被回收。不失一般性,如果一個物件沒有任何引用與之關聯,則說明該物件基本不太可能在其他地方被使用到,那麼這個物件就成為可被回收的物件了。這種方式成為引用計數法。

        這種方式的特點是實現簡單,而且效率較高,但是它無法解決迴圈引用的問題,因此在Java中並沒有採用這種方式(Python採用的是引用計數法)。看下面這段程式碼:

public class Main {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
 
        object1.object = object2;
        object2.object = object1;
 
        object1 = null;
        object2 = null;
    }
}
 
class MyObject{
    public Object object = null;
}
        最後面兩句將object1和object2賦值為null,也就是說object1和object2指向的物件已經不可能再被訪問,但是由於它們互相引用對方,導致它們的引用計數都不為0,那麼垃圾收集器就永遠不會回收它們。

        為了解決這個問題,在Java中採取了 可達性分析法。該方法的基本思想是通過一系列的“GC Roots”物件作為起點進行搜尋,如果在“GC Roots”和一個物件之間沒有可達路徑,則稱該物件是不可達的,不過要注意的是被判定為不可達的物件不一定就會成為可回收物件。被判定為不可達的物件要成為可回收物件必須至少經歷兩次標記過程,如果在這兩次標記過程中仍然沒有逃脫成為可回收物件的可能性,則基本上就真的成為可回收物件了。

        至於可達性分析法具體是如何操作的我暫時也沒有看得很明白,如果有哪位朋友比較清楚的話請不吝指教。

下面來看個例子:

Object aobj = new Object ( ) ;
Object bobj = new Object ( ) ;
Object cobj = new Object ( ) ;
aobj = bobj;
aobj = cobj;
cobj = null;
aobj = null;
        第幾行有可能會使得某個物件成為可回收物件?第7行的程式碼會導致有物件會成為可回收物件。至於為什麼留給讀者自己思考。

再看一個例子:
String str = new String("hello");
SoftReference<String> sr = new SoftReference<String>(new String("java"));
WeakReference<String> wr = new WeakReference<String>(new String("world"));

這三句哪句會使得String物件成為可回收物件?第2句和第3句,第2句在記憶體不足的情況下會將String物件判定為可回收物件,第3句無論什麼情況下String物件都會被判定為可回收物件。

最後總結一下平常遇到的比較常見的將物件判定為可回收物件的情況:

  • 顯示地將某個引用賦值為null或者將已經指向某個物件的引用指向新的物件,比如下面的程式碼
Object obj = new Object();
obj = null;
Object obj1 = new Object();
Object obj2 = new Object();
obj1 = obj2;
  • 區域性引用所指向的物件,比如下面這段程式碼:
void fun() {
 
.....
    for(int i=0;i<10;i++) {
        Object obj = new Object();
        System.out.println(obj.getClass());
    }   
}

迴圈每執行完一次,生成的Object物件都會成為可回收的物件。

  • 只有弱引用與其關聯的物件,比如:
WeakReference<String> wr = new WeakReference<String>(new String("world"));

二、典型的垃圾收集演算法

        在確定了哪些垃圾可以被回收後,垃圾收集器要做的事情就是開始進行垃圾回收,但是這裡面涉及到一個問題是:如何高效地進行垃圾回收。由於Java虛擬機器規範並沒有對如何實現垃圾收集器做出明確的規定,因此各個廠商的虛擬機器可以採用不同的方式來實現垃圾收集器,所以在此只討論幾種常見的垃圾收集演算法的核心思想。

  • Mark-Sweep(標記-清除)演算法

這是最基礎的垃圾回收演算法,之所以說它是最基礎的是因為它最容易實現,思想也是最簡單的。標記-清除演算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的物件,清除階段就是回收被標記的物件所佔用的空間。具體過程如下圖所示:

  • 從圖中可以很容易看出標記-清除演算法實現起來比較容易,但是有一個比較嚴重的問題就是容易產生記憶體碎片,碎片太多可能會導致後續過程中需要為大物件分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作。Copying(複製)演算法

        為了解決Mark-Sweep演算法的缺陷,Copying演算法就被提了出來。它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用的記憶體空間一次清理掉,這樣一來就不容易出現記憶體碎片的問題。具體過程如下圖所示:

        這種演算法雖然實現簡單,執行高效且不容易產生記憶體碎片,但是卻對記憶體空間的使用做出了高昂的代價,因為能夠使用的記憶體縮減到原來的一半。

        很顯然,Copying演算法的效率跟存活物件的數目多少有很大的關係,如果存活物件很多,那麼Copying演算法的效率將會大大降低。

  • Mark-Compact(標記-整理)演算法

        為了解決Copying演算法的缺陷,充分利用記憶體空間,提出了Mark-Compact演算法。該演算法標記階段和Mark-Sweep一樣,但是在完成標記之後,它不是直接清理可回收物件,而是將存活物件都向一端移動,然後清理掉端邊界以外的記憶體。具體過程如下圖所示:

  • Generational Collection(分代收集)演算法

        分代收集演算法是目前大部分JVM的垃圾收集器採用的演算法。它的核心思想是根據物件存活的生命週期將記憶體劃分為若干個不同的區域。一般情況下將堆區劃分為老年代(Tenured Generation)和新生代(Young Generation),老年代的特點是每次垃圾收集時只有少量物件需要被回收,而新生代的特點是每次垃圾回收時都有大量的物件需要被回收,那麼就可以根據不同代的特點採取最適合的收集演算法。

        目前大部分垃圾收集器對於新生代都採取Copying演算法,因為新生代中每次垃圾回收都要回收大部分物件,也就是說需要複製的操作次數較少,但是實際中並不是按照1:1的比例來劃分新生代的空間的,一般來說是將新生代劃分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden空間和其中的一塊Survivor空間,當進行回收時,將Eden和Survivor中還存活的物件複製到另一塊Survivor空間中,然後清理掉Eden和剛才使用過的Survivor空間。

        而由於老年代的特點是每次回收都只回收少量物件,一般使用的是Mark-Compact演算法。

        注意,在堆區之外還有一個代就是永久代(Permanet Generation),它用來儲存class類、常量、方法描述等。對永久代的回收主要回收兩部分內容:廢棄常量和無用的類。

三、典型的垃圾收集器

        垃圾收集演算法是 記憶體回收的理論基礎,而垃圾收集器就是記憶體回收的具體實現。下面介紹一下HotSpot(JDK 7)虛擬機器提供的幾種垃圾收集器,使用者可以根據自己的需求組合出各個年代使用的收集器。

                    

  • Serial/Serial Old

        Serial/Serial Old收集器是最基本最古老的收集器,它是一個單執行緒收集器,並且在它進行垃圾收集時,必須暫停所有使用者執行緒。Serial收集器是針對新生代的收集器,採用的是Copying演算法,Serial Old收集器是針對老年代的收集器,採用的是Mark-Compact演算法。它的優點是實現簡單高效,但是缺點是會給使用者帶來停頓。

  • ParNew

        ParNew收集器是Serial收集器的多執行緒版本,使用多個執行緒進行垃圾收集。

  • Parallel Scavenge

        Parallel Scavenge收集器是一個新生代的多執行緒收集器(並行收集器),它在回收期間不需要暫停其他使用者執行緒,其採用的是Copying演算法,該收集器與前兩個收集器有所不同,它主要是為了達到一個可控的吞吐量。

  • Parallel Old

        Parallel Old是Parallel Scavenge收集器的老年代版本(並行收集器),使用多執行緒和Mark-Compact演算法。

  • CMS

        CMS(Current Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器,它是一種併發收集器,採用的是Mark-Sweep演算法。

  • G1

        G1收集器是當今收集器技術發展最前沿的成果,它是一款面向服務端應用的收集器,它能充分利用多CPU、多核環境。因此它是一款並行與併發收集器,並且它能建立可預測的停頓時間模型。

        下面補充一下關於記憶體分配方面的東西:

        物件的記憶體分配,往大方向上講就是在堆上分配,物件主要分配在新生代的Eden Space和From Space,少數情況下會直接分配在老年代。如果新生代的Eden Space和From Space的空間不足,則會發起一次GC,如果進行了GC之後,Eden Space和From Space能夠容納該物件就放在Eden Space和From Space。在GC的過程中,會將Eden Space和From  Space中的存活物件移動到To Space,然後將Eden Space和From Space進行清理。如果在清理的過程中,To Space無法足夠來儲存某個物件,就會將該物件移動到老年代中。在進行了GC之後,使用的便是Eden space和To Space了,下次GC時會將存活物件複製到From Space,如此反覆迴圈。當物件在Survivor區躲過一次GC的話,其物件年齡便會加1,預設情況下,如果物件年齡達到15歲,就會移動到老年代中。

        一般來說,大物件會被直接分配到老年代,所謂的大物件是指需要大量連續儲存空間的物件,最常見的一種大物件就是大陣列,比如:

byte[] data = new byte[4*1024*1024]

        這種一般會直接在老年代分配儲存空間。

        當然分配的規則並不是百分之百固定的,這要取決於當前使用的是哪種垃圾收集器組合和JVM的相關引數。


四、GC執行機制

        由於物件進行了分代處理,因此垃圾回收區域、時間也不一樣。GC有兩種型別:Scavenge GC和Full GC。

  • Scavenge GC

        一般情況下,當新物件生成,並且在Eden申請空間失敗時,就會觸發Scavenge GC,對Eden區域進行GC,清除非存活物件,並且把尚且存活的物件移動到Survivor區。然後整理Survivor的兩個區。這種方式的GC是對年輕代的Eden區進行,不會影響到年老代。因為大部分物件都是從Eden區開始的,同時Eden區不會分配的很大,所以Eden區的GC會頻繁進行。因而,一般在這裡需要使用速度快、效率高的演算法,使Eden去能儘快空閒出來。

  • Full GC

        對整個堆進行整理,包括Young、Tenured和Perm。Full GC因為需要對整個堆進行回收,所以比Scavenge GC要慢,因此應該儘可能減少Full GC的次數。在對JVM調優的過程中,很大一部分工作就是對於FullGC的調節。有如下原因可能導致Full GC:

  1. 年老代(Tenured)被寫滿
  2. 持久代(Perm)被寫滿
  3. System.gc()被顯示呼叫
  4. 上一次GC之後Heap的各域分配策略動態變化

五、Java有了GC同樣會出現記憶體洩露問題

1.靜態集合類像HashMap、Vector等的使用最容易出現記憶體洩露,這些靜態變數的生命週期和應用程式一致,所有的物件Object也不能被釋放,因為他們也將一直被Vector等應用著。

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

        在這個例子中,程式碼棧中存在Vector 物件的引用 v 和 Object 物件的引用 o 。在 For 迴圈中,我們不斷的生成新的物件,然後將其新增到 Vector 物件中,之後將 o 引用置空。問題是當 o 引用被置空後,如果發生 GC,我們建立的 Object 物件是否能夠被 GC 回收呢?答案是否定的。因為, GC 在跟蹤程式碼棧中的引用時,會發現 v 引用,而繼續往下跟蹤,就會發現 v 引用指向的記憶體空間中又存在指向 Object 物件的引用。也就是說盡管o 引用已經被置空,但是 Object 物件仍然存在其他的引用,是可以被訪問到的,所以 GC 無法將其釋放掉。如果在此迴圈之後, Object 物件對程式已經沒有任何作用,那麼我們就認為此 Java 程式發生了記憶體洩漏。

2.各種連線,資料庫連線,網路連線,IO連線等沒有顯示呼叫close關閉,不被GC回收導致記憶體洩露。

3.監聽器的使用,在釋放物件的同時沒有相應刪除監聽器的時候也可能導致記憶體洩露。