1. 程式人生 > >深入理解JVM—第三章:垃圾收集器與記憶體分配策略

深入理解JVM—第三章:垃圾收集器與記憶體分配策略

概述

對於Java記憶體執行時區域的各位部分,其中程式計數器、虛擬機器棧、本地方法棧這三個區域都是隨執行緒而生,隨執行緒而滅。並且棧幀中分配的記憶體也是在編譯後就已知的。因此這幾個區域的記憶體分配和回收都具備確定性,所以我們在這幾個區域就不必過多地考慮回收問題。

而Java堆和方法區中的記憶體分配是動態的,要在執行期才知道記憶體的實際分配情況,所以垃圾收集器所關注的是Java堆和方法區中的記憶體。

哪些記憶體需要被回收?答:Java堆和方法區中的垃圾記憶體

1,JVM的垃圾判斷方法:

①引用計數演算法:給每個物件加一個引用計數器,有地方引用了這個物件,則該引用計數器加1,當引用失效了,引用計數器減1,最後當引用計數器為0的時候,那就說明該物件不被引用了。

該方法實現簡單、判斷效率高,但很難解決迴圈引用的問題。所以不被主流的Java虛擬機器所使用。

②可達性分析演算法:從GC Roots出發的引用鏈到某物件不可達,則該物件可被回收。

GC Roots的物件包括下面幾種:
a,虛擬機器棧(區域性變量表)中引用的物件
b,方法區中類靜態屬性引用的物件
c,方法區中常量引用的物件
d,本地方法棧JNI(即Native方法)引用的物件

對於可達性分析演算法中不可達的物件,也並非“非死不可”,他們只是處於“緩刑”狀態,要宣告一個物件的死亡,至少需要經歷兩次標記過程:若物件在可達性分析演算法中發現沒有與GC Roots相連線的引用鏈,那它將會第一次標記並且進行一次篩選,篩選的條件為此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,物件會被直接回收。若物件可以被虛擬機器執行finalize()方法,則看它呼叫之後,能夠“自救”,“自救”則復活,不“自救”則被回收。

2,引用的擴充
①強引用:只要強引用還在,物件就永遠不會被回收
②軟引用:描述有用但並非必須的物件,這些物件在記憶體將要溢位前,才會對其進行回收
③弱引用:描述非必須的物件,無論記憶體是否足夠,物件都一定會被回收
④虛引用:其唯一目的就是在物件被回收的時候,可以收到一個系統通知

3,方法區的垃圾回收

方法區的回收效率低,主要是回收廢棄常量和無用的類

回收廢棄常量是指回收那些被沒有其他地方引用的字面量或許符號引用

回收無用的類比較麻煩,無用的類要符合以下三個條件:
①該類的所有的例項都已經被回收,即java堆中不存在該類的任何例項
②載入該類的ClassLoader(類載入器)已經被回收
③該類對應的java.lang.Class(位元組碼)物件沒有在任何地方被引用,無法在任何地方通過反射訪問到該類的方法

垃圾記憶體如何回收?答:有相應的垃圾回收演算法對不同的記憶體區域進行回收。

1,標記-清除演算法:用於回收老年代(經歷好幾次回收仍存活或特別大的物件)。先標記要被回收的物件,標記完成後,統一回收所有被標記的物件

2,複製演算法:用於回收新生代(很快被回收或不是特別大的物件)。將可用的記憶體分成兩半,每次僅用一半,當這一半用完,將存活的物件複製到另一半上面去,最後對使用完的空間進行清理。

新生代中的記憶體實際分成一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor空間。當回收的時候,將Eden和Survivor中存活的物件一次性複製到另外一塊Survivor空間上,最後清理Eden和剛才用過的Survivor空間。HotSpot虛擬機器中預設Eden和survivor的大小比例為8:1。也就是每次新生代可用記憶體空間為90%。由於無法保證每次回收的時候存活物件不多於10%,所以當Survivor空間不夠的時候,需要通過分配擔保機制將這些新生代中所有存活物件都存放到老年代中。

3,標記-整理演算法:用於回收老年代。對所有可回收物件進行標記,然後讓所有存活物件均往一端移動,清楚端邊界以外的記憶體。

4,分代收集演算法:

根據物件存活週期不同,將Java堆中的記憶體分為新生代和老年代。

新生代:採用複製演算法。因每次垃圾收集都有大量的物件死去,只有少量存活,僅需付出少量的存活物件複製成本即可完成垃圾收集。

老年代:採用“標記-清理”或“標記-整理”演算法。因物件的存活率高,物件所佔的記憶體空間較大,且沒有額外的空間給它做分配擔保。

誰來執行回收動作?答:垃圾收集器

1,HotSpot的垃圾收集器

這裡寫圖片描述

2,垃圾收集器分類:

直到現在還沒有最好的垃圾收集器出現,更加沒有萬能的垃圾收集器,所以我們選擇的只是對具體應用最合適的收集器。

2.1 Serial收集器

作用於新生代,單執行緒效率高。

到目前為止,Serial收集器依然是Client模式下的預設的新生代垃圾收集器。
這裡寫圖片描述

2.2 ParNew收集器

Serial收集器的多執行緒版本。作用於新生代、並行多執行緒、能與CMS配合使用。

ParNew收集器是許多執行在Server模式下的預設新生代垃圾收集器,為什麼不選用Serial作為新生代的收集器呢?主要在於除了Serial收集器,目前只有ParNew收集器能夠與CMS收集器配合工作。

這裡寫圖片描述

2.3 Parallel Scavenge收集器

作用於新生代,並行的多執行緒,關注吞吐量,有GC自適應調節策略。

Parallel Scavenge收集器更關注可控制的吞吐量,吞吐量等於執行使用者程式碼的時間/(執行使用者程式碼的時間+垃圾收集時間)。如虛擬機器共執行100分鐘,垃圾收集花了1分鐘,吞吐量為99%。

虛擬機器可根據當前系統的執行情況收集效能監控資訊,動態調整三個優化引數以提供最合適的停頓時間或最大吞吐量。該調節方式稱為GC自適應的調節策略。

2.4 Serial Old收集器

Serial的老年代版本,單執行緒,使用“標記-整理”演算法。作為CMS收集器的後背預案。

這裡寫圖片描述

2.5 Parallel Old收集器

用於老年代、並行的多執行緒、注重吞吐量和CPU資源利用率。
使用“標記-整理”演算法,其通常與Parallel Scavenge收集器配合使用,

這裡寫圖片描述

2.6 CMS收集器

老年代版本,併發低停頓,應用於服務端

基於“標記-清除”演算法。

整個執行過程分為以下4個步驟:

初始標記
併發標記
重新標記
併發清除

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

這裡寫圖片描述

CMS收集器的缺點:
①CMS收集器對CPU資源非常敏感。當CPU資源不足的時候,CMS收集器佔CPU的比率大,導致應用程式變慢
②CMS收集器無法處理浮動垃圾。由於該浮動垃圾在標誌之後,本次清理無法包括它們,所以要等到下一次CC。
③收集後會產生大量的記憶體空間碎片,當無法給大物件分配空間,將開啟碎片整合,此時無法併發執行,停頓時間更長。

2.7 G1收集器

應用於服務端、可預測、可控的停頓,多執行緒

G1具備以下特點:
①並行與併發
②分代收集
③空間整合:不會產生記憶體碎片
④可預測停頓:將整個Java堆劃分為多個大小相等的region,每個region有新生代和老年代,後臺維護一個優先列表,根據允許的收集時間,優先回收價值最大的region,保證在規定時間內可以獲取儘可能高的收集效率。

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停頓時間制定回收計劃。整個執行過成功如下:

這裡寫圖片描述

3,垃圾收集器常用引數總結

這裡寫圖片描述

在HotSpot中如何實現這些垃圾判定以及回收演算法

1,列舉根節點

列舉根節點就是進行可達性分析的過程,從GC Roots節點找引用鏈。GC Roots的節點主要在全域性性的引用(常量或類靜態屬性),與執行上下文(棧幀中的區域性變量表)中。

在進行GC的時候,為了保證物件的引用關係不發生變化,必須停頓所有的Java執行執行緒。簡稱Stop the world。因此在列舉根節點的時候必須停頓。

目前的Java虛擬機器使用的都是準確式GC,知道記憶體中什麼位置是什麼型別的資料。當執行系統停頓下來,並不需要一個不漏地檢查完所有的執行上下文跟全域性的引用。在HotSpot虛擬機器中使用OopMap這種資料結構來儲存存活的物件的引用資訊。GC在掃描時就可以很快地知道這些資訊。

2,安全點

對於序列複用的指令因“長時間執行”,例如方法呼叫、迴圈跳轉、異常跳轉等,具有這些功能的指令才會產生安全點。

程式並非在任何地方都可以停下來開始GC,只有到達安全點(safepoint)才能暫停。也只有在安全點的位置才會產生OopMap。從而使得HotSpot在OopMap協助下,快速完成列舉根節點。

讓執行緒在安全點才停下來的兩種方式:
①搶先式中斷:先讓所有執行緒全部中斷,如果發現有執行緒中斷的位置不在安全點上,就恢復執行緒,讓它“跑”到安全點上。

②主動式中斷:當GC需要中斷時,僅僅修改中斷標誌,各個執行緒在執行時主動去輪詢這個標誌,發現中斷標誌為真,則執行緒自己中斷掛起。輪詢標記的地方是與安全點重合的,另外建立物件需要分配記憶體的地方也是與安全點重合的。

3,安全區域

在一段程式碼片段中,引用關係不發生變化,此區域任何地方都可以開始GC。該區域稱為安全區域(safe region)。例如處於sleep狀態的執行緒.

當執行緒執行到安全區域的程式碼時,先標記自己進入安全區域,線上程需要離開安全區域的時候,它要檢查系統是否已經完成了根節點列舉,從而確認哪些執行緒佔用的記憶體會被回收(或者整個GC過程)。如果完成了,那執行緒就繼續執行,否則它就必須等待直到收到可以安全離開安全區域的訊號為止。

4,上述三部分內容流程為:
安全點(安全區域)—>讓所有的執行緒暫停—>保證物件的引用關係不再發生變化—>產生OopMap—>記錄存活物件的引用—>列舉根節點—>開始GC回收動作。

記憶體分配與回收策略

1,物件優先在Eden分配

大多數情況下,物件在新生代Eden分配空間。當Eden區沒有足夠的空間進行分配時,虛擬機器將發動一次Minor GC(新生代GC)。若存活物件無法進入Survivor,則通過分配擔保機制轉移到老年代中。

2,大物件直接進入老年代

大物件指需要大量連續記憶體空間的Java物件。可通過引數PretenureSizeThreshold設定大物件的大小。

3,長期存活的物件將進入老年代

虛擬機器給每個物件定義了一個物件年齡計數器。如果物件出生並經過第一次Minor GC後仍存活,並且能被Survivor容納,將被移到Survivor空間中,且其物件年齡設為1,物件在Survivor中每熬過一次Minor GC,年齡增加1歲,當它的年齡增加到一定的程度(預設為15歲),將會晉升到老年代中。

4,動態物件年齡判定

Survivor空間中相同年齡的所有物件大小總和,大於Survivor空間的一半,年齡大於或等於該年齡的物件可直接進入老年代。

5,空間分配擔保

在發生Minor GC之前,虛擬機器會先檢查老年代最大可用連續空間是否大於新生代所有物件總空間,若大於,則可以安全進行Minor GC。否則,看虛擬機器是否允許擔保失敗,如果允許,則看老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,則嘗試進行一次Minor GC。如果小於,則不允許冒險,改為進行一次Full GC.

參考《深入理解Java虛擬機器》 周志明 著