您現在的位置是:首頁 > 籃球
Java記憶體區域詳細介紹
- 由 桑大強 發表于 籃球
- 2021-10-22
什麼是記憶體中的一塊臨時區域
一、Java記憶體區域
Java虛擬機器在執行程式時會把其自動管理的記憶體劃分為以上幾個區域,每個區域都有的用途以及建立銷燬的時機,其中藍色部分代表的是所有執行緒共享的資料區域,而綠色部分代表的是每個執行緒的私有資料區域。
方法區(Method Area):
方法區屬於執行緒共享的記憶體區域,又稱Non-Heap(非堆),主要用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料,根據Java 虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError 異常。值得注意的是在方法區中存在一個叫執行時常量池(Runtime Constant Pool)的區域,它主要用於存放編譯器生成的各種字面量和符號引用,這些內容將在類載入後存放到執行時常量池中,以便後續使用。
方法區主要用於儲存虛擬機器載入的類資訊、常量、靜態變數,以及編譯器編譯後的程式碼等資料。在jdk1。7及其之前,方法區是堆的一個“邏輯部分”(一片連續的堆空間),但為了與堆做區分,方法區還有個名字叫“非堆”,也有人用“永久代”(HotSpot對方法區的實現方法)來表示方法區。
從jdk1。7已經開始準備“去永久代”的規劃,jdk1。7的HotSpot中,已經把原本放在方法區中的靜態變數、字串常量池等移到堆記憶體中,(常量池除字串常量池還有class常量池等),這裡只是把字串常量池移到堆記憶體中;在jdk1。8中,方法區已經不存在,原方法區中儲存的類資訊、編譯後的程式碼資料等已經移動到了元空間(MetaSpace)中,元空間並沒有處於堆記憶體上,而是直接佔用的本地記憶體(NativeMemory)。根據網上的資料結合自己的理解對jdk1。3~1。6、jdk1。7、jdk1。8中方法區的變遷畫了張圖如下(如有不合理的地方希望讀者指出):
去永久代的原因有:
(1)字串存在永久代中,容易出現效能問題和記憶體溢位。
(2)類及方法的資訊等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢位,太大則容易導致老年代溢位。
(3)永久代會為 GC 帶來不必要的複雜度,並且回收效率偏低。
JVM堆(Java Heap):
Java 堆也是屬於執行緒共享的記憶體區域,它在虛擬機器啟動時建立,是Java 虛擬機器所管理的記憶體中最大的一塊,主要用於存放物件例項,幾乎所有的物件例項都在這裡分配記憶體,注意Java 堆是垃圾收集器管理的主要區域,因此很多時候也被稱做GC 堆,如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError 異常。
程式計數器(Program Counter Register):
屬於執行緒私有的資料區域,是一小塊記憶體空間,主要代表當前執行緒所執行的位元組碼行號指示器。位元組碼直譯器工作時,透過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。
虛擬機器棧(Java Virtual Machine Stacks):
屬於執行緒私有的資料區域,與執行緒同時建立,總數與執行緒關聯,代表Java方法執行的記憶體模型。每個方法執行時都會建立一個棧楨來儲存方法的的變量表、運算元棧、動態連結方法、返回值、返回地址等資訊。每個方法從呼叫直結束就對於一個棧楨在虛擬機器棧中的入棧和出棧過程,如下(圖有誤,應該為棧楨):
本地方法棧(Native Method Stacks):
本地方法棧屬於執行緒私有的資料區域,這部分主要與虛擬機器用到的 Native 方法相關,一般情況下,我們無需關心此區域。
元空間
上面說到,jdk1。8 中,已經不存在永久代(方法區),替代它的一塊空間叫做 “ 元空間 ”,和永久代類似,都是 JVM 規範對方法區的實現,但是元空間並不在虛擬機器中,而是使用本地記憶體,元空間的大小僅受本地記憶體限制,但可以透過 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 來指定元空間的大小。
二、垃圾回收
判斷物件是否可回收的方法
引用計數法
引用計數法的實現很簡單,在物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的物件就是不可能再被使用的。大部分情況下這個方法是可以發揮作用的,
但是在存在
迴圈引用
的情況下,引用計數法就無能為力了。
在 Java 程式中,a 和 b 是可以被回收的,因為 JVM 並沒有使用引用計數法判定物件是否可回收,而是
採用了可達性分析法
。
可達性分析法
這個演算法的基本思路就是透過一系列稱為“GC Roots”的根物件作為起始節點集 (GC Root Set),從這些節點開始,根據引用關係向下搜尋,搜尋過程所走過的路徑稱為“引用鏈” (Reference Chain),
如果某個物件到GC Roots間沒有任何引用鏈相連,則說明此物件不再被使用,也就可以被回收了
。要進行可達性分析就需要先
列舉
根節點 (GC Roots),在列舉根節點過程中,為防止物件的引用關係發生變化,需要暫停所有使用者執行緒 (垃圾收集之外的執行緒),這種暫停全部使用者執行緒的行為被稱為 (Stop The World)。
即使是不可達物件,也並非一定會被回收,如果該物件同時滿足以下幾個條件,那麼它仍有“逃生”的可能:
該物件有重寫的
finalize()
方法 (Object 類中的方法);
finalize()
方法中將其自身連結到了引用鏈上;
JVM 此前沒有呼叫過該物件的
finalize()
方法 (因為 JVM 在收集可回收物件時會呼叫且僅呼叫一次該物件的
finalize()
方法)。
在 Java 語言中,
固定可作為GC Roots的物件
包括以下幾種:
在虛擬機器棧 (棧幀中的本地變量表) 中引用的物件,比如各個執行緒被呼叫的方法堆疊中使用到的引數、區域性變數、臨時變數等。
在方法區中類靜態屬性引用的物件,比如Java類的引用型別靜態變數。
在方法區中常量引用的物件,比如字串常量池(String Table)裡的引用。
在本地方法棧中JNI (即通常所說的Native方法) 引用的物件。
Java虛擬機器內部的引用,如基本資料型別對應的Class物件,一些常見的異常物件 (比如
NullPointExcepiton、OutOfMemoryError) 等,還有系統類載入器。
所有被同步鎖 (synchronized關鍵字) 持有的物件。
反映Java虛擬機器內部情況的 JM XBean、JVM TI 中註冊的回撥、原生代碼快取等。
三、垃圾收集演算法介紹
標記-清除演算法
標記-清除演算法的思想很簡單,顧名思義,該演算法的過程分為標記和清除兩個階段:首先標記出所有需要回收的物件,其中標記過程就是
使用可達性分析法判斷
物件是否屬於垃圾的過程。在標記完成後,統一回收掉所有被標記的物件,也可以反過來,
標記存活的物件,統一回收所有未被標記的物件
。示意圖如下:
標記清除演算法
這個演算法雖然很簡單,但是
有兩個明顯的缺點:
執行效率不穩定。如果 Java 堆中包含大量物件,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨物件數量增長而降低;
導致記憶體空間碎片化。標記、清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致當以後在程式執行過程中需要分配較大物件時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作,非常影響程式執行效率。
標記-複製演算法
標記-複製演算法常簡稱複製演算法,這一演算法正好解決了標記-清除演算法在面對大量可回收物件時執行效率低下的問題。其實現方法也很易懂:
在可用記憶體中劃
分出兩塊大小相同的區域
,每次只使用其中一塊,另一塊保持空閒狀態,第一塊用完的時候,就把
存活的物件全部複製
到第二塊區域,然後把第一塊全部清空
。如下圖所示:
這個演算法很
適合用於物件存活率低
的情況,
因為它只關注存活物件而無需理會可回收物件,所以 JVM 中
新生代
的垃圾收集正是採用的這一演算法。
但是其缺點也很明顯,每次都要浪費一半的記憶體,未免太過奢侈,不過 JVM 中的新生代有更精細的記憶體劃分,比較好地解決了這個問題,見下文。
標記-整理演算法
這個演算法完
美解決了標記-清除演算法的
空間碎片化
問題
,其標記過程與“標記-清除”演算法一樣,但後續步驟
不是直接對可回收物件進行清理,而是讓所有存活的物件都向記憶體空間一端移動,然後直接清理掉邊界以外的記憶體
。
這個演算法雖然可以很好地解決空間碎片化問題,但是每次垃圾回收都要移動存活的物件,還要對引用這些物件的地方進行更新,物件移動的操作也需要全程暫停使用者執行緒 (Stop The World)。
分代收集演算法
與其說是演算法,不如說是理論。如今大多數虛擬機器的實現版本都遵循了“分代收集”的理論進行設計,這個理論可以看作是經驗之談,因為開發人員在開發過程中發現了 JVM 中存活物件的數量和它們的年齡之間有著某種規律,如下圖:
JVM 中存活物件數量與年齡之間的關係
在此基礎上,人們提出了以下假說:
絕大多數物件都是朝生夕滅的。
熬過越多次垃圾收集過程的物件就越難以消亡。
根據這兩個假說,可以把 JVM 的堆記憶體大致分為新生代和老年代,
新生代物件
大多存活時間短,每次回收時只關注如何
保留少量存活
而不是去標記那些大量將要被回收的物件,就能以較低代價回收到大量的空間,所以這一區域一般
採用標記-複製算
法進行垃圾收集,頻率比較高。而
老年代
則是一些難以消亡的物件,可以採用
標記-清除和標記整理演算法
進行垃圾收集,頻率可以低一些。
按照
Hotspot 虛擬機器
的實現,針對新生代和老年代的垃圾收集又分為不同的型別,也有不同的名詞,如下:
部分收集 (Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分為:新生代收集 (Minor GC / Young GC):指目標只是新生代的垃圾收集。老年代收集 (Major GC / Old GC):指目標只是老年代的垃圾收集,
目前只有CMS收集器的併發收集階段是單獨收集老年代的行為
。
混合收集 (Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集,目前只有G1收集器會有這種行為
。
整堆收集 (Full GC):收集整個Java堆和方法區的垃圾收集。
人們經常會混淆 Major GC 和 Full GC,不過這也有情可原,因為這兩種 GC 行為都包含了老年代的垃圾收集,而單獨的老年代收集 (Major GC) 又比較少見,大多數情況下只要包含老年代收集,就會是整堆收集 (Full GC),不過還是分得清楚一點比較好哈。
四、JVM 的記憶體分配和垃圾收集機制
經過前面的鋪墊,現在終於可以一窺 JVM 的記憶體分配和垃圾收集機制的真面目了。
JVM 堆記憶體的劃分
JVM 堆記憶體劃分,從Java 8開始不再有永久代
Java 堆是 JVM 所管理的記憶體中最大的一塊,也是垃圾收集器的管理區域。大多數垃圾收集器都會將堆記憶體劃分為上圖所示的幾個區域,
整體分為新生代和老年代,比例為 1 : 2,新生代又進一步分為 Eden、From Survivor 和 To Survivor,預設比例為 8 : 1 : 1
,請注意,可透過 SurvivorRatio 引數進行設定。請注意,從 JDK 8 開始,JVM 中已經不再有永久代的概念了。Java 堆上的無論哪個區域,儲存的都只能是物件的例項,
將Java 堆細分的目的只是為了更好地回收記憶體,或者更快地分配記憶體。
分代收集原理
(1)新生代中物件的分配與回收
大多數情況下,
物件優先在新生代 Eden 區中分配,當 Eden 區沒有足夠空間進行分配時,虛擬機器將發起一次 Minor GC。Eden、From Survivor 和 To Survivor 的比例為 8 : 1 : 1
,之所以按這個比例是因為絕大多數物件都是朝生夕滅的,垃圾收集時 Eden 存活的物件數量不會太多,Survivor 空間小一點也足以容納,每次新生代中可用記憶體空間為整個新生代容量的90% (Eden 的 80% 加上 To Survivor 的 10%),只有From Survivor 空間,即 10% 的新生代是會被“浪費”的。不會像原始的標記-複製演算法那樣浪費一半的記憶體空間。From Survivor 和 To Survivor 的空間並不是固定的,而是在 S0 和 S1 之間動態轉換的,第一次 Minor GC 時會選擇 S1 作為 To Survivor,並將 Eden 中存活的物件複製到其中,並將物件的年齡加1,注意
新生代使用的垃圾收集演算法是標記-複製演算法的改良版
。下面是示意圖,請注意其中第一步的變色是為了醒目,虛擬機器只做了標記存活物件的操作。
第一次 Minor GC 示意圖
在後續的 Minor GC 中,S0 和 S1會交替轉化為 From Survivor 和 To Survivor,Eden 和 From Survivor 中的存活物件會複製到 To Survivor 中,並將年齡加大 1。如下圖所示:
(2)物件晉升老年代
在以下這些情況下,物件會晉升到老年代。
長期存活物件將進入老年代
物件在 Survivor 區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度 (預設為15),就會被晉升到老年代中
。物件晉升老年代的年齡閾值,可以透過引數 -XX:MaxTenuringThreshold 設定,這個引數的最大值是15,因為物件年齡資訊儲存在物件頭中,佔4個位元 (bit)的記憶體,所能表示最大數字就是15。
3。大物件可以直接進入老年代
對於大物件,尤其是很長的字串,或者元素數量很多的陣列,如果分配在 Eden 中,會很容易過早佔滿 Eden 空間導致 Minor GC,而且大物件在 Eden 和兩個 Survivor 之間的來回複製也還會有很大的記憶體複製開銷。所以我們可以透過設定 -XX:PretenureSizeThreshold 的虛擬機器引數讓大物件直接進入老年代。
4。動態物件年齡判斷
為了能更好地適應不同程式的記憶體狀況,
HotSpot 虛擬機器
並不是永遠要求
物件的年齡必須達到 -XX:MaxTenuringThreshold 才能晉升老年代,
如果在 Survivor 空間中相同年齡所有物件大小的總和大於 Survivor 空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代
,無須等到 -XX:MaxTenuringThreshold 中要求的年齡
。
5。空間分配擔保 (Handle Promotion)
當 Survivor 空間不足以容納一次 Minor GC 之後存活的物件時,就需要依賴其他記憶體區域 (實際上大多數情況下就是老年代) 進行分配擔保。在發生 Minor GC 之前,虛擬機器必須先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果這個條件成立,那這一次 Minor GC 可以確保是安全的。如果不成立,則虛擬機器會先檢視 - XX:HandlePromotionFailure 引數的設定值是否允許擔保失敗 (Handle Promotion Failure);如果允許,那會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試進行一次 Minor GC,儘管這次 Minor GC 是有風險的;如果小於,或者-XX: HandlePromotionFailure設定不允許冒險,那這時就要改為進行一次 Full GC。
五、JVM 記憶體溢位
1、堆記憶體溢位
堆記憶體中主要存放物件、陣列等,只要不斷地建立這些物件,並且保證 GC Roots 到物件之間有可達路徑來避免垃圾收集回收機制清除這些物件,當這些物件所佔空間超過最大堆容量時,就會產生 OutOfMemoryError 的異常。堆記憶體異常示例如下:
/*** 設定最大堆最小堆:-Xms20m -Xmx20m* 執行時,不斷在堆中建立OOMObject類的例項物件,且while執行結束之前,GC Roots(程式碼中的oomObjectList)到物件(每一個OOMObject物件)之間有可達路徑,垃圾收集器就無法回收它們,最終導致記憶體溢位。*/public class HeapOOM {static class OOMObject {}public static void main(String[] args) {List
執行後會報異常,在堆疊資訊中可以看到:
java。lang。OutOfMemoryError: Java heap space 的資訊,說明在堆記憶體空間產生記憶體溢位的異常。
新產生的物件最初分配在新生代,新生代滿後會進行一次 Minor GC,如果 Minor GC 後空間不足會把該物件和新生代滿足條件的物件放入老年代,老年代空間不足時會進行 Full GC,之後如果空間還不足以存放新物件則丟擲 OutOfMemoryError 異常
。
常見原因:記憶體中載入的資料過多如一次從資料庫中取出過多資料;集合對物件引用過多且使用完後沒有清空;程式碼中存在死迴圈或迴圈產生過多重複物件;堆記憶體分配不合理;網路連線問題、資料庫問題等。
2、虛擬機器棧/本地方法棧溢位
(1)StackOverflowError:
當執行緒請求的棧的深度大於虛擬機器所允許的最大深度,則丟擲StackOverflowError,簡單理解就是虛擬機器棧中的棧幀數量過多(一個執行緒巢狀呼叫的方法數量過多)時,就會丟擲StackOverflowError異常。
最常見的場景就是方法無限遞迴呼叫,如下:
/*** 設定每個執行緒的棧大小:-Xss256k* 執行時,不斷呼叫doSomething()方法,main執行緒不斷建立棧幀併入棧,導致棧的深度越來越大,最終導致棧溢位。*/public class StackSOF {private int stackLength=1;public void doSomething(){stackLength++;doSomething();}public static void main(String[] args) {StackSOF stackSOF=new StackSOF();try {stackSOF。doSomething();}catch (Throwable e){//注意捕獲的是ThrowableSystem。out。println(“棧深度:”+stackSOF。stackLength);throw e;}}}
上述程式碼執行後丟擲:
Exception in thread “Thread-0” java。lang。StackOverflowError 的異常。
(2)OutOfMemoryError:
如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,則丟擲 OutOfMemoryError。
我們可以這樣理解,
虛擬機器中可以供棧佔用的空間≈可用物理記憶體 - 最大堆記憶體 - 最大方法區記憶體
,比如一臺機器記憶體為 4G,系統和其他應用佔用 2G,虛擬機器可用的物理記憶體為 2G,最大堆記憶體為 1G,最大方法區記憶體為 512M,那可供棧佔有的記憶體大約就是 512M,假如我們設定每個執行緒棧的大小為 1M,那虛擬機器中最多可以建立 512個執行緒,超過 512個執行緒再建立就沒有空間可以給棧了,就報 OutOfMemoryError 異常了。
棧上能夠產生 OutOfMemoryError 的示例如下:
/*** 設定每個執行緒的棧大小:-Xss2m* 執行時,不斷建立新的執行緒(且每個執行緒持續執行),每個執行緒對一個一個棧,最終沒有多餘的空間來為新的執行緒分配,導致OutOfMemoryError*/public class StackOOM {private static int threadNum = 0;public void doSomething() {try {Thread。sleep(100000000);} catch (InterruptedException e) {e。printStackTrace();}}public static void main(String[] args) {final StackOOM stackOOM = new StackOOM();try {while (true) {threadNum++;Thread thread = new Thread(new Runnable() {@Overridepublic void run() {stackOOM。doSomething();}});thread。start();}} catch (Throwable e) {System。out。println(“目前活動執行緒數量:” + threadNum);throw e;}}}
上述程式碼執行後會報異常
在堆疊資訊中可以看到
java.lang.OutOfMemoryError: unable to create new native thread
的資訊,無法建立新的執行緒,說明是在擴充套件棧的時候產生的記憶體溢位異常。
總結:線上程較少的時候,某個執行緒請求深度過大,會報 StackOverflow 異常,解決這種問題可以適當加大棧的深度(增加棧空間大小),也就是把 -Xss 的值設定大一些,但一般情況下是程式碼問題的可能性較大;
在虛擬機器產生執行緒時,無法為該執行緒申請棧空間了,會報 OutOfMemoryError 異常,解決這種問題可以適當減小棧的深度,也就是把 -Xss 的值設定小一些,每個執行緒佔用的空間小了,總空間一定就能容納更多的執行緒,但是作業系統對一個程序的執行緒數有限制,經驗值在 3000~5000 左右。
在 jdk1。5 之前 -Xss 預設是 256k,jdk1。5 之後預設是 1M,這個選項對系統硬性還是蠻大的,設定時要根據實際情況,謹慎操作。
3、方法區溢位
前面說到,方法區主要用於儲存虛擬機器載入的類資訊、常量、靜態變數,以及編譯器編譯後的程式碼等資料,所以
方法區溢位的原因就是沒有足夠的記憶體來存放這些資料
。
由於在 jdk1。6 之前字串常量池是存在於方法區中的,所以基於 jdk1。6 之前的虛擬機器,可以透過不斷產生不一致的字串(同時要保證和 GC Roots 之間保證有可達路徑)來模擬方法區的 OutOfMemoryError 異常;但方法區還儲存載入的類資訊,所以基於 jdk1。7 的虛擬機器,可以透過動態不斷建立大量的類來模擬方法區溢位。
/*** 設定方法區最大、最小空間:-XX:PermSize=10m -XX:MaxPermSize=10m* 執行時,透過cglib不斷建立JavaMethodAreaOOM的子類,方法區中類資訊越來越多,最終沒有可以為新的類分配的記憶體導致記憶體溢位*/public class JavaMethodAreaOOM {public static void main(final String[] args){try {while (true){Enhancer enhancer=new Enhancer();enhancer。setSuperclass(JavaMethodAreaOOM。class);enhancer。setUseCache(false);enhancer。setCallback(new MethodInterceptor() {@Overridepublic Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {return methodProxy。invokeSuper(o,objects);}});enhancer。create();}}catch (Throwable t){t。printStackTrace();}}}
上述程式碼執行後會報:
java。lang。OutOfMemoryError: PermGen space 的異常,說明是在方法區出現了記憶體溢位的錯誤。
4、本機直接記憶體溢位
本機直接記憶體(DirectMemory)並不是虛擬機器執行時資料區的一部分,也不是 Java 虛擬機器規範中定義的記憶體區域,但 Java 中用到 NIO 相關操作時(比如 ByteBuffer 的 allocteDirect 方法申請的是本機直接記憶體),也可能會出現記憶體溢位的異常。
六、常見的垃圾回收器
HotSpot虛擬機器所包含的垃圾回收器
上圖展示了7種作用於不同分代的收集器,如果兩個收集器之間存在連線,則說明它們可以搭配使用,虛擬機器所處的區域則表示它是屬於新生代還是老年代收集器。
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:CMS、Serial Old、Parallel Old
整理收集器:G1,是JDK1。7 Update14這個版本中正式提供的商業收集器,它可以同時適用於新生代和年老代
幾個概念
並行收集
:指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍處於等待狀態。
併發收集
:指使用者執行緒與垃圾收集執行緒同時工作(不一定是並行的可能會交替執行)。使用者程式在繼續執行,而垃圾收集程式執行在另一個CPU上。
吞吐量
:即CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值(吞吐量 = 執行使用者程式碼時間 / ( 執行使用者程式碼時間 + 垃圾收集時間 ))。
例如:虛擬機器共執行100分鐘,垃圾收集器花掉1分鐘,那麼吞吐量就是99%
(1)Serial 收集器
Serial和Serial Old是JDK誕生之後的第一個垃圾回收器,發展歷史最為悠久。
Serial的特點
:單執行緒、簡單高效(與其他收集器的單執行緒相比),對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。收集器進行垃圾回收時,必須暫停其他所有的工作執行緒,直到它結束(Stop The World)。
Stop The World(STW):JVM在後臺自動發起和自動完成的,在使用者不可見的情況下,把使用者正常的工作執行緒全部停掉,即GC停頓,會帶給使用者不良的體驗;
Serial的適用範圍:適用於客戶的模式下的虛擬機器。在使用者的桌面應用場景中,可用記憶體一般不大(幾十M至一兩百M),可以在較短時間內完成垃圾收集(幾十MS至一百多MS),只要不頻繁發生,這是可以接受的。
Serial和Serial Old收集器的執行圖示:
可以透過
“-XX:+UseSerialGC
” 引數來顯式地使用序列垃圾收集器。
(2)ParNew收集器
ParNew收集器其實是Serial收集器的
多執行緒版本
。
除了使用多執行緒外其餘行為均和Serial收集器一模一樣(引數控制、收集演算法、Stop The World、物件分配規則、回收策略等)。
ParNew的特點
:
多執行緒、ParNew收集器預設開啟的收集執行緒數與CPU的數量相同,在CPU非常多的環境中,可以使用-XX:ParallelGCThreads引數來限制垃圾收集的執行緒數。和Serial收集器一樣存在Stop The World問題
ParNew的應該場景
:在Server模式下,ParNew收集器是一個非常重要的收集器,因為除Serial外,目前只有它能與CMS收集器配合工作;但在單個CPU環境中,不會比Serail收集器有更好的效果,因為存線上程互動開銷。
ParNew/Serial Old組合收集器執行示意圖:
引數設定:
“-XX:+UseConcMarkSweepGC”:指定使用CMS後,會預設使用ParNew作為新生代收集器;
“-XX:+UseParNewGC”:強制指定使用ParNew;
“-XX:ParallelGCThreads”:指定垃圾收集的執行緒數量,ParNew預設開啟的收集執行緒與CPU的數量相同;
(3)Parallel Scavenge收集器
如果JVM沒有做任何調優的情況下,預設使用的就是Parallel Scavenge和Parallel Old,簡稱PS+PO。
Parallel Scavenge垃圾收集器因為與吞吐量關係密切,也稱為吞吐量收集器(Throughput Collector)。
它的特點:Parallel Scavenge屬於新生代收集器也是採用複製演算法的收集器,又是並行多執行緒收集器(與ParNew收集器類似)。
該收集器的目標是達到一個可控制的吞吐量。還有一個值得關注的點是:GC自適應調節策略(與ParNew收集器最重要的一個區別)
GC自適應調節策略
:Parallel Scavenge收集器可設定-XX:+UseAdptiveSizePolicy引數。當開關開啟時不需要手動指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRation)、晉升老年代的物件年齡(
-XX:PretenureSizeThreshold)等,虛擬機器會根據系統的執行狀況收集效能監控資訊,動態設定這些引數以提供最優的停頓時間和最高的吞吐量,這種調節方式稱為GC的自適應調節策略。
應用場景:
高吞吐量為目標,即減少垃圾收集時間,讓使用者程式碼獲得更長的執行時間;
當應用程式執行在具有多個CPU上,對暫停時間沒有特別高的要求時,即程式主要在後臺進行計算,而不需要與使用者進行太多互動;
例如,那些執行批次處理、訂單處理、工資支付、科學計算的應用程式;
引數設定:
Parallel Scavenge收集器提供兩個引數用於精確控制吞吐量:
(A)、“-XX:MaxGCPauseMillis”
控制最大垃圾收集停頓時間,大於0的毫秒數;
MaxGCPauseMillis設定得稍小,停頓時間可能會縮短,但也可能會使得吞吐量下降;
因為可能導致垃圾收集發生得更頻繁;
(B)、“-XX:GCTimeRatio”
設定垃圾收集時間佔總時間的比率,0 GCTimeRatio相當於設定吞吐量大小; 垃圾收集執行時間佔應用程式執行時間的比例的計算方法是: 1 / (1 + n) 例如,選項-XX:GCTimeRatio=19,設定了垃圾收集時間佔總時間的5%——1/(1+19); 預設值是1%——1/(1+99),即n=99; 垃圾收集所花費的時間是年輕一代和老年代收集的總時間; 如果沒有滿足吞吐量目標,則增加年輕代的記憶體大小以儘量增加使用者程式執行的時間; 此外,還有一個值得關注的引數: (C)、“-XX:+UseAdptiveSizePolicy” 開啟這個引數後,就不用手工指定一些細節引數,如: 新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRation)、晉升老年代的物件年齡(-XX:PretenureSizeThreshold)等; JVM會根據當前系統執行情況收集效能監控資訊,動態調整這些引數,以提供最合適的停頓時間或最大的吞吐量,這種調節方式稱為GC自適應的調節策略(GC Ergonomiscs); 這是一種值得推薦的方式: (1)、只需設定好記憶體資料大小(如“-Xmx”設定最大堆); (2)、然後使用“-XX:MaxGCPauseMillis”或“-XX:GCTimeRatio”給JVM設定一個最佳化目標; (3)、那些具體細節引數的調節就由JVM自適應完成; 這也是Parallel Scavenge收集器與ParNew收集器一個重要區別; 上面介紹的都是新生代收集器,接下來開始介紹老年代收集器。 (4)Serial Old 收集器 Serial Old是Serial收集器的老年代版本,它用的是標記-整理演算法,用的也 是單執行緒 。 應用場景 :主要也是使用在Client模式下的虛擬機器中。也可在Server模式下使用。 Server模式下主要的兩大用途: 在JDK1。5以及以前的版本中與Parallel Scavenge收集器搭配使用。 作為CMS收集器的後備方案,在併發收集Concurent Mode Failure時使用。 執行示意圖: (5)Parallel Old 收集器 Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本,在JDK1。6中才開始提供。 特點: 針對老年代;採用“標記-整理”演算法;多執行緒收集。 應用場景: JDK1。6及之後用來代替老年代的Serial Old收集器;特別是在Server模式,多CPU的情況下;這樣在注重吞吐量以及CPU資源敏感的場景,就有了Parallel Scavenge加Parallel Old收集器的“給力”應用組合; 引數設定: “-XX:+UseParallelOldGC”:指定使用Parallel Old收集器; 執行示意圖: (6)CMS收集器 併發標記清理(Concurrent Mark Sweep,CMS)收集器也稱為併發低停頓收集器(Concurrent Low Pause Collector)或低延遲(low-latency)垃圾收集器 。 特點: 針對老年代; 基於“標記-清除”演算法(不進行壓縮操作,產生記憶體碎片); 以獲取最短回收停頓時間為目標; 併發收集、低停頓; 需要更多的記憶體(看後面的缺點); CMS是HotSpot在JDK1。5推出的第一款真正意義上的併發(Concurrent)收集器,第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作。 應用場景: 與使用者互動較多的場景; 希望系統停頓時間最短,注重服務的響應速度; 以給使用者帶來較好的體驗; 如常見WEB、B/S系統的伺服器上的應用; 引數設定: “-XX:+UseConcMarkSweepGC”:指定使用CMS收集器; CMS收集器運作過程: 比前面幾種收集器更復雜,可以分為4個步驟。 (A)初始標記(CMS initial mark) 僅標記一下GC Roots能直接關聯到的物件(根物件)速度很快;但需要“Stop The World”; (B)併發標記(CMS concurrent mark) 進行GC Roots Tracing的過程;剛才產生的集合中標記出存活物件;應用程式也在執行;並不能保證可以標記出所有的存活物件; (C)重新標記(CMS remark) 為了修正併發標記期間因使用者程式繼續運作而導致標記變動的那一部分物件的標記記錄;需要“Stop The World”,且停頓時間比初始標記稍長,但遠比並發標記短;採用多執行緒並行執行來提升效率; (D)併發清除(CMS concurrent sweep) 回收所有的垃圾物件; 整個過程中耗時最長的併發標記和併發清除都可以與使用者執行緒一起工作,所以總體上說,CMS收集器的記憶體回收過程與使用者執行緒一起併發執行。 CMS收集器3個明顯的缺點: 1.對CPU資源非常敏感 併發收集雖然不會暫停使用者執行緒,但因為佔用一部分CPU資源,還是會導致應用程式變慢,總吞吐量降低。 CMS的預設收集執行緒數量是=(CPU數量+3) / 4; 當CPU數量多於4個,收集執行緒佔用的CPU資源多於25%,對使用者程式影響可能較大;不足4個時,影響更大,可能無法接受。 增量式併發收集器: 針對這種情況,曾出現了“增量式併發收集器”(Incremental Concurrent Mark Sweep/i-CMS); 類似使用搶佔式來模擬多工機制的思想,讓收集執行緒和使用者執行緒交替執行,減少收集執行緒執行時間; 但效果並不理想,JDK1。6後就官方不再提倡使用者使用。 2.無法處理浮動垃圾,可能出現"Concurrent Mode Failure"失敗。 (1)浮動垃圾(Floating Garbage) 在併發清除時,使用者執行緒新產生的垃圾,稱為浮動垃圾; 這使得併發清除時需要預留一定的記憶體空間,不能像其他收集器在老年代幾乎填滿再進行收集; 也可以認為CMS所需要的空間比其他垃圾收集器大; “ -XX:CMSInitiatingOccupancyFraction”:設定CMS預留記憶體空間; JDK1。5預設值為68%; JDK1。6變為大約92%; (2)"Concurrent Mode Failure"失敗 如果CMS預留記憶體空間無法滿足程式需要,就會出現一次“Concurrent Mode Failure”失敗;這時JVM啟用後備預案:臨時啟用Serail Old收集器,而導致另一次Full GC的產生;這樣的代價是很大的,所以 CMSInitiatingOccupancyFraction不能設定得太大。 3.產生大量記憶體碎片 由於CMS基於“標記-清除”演算法,清除後不進行壓縮操作,產生大量不連續的記憶體碎片會導致分配大記憶體物件時,無法找到足夠的連續記憶體,從而需要提前觸發另一次Full GC動作。 解決方法: (1)、“-XX:+UseCMSCompactAtFullCollection” 使得CMS出現上面這種情況時不進行Full GC,而開啟記憶體碎片的合併整理過程; 但合併整理過程無法併發,停頓時間會變長; 預設開啟(但不會進行,結合下面的CMSFullGCsBeforeCompaction); (2)、“-XX:+CMSFullGCsBeforeCompaction” 設定執行多少次不壓縮的Full GC後,來一次壓縮整理; 為減少合併整理過程的停頓時間; 預設為0,也就是說每次都執行Full GC,不會進行壓縮整理; 由於空間不再連續,CMS需要使用可用“空閒列表”記憶體分配方式,這比簡單實用“碰撞指標”分配記憶體消耗大; (7)G1收集器 G1垃圾收集器全稱是Garbage-First,意義為進行最有價值的垃圾回收(漢語言的優美此刻完美體現)。G1是面向伺服器級的垃圾收集器,主要針對多CPU以及大容量記憶體的機器,具有高吞吐量以及低GC停頓時間。 G1垃圾收集器的堆劃分和其他垃圾收集器不大一樣,G1將java 堆劃分為若干個大小相等的堆記憶體區域,後面稱為region。JVM堆中最多可以存在2048個region,每個region的大小就是堆的記憶體空間除以region個數,當然可以透過配置 -XX:G1HeapRegionSize 引數對region大小進行手動指定,不過因為G1內部有非常多的堆空間最佳化機制,所以推薦使用預設配置,不建議修改。 G1堆記憶體空間示例圖 G1收集器堆空間分割槽情況 G1除了有其他垃圾收集器包含的eden、survivor、old外,還有一個特殊的humongous區,專門用於儲存大物件,以下是每個分割槽功能以及特點: Eden、Survivor:新生代空間,G1中新生代預設初始空間佔比為5%,可以透過配置修改此初始值,不過因為G1內部會在新生代空間不足時,根據內部演算法自動增加新生代記憶體空間,所以不建議修改初始值。JVM在執行過程中,會不斷給新生代空間分配記憶體,最高不超過60%,可以透過 -XX:G1MaxNewSizePercent 引數進行調整。新生代中eden和survior的記憶體分配為8:1:1 Humongous:G1大物件分配區,在G1中,大物件的判定規則為超過單個region區域記憶體的50%即為大物件,大物件不需要心如新生代,直接進入humongous區,避免了大物件經歷多次gc,影響jvm整體gc效能,也避免了這種短期生存的大物件擠壓老年代記憶體空間,避免full gc的提前發生。如果單個物件大小超過一個region區域記憶體限制,G1則會給此物件分配多個連續的region空間進行儲存。full gc執行時,也會對humongous區域的物件進行gc回收 Old:除了其分佈在多個region空間的特點之外,和其他收集器老年代功能以及用法一致 G1垃圾收集過程 G1垃圾收集整體過程和CMS類似核心思想也是降低業務執行緒停頓時間,讓業務執行緒和gc執行緒並行工作,提高使用者體驗,不過G1的具體實現上更為優秀(當然大記憶體和CPU資源是必不可少) 初始標記:此階段和CMS初始標記一致,會標記GC Root根直接關聯的物件,此時暫停除GC執行緒外的所有 併發標記:此階段和CMS併發標記一致,會併發標記初始標記物件的關聯物件,業務執行緒同時執行,並記錄此時業務執行緒導致的物件引用更新 最終標記:此階段和CMS併發標記一致,會修正併發標記階段業務執行緒導致的物件引用更新,此時暫停除GC執行緒以外的執行緒 篩選回收:G1的回收演算法使用的是複製演算法,篩選回收的意思是G1會根據 其引數設定對region區域進行選擇性回收,G1內部會分析每個region的回收價值以及回收所需時長,如果單個region的可回收物件大小不足15%(可配置),G1則不會回收此region區域。同時G1會根據region區域需要的回收時長以及JVM設定的GC停頓時間來合理地進行記憶體回收。例如使用者設定的GC停頓時長為100ms,G1會根據後臺維護的優先列表來計算在100ms內能回收的最大region數,回收時間過程的不會進行回收,如果region合計回收時間遠小於100ms,G1也不會進行回收,而是會開闢新的region空間存放物件 G1收集器執行示意圖 G1優勢 高效能且與支援並行:在多核CPU前提下,充分使用CPU資源,做到併發且並行地進行垃圾收集,極大地減少了 stop the world的時長 支援分代收集:G1收集器可以直接對整個JVM 堆進行管理,不像CMS、ParNew等收集器只能對老年代或者新生代進行垃圾清理 空間整合:G1收集器採用的是複製收集演算法,效率高於CMS的標記回收演算法 停頓時長可設定:G1根據自帶的停頓預測演算法,讓使用者執行緒的停頓時間儘可能借用JVM設定的停頓時間,極大的提升了使用者體驗 G1垃圾收集方式 YoungGC: 對G1 Eden區記憶體回收的回收方式,不過G1並不是在Eden區一滿就會觸發Young GC,而是會計算當前Eden區如果執行young gc需要執行的時間是否接近 預測停頓時長(透過引數 -XX:MaxGCPauseMills 進行設定),如果接近則會執行young gc,如果遠小於預測停頓時長,則會繼續分配region給eden空間給新物件存放 MixedGC :此回收方式會對新生代以及部分老年代以及大物件區進行回收,老年區的回收空間大小是根據G1的內部演算法,結合jvm設定的預測停頓時間,將老年區價值高的垃圾物件優先進行回收,使用的是複製演算法,會將標記的有用物件複製到其他region中,再對需要清除的region進行全量清除,當無剩餘region空間作為複製演算法的目標空間時,就會觸發full gc FullGC: 此回收方式非常簡單粗暴,即使用單執行緒對記憶體空間進行標記清理以及壓縮處理 G1收集器引數設定 -XX:+UseG1GC:G1開啟引數 -XX:ParallelGCThreads:併發標記GC執行緒數 -XX:G1HeapRegionSize:指定region分割槽大小,必須是2的整數次冪,最大32m,預設演算法會將jvm堆空間劃分為2048個region -XX:MaxGCPauseMillis:預測GC最大暫停時長,此引數對於G1收集器非常關鍵,預設200毫秒,需要根據具體業務進行設定 -XX:G1NewSizePercent:新生代初始記憶體佔比(預設5%,不建議修改) -XX:G1MaxNewSizePercent:新生代最大空間佔比(預設60%,根據實際業務調整) -XX:TargetSurvivorRatio:survivor觸發轉移物件至老年代的閾值(預設50%),當survivor分割槽記憶體空間超過此閾值,會將年齡相比最大的物件移入老年區,直至survivor空間所用記憶體低於閾值 -XX:MaxTenuringThreshold:新生代區最大年齡閾值,物件年齡達到此閾值後物件移入老年代 -XX:InitiatingHeapOccupancyPercent:老年代空間佔整體堆空間比例超過此閾值時,會觸發mixgc,預設為45% -XX:G1HeapWastePercent:mixgc釋放記憶體空間閾值,當jvm執行mixed gc後,會不斷地有未空閒的region塊被釋放,當釋放的region總記憶體量佔堆空間比例達到此閾值後,停止mixed gc,預設5% -XX:G1MixedGCLiveThresholdPercent:當進行gc時,會計算當前region不可回收物件佔region空間比例,如果該比例高於此閾值,則不會回收,預設85% -XX:G1MixedGCCountTarget:因為G1為了提高使用者體驗,回收過程中的篩選回收會分多次進行,此引數用於這是篩選回收拆分執行次數,預設8次 G1收集器使用建議 其實所有的垃圾回收器的最佳化都是大同小異,都是需要防止minor gc的頻繁觸發以及防止新生代的短期存活物件進入老年代,而G1收集器最佳化的最重要的點就是 -XX:MaxGCPauseMillis 引數,透過此引數可以設定新生代垃圾回收頻率,同時可以基於對業務的評估,設定此引數,防止survivor區物件超過50%而短期存活物件被迫進入老年代的問題 (9)ZGC垃圾回收器 ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延遲垃圾回收器,它的設計目標包括: 停頓時間不超過10ms; 停頓時間不會隨著堆的大小,或者活躍物件的大小而增加; 支援8MB~4TB級別的堆(未來支援16TB)。 從設計目標來看,我們知道 ZGC適用於大記憶體低延遲服務的記憶體管理和回收 。本文主要介紹ZGC在低延時場景中的應用和卓越表現,文章內容主要分為四部分: GC之痛 :介紹實際業務中遇到的GC痛點,並分析CMS收集器和G1收集器停頓時間瓶頸; ZGC原理 :分析ZGC停頓時間比G1或CMS更短的本質原因,以及背後的技術原理; ZGC調優實踐 :重點分享對ZGC調優的理解,並分析若干個實際調優案例; 升級ZGC效果 :展示在生產環境應用ZGC取得的效果。 GC之痛 很多低延遲高可用Java服務的系統可用性經常受GC停頓的困擾。GC停頓指垃圾回收期間STW(Stop The World),當STW時,所有應用執行緒停止活動,等待GC停頓結束。 部分上游業務要求風控服務65ms內返回結果,並且可用性要達到99。99%。但因為GC停頓,我們未能達到上述可用性目標。當時使用的是CMS垃圾回收器,單次Young GC 40ms,一分鐘10次,介面平均響應時間30ms。透過計算可知,有( 40ms + 30ms ) * 10次 / 60000ms = 1。12%的請求的響應時間會增加0 ~ 40ms不等,其中30ms * 10次 / 60000ms = 0。5%的請求響應時間會增加40ms。 可見,GC停頓對響應時間的影響較大。為了降低GC停頓對系統可用性的影響,我們從降低單次GC時間和降低GC頻率兩個角度出發進行了調優,還測試過G1垃圾回收器,但這三項措施均未能降低GC對服務可用性的影響。 CMS與G1停頓時間瓶頸 在介紹ZGC之前,首先回顧一下CMS和G1的GC過程以及停頓時間的瓶頸。CMS新生代的Young GC、G1和ZGC都基於標記-複製演算法,但演算法具體實現的不同就導致了巨大的效能差異。 標記-複製演算法應用在CMS新生代(ParNew是CMS預設的新生代垃圾回收器)和G1垃圾回收器中。標記-複製演算法可以分為三個階段: 標記階段,即從GC Roots集合開始,標記活躍物件; 轉移階段,即把活躍物件複製到新的記憶體地址上; 重定位階段,因為轉移導致物件的地址發生了變化,在重定位階段,所有指向物件舊地址的指標都要調整到物件新的地址上。 下面以G1為例,透過G1中標記-複製演算法過程(G1的Young GC和Mixed GC均採用該演算法),分析G1停頓耗時的主要瓶頸。G1垃圾回收週期如下圖所示: G1的混合回收過程可以分為標記階段、清理階段和複製階段。 標記階段停頓分析 初始標記階段 :初始標記階段是指從GC Roots出發標記全部直接子節點的過程,該階段是STW的。由於GC Roots數量不多,通常該階段耗時非常短。 併發標記階段 :併發標記階段是指從GC Roots開始對堆中物件進行可達性分析,找出存活物件。該階段是併發的,即應用執行緒和GC執行緒可以同時活動。併發標記耗時相對長很多,但因為不是STW,所以我們不太關心該階段耗時的長短。 再標記階段 :重新標記那些在併發標記階段發生變化的物件。該階段是STW的。 清理階段停頓分析 清理階段清點出有存活物件的分割槽和沒有存活物件的分割槽,該階段不會清理垃圾物件,也不會執行存活物件的複製。該階段是STW的。 複製階段停頓分析 複製演算法中的轉移階段需要分配新記憶體和複製物件的成員變數。轉移階段是STW的,其中記憶體分配通常耗時非常短,但物件成員變數的複製耗時有可能較長,這是因為複製耗時與存活物件數量與物件複雜度成正比。物件越複雜,複製耗時越長。 四個STW過程中,初始標記因為只標記GC Roots,耗時較短。再標記因為物件數少,耗時也較短。清理階段因為記憶體分割槽數量少,耗時也較短。轉移階段要處理所有存活的物件,耗時會較長。因此,G1停頓時間的瓶頸主要是標記-複製中的轉移階段STW。為什麼轉移階段不能和標記階段一樣併發執行呢?主要是G1未能解決轉移過程中準確定位物件地址的問題。 G1的Young GC和CMS的Young GC,其標記-複製全過程STW,這裡不再詳細闡述。 ZGC原理 全併發的ZGC 與CMS中的ParNew和G1類似,ZGC也採用標記-複製演算法,不過ZGC對該演算法做了重大改進:ZGC在標記、轉移和重定位階段幾乎都是併發的,這是ZGC實現停頓時間小於10ms目標的最關鍵原因。 ZGC垃圾回收週期如下圖所示: ZGC只有三個STW階段: 初始標記 , 再標記 , 初始轉移 。其中,初始標記和初始轉移分別都只需要掃描所有GC Roots,其處理時間和GC Roots的數量成正比,一般情況耗時非常短;再標記階段STW時間很短,最多1ms,超過1ms則再次進入併發標記階段。即,ZGC幾乎所有暫停都只依賴於GC Roots集合大小,停頓時間不會隨著堆的大小或者活躍物件的大小而增加。與ZGC對比,G1的轉移階段完全STW的,且停頓時間隨存活物件的大小增加而增加。 ZGC關鍵技術 ZGC透過著色指標和讀屏障技術,解決了轉移過程中準確訪問物件的問題,實現了併發轉移。大致原理描述如下:併發轉移中“併發”意味著GC執行緒在轉移物件的過程中,應用執行緒也在不停地訪問物件。假設物件發生轉移,但物件地址未及時更新,那麼應用執行緒可能訪問到舊地址,從而造成錯誤。而在ZGC中,應用執行緒訪問物件將觸發“讀屏障”,如果發現物件被移動了,那麼“讀屏障”會把讀出來的指標更新到物件的新地址上,這樣應用執行緒始終訪問的都是物件的新地址。那麼,JVM是如何判斷物件被移動過呢?就是利用物件引用的地址,即著色指標。下面介紹著色指標和讀屏障技術細節。 著色指標 | 著色指標是一種將資訊儲存在指標中的技術。 ZGC僅支援64位系統,它把64位虛擬地址空間劃分為多個子空間,如下圖所示: 其中,[0~4TB) 對應Java堆,[4TB ~ 8TB) 稱為M0地址空間,[8TB ~ 12TB) 稱為M1地址空間,[12TB ~ 16TB) 預留未使用,[16TB ~ 20TB) 稱為Remapped空間。 當應用程式建立物件時,首先在堆空間申請一個虛擬地址,但該虛擬地址並不會對映到真正的物理地址。ZGC同時會為該物件在M0、M1和Remapped地址空間分別申請一個虛擬地址,且這三個虛擬地址對應同一個物理地址,但這三個空間在同一時間有且只有一個空間有效。ZGC之所以設定三個虛擬地址空間,是因為它使用“空間換時間”思想,去降低GC停頓時間。“空間換時間”中的空間是虛擬空間,而不是真正的物理空間。後續章節將詳細介紹這三個空間的切換過程。 與上述地址空間劃分相對應,ZGC實際僅使用64位地址空間的第0~41位,而第42~45位儲存元資料,第47~63位固定為0。 ZGC將物件存活資訊儲存在42~45位中,這與傳統的垃圾回收並將物件存活資訊放在物件頭中完全不同。 讀屏障 | 讀屏障是JVM嚮應用程式碼插入一小段程式碼的技術。當應用執行緒從堆中讀取物件引用時,就會執行這段程式碼。需要注意的是,僅“從堆中讀取物件引用”才會觸發這段程式碼。 讀屏障示例: Object o = obj。FieldA // 從堆中讀取引用,需要加入屏障 ZGC中讀屏障的程式碼作用 :在物件標記和轉移過程中,用於確定物件的引用地址是否滿足條件,並作出相應動作。 ZGC併發處理演示 接下來詳細介紹ZGC一次垃圾回收週期中地址檢視的切換過程: 初始化 :ZGC初始化之後,整個記憶體空間的地址檢視被設定為Remapped。程式正常執行,在記憶體中分配物件,滿足一定條件後垃圾回收啟動,此時進入標記階段。 併發標記階段 :第一次進入標記階段時檢視為M0,如果物件被GC標記執行緒或者應用執行緒訪問過,那麼就將物件的地址檢視從Remapped調整為M0。所以,在標記階段結束之後,物件的地址要麼是M0檢視,要麼是Remapped。如果物件的地址是M0檢視,那麼說明物件是活躍的;如果物件的地址是Remapped檢視,說明物件是不活躍的。 併發轉移階段 :標記結束後就進入轉移階段,此時地址檢視再次被設定為Remapped。如果物件被GC轉移執行緒或者應用執行緒訪問過,那麼就將物件的地址檢視從M0調整為Remapped。 其實,在標記階段存在兩個地址檢視M0和M1,上面的過程顯示只用了一個地址檢視。之所以設計成兩個,是為了區別前一次標記和當前標記。即第二次進入併發標記階段後,地址檢視調整為M1,而非M0。 著色指標和讀屏障技術不僅應用在併發轉移階段,還應用在併發標記階段:將物件設定為已標記,傳統的垃圾回收器需要進行一次記憶體訪問,並將物件存活資訊放在物件頭中;而在ZGC中,只需要設定指標地址的第42~45位即可,並且因為是暫存器訪問,所以速度比訪問記憶體更快。 ZGC調優實踐 ZGC不是“銀彈”,需要根據服務的具體特點進行調優。網路上能搜尋到實戰經驗較少,調優理論需自行摸索,我們在此階段也耗費了不少時間,最終才達到理想的效能。本文的一個目的是列舉一些使用ZGC時常見的問題,幫助大家使用ZGC提高服務可用性。 調優基礎知識 理解ZGC重要配置引數 以我們服務在生產環境中ZGC引數配置為例,說明各個引數的作用: 重要引數配置樣例: -Xms10G -Xmx10G -XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -XX:ConcGCThreads=2 -XX:ParallelGCThreads=6 -XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5 -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive -Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t。log:time,tid,tags:filecount=5,filesize=50m -Xms -Xmx :堆的最大記憶體和最小記憶體,這裡都設定為10G,程式的堆記憶體將保持10G不變。 -XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize : 設定CodeCache的大小, JIT編譯的程式碼都放在CodeCache中,一般服務64m或128m就已經足夠。我們的服務因為有一定特殊性,所以設定的較大,後面會詳細介紹。 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC :啟用ZGC的配置。 -XX:ConcGCThreads :併發回收垃圾的執行緒。預設是總核數的12。5%,8核CPU預設是1。調大後GC變快,但會佔用程式執行時的CPU資源,吞吐會受到影響。 -XX:ParallelGCThreads :STW階段使用執行緒數,預設是總核數的60%。 -XX:ZCollectionInterval :ZGC發生的最小時間間隔,單位秒。 -XX:ZAllocationSpikeTolerance :ZGC觸發自適應演算法的修正係數,預設2,數值越大,越早的觸發ZGC。 -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive :是否啟用主動回收,預設開啟,這裡的配置表示關閉。 -Xlog :設定GC日誌中的內容、格式、位置以及每個日誌的大小。 理解ZGC觸發時機 相比於CMS和G1的GC觸發機制,ZGC的GC觸發機制有很大不同。ZGC的核心特點是併發,GC過程中一直有新的物件產生。如何保證在GC完成之前,新產生的物件不會將堆佔滿,是ZGC引數調優的第一大目標。因為在ZGC中,當垃圾來不及回收將堆佔滿時,會導致正在執行的執行緒停頓,持續時間可能長達秒級之久。 ZGC有多種GC觸發機制,總結如下: 阻塞記憶體分配請求觸發 :當垃圾來不及回收,垃圾將堆佔滿時,會導致部分執行緒阻塞。我們應當避免出現這種觸發方式。日誌中關鍵字是“Allocation Stall”。 基於分配速率的自適應演算法 :最主要的GC觸發方式,其演算法原理可簡單描述為”ZGC根據近期的物件分配速率以及GC時間,計算出當記憶體佔用達到什麼閾值時觸發下一次GC”。自適應演算法的詳細理論可參考彭成寒《新一代垃圾回收器ZGC設計與實現》一書中的內容。透過ZAllocationSpikeTolerance引數控制閾值大小,該引數預設2,數值越大,越早的觸發GC。我們透過調整此引數解決了一些問題。日誌中關鍵字是“Allocation Rate”。 基於固定時間間隔 :透過ZCollectionInterval控制,適合應對突增流量場景。流量平穩變化時,自適應演算法可能在堆使用率達到95%以上才觸發GC。流量突增時,自適應演算法觸發的時機可能會過晚,導致部分執行緒阻塞。我們透過調整此引數解決流量突增場景的問題,比如定時活動、秒殺等場景。日誌中關鍵字是“Timer”。 主動觸發規則 :類似於固定間隔規則,但時間間隔不固定,是ZGC自行算出來的時機,我們的服務因為已經加了基於固定時間間隔的觸發機制,所以透過-ZProactive引數將該功能關閉,以免GC頻繁,影響服務可用性。日誌中關鍵字是“Proactive”。 預熱規則 :服務剛啟動時出現,一般不需要關注。日誌中關鍵字是“Warmup”。 外部觸發 :程式碼中顯式呼叫System。gc()觸發。日誌中關鍵字是“System。gc()”。 元資料分配觸發 :元資料區不足時導致,一般不需要關注。日誌中關鍵字是“Metadata GC Threshold”。 理解ZGC日誌 一次完整的GC過程,需要注意的點已在圖中標出。 注意:該日誌過濾了進入安全點的資訊。正常情況,在一次GC過程中還穿插著進入安全點的操作。 GC日誌中每一行都註明了GC過程中的資訊,關鍵資訊如下: Start :開始GC,並標明的GC觸發的原因。上圖中觸發原因是自適應演算法。 Phase-Pause Mark Start :初始標記,會STW。 Phase-Pause Mark End :再次標記,會STW。 Phase-Pause Relocate Start :初始轉移,會STW。 Heap資訊 :記錄了GC過程中Mark、Relocate前後的堆大小變化狀況。High和Low記錄了其中的最大值和最小值,我們一般關注High中Used的值,如果達到100%,在GC過程中一定存在記憶體分配不足的情況,需要調整GC的觸發時機,更早或者更快地進行GC。 GC資訊統計 :可以定時的列印垃圾收集資訊,觀察10秒內、10分鐘內、10個小時內,從啟動到現在的所有統計資訊。利用這些統計資訊,可以排查定位一些異常點。 日誌中內容較多,關鍵點已用紅線標出,含義較好理解,更詳細的解釋大家可以自行在網上查閱資料。 理解ZGC停頓原因 我們在實戰過程中共發現了6種使程式停頓的場景,分別如下: GC時,初始標記 :日誌中Pause Mark Start。 GC時,再標記 :日誌中Pause Mark End。 GC時,初始轉移 :日誌中Pause Relocate Start。 記憶體分配阻塞 :當記憶體不足時執行緒會阻塞等待GC完成,關鍵字是“Allocation Stall”。 安全點 :所有執行緒進入到安全點後才能進行GC,ZGC定期進入安全點判斷是否需要GC。先進入安全點的執行緒需要等待後進入安全點的執行緒直到所有執行緒掛起。 dump執行緒、記憶體 :比如jstack、jmap命令。 記憶體分配阻塞,系統停頓可達到秒級 案例一:秒殺活動中流量突增,出現效能毛刺 日誌資訊 :對比出現效能毛刺時間點的GC日誌和業務日誌,發現JVM停頓了較長時間,且停頓時GC日誌中有大量的“Allocation Stall”日誌。 分析 :這種案例多出現在“自適應演算法”為主要GC觸發機制的場景中。ZGC是一款併發的垃圾回收器,GC執行緒和應用執行緒同時活動,在GC過程中,還會產生新的物件。GC完成之前,新產生的物件將堆佔滿,那麼應用執行緒可能因為申請記憶體失敗而導致執行緒阻塞。當秒殺活動開始,大量請求打入系統,但自適應演算法計算的GC觸發間隔較長,導致GC觸發不及時,引起了記憶體分配阻塞,導致停頓。 解決方法: 開啟”基於固定時間間隔“的GC觸發機制:-XX:ZCollectionInterval。比如調整為5秒,甚至更短。 增大修正係數-XX:ZAllocationSpikeTolerance,更早觸發GC。ZGC採用正態分佈模型預測記憶體分配速率,模型修正係數ZAllocationSpikeTolerance預設值為2,值越大,越早的觸發GC,Zeus中所有叢集設定的是5。 案例二:壓測時,流量逐漸增大到一定程度後,出現效能毛刺 日誌資訊 :平均1秒GC一次,兩次GC之間幾乎沒有間隔。 分析 :GC觸發及時,但記憶體標記和回收速度過慢,引起記憶體分配阻塞,導致停頓。 解決方法 :增大-XX:ConcGCThreads,加快併發標記和回收速度。ConcGCThreads預設值是核數的1/8,8核機器,預設值是1。該引數影響系統吞吐,如果GC間隔時間大於GC週期,不建議調整該引數。 GC Roots 數量大,單次GC停頓時間長 案例三:單次GC停頓時間30ms,與預期停頓10ms左右有較大差距 日誌資訊 :觀察ZGC日誌資訊統計,“Pause Roots ClassLoaderDataGraph”一項耗時較長。 分析 :dump記憶體檔案,發現系統中有上萬個ClassLoader例項。我們知道ClassLoader屬於GC Roots一部分,且ZGC停頓時間與GC Roots成正比,GC Roots數量越大,停頓時間越久。再進一步分析,ClassLoader的類名錶明,這些ClassLoader均由Aviator元件生成。分析Aviator原始碼,發現Aviator對每一個表示式新生成類時,會建立一個ClassLoader,這導致了ClassLoader數量巨大的問題。在更高Aviator版本中,該問題已經被修復,即僅建立一個ClassLoader為所有表示式生成類。 解決方法 :升級Aviator元件版本,避免生成多餘的ClassLoader。 案例四:服務啟動後,執行時間越長,單次GC時間越長,重啟後恢復 日誌資訊 :觀察ZGC日誌資訊統計,“Pause Roots CodeCache”的耗時會隨著服務執行時間逐漸增長。 分析 :CodeCache空間用於存放Java熱點程式碼的JIT編譯結果,而CodeCache也屬於GC Roots一部分。透過新增-XX:+ PrintCodeCacheOnCompilation引數,列印CodeCache中的被最佳化的方法,發現大量的Aviator表示式程式碼。定位到根本原因,每個表示式都是一個類中一個方法。隨著執行時間越長,執行次數增加,這些方法會被JIT最佳化編譯進入到Code Cache中,導致CodeCache越來越大。 解決方法 :JIT有一些引數配置可以調整JIT編譯的條件,但對於我們的問題都不太適用。我們最終透過業務最佳化解決,刪除不需要執行的Aviator表示式,從而避免了大量Aviator方法進入CodeCache中。 值得一提的是,我們並不是在所有這些問題都解決後才全量部署所有叢集。即使開始有各種各樣的毛刺,但計算後發現,有各種問題的ZGC也比之前的CMS對服務可用性影響小。所以從開始準備使用ZGC到全量部署,大概用了2周的時間。在之後的3個月時間裡,我們邊做業務需求,邊跟進這些問題,最終逐個解決了上述問題,從而使ZGC在各個叢集上達到了一個更好表現。 升級ZGC效果 延遲降低 | TP(Top Percentile)是一項衡量系統延遲的指標:TP999表示99。9%請求都能被響應的最小耗時;TP99表示99%請求都能被響應的最小耗時。 在Zeus服務不同叢集中,ZGC在低延遲(TP999 < 200ms)場景中收益較大: TP999 :下降12~142ms,下降幅度18%~74%。 TP99 :下降5~28ms,下降幅度10%~47%。 超低延遲(TP999 < 20ms)和高延遲(TP999 > 200ms)服務收益不大,原因是這些服務的響應時間瓶頸不是GC,而是外部依賴的效能。 吞吐下降 對吞吐量優先的場景,ZGC可能並不適合。例如,Zeus某離線叢集原先使用CMS,升級ZGC後,系統吞吐量明顯降低。究其原因有二:第一,ZGC是單代垃圾回收器,而CMS是分代垃圾回收器。單代垃圾回收器每次處理的物件更多,更耗費CPU資源;第二,ZGC使用讀屏障,讀屏障操作需耗費額外的計算資源。 總結 ZGC作為下一代垃圾回收器,效能非常優秀。ZGC垃圾回收過程幾乎全部是併發,實際STW停頓時間極短,不到10ms。這得益於其採用的著色指標和讀屏障技術。 Zeus在升級JDK 11+ZGC中,透過將風險和問題分類,然後各個擊破,最終順利實現了升級目標,GC停頓也幾乎不再影響系統可用性。 最後推薦大家升級ZGC,Zeus系統因為業務特點,遇到了較多問題,而風控其他團隊在升級時都非常順利。 參考文獻 ZGC官網 彭成寒。《新一代垃圾回收器ZGC設計與實現》。 機械工業出版社, 2019。 附錄 如何使用新技術 在生產環境升級JDK 11,使用ZGC,大家最關心的可能不是效果怎麼樣,而是這個新版本用的人少,網上實踐也少,靠不靠譜,穩不穩定。其次是升級成本會不會很大,萬一不成功豈不是白白浪費時間。所以,在使用新技術前,首先要做的是評估收益、成本和風險。 評估收益 對於JDK這種世界關注的程式,大版本升級所引入的新技術一般已經在理論上經過驗證。我們要做的事情就是確定當前系統的瓶頸是否是新版本JDK可解決的問題,切忌問題未診斷清楚就採取措施。評估完收益之後再評估成本和風險,收益過大或者過小,其他兩項影響權重就會小很多。 以本文開頭提到的案例為例,假設GC次數不變(10次/分鐘),且單次GC時間從40ms降低10ms。透過計算,一分鐘內有100/60000 = 0。17%的時間在進行GC,且期間所有請求僅停頓10ms,GC期間影響的請求數和因GC增加的延遲都有所減少。 評估成本 這裡主要指升級所需要的人力成本。此項相對比較成熟,根據新技術的使用手冊判斷改動點。跟做其他專案區別不大,不再具體細說。 在我們的實踐中,兩週時間完成線上部署,達到安全穩定執行的狀態。後續持續迭代3個月,根據業務場景對ZGC進行了更契合的最佳化適配。 評估風險 升級JDK的風險可以分為三類: 相容性風險 :Java程式JAR包依賴很多,升級JDK版本後程序是否能執行起來。例如我們的服務是從JDK 7升級到JDK 11,需要解決較多JAR包不相容的問題。 功能風險 :執行起來後,是否會有一些元件邏輯變更,影響現有功能的邏輯。 效能風險 :功能如果沒有問題,效能是否穩定,能穩定的在線上執行。 經過分類後,每類風險的應對轉化成了常見的測試問題,不再屬於未知風險。風險是指不確定的事情,如果不確定的事情都能轉化成可確定的事情,意味著風險已消除。