10年程序員漫談Java GC的那些事(基礎篇)

前言

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

Java堆內存

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

10年程序員漫談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參數,運行結果:

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

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

2、可達性分析法

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

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

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

10年程序員漫談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方法。

10年程序員漫談Java GC的那些事(基礎篇)


分享到:


相關文章: