09.07 Java GC的那些事

與C語言不同,Java內存(堆內存)的分配與回收由JVM垃圾收集器自動完成,這個特性深受大家歡迎,能夠幫助程序員更好的編寫代碼,本文以HotSpot虛擬機為例,說一說Java GC的那些事。

Java堆內存

在 JVM內存的那些事 一文中,我們已經知道Java堆是被所有線程共享的一塊內存區域,所有對象實例和數組都在堆上進行內存分配。為了進行高效的垃圾回收,虛擬機把堆內存劃分成新生代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation)3個區域。

Java GC的那些事

新生代

新生代由 Eden 與 Survivor Space(S0,S1)構成,大小通過-Xmn參數指定,Eden 與 Survivor Space 的內存大小比例默認為8:1,可以通過-XX:SurvivorRatio 參數指定,比如新生代為10M 時,Eden分配8M,S0和S1各分配1M。

Eden:希臘語,意思為伊甸園,在聖經中,伊甸園含有樂園的意思,根據《舊約·創世紀》記載,上帝耶和華照自己的形像造了第一個男人亞當,再用亞當的一個肋骨創造了一個女人夏娃,並安置他們住在了伊甸園。

大多數情況下,對象在Eden中分配,當Eden沒有足夠空間時,會觸發一次Minor GC,虛擬機提供了-XX:+PrintGCDetails參數,告訴虛擬機在發生垃圾回收時打印內存回收日誌。

Survivor:意思為倖存者,是新生代和老年代的緩衝區域。

當新生代發生GC(Minor GC)時,會將存活的對象移動到S0內存區域,並清空Eden區域,當再次發生Minor GC時,將Eden和S0中存活的對象移動到S1內存區域。

存活對象會反覆在S0和S1之間移動,當對象從Eden移動到Survivor或者在Survivor之間移動時,對象的GC年齡自動累加,當GC年齡超過默認閾值15時,會將該對象移動到老年代,可以通過參數-XX:MaxTenuringThreshold 對GC年齡的閾值進行設置。

老年代

老年代的空間大小即-Xmx 與-Xmn 兩個參數之差,用於存放經過幾次Minor GC之後依舊存活的對象。當老年代的空間不足時,會觸發Major GC/Full GC,速度一般比Minor GC慢10倍以上。

永久代

在JDK8之前的HotSpot實現中,類的元數據如方法數據、方法信息(字節碼,棧和變量大小)、運行時常量池、已確定的符號引用和虛方法表等被保存在永久代中,32位默認永久代的大小為64M,64位默認為85M,可以通過參數-XX:MaxPermSize進行設置,一旦類的元數據超過了永久代大小,就會拋出OOM異常。

虛擬機團隊在JDK8的HotSpot中,把永久代從Java堆中移除了,並把類的元數據直接保存在本地內存區域(堆外內存),稱之為元空間。

這樣做有什麼好處?

有經驗的同學會發現,對永久代的調優過程非常困難,永久代的大小很難確定,其中涉及到太多因素,如類的總數、常量池大小和方法數量等,而且永久代的數據可能會隨著每一次Full GC而發生移動。

而在JDK8中,類的元數據保存在本地內存中,元空間的最大可分配空間就是系統可用內存空間,可以避免永久代的內存溢出問題,不過需要監控內存的消耗情況,一旦發生內存洩漏,會佔用大量的本地內存。

ps:JDK7之前的HotSpot,字符串常量池的字符串被存儲在永久代中,因此可能導致一系列的性能問題和內存溢出錯誤。在JDK8中,字符串常量池中只保存字符串的引用。

如何判斷對象是否存活

GC動作發生之前,需要確定堆內存中哪些對象是存活的,一般有兩種方法:引用計數法和可達性分析法。

1、引用計數法

在對象上添加一個引用計數器,每當有一個對象引用它時,計數器加1,當使用完該對象時,計數器減1,計數器值為0的對象表示不可能再被使用。

引用計數法實現簡單,判定高效,但不能解決對象之間相互引用的問題。


public class GCtest {

private Object instance = null;

private static final int _10M = 10 * 1 << 20;

// 一個對象佔10M,方便在GC日誌中看出是否被回收

private byte[] bigSize = new byte[_10M];

public static void main(String[] args) {

GCtest objA = new GCtest();

GCtest objB = new GCtest();

objA.instance = objB;

objB.instance = objA;

objA = null;

objB = null;

System.gc();

}

}

通過添加-XX:+PrintGC參數,運行結果:

1

[GC (System.gc()) [PSYoungGen: 26982K->1194K(75776K)] 26982K->1202K(249344K), 0.0010103 secs]

從GC日誌中可以看出objA和objB雖然相互引用,但是它們所佔的內存還是被垃圾收集器回收了。

2、可達性分析法

通過一系列稱為 “GC Roots” 的對象作為起點,從這些節點開始向下搜索,搜索路徑稱為 “引用鏈”,以下對象可作為GC Roots:

  • 本地變量表中引用的對象
  • 方法區中靜態變量引用的對象
  • 方法區中常量引用的對象
  • Native方法引用的對象

當一個對象到 GC Roots 沒有任何引用鏈時,意味著該對象可以被回收。

Java GC的那些事

在可達性分析法中,判定一個對象objA是否可回收,至少要經歷兩次標記過程:

1、如果對象objA到 GC Roots沒有引用鏈,則進行第一次標記。

2、如果對象objA重寫了finalize()方法,且還未執行過,那麼objA會被插入到F-Queue隊列中,由一個虛擬機自動創建的、低優先級的Finalizer線程觸發其finalize()方法。finalize()方法是對象逃脫死亡的最後機會,GC會對隊列中的對象進行第二次標記,如果objA在finalize()方法中與引用鏈上的任何一個對象建立聯繫,那麼在第二次標記時,objA會被移出“即將回收”集合。

看看具體實現


public class FinalizerTest {

public static FinalizerTest object;

public void isAlive() {

System.out.println("I'm alive");

}

@Override

protected void finalize() throws Throwable {

super.finalize();

System.out.println("method finalize is running");

object = this;

}

public static void main(String[] args) throws Exception {

object = new FinalizerTest();

// 第一次執行,finalize方法會自救

object = null;

System.gc();

Thread.sleep(500);

if (object != null) {

object.isAlive();

} else {

System.out.println("I'm dead");

}

// 第二次執行,finalize方法已經執行過

object = null;

System.gc();

Thread.sleep(500);

if (object != null) {

object.isAlive();

} else {

System.out.println("I'm dead");

}

}

}

執行結果:


method finalize is running

I'm alive

I'm dead

從執行結果可以看出:

第一次發生GC時,finalize方法的確執行了,並且在被回收之前成功逃脫;

第二次發生GC時,由於finalize方法只會被JVM調用一次,object被回收。

當然了,在實際項目中應該儘量避免使用finalize方法。

收集算法

垃圾收集算法主要有:標記-清除、複製和標記-整理。

1、標記-清除算法

對待回收的對象進行標記。

算法缺點:效率問題,標記和清除過程效率都很低;空間問題,收集之後會產生大量的內存碎片,不利於大對象的分配。

2、複製算法

複製算法將可用內存劃分成大小相等的兩塊A和B,每次只使用其中一塊,當A的內存用完了,就把存活的對象複製到B,並清空A的內存,不僅提高了標記的效率,因為只需要標記存活的對象,同時也避免了內存碎片的問題,代價是可用內存縮小為原來的一半。

3、標記-整理算法

在老年代中,對象存活率較高,複製算法的效率很低。在標記-整理算法中,標記出所有存活的對象,並移動到一端,然後直接清理邊界以外的內存。

對象標記過程

在可達性分析過程中,為了準確找出與GC Roots相關聯的對象,必須要求整個執行引擎看起來像是被凍結在某個時間點上,即暫停所有運行中的線程,不可以出現對象的引用關係還在不斷變化的情況。

如何快速枚舉GC Roots?

GC Roots主要在全局性的引用(常量或類靜態屬性)與執行上下文(本地變量表中的引用)中,很多應用僅僅方法區就上百兆,如果進行遍歷查找,效率會非常低下。

在HotSpot中,使用一組稱為OopMap的數據結構進行實現。類加載完成時,HotSpot把對象內什麼偏移量上是什麼類型的數據計算出來存儲到OopMap中,通過JIT編譯出來的本地代碼,也會記錄下棧和寄存器中哪些位置是引用。GC發生時,通過掃描OopMap的數據就可以快速標識出存活的對象。

如何安全的GC?

線程運行時,只有在到達安全點(Safe Point)才能停頓下來進行GC。

基於OopMap數據結構,HotSpot可以快速完成GC Roots的遍歷,不過HotSpot並不會為每條指令都生成對應的OopMap,只會在Safe Point處記錄這些信息。

所以Safe Point的選擇很重要,如果太少可能導致GC等待的時間太長,如果太頻繁可能導致運行時的性能問題。大部分指令的執行時間都非常短暫,通常會選擇一些執行時間較長的指令作為Safe Point,如方法調用、循環跳轉和異常跳轉等。

關於Safe Point更多的信息,可以看看這篇文章 JVM的Stop The World,安全點,黑暗的地底世界

發生GC時,如何讓所有線程跑到最近的Safe Point再暫停?

當發生GC時,不直接對線程進行中斷操作,而是簡單的設置一箇中斷標誌,每個線程運行到Safe Point的時候,主動去輪詢這個中斷標誌,如果中斷標誌為真,則將自己進行中斷掛起。

這裡忽略了一個問題,當發生GC時,運行中的線程可以跑到Safe Point後進行掛起,而那些處於Sleep或Blocked狀態的線程在此時無法響應JVM的中斷請求,無法到Safe Point處進行掛起,針對這種情況,可以使用安全區域(Safe Region)進行解決。

Safe Region是指在一段代碼片段中,對象的引用關係不會發生變化,在這個區域中的任何位置開始GC都是安全的。

1、當線程運行到Safe Region的代碼時,首先標識已經進入了Safe Region,如果這段時間內發生GC,JVM會忽略標識為Safe Region狀態的線程;

2、當線程即將離開Safe Region時,會檢查JVM是否已經完成GC,如果完成了,則繼續運行,否則線程必須等待直到收到可以安全離開Safe Region的信號為止;

垃圾收集器

Java虛擬機規範並沒有規定垃圾收集器應該如何實現,用戶可以根據系統特點對各個區域所使用的收集器進行組合使用。

Java GC的那些事

上圖展示了7種不同分代的收集器,如果兩兩之間存在連線,說明可以組合使用。

1、Serial收集器(串行GC)

Serial 是一個採用單個線程並基於複製算法工作在新生代的收集器,進行垃圾收集時,必須暫停其他所有的工作線程。對於單CPU環境來說,Serial由於沒有線程交互的開銷,可以很高效的進行垃圾收集動作,是Client模式下新生代默認的收集器。

2、ParNew收集器(並行GC)

ParNew其實是serial的多線程版本,除了使用多條線程進行垃圾收集之外,其餘行為與Serial一樣。

3、Parallel Scavenge收集器(並行回收GC)

Parallel Scavenge是一個採用多線程基於複製算法並工作在新生代的收集器,其關注點在於達到一個可控的吞吐量,經常被稱為“吞吐量優先”的收集器。

吞吐量 = 用戶代碼運行時間 /(用戶代碼運行時間 + 垃圾收集時間)

Parallel Scavenge提供了兩個參數用於精確控制吞吐量:

1、-XX:MaxGCPauseMillis 設置垃圾收集的最大停頓時間

2、-XX:GCTimeRatio 設置吞吐量大小

4、Serial Old收集器(串行GC)

Serial Old 是一個採用單線程基於標記-整理算法並工作在老年代的收集器,是Client模式下老年代默認的收集器。

5、Parallel Old收集器(並行GC)

Parallel Old是一個採用多線程基於標記-整理算法並工作在老年代的收集器。在注重吞吐量以及CPU資源敏感的場合,可以優先考慮Parallel Scavenge和Parallel Old的收集器組合。

6、CMS收集器(併發GC)

CMS(Concurrent Mark Sweep)是一種以獲取最短回收停頓時間為目標的收集器,工作在老年代,基於“標記-清除”算法實現,整個過程分為以下4步:

1、初始標記:這個過程只是標記以下GC Roots能夠直接關聯的對象,但是仍然會Stop The World;

2、併發標記:進行GC Roots Tracing的過程,可以和用戶線程一起工作。

3、重新標記:用於修正併發標記期間由於用戶程序繼續運行而導致標記產生變動的那部分記錄,這個過程會暫停所有線程,但其停頓時間遠比並發標記的時間短;

4、併發清理:可以和用戶線程一起工作。

CMS收集器的缺點:

1、對CPU資源比較敏感,在併發階段,雖然不會導致用戶線程停頓,但是會佔用一部分線程資源,降低系統的總吞吐量。

2、無法處理浮動垃圾,在併發清理階段,用戶線程的運行依然會產生新的垃圾對象,這部分垃圾只能在下一次GC時收集。

3、CMS是基於標記-清除算法實現的,意味著收集結束後會造成大量的內存碎片,可能導致出現老年代剩餘空間很大,卻無法找到足夠大的連續空間分配當前對象,不得不提前觸發一次Full GC。

JDK1.5實現中,當老年代空間使用率達到68%時,就會觸發CMS收集器,如果應用中老年代增長不是太快,可以通過-XX:CMSInitiatingOccupancyFraction參數提高觸發百分比,從而降低內存回收次數提高系統性能。

JDK1.6實現中,觸發CMS收集器的閾值已經提升到92%,要是CMS運行期間預留的內存無法滿足用戶線程需要,會出現一次”Concurrent Mode Failure”失敗,這是虛擬機會啟動Serial Old收集器對老年代進行垃圾收集,當然,這樣應用的停頓時間就更長了,所以這個閾值也不能設置的太高,如果導致了”Concurrent Mode Failure”失敗,反而會降低性能,至於如何設置這個閾值,還得長時間的對老年代空間的使用情況進行監控。

7、G1收集器

G1(Garbage First)是JDK1.7提供的一個工作在新生代和老年代的收集器,基於“標記-整理”算法實現,在收集結束後可以避免內存碎片問題。

G1優點:

1、並行與併發:充分利用多CPU來縮短Stop The World的停頓時間;

2、分代收集:不需要其他收集配合就可以管理整個Java堆,採用不同的方式處理新建的對象、已經存活一段時間和經歷過多次GC的對象獲取更好的收集效果;

3、空間整合:與CMS的”標記-清除”算法不同,G1在運行期間不會產生內存空間碎片,有利於應用的長時間運行,且分配大對象時,不會導致由於無法申請到足夠大的連續內存而提前觸發一次Full GC;

4、停頓預測:G1中可以建立可預測的停頓時間模型,能讓使用者明確指定在M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。

使用G1收集器時,Java堆的內存佈局與其他收集器有很大區別,整個Java堆會被劃分為多個大小相等的獨立區域Region,新生代和老年代不再是物理隔離了,都是一部分Region(不需要連續)的集合。G1會跟蹤各個Region的垃圾收集情況(回收空間大小和回收消耗的時間),維護一個優先列表,根據允許的收集時間,優先回收價值最大的Region,避免在整個Java堆上進行全區域的垃圾回收,確保了G1收集器可以在有限的時間內儘可能收集更多的垃圾。

不過問題來了:使用G1收集器,一個對象分配在某個Region中,可以和Java堆上任意的對象有引用關係,那麼如何判定一個對象是否存活,是否需要掃描整個Java堆?其實這個問題在之前收集器中也存在,如果回收新生代的對象時,不得不同時掃描老年代的話,會大大降低Minor GC的效率。

針對這種情況,虛擬機提供了一個解決方案:G1收集器中Region之間的對象引用關係和其他收集器中新生代與老年代之間的對象引用關係被保存在Remenbered Set數據結構中,用來避免全堆掃描。G1中每個Region都有一個對應的Remenbered Set,當虛擬機發現程序對Reference類型的數據進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的對象是否處於相同的Region中,如果不是,則通過CardTable把相關引用信息記錄到被引用對象所屬Region的Remenbered Set中。


分享到:


相關文章: