深入理解Java虛擬機-第三章 垃圾收集器與內存分配策略(上)

第三章 垃圾收集器與內存分配策略(上)

3.1 概述

垃圾處理器實際上僅關注三個點:哪些內存需要回收、什麼時候回收以及如何回收。三點會在後面一一講解
前面第二章大致講述了 JVM 運行時區域的各個部分,我們會發現程序計數器、虛擬機棧、本地方法棧這三個區域基本上是隨着線程生而生,死而死。虛擬機棧中的棧幀在類結構確定下來的時候基本就可以知道分配多大的大小。所以這幾個區域的內存分配和回收基本是確定的。我們基本不用考慮這幾個區域的垃圾回收問題,因爲隨着線程結束或方法結束時,內存自然而然就釋放掉了。於是我們把目光主要聚集在 Java 堆和方法區這兩個區域。

3.2 對象已死嗎

3.2.1 引用計數算法

引用計數算法其實非常簡單,就是給對象添加一個引用計數器,只要對象被引用一次,他的計數器就加1,而當每個引用失效後,計數器減1。任何情況下只要計數器爲0,這個對象就沒有人使用了,即可回收。這種算法固然簡單有效,但還是產生了問題。請看如下代碼:

/**
 * 互相引用後,計數器都不爲零
 */
public class ReferencceCountingGC{
	public Object instance = null;
	
	private static final int _1MB = 1024*1024;
	
	/**
	 * 這個成員屬性的唯一意義就是佔些內存,以便能在GC日誌中看清楚是否被回收過
	 */
	private byte[] bigSize = new byte[2 * _1MB];
	
	public static void main(String[] args){
		// 這時,一個新的對象(我們稱對象1)被創建出來並且被指向給rcgc1。
		// 所以對象1的計數器爲1
		ReferencceCountingGC rcgc1 = new ReferencceCountingGC();
		// 這時,又一個新的對象(我們稱對象2)被創建出來並且被指向給rcgc2。
		// 所以對象2的計數器也爲1
		ReferencceCountingGC rcgc2 = new ReferencceCountingGC();
		
		// 對象2又被指向給對象1的 instance 字段。
		// 所以對象2的計數器加1,當前計數器爲2
		rcgc1.instance = rcgc2;
		// 同理,對象1的計數器加1,當前計數器爲2
		rcgc2.instance = rcgc1;
		
		// 此時將 rcgc1 和 rcgc2 都釋放掉
		// 對象1 和 對象2 的計數器各減1 ,當前爲一
		rcgc1 = null;
		rcgc2 = null;
		// 理論上這兩個對象的計數器永遠不可能爲0了,所以永遠不會被回收
		System.gc()
	}
}

經過上述代碼論證,如果採用計數器算法,這種互相引用將永遠無法回收。但是當你加入參數後真正跑這個方法的時候你會發現,其實這兩個對象是被回收了的。爲什麼呢,其實很好解釋,因爲 JVM 判斷對象是否存活並不是採用的引用計數算法 😛 。

2.3.2 可達性分析算法

在主流的商業語言中(例如C++、Java),判斷對象是否存活都是靠可達性算法來實現的。所謂可達性算法,如圖所示,就是通過一系列被稱爲“GC Roots”的對象作爲起始點,然後向下搜索引用,搜索所走過的路被稱爲引用鏈。當一個對象不在任何一條引用鏈上的時候,這個對象被視爲不可達對象,即無用對象。就是說這個對象沒有任何一個人引用。代表他可以被回收了。這樣就解釋了2.3.1裏互相引用爲什麼可以被回收掉,雖然對象1和對象2的成員變量中還有彼此的引用,但是 GC Roots 所查找的引用鏈中找不到對這兩個對象的引用(因爲 rcgc1 和 rcgc2 兩個根引用都被置空),就像圖中的不可達對象,雖然還有引用但是他們到 GC Roots是不可達的,所以他們被判定爲可回收對象。
可達性分析算法判定對象是否可回收
在 Java 語言中,可作爲 GC Roots 的對象有以下幾種

  • 虛擬機棧(棧幀中的局部變量表)中引用的對象
  • 方法區裏類的靜態變量引用的對象
  • 方法區裏常量引用的對象
  • 本地方法棧中JNI(即一般說法的 Native 方法)引用的對象

3.2.3 再談引用

JDK 1.2 以前,Java 對引用的定義很傳統:

如果 reference 類型的數據中存儲的數值代表的是另一塊內存的起始地址,就成這塊內存代表着一個引用。

這種定義很純粹但很狹隘,我們無法描述一些雞肋的對象。就是說內存足夠我希望你保留,但是當內存不夠了你就給滾蛋。於是1.2版本後,JDK 針對引用在此細分爲四種。這裏我覺得書中的描述不太通俗易懂,於是引入網上一大神在某公衆號回覆的評論來再次解釋:

在 Java 語言中,除了基本數據類型外,其他的都是指向各類對象的對象引用;Java中根據其生命週期的長短,將引用分爲4類。

1 強引用
特點:我們平常典型編碼 Object obj = new Object() 中的 obj 就是強引用。通過關鍵字 new 創建的對象所關聯的引用就是強引用。 當 JVM 內存空間不足,JVM 寧願拋出 OutOfMemoryError 運行時錯誤(OOM),使程序異常終止,也不會靠隨意回收具有強引用的“存活”對象來解決內存不足的問題。對於一個普通的對象,如果沒有其他的引用關係,只要超過了引用的作用域或者顯式地將相應(強)引用賦值爲 null,就是可以被垃圾收集的了,具體回收時機還是要看垃圾收集策略。

2 軟引用
特點:軟引用通過SoftReference類實現。 軟引用的生命週期比強引用短一些。只有當 JVM 認爲內存不足時,纔會去試圖回收軟引用指向的對象:即JVM 會確保在拋出 OutOfMemoryError 之前,清理軟引用指向的對象。軟引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果軟引用所引用的對象被垃圾回收器回收,Java虛擬機就會把這個軟引用加入到與之關聯的引用隊列中。後續,我們可以調用 ReferenceQueue 的 poll() 方法來檢查是否有它所關心的對象被回收。如果隊列爲空,將返回一個 null ,否則該方法返回隊列中前面的一個 Reference 對象。
應用場景:軟引用通常用來實現內存敏感的緩存。如果還有空閒內存,就可以暫時保留緩存,當內存不足時清理掉,這樣就保證了使用緩存的同時,不會耗盡內存。

3 弱引用
特點:弱引用通過 WeakReference 類實現。 弱引用的生命週期比軟引用短。在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現了具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。由於垃圾回收器是一個優先級很低的線程,因此不一定會很快回收弱引用的對象。弱引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果弱引用所引用的對象被垃圾回收,Java虛擬機就會把這個弱引用加入到與之關聯的引用隊列中。
應用場景:弱應用同樣可用於內存敏感的緩存。

4 虛引用
特點:虛引用也叫幻象引用,通過 PhantomReference 類來實現。無法通過虛引用訪問對象的任何屬性或函數。幻象引用僅僅是提供了一種確保對象被 finalize 以後,做某些事情的機制。如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收器回收。虛引用必須和引用隊列 (ReferenceQueue)聯合使用。當垃圾回收器準備回收一個對象時,如果發現它還有虛引用,就會在回收對象的內存之前,把這個虛引用加入到與之關聯的引用隊列中。
ReferenceQueue queue = new ReferenceQueue();
PhantomReference pr = new PhantomReference(object, queue);
程序可以通過判斷引用隊列中是否已經加入了虛引用,來了解被引用的對象是否將要被垃圾回收。如果程序發現某個虛引用已經被加入到引用隊列,那麼就可以在所引用的對象的內存被回收之前採取一些程序行動。
應用場景:可用來跟蹤對象被垃圾回收器回收的活動,當一個虛引用關聯的對象被垃圾收集器回收之前會收到一條系統通知。

3.2.4 生存還是死亡(對象的自我救贖)

其實即使可達性分析算法分析出的不可達對象也不一定是“非死不可”的。爲何這麼說,其實系統在執行垃圾回收時,會進行兩遍標記。先是執行可達性分析算法,發現不可達對象時,進行一次標記。然後系統會去篩選一遍,將需要執行 finalize() 方法的實例篩選出來。除了系統已經執行過 finalize() 方法和沒有重寫過 finalize() 方法的實例,剩下的就是需要執行的。
如果這個對象需要執行 finalize() 方法,則會被放入一個叫 F-Queue 的隊列當中,並且在稍後由一個虛擬機創建的低優先級的 Finalizer 線程去挨個執行 finalize() 方法。這裏的執行僅僅承諾執行,但是不承諾等待返回。因爲這裏如果出現死循環、死鎖等事故的話,會導致隊列剩下的無法執行。
如果對象想要“自我救贖”的話,只需要在 finalize() 方法中重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this)賦值給某個類變量或對象的成員變量,那麼第二次標記時它將被移除出“即將回收”的集合。舉例如下:

public class FinalizeEscapeGC {
	public static FinalizeEscapeGC fegc = null;

	@Override
	protected void finalize() throws Throwable {
		super.finalize();
		System.out.println("finalize method executed");	
		FinalizeEscapeGC.fegc = this;
	}

	public static void main(String[] args) throws Throwable {
        // 此時生成對象1
        fegc = new FinalizeEscapeGC();
        System.out.println("fegc's address is " + fegc.toString());
        // 對象1從引用鏈上脫離
        fegc = null;
        // 告訴系統進行垃圾回收
        System.gc();
        // 因爲 finalize 方法的優先級很低,所以睡500毫秒等待
        Thread.sleep(500);
        if (null != fegc) {
            System.out.println("I'm alive");
            // 此處打印地址,
            System.out.println("after gc, fegc's address is " + fegc.toString());
        } else {
            System.out.println("I'm dead");
        }

        // 對象1從引用鏈上脫離
        fegc = null;
        // 告訴系統進行垃圾回收
        System.gc();
        // 因爲 finalize 方法的優先級很低,所以睡500毫秒等待
        Thread.sleep(500);
        if (null != fegc) {
            System.out.println("I'm alive");
            // 此處打印地址,
            System.out.println("after gc, fegc's address is " + fegc.toString());
        } else {
            System.out.println("I'm dead");
        }

    }

}

執行結果如下:

fegc's address is com.simon.jvm.FinalizeEscapeGC@66d3c617
finalize method executed
I'm alive
after gc, fegc's address is com.simon.jvm.FinalizeEscapeGC@66d3c617
I'm dead

通過打印我們可以看出,第一次打印的對象和 GC 後的對象是同一個。也就是說,這個對象並沒有被回收掉。說明了我們的邏輯是正確的,但是同樣的代碼第二次爲什麼又被回收掉了?還記得我們的前提條件嗎?
除了系統已經執行過 finalize() 方法和沒有重寫過 finalize() 方法的實例,剩下的就是需要執行的
明白了,第二次再進行二次標記的時候,它就屬於系統執行過 finalize() 方法這一批了,所以不會再執行一遍。於是就被回收掉了。也就是說,任何一個對象的 finalize() 方法都只會被系統自動調用一次,如果對象面臨下一次回收,他的 finalize() 方法是不會被再次執行的。

3.2.5 回收方法區

JVM針對方法區的回收,要比針對堆的回收有更嚴格的檢查機制和篩選條件。方法區的垃圾收集主要回收的是廢棄常量及無用類。回收廢棄常量比較好理解,他跟回收 Java 堆的實例對象非常相似。例如,有一個字符串 “ABC” 進入了常量池,但是當前系統沒有任何一個 String 對象爲 “ABC” 的,也就是說這個字符串沒有任何引用,就需要被回收。常量池中的其他符號引用量也與字面量類似。
但是判斷一個類是否是無用類就比較麻煩,需要滿足以下三個條件

  • 該類的所有實例都已被回收,也就是 Java 堆中不存在任何這個類的實例。
  • 加載該類的所有ClassLoader 都已經被回收
  • 該類對應的 java.lang.Class 對象沒有在任何地方被引用,也就是說無法在任何地方通過反射訪問該類的方法
    虛擬機每次回收無用類前都需要預檢查上面三個條件,只有三個條件都通過了才允許回收,最終是否回收則由相應參數控制。

3.3 垃圾收集算法

3.3.1 標記 - 清除算法

標記 - 清除(Mark-Sweep)算法是一個最基礎的收集算法,後續的算法多多少少是基於這個算法的思路來進行的。
就像他的名字,這種算法的過程就是先標記,再清除。先通過前面講的可達性算法,標記處不可達對象,然後再一次性回收。清除效果如下:
標記 - 清除算法
缺點有如下兩點:

  • 效率問題:標記和清除兩個動作理論上的效率其實並不高,所以算法性能比較低
  • 空間問題:標記和清除後,就會留有大量的碎片空間。這樣分配對象的時候就不得不採用空閒列表方式,但是會有個明顯的問題,就是大對象如果沒有足夠的碎片空間去分配的話,就需要再次執行垃圾回收直到有爲止。這樣明顯會提升垃圾回收的次數,性能問題也就不言而喻。

3.3.2 複製算法

複製算法講的就是將可用內存化爲容量大小相等的兩部分,每次只用其中的一部分,當這一塊的內存用完了,就將存活的對象複製到另一塊上,然後本塊內存整個釋放(如圖)。這樣使得每次垃圾回收是整塊回收,釋放的空間是規整的連續的,可以用指針碰撞法直接分配內存空間。不僅提高了垃圾回收的效率,也提高了分配空間的速度。
複製算法
目前的商業虛擬機都採用這種收集算法來回收新生代,但是這樣一次只能用一半的空間,未免太浪費,也並不是很合適。經過IBM公司的專門研究表明,新生代中的對象98%都是“朝生夕死”的,所以並不需要按照1:1劃分。HotSpot從一開始就設計出 Eden 區、From Survivor 區和 To Survivor 區。並以8:1:1的方式劃分。每次使用時就使用 Eden 區加其中一個 Survivor 區。這樣整個的可用區就是90%,只有10%的內存會被“浪費”。當然我們沒辦法保證每次回收都只有不多於10%的對象,如果有大對象就需要依賴其他內存,也就是老年代。我們管這個叫分配擔保。
原書中對於分配擔保描述的形象生動,記錄在此:

內存的分配擔保就好比我們去銀行借款,如果我們信譽很好,在98%的情況下都能按時償還,於是銀行可能會默認我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認爲沒有風險了。內存的分配擔保也一樣,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代。

3.3.3 標記 - 整理算法

因爲複製算法在對象存活率較高的情況下就要進行很多的複製操作(從 一個 S 區到另一個 S 區),這引起了效率問題。並且複製算法需要分配擔保,年輕代可以讓老年代擔保,但老年代就不可能再擔保給別人不然就反反覆覆無窮盡也了。這時就推出了標記 - 整理算法(Mark - Compact),與標記 - 清理算法相似的是,他們都會去標記不可達對象,但是不同的是標記 - 整理算法標記後不進行清除操作,而是將所有存活的對象都向一段移動,最後直接清理掉端邊界意外的內存(如圖)。
標記 - 整理算法

3.3.4 分代收集算法

這種算法說白了就是一種分類收集的思想。就是根據對象存貨週期的不同將內存劃分爲幾塊。像生存週期比較短的,就劃分成年輕代使用複製算法,只需要付出少量存活對象的複製成本就可以完成收集。而老年代這種對象存活率較高、沒有額外空間對它進行分配擔保的,就需要用標記 - 整理或標記 - 清除算法了。

3.4 HotSpot 的算法實現

3.4.1 枚舉根節點

所謂枚舉根節點,實際上就是前文提到的可達性分析中的 GC Roots。我們要一個不拉的將所有的 GC Roots 都找出來,但是找這個的過程中一定會有引用的變更。所以爲保準確性,我們引入 STW(Stop The World) 概念,就是所有線程都要停止下來等待我檢測完(類似媽媽打掃衛生時要你不要動一樣)。這樣一定會造成用戶的卡頓,所以我們一定要減少 STW 的時間和次數。但是有的時候僅僅方法區就幾百兆,這怎麼縮短時間呢。此處引用原文一段話來解釋如何不用遍歷所有的地方就能知道哪裏有引用

目前主流的Java虛擬機都採用的是準確式GC,當執行系統停頓下來後,我們不需要一個不漏地檢查完所有執行上下文和全局的引用位置。在HotSpot的實現中,是使用一組稱爲OOPMap的數據結構來達到這個目的的,首先在類加載完成的時候,HotSpot就把對象內什麼偏移量上是什麼類型的數據計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣,GC在掃描的時候,就可以根據OOPMap上記錄的信息準確定位到哪個區域中有對象的引用,這樣大大減少了通過逐個遍歷來找出對象引用的時間消耗。

其實這裏即使引用原文的話,我的印象也是比較模糊的(這裏理解的請直接看3.4.2。。)。我迷惑的點是在這個 OopMap 到底是存了個什麼東西。或者說到底一個實例對象是對應着一個 OOPMap 還是一組 OopMap 。在網上查了許多資料,我又翻來覆去讀這一段話,豁然開朗。不過可能有些不太準確歡迎指正。
我們知道,GC 枚舉根節點的時候,主要針對的目標是實例,看哪些地方正在使用這些實例(也就是說找這些實例的引用)。那麼這些實例的引用存在哪呢——方法區和虛擬機棧棧幀中的局部變量表裏,也就是說我找到了這些引用 相應的實例我就找到了,所以引入 OopMap 這個東西。
有兩個地方會記錄或叫更新 OopMap ,原文說首先在類加載完成的時候,HotSpot就把對象內什麼偏移量上是什麼類型的數據計算出來,後半句應該就是並放入 OopMap,也就是說 OopMap 是在類加載完成的時候就會記錄一份,但是記錄了什麼呢,原文說的對象我理解實際上是應該是類對象而不是類實例,也就是 java.lang.Class 對象。在加載類之後,方法區內就已經存了類信息,這時計算這個類的成員變量或靜態常量是什麼類型的並且存起來了。
第二個地方呢,原文是 在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這個特定的位置其實說的有些模糊,猛地看上去像是在描述在某個地方存儲着東西。他這裏想描述的這個“特定的位置”其實就是後文的安全點,代表的其實是代碼的行數。比如執行到return 這行代碼時,記錄下這條指令的時候,棧上和寄存器裏哪些位置是引用。
還是引入 RednaxelaFX 大神的話看上去比較清晰(這些其實是我得出結論後再查到的,與我的猜想大差不差。成就感爆棚哈哈哈):

在HotSpot中,對象的類型信息裏有記錄自己的OopMap,記錄了在該類型的對象內什麼偏移量上是什麼類型的數據。所以從對象開始向外的掃描可以是準確的;這些數據是在類加載過程中計算得到的。

每個被JIT編譯過後的方法也會在一些特定的位置記錄下OopMap,記錄了執行到該方法的某條指令的時候,棧上和寄存器裏哪些位置是引用。這樣GC在掃描棧的時候就會查詢這些OopMap就知道哪裏是引用了。這些特定的位置主要在:
1、循環的末尾
2、方法臨返回前 / 調用方法的call指令後
3、可能拋異常的位置
這種位置被稱爲“安全點”(safepoint)。
這樣通過遍歷這幾個 OopMap 即可得到現在究竟有多少個 GC Roots。
原文請戳這裏

3.4.2 安全點(SafePoint)

在 OopMap 的幫助下,HotSpot 可以準確快速的掃描到GC Roots。但是問題也很明顯,導致 OopMap 變更的指令也太多了,也不能執行一步就生成一個 OopMap 吧,這樣佔用的空間就太大了。於是就誕生了安全點這個概念,也就是上一小節提到的特定的位置。就是說,程序不是能在任何地方都可以停下來 GC 的,只有到達安全點纔可以。那麼安全點的選定就變得尤爲重要,少了,GC等的時間太長,多了又拖慢效率。本着“是否具有程序長時間執行的特徵”爲標準進行選定(句話是原文,咱也不知道不知道作者自己能不能看明白這話啥意思,反正我是看不懂),反正選定了幾個位置如下:

  • 循環的末尾
  • 方法臨返回前 / 調用方法的call指令後
  • 可能拋異常的位置

書中只提了JIT在編譯時會在記錄OopMap,那麼在解釋器中執行的方法和Native方法呢,引入R神的回覆作以記錄:

在解釋器中執行的方法則可以通過解釋器裏的功能自動生成出OopMap出來給GC用。

對Java線程中的JNI方法,它們既不是由JVM裏的解釋器執行的,也不是由JVM的JIT編譯器生成的,所以會缺少OopMap信息。那麼GC碰到這樣的棧幀該如何維持準確性呢?
HotSpot的解決方法是:所有經過JNI調用邊界(調用JNI方法傳入的參數、從JNI方法傳回的返回值)的引用都必須用“句柄”(handle)包裝起來。JNI需要調用Java API的時候也必須自己用句柄包裝指針。在這種實現中,JNI方法裏寫的“jobject”實際上不是直接指向對象的指針,而是先指向一個句柄,通過句柄才能間接訪問到對象。這樣在掃描到JNI方法的時候就不需要掃描它的棧幀了——只要掃描句柄表就可以得到所有從JNI方法能訪問到的GC堆裏的對象。
但這也就意味着調用JNI方法會有句柄的包裝/拆包裝的開銷,是導致JNI方法的調用比較慢的原因之一。

對於安全點,另一個需要考慮的問題就是如何在需要 GC 的時候,讓所有的線程都跑到最近的安全電上再停下來。這有搶先式中斷和主動式終端兩種方式供選擇:

  • 搶先式中斷:現將所有線程中斷,然後恢復沒到安全點的線程讓他繼續跑到安全點
  • 主動式終端:更新中斷標誌,所有線程輪詢這個標誌。一旦發現標誌更換,就自己中斷掛起。查詢標誌的地方跟安全點是重合的(還有創建對象分配內存的時候也要去查詢)。這樣就保證了中斷的時候一定是在安全點的。

3.4.3 安全區域

安全點看上去完美解決了如何進入GC的問題,但是如果這時線程被阻塞住了或者在sleep怎麼辦。理論上他是不會執行的,也就沒辦法進入所謂的安全點。那怎麼辦,GC 不能一直等着他吧。這是就引入了安全區(Safe Region)的概念。
安全區域是指在一段代碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始 GC 都是安全的。我們也可以吧 Safe Region 看作是被擴展了的 Safepoint。
當線程執行到安全區的時候,先標識下自己已經進入了安全區。這樣在 JVM 要發起 GC 的時候,就不用管這些進了安全區的線程了。當這些線程走出安全區的時候,要先檢查系統有沒有在 GC ,如果有的話,要等 GC 完畢之後再走出去。

3.5 垃圾收集器

如果說垃圾回收算法是內存回收的方法論,那麼垃圾收集器就是具體實現。僅 HotSpot 虛擬機就提供了 7 種垃圾收集器,具體分類如下圖所示。
垃圾收集器
上圖展示了 7 種作用於不同分代的收集器,如果兩個收集器之間有連線的話,說明他們可以搭配使用。世界上不存在哪個收集器最好哪個最差(就如同語言一樣,沒有最好。PHP除外),只是針對不同場景使用不同的收集器會有更好的效果。

3.5.1 Serial / Serial Old 收集器

Serial 收集器:這是一個最基本、發展歷史最悠久的收集器,曾經(在JDK 1.3.1之前)是虛擬機新生代收集的唯一選擇。這個收集器是一個單線程的收集器,這裏的“單線程”的意義並不僅僅說明它只會使用一個 CPU 或一條收集線程去完成GC工作,他還代表在垃圾收集時會“Stop The World”,即在用戶不可見的情況下把用戶正常工作的線程全部停掉。雖然好像老而舊,但是它仍然簡單而高效(畢竟專門來做GC,也不需要考慮併發等問題),在用戶的桌面場景中分配給虛擬機的內存一般來說比較小,停頓時間完全可以控制在幾十到一百毫秒之內。這個停頓是可以接受的,所以 Serial 收集器對於運行在 Client 模式下的虛擬機來說是一個很好的選擇。

Serial Old 收集器:這是 Serial 收集器的老年代版本,他同樣是一個單線程的收集器。使用的是上文提到過得 標記 - 整理算法。它的主要意義跟 Serial 一樣,都是用於 Client 模式下的虛擬機。但是 Old版本還有其餘兩種用途:

  1. 在 JDK 1.5 以及之前版本,跟 Parallel Scavenge 收集器搭配使用(雖然 PS 中有自己的 PS markSweep 收集器來進行老年代收集,但實現上跟 Serial Old 非常相似,此處放在一起)。
  2. 用於作爲 CMS 收集器的後備預案,在併發收集發生 Concurrent Mode Failure 時使用。

工作模式如下圖所示:
Serial / Serial Old 收集器運行示意圖
使用-XX:+UseSerialGC可以使用Serial+Serial Old模式運行進行內存回收(這也是虛擬機在Client模式下運行的默認值)

3.5.2 ParNew 收集器

ParNew 收集器說白了就是 Serial 收集器的“多線程”版,他的實現跟 Serial 收集器除了啓動多條線程收集垃圾意外並沒有很多創新的地方,但是他卻是普遍應用於 Server 端。爲啥呢,因爲 JDK 1.5 版本開發出的 CMS 收集器(HotSpot 虛擬機真正意義上第一款併發的垃圾收集器,他能做到讓垃圾收集線程和用戶線程(基本上)同步)只能配合 ParNew 或 Serial ( CMS 爲老年代收集器,需要搭配一個年輕代的收集器使用)。但是在現在這種多核 CPU 環境下,ParNew 的效率要明顯高於 Serial (單核他的效率要低於 Serial,甚至雙核的效率可能都比不過 Serial )。
工作流程如圖:
ParNew / Serial Old 收集器運行示意圖

注意:從ParNew收集器開始,後面還會接觸到幾款併發和並行的收集器。併發和並行,這兩個名詞都是併發編程中的概念,解釋如下:

  • 並行(Parallel):指多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態。
  • 併發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不一定是並行的,可能會交替執行),用戶程序在繼續運行,而垃圾收集程序運行於另一個CPU上

3.5.3 Parallel Scavenge / Parallel Old 收集器

Parallel Scavenge 收集器(下面簡稱PS)是一個新生代的垃圾收集器。同樣的,這也是一個並行的收集器,這跟 ParNew 有啥區別呢。區別還挺大的,他們之間的區別最主要是在於目標不同。 CMS 等收集器,都是爲減少 STW 而設計的,PS 不同,他是爲了提高系統吞吐量而生。所謂吞吐量就是 CPU 用於運行用戶代碼的時間與 CPU 總消耗時間的比值,即 吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)。
減少 STW 的時間是爲了提高用戶體驗,而提高吞吐量則是爲了高效率運用 CPU 。比如像 ElasticSearch / Hadoop 這種高運算量平臺,其實更在意的是儘快完成程序的運算任務。
Parallel Scavenge 收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數。
這裏要注意,MaxGCPauseMillis 這個參數並不一定是越短越好,因爲GC停頓時間縮短是以犧牲吞吐量和新生代空間換來的。原來10秒一次,一次100毫秒。現在5秒一次,一次70毫秒。看上去好像是持續時間短了,但是原來10秒鐘只停100毫秒,現在10秒要停140毫秒。
Parallel Old 收集器(下面簡稱 PO)是 PS 收集器的老年代版本。採用了多線程和標記 - 整理算法。
工作過程如圖:
Parallel Scavenge / Parallel Old 收集器工作流程

這章有點長,學了一天也纔剛學完幾個簡單的收集器。剩下兩個大收集器(CMS 、G1)和剩餘的零碎知識點放到下半部分。

本文僅是在自我學習 《深入理解Java虛擬機》這本書後進行的自我總結,有錯歡迎友善指正

歡迎友善交流,不喜勿噴~
Hope can help~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章