深入理解Java虛擬機(三)HotSpot算法和垃圾收集器

前面介紹了對象存活判定算法和垃圾收集算法,在HotSpot虛擬機上實現這些算法時,必須對算法的執行效率有嚴格的考量,才能保證虛擬機高效運行。

1. 可達性分析算法的實現(枚舉根節點)

1.1 GC Roots根節點的選擇

可作爲GC Roots的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中。

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

1.2 可達性分析

該算法的基本思路就是通過一些被稱爲引用鏈(GC Roots)的對象作爲起點,從這些節點開始向下搜索,搜索走過的路徑被稱爲(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時(即從GC Roots節點到該節點不可達),則證明該對象是不可用的。這個過程會出現GC停頓,意思就是在GC的時候Java的執行線程都被停頓,好像被凍結在某一個時間點,也叫“Stop the world”。

  • 準確式GC:目前主流的Java虛擬機都是用準確式GC(準確式GC,就是讓虛擬機知道內存中的某個位置的數據是什麼類型),當“Stop the world”的時候並不需要檢查所有的引用位置,虛擬機通過使用OopMap這個數據結構知道哪些地方存放着對象的引用。

1.3 OopMap

垃圾收集時,收集線程會對棧上的內存進行掃描,看看哪些位置存儲了 Reference 類型。如果發現某個位置確實存的是 Reference 類型,就意味着它所引用的對象這一次不能被回收。但問題是,棧上的本地變量表裏面只有一部分數據是 Reference 類型的(它們是我們所需要的),那些非 Reference 類型的數據對我們而言毫無用處,但我們還是不得不對整個棧全部掃描一遍,這是對時間和資源的一種浪費。

一個線程意味着一個棧,一個棧由多個棧幀組成,一個棧幀對應着一個方法,一個方法裏面可能有多個安全點。 gc 發生時,程序首先運行到最近的一個安全點停下來,然後更新自己的 OopMap ,記下棧上哪些位置代表着引用。枚舉根節點時,遞歸遍歷每個棧幀的 OopMap ,通過棧中記錄的被引用對象的內存地址,即可找到這些對象( GC Roots )。參考這裏

2. 安全點

  • 現在虛擬機通過OopMap已經可以快速的知道對象存儲在哪個位置,但是並沒有爲每一個指令都創建了一個OopMap(可能導致引用關係變化,或者說OopMap內容變化的指令非常多,如果爲每一條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高)。
  • HotSpot只是在“特定的位置”記錄了OopMap這些信息,這些位置稱爲安全點(Safepoint),即程序執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。

安全點的位置經常被設置在:

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

3. 讓程序在安全點停下來

如何在GC發生時讓所有線程(這裏不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來,有兩種方法。

  • 搶先式中斷(Preemptive Suspension):搶先式中斷不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它“跑”到安全點上。現在幾乎沒有虛擬機實現採用搶先式中斷來暫停線程從而響應GC事件。
  • 主動式中斷(Voluntary Suspension):當GC需要中斷線程的時候,不直接對線程操作,僅僅簡單地在安全點設置一個標誌,各個線程執行時主動去輪詢這個標誌,發現中斷標誌爲真時就自己中斷掛起。(例如,現在有一條線程正在執行,自動去輪詢安全點,如果一旦發現當前的安全點是可進入GC的Safepoint(標誌位爲真),那麼就立即自動掛起讓當前所在的安全點進行GC)

4. 安全區域

線程處於Sleep狀態或者Blocked狀態,這時候線程無法響應JVM的中斷求,“走”到安全的地方去中斷掛起,JVM也顯然不太可能等待線程重新被分配CPU時間。對於這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指在一段代碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴展了的Safepoint。

  • 安全點的使用似乎解決了OopMap計算的效率的問題,但是這裏還有一個問題。安全點需要程序自己跑過去,那麼對於那些已經停在路邊休息或者看風景的程序(比如那些處在Sleep或者Blocked狀態的線程),他們可能並不會在很短的時間內跑到安全點去。所以這裏爲了解決這個問題,又引入了安全區域的概念。

  • 安全區域很好理解,就是在程序的一段代碼片段中並不會導致引用關係發生變化,也就不用去更新OopMap表了,那麼在這段代碼區域內任何地方進行GC都是沒有問題的。這段區域就稱之爲安全區域。線程執行的過程中,如果進入到安全區域內,就會標誌自己已經進行到安全區域了。那麼虛擬機要進行GC的時候,發現該線程已經運行到安全區域,就不會管該線程的死活了。所以,該線程在脫離安全區域的時候,要自己檢查系統是否已經完成了GC或者根節點枚舉(這個跟GC的算法有關係),如果完成了就繼續執行,如果未完成,它就必須等待收到可以安全離開安全區域的Safe Region的信號爲止。

5. 垃圾收集器

如果說收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現。HotSpot虛擬機的垃圾收集器如下:
這裏寫圖片描述

作用於不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機所處的區域,則表示它是屬於新生代收集器還是老年代收集器。

5.1 Serial收集器

這個收集器是一個單線程的收集器,但它的“單線程”的意義並不僅僅說明它只會使用一個CPU或一條收集線程去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束。該收集器的原理如下:

這裏寫圖片描述

Serial收集器並不是一個“老而無用、食之無味棄之可惜”的雞肋,但實際上到現在爲止,它依然是虛擬機運行在Client模式下的默認新生代收集器。它也有着優於其他收集器的地方:**簡單而高效(與其他收集器的單線程比),對於限定單個CPU的環境來說,Serial收集器由於沒有線程交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。**在用戶的桌面應用場景中,分配給虛擬機管理的內存一般來說不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的內存,桌面應用基本上不會再大了),停頓時間完全可以控制在幾十毫秒最多一百多毫秒以內,只要不是頻繁發生,這點停頓是可以接受的。所以,Serial收集器對於運行在Client模式下的虛擬機來說是一個很好的選擇。

5.2 ParNew收集器

ParNew收集器其實就是Serial收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其餘行爲包括Serial收集器可用的所有控制參數(例如:XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一樣,在實現上,這兩種收集器也共用了相當多的代碼。ParNew收集器的工作過程如下:
這裏寫圖片描述

主要在於除了Serial收集器,目前只有ParNew收集器能夠與CMS收集器配合工作。

5.3 Parallel Scavenge收集器

Parallel Scavenge收集器是一個新生代垃圾收集器,其使用的算法是複製算法,也是並行的多線程收集器。

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

Parallel Scavenge收集器更關注可控制的吞吐量,吞吐量等於運行用戶代碼的時間/(運行用戶代碼的時間+垃圾收集時間),虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

停頓時間越短就越適合需要與用戶交互的程序,良好的響應速度能提升用戶體驗,而高吞吐量則可以高效率地利用CPU時間,儘快完成程序的運算任務,主要適合在後臺運算而不需要太多交互的任務。

Parallel Scavenge收集器使用兩個參數控制吞吐量:-XX:MaxGCPauseMillis控制最大的垃圾收集停頓時間,-XX:GCRatio直接設置吞吐量的大小。

直觀上,只要最大的垃圾收集停頓時間越小,吞吐量是越高的,但是GC停頓時間的縮短是以犧牲吞吐量和新生代空間作爲代價的。比如原來10秒收集一次,每次停頓100毫秒,現在變成5秒收集一次,每次停頓70毫秒。停頓時間下降的同時,吞吐量也下降了。

除此之外,Parallel Scavenge收集器還可以設置參數-XX:+UseAdaptiveSizePocily來動態調整停頓時間或者最大的吞吐量,這種方式稱爲GC自適應調節策略,這點是ParNew收集器所沒有的。

5.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用“標記-整理”算法。這個收集器的主要意義也是在於給Client模式下的虛擬機使用。如果在Server模式下,那麼它主要還有兩大用途:一種用途是在JDK 1.5以及之前的版本中與Parallel Scavenge收集器搭配使用 ,另一種用途就是作爲CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用。

5.5 Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多線程和“標記-整理”算法進行垃圾回收。使用多線程和“標記-整理”算法。

這個收集器是在JDK 1.6中才開始提供的,在此之前,新生代的Parallel Scavenge收集器一直處於比較尷尬的狀態。原因是,如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外別無選擇(還記得上面說過Parallel Scavenge收集器無法與CMS收集器配合工作嗎?)。由於老年代Serial Old收集器在服務端應用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整體應用上獲得吞吐量最大化的效果,由於單線程的老年代收集中無法充分利用服務器多CPU的處理能力,在老年代很大而且硬件比較高級的環境中,這種組合的吞吐量甚至還不一定有ParNew加CMS的組合“給力”。直到Parallel Old收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old 收集器。

Parallel Scavenge/Parallel Old收集器的工作過程如下:
這裏寫圖片描述

5.5 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器。目前很大一部分的Java應用集中在互聯網站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS收集器就非常符合這類應用的需求。CMS主要分爲4個步驟:

  • 初始標記(CMS initial mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS concurrent sweep)

初始標記和重新標記這兩個步驟仍然需要暫停Java執行線程,初始標記只是標記GC Roots能夠關聯到的對象,併發標記就是執行GC Roots Tracing的過程,而重新標記就是爲了修正併發標記期間因用戶程序執行而導致標記發生變動使得標記錯誤的記錄。其執行過程如下:

這裏寫圖片描述

CMS的優點很明顯:併發收集、低停頓(由於進行垃圾收集的時間主要耗在併發標記與併發清除這兩個過程,雖然初始標記和重新標記仍然需要暫停用戶線程,但是從總體上看,這部分佔用的時間相比其他兩個步驟很小,所以可以認爲是低停頓的)。

儘管如此,CMS收集器的缺點也是很明顯的:

  • 對CPU資源太敏感,這點可以這麼理解,雖然在併發標記階段用戶線程沒有暫停,但是由於收集器佔用了一部分CPU資源,導致程序的響應速度變慢
  • CMS收集器無法處理浮動垃圾。所謂的“浮動垃圾”,就是在併發標記階段,由於用戶程序在運行,那麼自然就會有新的垃圾產生,這部分垃圾被標記過後,CMS無法在當次集中處理它們(爲什麼?原因在於CMS是以獲取最短停頓時間爲目標的,自然不可能在一次垃圾處理過程中花費太多時間),只好在下一次GC的時候處理。這部分未處理的垃圾就稱爲“浮動垃圾”
  • 由於CMS收集器是基於“標記-清除”算法的,前面說過這個算法會導致大量的空間碎片的產生,一旦空間碎片過多,大對象就沒辦法給其分配內存,那麼即使內存還有剩餘空間容納這個大對象,但是卻沒有連續的足夠大的空間放下這個對象,所以虛擬機就會觸發一次Full GC(這個後面還會提到)這個問題的解決是通過控制參數-XX:+UseCMSCompactAtFullCollection,用於在CMS垃圾收集器頂不住要進行FullGC的時候開啓空間碎片的合併整理過程。

5.6 G1收集器

G1(Garbage-First)收集器是現今收集器技術的最新成果之一,之前一直處於實驗階段,直到jdk7u4之後,才正式作爲商用的收集器。

G1收集器有以下特點:

  • 並行與併發
  • 分代收集(仍然保留了分代的概念)
  • 空間整合(整體上屬於“標記-整理”算法,不會導致空間碎片)
  • 可預測的停頓(比CMS更先進的地方在於能讓使用者明確指定一個長度爲M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒)

G1收集器的運作大致可劃分爲以下幾個步驟:

  • 初始標記(Initial Marking)
  • 併發標記(Concurrent Marking)
  • 最終標記(Final Marking)
  • 篩選回收(Live Data Counting and Evacuation)

初始標記階段僅僅只是標記一下GC Roots能夠直接關聯的對象,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段的用戶程序併發運行的時候,能在正確可用的Region中創建對象,這個階段需要暫停線程。併發標記階段從GC Roots進行可達性分析,找出存活的對象,這個階段食慾用戶線程併發執行的。最終標記階段則是修正在併發標記階段因爲用戶程序的併發執行而導致標記產生變動的那一部分記錄,這部分記錄被保存在Remembered Set Logs中,最終標記階段再把Logs中的記錄合併到Remembered Set中,這個階段是並行執行的,仍然需要暫停用戶線程。最後在篩選階段首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間制定回收計劃。整個執行過成功如下:

這裏寫圖片描述

垃圾收集器常用參數總結

這裏寫圖片描述

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