對于Java開發者來說,GC(垃圾回收器)就如同一個神秘的黑匣子,它在背后不知疲倦地運作,卻也時常給我們帶來諸多疑惑和挫折。今天,就讓我們切開這個黑匣子,深入解析Java GC的工作原理,助你了解其中的奧秘,從此不再被GC所擾。
這篇文章的主要內容包括:
- 對象存活判斷及引用類型辨析
- 三種主流GC算法的原理和特點
- 如何正確解讀GC日志
一、對象存活與引用類型
在深入GC算法前,我們先了解一下Java中對象的生命周期。通過判斷對象是否可被訪問,JVM會決定其是否會被回收。這里涉及一個重要的概念——引用。
1、對象的生命周期
對象的生命周期開始于它的創建,結束于它的回收。在Java中,對象的回收由垃圾回收器(GC)負責,GC會根據引用的類型和數量來決定何時回收對象。
-
強引用(Strong Reference) - 最普通的對象引用方式,只要存在強引用指向對象,它就不會被GC回收。
-
軟引用(Soft Reference) - 有時候用于實現內存敏感的高速緩存,當內存不足時會被GC回收。
-
弱引用(Weak Reference) - 如WeakHashMap所使用,即使沒被GC回收,也可能會被回收。
-
虛引用(Phantom Reference) - 最弱的引用,唯一目的是能在對象被GC回收時收到系統通知。
理解了引用類型,我們就能判斷對象在何種情況下會被回收。比如,當一個對象沒有任何強引用指向它,那它就處于可被回收狀態。
### 2、強引用(Strong Reference)
-
定義: 強引用是最常見的引用類型,如果一個對象具有強引用,那么它永遠不會被垃圾回收器回收,直到這個引用被顯式地設置為
null
。 -
示例:
Object obj = new Object();
// obj是一個強引用,只要obj存在,對象就不會被回收
3、軟引用(Soft Reference)
- 定義: 軟引用用來描述一些有用但非必需的對象。當系統內存不足時,這些對象會被垃圾回收器回收。
- 用途: 軟引用通常用于實現內存敏感的緩存。
- 示例:
import java.lang.ref.SoftReference;Object obj = new Object();
SoftReference<Object> softRef = new SoftReference<>(obj);
obj = null; // 顯式地清空強引用// 當系統內存不足時,軟引用指向的對象可能會被回收
if (softRef.get() == null) {System.out.println("對象已被回收");
}
4、弱引用(Weak Reference)
- 定義: 弱引用不足以阻止對象的垃圾回收。也就是說,只要垃圾回收器發現了弱引用,不管當前內存是否足夠,都會回收其指向的對象。
- 用途: 弱引用通常用于監聽對象的消失,例如,用于實現弱鍵(WeakHashMap)。
- 示例:
import java.lang.ref.WeakReference;Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(obj);
obj = null; // 顯式地清空強引用// 對象幾乎立即會被回收,因為弱引用不會阻止垃圾回收
if (weakRef.get() == null) {System.out.println("對象已被回收");
}
5、虛引用(Phantom Reference)
- 定義: 虛引用是最弱的一種引用類型。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來獲取一個對象的實例。
- 用途: 虛引用主要用于在對象被回收后收到一個系統通知,用來跟蹤對象被垃圾回收的狀態。
- 示例:
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;Object obj = new Object();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, new ReferenceQueue<Object>());
obj = null; // 顯式地清空強引用// 虛引用可以注冊到引用隊列上
// 當對象被回收后,虛引用會被放入引用隊列
if (phantomRef.isEnqueued()) {System.out.println("對象已被回收");
}
二、三種主流GC算法原理
JVM的GC算法負責探測并回收上述可回收的對象,以釋放內存空間。目前主流的GC算法主要有以下三種:
1、標記-清除(Mark-Sweep)算法原理
這是最基礎和常見的GC算法。
(1)、它分兩個階段工作
-
標記階段(Marking Phase):
- 垃圾回收器從根對象(如類變量、局部變量等)開始,遞歸地訪問所有可達的對象。
- 所有被訪問到的對象都會被標記為“存活”。
-
清除階段(Sweeping Phase)
- 在標記階段結束后,垃圾回收器會遍歷堆內存。
- 對于未被標記的對象,垃圾回收器會認為它們是“垃圾”,并進行回收。
- 清除后,這些內存空間會被重新整理,為新對象的分配做準備。
(2)、標記-清除算法的問題
-
內存碎片:清除階段可能會導致內存碎片,因為回收的對象可能不是連續的。
-
效率問題:標記和清除階段可能需要暫停整個應用程序(Stop-The-World,STW),影響性能。
(3)、Java案例代碼演示
下面演示了標記-清除算法的工作原理:
public class MarkSweepDemo {public static void main(String[] args) {// 創建一些對象Object obj1 = new Object();Object obj2 = new Object();Object obj3 = new Object();// 假設obj1和obj2是根對象,它們被標記為存活// obj3沒有被根對象直接或間接引用,將被標記為垃圾// 模擬標記階段mark(obj1); // 標記obj1mark(obj2); // 標記obj2// obj3不會被標記// 模擬清除階段sweep();// 檢查對象是否被回收System.out.println("obj1 is alive: " + (obj1 == null ? "No" : "Yes"));System.out.println("obj2 is alive: " + (obj2 == null ? "No" : "Yes"));System.out.println("obj3 is alive: " + (obj3 == null ? "No" : "Yes"));}// 模擬標記過程private static void mark(Object obj) {// 這里只是模擬,實際的標記過程由GC執行System.out.println(obj + " is marked as alive.");}// 模擬清除過程private static void sweep() {// 清除未被標記的對象,這里只是模擬System.out.println("Sweeping phase: clearing unmarked objects.");}
}
在這個示例中,我們創建了三個對象obj1
、obj2
和obj3
。在模擬的標記階段,obj1
和obj2
被標記為存活,而obj3
沒有被標記。在模擬的清除階段,未被標記的obj3
將被“回收”(在實際的Java程序中,對象的回收是由JVM的垃圾回收器自動完成的)。
請注意,這個示例只是為了演示標記-清除算法的原理,并不是實際的Java垃圾回收過程。在實際的Java程序中,你不需要手動進行標記和清除,這些工作都是由JVM自動完成的。
2、復制(Copying)
復制(Copying)算法,也被稱為“半區算法”或“新生代算法”,是一種簡單且高效的垃圾回收算法,尤其適用于對象生命周期短的場景,如Java中的新生代(Young Generation)。
復制算法將堆內存分為兩個相等的區域:一個用于分配新對象(稱為From區),另一個用于垃圾回收時的復制操作(稱為To區)。
// 初始內存布局
// ----容器1---- ----容器2----
// [obj1,obj2] []// 進行GC后
// ----容器1---- ----容器2----
// [] [obj1,obj2]
(1)、算法的步驟如下
-
對象分配:
- 新對象首先在From區分配。
-
標記階段:
- 從根對象開始,標記所有存活的對象。這一步與標記-清除算法相同。
-
復制階段:
- 將所有存活的對象從From區復制到To區,同時更新所有引用,使其指向To區的新位置。
- 復制完成后,From區的所有對象都可以被清除。
-
角色交換:
-
復制完成后,From區和To區的角色互換。新的To區變為新的From區,用于下一次垃圾回收。
-
(2)、復制算法的優點
-
內存碎片問題:由于每次回收后都會進行復制,因此不會產生內存碎片。
-
簡單高效:復制算法實現簡單,且在對象生命周期短的情況下效率很高。
(3)、復制算法的缺點
-
內存空間浪費:由于需要兩個區域,因此需要額外的內存空間。
-
復制開銷:復制存活對象需要一定的時間開銷。
(4)、Java案例代碼演示
下面代碼演示了復制算法的工作原理:
public class CopyingGCDemo {public static void main(String[] args) {// 模擬From區和To區Object[] fromArea = new Object[10];Object[] toArea = new Object[10];// 假設fromArea中前5個對象是新分配的for (int i = 0; i < 5; i++) {fromArea[i] = new Object();}// 模擬標記階段mark(fromArea);// 模擬復制階段int toIndex = 0;for (int i = 0; i < fromArea.length; i++) {if (fromArea[i] != null) {toArea[toIndex++] = fromArea[i];}}// 角色交換fromArea = toArea;toArea = new Object[10]; // 為下一次回收準備新的To區// 檢查對象是否被復制for (int i = 0; i < fromArea.length; i++) {if (fromArea[i] != null) {System.out.println("Object at index " + i + " is alive and copied.");}}}// 模擬標記過程private static void mark(Object[] area) {// 這里只是模擬,實際的標記過程由GC執行for (Object obj : area) {if (obj != null) {System.out.println(obj + " is marked as alive.");}}}
}
在這個示例中,我們使用兩個數組fromArea
和toArea
來模擬From區和To區。我們首先在fromArea
中分配了一些新對象,然后模擬了標記階段,接著將存活的對象復制到toArea
,并更新索引。最后,我們交換了fromArea
和toArea
的角色,準備下一次垃圾回收。
請注意,這個示例只是為了演示復制算法的原理,并不是實際的Java垃圾回收過程。在實際的Java程序中,你不需要手動進行復制,這些工作都是由JVM的垃圾回收器自動完成的。
3、標記-整理(Mark-Compact)
標記-整理算法是垃圾回收中的另一種算法,它結合了標記-清除算法和復制算法的優點,旨在解決清除算法中的內存碎片問題。
(1)、標記-整理算法三個階段
-
標記階段(Marking Phase)
- 從根對象開始,遞歸地標記所有可達對象。被標記的對象被認為是存活的。
-
整理階段(Compacting Phase):
- 將所有存活的對象向內存的一端移動,未被標記的對象則被忽略。
- 移動對象時,會更新所有指向這些對象的引用,確保它們指向新的位置。
-
清除階段(Clearing Phase)(可選):
- 在整理完成后,可能需要清除未被移動的對象占用的空間,以避免內存碎片。
// 初始內存布局
// ----內存區----
// [obj1, ,obj2, , , ,obj3]// 標記整理后
// ----內存區----
// [obj1,obj2,obj3, ]
(2)、標記-整理算法的優點
- 內存碎片:通過整理階段,可以減少或消除內存碎片。
- 內存利用率:由于整理了存活對象,所以可以更有效地利用內存空間。
(3)、標記-整理算法的缺點
- 移動開銷:移動對象和更新引用需要額外的時間開銷。
- 暫停時間:標記和整理階段可能需要暫停應用程序,影響性能。
(4)、案例代碼演示
以下模擬了標記-整理算法的工作原理。
public class MarkCompactDemo {static class ObjectWithIndex {Object object;int index;ObjectWithIndex(Object object, int index) {this.object = object;this.index = index;}}public static void main(String[] args) {// 假設有5個對象,其中3個是存活的Object[] objects = new Object[5];objects[0] = new Object();objects[1] = new Object();objects[2] = new Object();objects[3] = null; // 假設這個對象是垃圾objects[4] = new Object();// 模擬根引用Object root = objects[0];// 標記階段mark(objects, root);// 整理階段int newIndex = 0;for (int i = 0; i < objects.length; i++) {if (objects[i] != null) {objects[newIndex] = objects[i];objects[i] = null; // 清除原位置newIndex++;}}// 清除階段(可選)// 在實際的GC中,這一步通常由垃圾回收器自動完成// 輸出整理后的對象for (int i = 0; i < newIndex; i++) {System.out.println("Object at index " + i + " is alive.");}}private static void mark(Object[] objects, Object root) {// 使用一個集合來記錄已訪問的對象HashSet<Object> marked = new HashSet<>();// 使用隊列模擬遞歸過程Queue<Object> queue = new LinkedList<>();queue.add(root);while (!queue.isEmpty()) {Object current = queue.poll();if (marked.add(current)) { // 如果對象未被標記for (int i = 0; i < objects.length; i++) {if (objects[i] == current) {System.out.println("Object at index " + i + " is marked as alive.");break;}}// 假設current對象有引用屬性,模擬遞歸標記// 這里簡化處理,實際情況需要根據對象的實際引用進行標記}}}
}
在這個示例中,我們創建了一個對象數組objects
,其中包含幾個對象和一個null
。我們模擬了一個根對象root
,它引用了數組中的第一個對象。mark
方法模擬了標記階段,使用一個隊列和一個集合來遞歸地標記所有可達對象。然后,我們在main
方法中模擬了整理階段,將所有存活的對象移動到數組的開始位置,并清除了原位置。
請注意,這個示例僅用于演示標記-整理算法的基本原理,實際的Java垃圾回收過程要復雜得多,并且由JVM自動管理。
三、解讀GC日志
理解了垃圾回收的原理后,我們來看看如何解讀GC日志 。
GC(Garbage Collection)日志是Java虛擬機(JVM)在執行垃圾回收時生成的日志信息,它記錄了GC的觸發時間、持續時間、回收的內存量、使用的GC算法等信息。通過分析GC日志,我們可以了解應用的內存使用情況,發現潛在的問題和性能瓶頸。
1、如何開啟GC日志
在Java應用啟動時,可以通過設置JVM參數來開啟GC日志:
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
PrintGC
:打印GC發生的基本日志。PrintGCDetails
:打印GC的詳細日志。PrintGCDateStamps
:在日志中包含日期時間戳。PrintGCTimeStamps
:在日志中包含自JVM啟動以來的時間戳。
2、GC日志的關鍵信息
-
時間戳:GC事件發生的時間。
-
GC類型:如Minor GC(新生代回收)、Full GC(全堆回收)等。
-
持續時間:GC事件持續的時間。
-
回收前后的內存使用情況:包括新生代、老年代等內存區域的內存使用情況。
-
GC原因:觸發GC的原因,如分配失敗、系統內存不足等。
3、解讀GC日志示例
假設我們有以下GC日志片段:
2024-05-23T14:37:12.123+0000 [GC [PSYoungGen: 73328K->6336K(94208K)] 73328K->6336K(190464K), 0.003602 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
2024-05-23T14:37:12.126+0000 [Full GC [PSYoungGen: 6336K->0K(94208K)] [ParOldGen: 0K->5120K(95296K)] 6336K->5120K(189504K), [Metaspace: 3258K->3258K(1056768K)], 0.006773 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
解讀:
(1)、時間戳:
-
第一條日志發生在
2024-05-23T14:37:12.123+0000
。 -
第二條日志發生在
2024-05-23T14:37:12.126+0000
。
(2)、GC類型:
-
第一條日志是一次Minor GC。
-
第二條日志是一次Full GC。
(3)、持續時間:
-
第一次Minor GC持續了
0.003602
秒。 -
第二次Full GC持續了
0.006773
秒。
(4)、內存使用情況:
- 第一次Minor GC前,新生代使用了
73328K
,回收后剩余6336K
。 - 第一次Minor GC后,整個堆使用了
6336K
。 - 第二次Full GC前,新生代使用了
6336K
,老年代未使用,回收后新生代0K
,老年代5120K
。 - 第二次Full GC后,整個堆使用了
5120K
。
(5)、GC原因:
- 第一次Minor GC可能因為新生代空間不足。
- 第二次Full GC可能因為Minor GC后仍然有內存需求,或者達到了Full GC的條件。
4、監控應用內存使用狀況
通過定期檢查GC日志,我們可以監控應用的內存使用情況:
-
頻繁的GC:如果GC頻繁發生,可能表明內存使用率高,需要優化內存使用。
-
長時間的GC:長時間的GC可能導致應用響應變慢,需要關注。
-
內存泄漏:如果老年代的內存持續增長,可能存在內存泄漏。
5、發現潛在問題和性能瓶頸
- 內存分配率:如果內存分配率持續高于GC回收率,可能導致內存不足。
- Full GC頻率:頻繁的Full GC可能影響性能,需要優化。
- 內存碎片:如果老年代的內存使用不連續,可能導致內存碎片問題。
通過分析GC日志,我們可以對應用的內存使用情況有一個清晰的了解,并據此進行性能調優。在實際的生產環境中,還可以使用專業的監控工具來自動化這一過程,及時發現并解決潛在的問題。
四、結語
以上內容涵蓋了GC的常見知識,但Java GC為主題的探討絕不止于此。比如說,JDK中還引入了全新的ZGC算法,用于低延遲處理;G1作為一種優秀的分代實現,如何工作;怎樣有效地配置GC參數…等等,這些都是值得我們去學習和思考的重要話題。