JVM--垃圾回收GC篇


Java 自動內存管理最核心的功能是  內存中對象的分配與回收。

Java 堆是垃圾收集器管理的主要區域,因此也被稱作GC 堆(Garbage Collected Heap).從垃圾回收的角度,由於現在收集器基本都采用分代垃圾收集算法,所以 Java 堆還可以細分為:新生代和老年代:再細致一點有:Eden 空間、From Survivor、To Survivor 空間等。進一步划分的目的是更好地回收內存,或者更快地分配內存。

堆空間的基本結構:

 

 

 

 

 

 

1.上圖所示的 eden 區、s0("From") 區、s1("To") 區都屬於新生代,tentired 區屬於老年代。大部分情況,對象都會首先在 Eden 區域分配。

2.在一次新生代垃圾回收后,如果對象還存活,則會進入 s1("To"),並且對象的年齡還會加 1(Eden 區->Survivor 區后對象的初始年齡變為 1),當它的年齡增加到一定程度(默認為 15 歲),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 來設置。

3.經過這次GC后,Eden區和"From"區已經被清空。這個瞬間,"From"和"To"會交換他們的角色,也就是新的"To"就是上次GC前的“From”,新的"From"就是上次GC前的"To"。不管怎樣,都會保證名為To的Survivor區域是空的。Minor GC會一直重復這樣的過程,直到“To”區被填滿,"To"區被填滿之后,會將所有對象移動到老年代中。

 

 

 

對象優先在 eden 區分配

目前主流的垃圾收集器都會采用分代回收算法,因此需要將堆內存分為新生代和老年代,這樣我們就可以根據各個年代的特點選擇合適的垃圾收集算法。

大多數情況下,對象在新生代中 eden 區分配。當 eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC.下面我們來進行實際測試以下。

在測試之前我們先來看看 Minor GC 和 Full GC 有什么不同呢?

  • 新生代 GC(Minor GC):指發生新生代的的垃圾收集動作,Minor GC 非常頻繁,回收速度一般也比較快。
  • 老年代 GC(Major GC/Full GC):指發生在老年代的 GC,出現了 Major GC 經常會伴隨至少一次的 Minor GC(並非絕對),Major GC 的速度一般會比 Minor GC 的慢 10 倍以上。

補充:個人在網上查閱相關資料的時候發現如題所說的觀點。有的文章說 Full GC與Major GC一樣是屬於對老年代的GC,也有的文章說 Full GC 是對整個堆區的GC,所以這點需要各位同學自行分辨Full GC語義。見: 知乎討論

測試代碼:

public class GCTest {

    public static void main(String[] args) {
        byte[] allocation1, allocation2;
        allocation1 = new byte[30900*1024];
        //allocation2 = new byte[900*1024];
    }
}

通過以下方式運行:

 

添加的參數:-XX:+PrintGCDetails

 

 運行結果: (紅色字體描述有誤,應該是對應於 JDK1.7 的永久代):

 

 從上圖我們可以看出 eden 區內存幾乎已經被分配完全(即使程序什么也不做,新生代也會使用 2000 多 k 內存)。假如我們再為 allocation2 分配內存會出現什么情況呢?

allocation2 = new byte[900*1024];

 

分配擔保機制

簡單解釋一下為什么會出現這種情況: 因為給 allocation2 分配內存的時候 eden 區內存幾乎已經被分配完了,我們剛剛講了當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC.GC 期間虛擬機又發現 allocation1 無法存入 Survivor 空間,所以只好通過 分配擔保機制:把新生代的對象提前轉移到老年代中去,老年代上的空間足夠存放 allocation1,所以不會出現 Full GC。執行 Minor GC 后,后面分配的對象如果能夠存在 eden 區的話,還是會在 eden 區分配內存。可以執行如下代碼驗證:

public class GCTest {

    public static void main(String[] args) {
        byte[] allocation1, allocation2,allocation3,allocation4,allocation5;
        allocation1 = new byte[32000*1024];
        allocation2 = new byte[1000*1024];
        allocation3 = new byte[1000*1024];
        allocation4 = new byte[1000*1024];
        allocation5 = new byte[1000*1024];
    }
}

大對象直接進入老年代

大對象就是需要大量連續內存空間的對象(比如:字符串、數組)。

為什么要這樣呢?

為了避免為大對象分配內存時由於分配擔保機制帶來的復制而降低效率。

 

長期存活的對象將進入老年代

既然虛擬機采用了分代收集的思想來管理內存,那么內存回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。為了做到這一點,虛擬機給每個對象一個對象年齡(Age)計數器。

如果對象在 Eden 出生並經過第一次 Minor GC 后仍然能夠存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並將對象年齡設為 1.對象在 Survivor 中每熬過一次 MinorGC,年齡就增加 1 歲,當它的年齡增加到一定程度(默認為 15 歲),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 來設置。

動態對象年齡判定修正

大部分情況,對象都會首先在 Eden 區域分配,在一次新生代垃圾回收后,如果對象還存活,則會進入 s0 或者 s1,並且對象的年齡還會加 1(Eden 區->Survivor 區后對象的初始年齡變為 1),當它的年齡增加到一定程度(默認為 15 歲),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 來設置。

網友修正:

Hotspot遍歷所有對象時,按照年齡從小到大對其所占用的大小進行累積,當累積的某個年齡大小超過了survivor區的一半時,取這個年齡和MaxTenuringThreshold中更小的一個值,作為新的晉升年齡閾值”。

動態年齡計算的代碼如下:

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
    //survivor_capacity是survivor空間的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
 total += sizes[age];//sizes數組是每個年齡段對象大小
 if (total > desired_survivor_size) break;
 age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
    ...
}

關於默認的晉升年齡是15,這個說法的來源大部分都是《深入理解Java虛擬機》這本書。 如果你去Oracle的官網閱讀相關的虛擬機參數,你會發現-XX:MaxTenuringThreshold=threshold這里有個說明

Sets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.

默認晉升年齡並不都是15,這個是要區分垃圾收集器的,CMS就是6.

判斷對象已經死亡

堆中幾乎放着所有的對象實例,對堆垃圾回收前的第一步就是要判斷那些對象已經死亡(即不能再被任何途徑使用的對象)。

 

 

引用計數法

給對象中添加一個引用計數器,每當有一個地方引用它,計數器就加 1;當引用失效,計數器就減 1;任何時候計數器為 0 的對象就是不可能再被使用的。

這個方法實現簡單,效率高,但是目前主流的虛擬機中並沒有選擇這個算法來管理內存,其最主要的原因是它很難解決對象之間相互循環引用的問題。 所謂對象之間的相互引用問題,如下面代碼所示:除了對象 objA 和 objB 相互引用着對方之外,這兩個對象之間再無任何引用。但是他們因為互相引用對方,導致它們的引用計數器都不為 0,於是引用計數算法無法通知 GC 回收器回收他們。

public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;

    }
}

 

可達性分析算法

這個算法的基本思想就是通過一系列的稱為 “GC Roots” 的對象作為起點,從這些節點開始向下搜索,節點所走過的路徑稱為引用鏈,當一個對象到 GC Roots 沒有任何引用鏈相連的話,則證明此對象是不可用的。

 

 

再談引用

無論是通過引用計數法判斷對象引用數量,還是通過可達性分析法判斷對象的引用鏈是否可達,判定對象的存活都與“引用”有關。

JDK1.2 之前,Java 中引用的定義很傳統:如果 reference 類型的數據存儲的數值代表的是另一塊內存的起始地址,就稱這塊內存代表一個引用。

JDK1.2 以后,Java 對引用的概念進行了擴充,將引用分為強引用、軟引用、弱引用、虛引用四種(引用強度逐漸減弱)

1.強引用(StrongReference)

以前我們使用的大部分引用實際上都是強引用,這是使用最普遍的引用。如果一個對象具有強引用,那就類似於必不可少的生活用品,垃圾回收器絕不會回收它。當內存空間不足,Java 虛擬機寧願拋出 OutOfMemoryError 錯誤,使程序異常終止,也不會靠隨意回收具有強引用的對象來解決內存不足問題。

2.軟引用(SoftReference)

如果一個對象只具有軟引用,那就類似於可有可無的生活用品。如果內存空間足夠,垃圾回收器就不會回收它,如果內存空間不足了,就會回收這些對象的內存。只要垃圾回收器沒有回收它,該對象就可以被程序使用。軟引用可用來實現內存敏感的高速緩存。

軟引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果軟引用所引用的對象被垃圾回收,JAVA 虛擬機就會把這個軟引用加入到與之關聯的引用隊列中。

3.弱引用(WeakReference)

如果一個對象只具有弱引用,那就類似於可有可無的生活用品。弱引用與軟引用的區別在於:只具有弱引用的對象擁有更短暫的生命周期。在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。不過,由於垃圾回收器是一個優先級很低的線程, 因此不一定會很快發現那些只具有弱引用的對象。

弱引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果弱引用所引用的對象被垃圾回收,Java 虛擬機就會把這個弱引用加入到與之關聯的引用隊列中。

4.虛引用(PhantomReference)

"虛引用"顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定對象的生命周期。如果一個對象僅持有虛引用,那么它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。

虛引用主要用來跟蹤對象被垃圾回收的活動。

虛引用與軟引用和弱引用的一個區別在於: 虛引用必須和引用隊列(ReferenceQueue)聯合使用。當垃圾回收器准備回收一個對象時,如果發現它還有虛引用,就會在回收對象的內存之前,把這個虛引用加入到與之關聯的引用隊列中。程序可以通過判斷引用隊列中是否已經加入了虛引用,來了解被引用的對象是否將要被垃圾回收。程序如果發現某個虛引用已經被加入到引用隊列,那么就可以在所引用的對象的內存被回收之前采取必要的行動。

特別注意,在程序設計中一般很少使用弱引用與虛引用,使用軟引用的情況較多,這是因為軟引用可以加速 JVM 對垃圾內存的回收速度,可以維護系統的運行安全,防止內存溢出(OutOfMemory)等問題的產生。

不可達的對象並非“非死不可”

即使在可達性分析法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑階段”,要真正宣告一個對象死亡,至少要經歷兩次標記過程;可達性分析法中不可達的對象被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行 finalize 方法。當對象沒有覆蓋 finalize 方法,或 finalize 方法已經被虛擬機調用過時,虛擬機將這兩種情況視為沒有必要執行。

被判定為需要執行的對象將會被放在一個隊列中進行第二次標記,除非這個對象與引用鏈上的任何一個對象建立關聯,否則就會被真的回收。

如何判斷一個常量是廢棄常量

運行時常量池主要回收的是廢棄的常量。那么,我們如何判斷一個常量是廢棄常量呢?

假如在常量池中存在字符串 "abc",如果當前沒有任何 String 對象引用該字符串常量的話,就說明常量 "abc" 就是廢棄常量,如果這時發生內存回收的話而且有必要的話,"abc" 就會被系統清理出常量池。

注意: JDK1.7 及之后版本的 JVM 已經將運行時常量池從方法區中移了出來,在 Java 堆(Heap)中開辟了一塊區域存放運行時常量池。

如何判斷一個類是無用的類

方法區主要回收的是無用的類,那么如何判斷一個類是無用的類的呢?

判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面 3 個條件才能算是 “無用的類” :

  • 該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例。
  • 加載該類的 ClassLoader 已經被回收。
  • 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

虛擬機可以對滿足上述 3 個條件的無用類進行回收,這里說的僅僅是“可以”,而並不是和對象一樣不使用了就會必然被回收。

 

垃圾收集算法

標記-清除算法

該算法分為“標記”和“清除”階段:首先比較出所有需要回收的對象,在標記完成后統一回收掉所有被標記的對象。它是最基礎的收集算法,后續的算法都是對其不足進行改進得到。這種垃圾收集算法會帶來兩個明顯的問題:

1.效率問題

2.空間問題(標記清除后會產生大量不連續的碎片)

 

 

復制算法

為了解決效率問題,“復制”收集算法出現了。它可以將內存分為大小相同的兩塊,每次使用其中的一塊。當這一塊的內存使用完后,就將還存活的對象復制到另一塊去,然后再把使用的空間一次清理掉。這樣就使每次的內存回收都是對內存區間的一半進行回收。

 

 

 

標記-整理算法

根據老年代的特點提出的一種標記算法,標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象回收,而是讓所有存活的對象向一端移動,然后直接清理掉端邊界以外的內存。

 

 

分代收集算法

當前虛擬機的垃圾收集都采用分代收集算法,這種算法沒有什么新的思想,只是根據對象存活周期的不同將內存分為幾塊。一般將 java 堆分為新生代和老年代,這樣我們就可以根據各個年代的特點選擇合適的垃圾收集算法。

1.比如在新生代中,每次收集都會有大量對象死去,所以可以選擇復制算法,只需要付出少量對象的復制成本就可以完成每次垃圾收集。

2.而老年代的對象存活幾率是比較高的,而且沒有額外的空間對它進行分配擔保,所以我們必須選擇“標記-清除”或“標記-整理”算法進行垃圾收集。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM