| 概念 | 定義 | Java 中表現 |
| 記憶體洩漏 | 程式無法訪問的記憶體無法歸還給 OS | 理論上不存在 (GC 會回收所有不可達物件) |
| 無意識物件保留 | 程式邏輯上不再需要,但仍被引用鏈持有 | Java 實際的「記憶體洩漏」,導致 OOM |
GC 根與可達性分析
GC 從 GC Roots 開始追蹤引用鏈,不可達物件視為垃圾:
- 執行緒棧區域區域變數 (Local variables in stack frames)
- JNI 原生棧區域變數
- 方法區類靜態屬性
- 方法區常量引用
- 同步鎖持有的物件
- JVM 內部持有的物件 (Class 對象、異常對象、系統類加載器等)
二、經典洩漏模式與修正
模式一:集合類持有失效引用 (最常見)
// ❌ 錯誤:靜態集合持續增長,元素永不移除
public class Cache {
private static final Map cache = new HashMap<>();
public static void put(String key, Object value) {
cache.put(key, value); // 永不清理
}
}
// ✅ 修正:使用弱引用或 LRU 淘汰
public class Cache {
// 方案 1: WeakHashMap - Key 為弱引用,GC 時自動移除
private static final Map cache = new WeakHashMap<>();
// 方案 2: Guava Cache / Caffeine - 內建過期/淘汰策略
private static final Cache cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
// 方案 3: 手動清理 + 定時任務
private static final Map cache = new ConcurrentHashMap<>();
static {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
cache.entrySet().removeIf(e -> isExpired(e.getValue()));
}, 1, 1, TimeUnit.HOURS);
}
}
模式二:監聽器/回調未移除
// ❌ 錯誤:註冊監聽器但從未移除
public class EventManager {
private final List listeners = new ArrayList<>();
public void register(Listener l) { listeners.add(l); }
// 缺少 unregister()
}
// ✅ 修正:提供移除方法,或使用弱引用監聽器
public class EventManager {
private final List> listeners = new ArrayList<>();
public void register(Listener l) {
listeners.add(new WeakReference<>(l));
}
public void notify(Event e) {
listeners.removeIf(ref -> ref.get() == null); // 清理失效引用
for (WeakReference ref : listeners) {
Listener l = ref.get();
if (l != null) l.onEvent(e);
}
}
}
模式三:ThreadLocal 洩漏
// ❌ 錯誤:ThreadLocal 靜態欄位持有大物件,執行緒池複用時永不釋放
public class RequestContext {
private static final ThreadLocal context = new ThreadLocal<>();
public static void set(BigData data) { context.set(data); }
// 缺少 remove()
}
// ✅ 修正:finally 區塊必須清理
public class RequestContext {
private static final ThreadLocal context = new ThreadLocal<>();
public static void runWithContext(BigData data, Runnable action) {
context.set(data);
try {
action.run();
} finally {
context.remove(); // 關鍵!
}
}
}
模式四:內部類持有外部類引用
// ❌ 錯誤:非靜態內部類實例隱含持有外部類引用
public class Outer {
private BigData bigData = new BigData(); // 大物件
public Runnable createTask() {
return new Runnable() { // 匿名內部類
@Override public void run() {
System.out.println("Task running");
// 隱含持有 Outer.this,導致 bigData 無法 GC
}
};
}
}
// ✅ 修正:使用靜態內部類 + 弱引用傳遞必要資料
public class Outer {
private BigData bigData = new BigData();
public Runnable createTask() {
// 只傳遞需要的資料,不持有整個 Outer
String neededInfo = bigData.extractSummary();
return new TaskRunner(neededInfo); // 靜態內部類
}
private static class TaskRunner implements Runnable {
private final String info;
TaskRunner(String info) { this.info = info; }
@Override public void run() { System.out.println(info); }
}
}
模式五:快取未設定過期/大小限制
// ❌ 無界快取
Map cache = new HashMap<>();
// ✅ 有界快取 (LinkedHashMap LRU)
Map cache = new LinkedHashMap(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_SIZE; // 超過容量移除最舊
}
};
三、null 賦值爭議:有效還是有害?
區域變數:無效甚至有害
public void process(String input) {
StringBuffer sb = new StringBuffer();
sb.append(input).append(" processed");
String result = sb.toString();
sb = null; // ❌ 無意義:方法結束時 sb 自動超出作用域,棧框銷毀等同 null
return result;
}
原則:縮小變數作用域 優於手動設 null。JIT 編譯器會進行逃逸分析,自動優化。
靜態/實例欄位:特定場景有效
public class ImageCache {
private BufferedImage hugeImage; // 實例欄位
public void loadNewImage(File file) throws IOException {
// 關鍵:舊圖片可能佔用大量記憶體 (如 50MB)
// 若不置 null,新舊同時存在直到 GC 觸發
this.hugeImage = null; // 立即釋放舊引用,幫助 GC
this.hugeImage = ImageIO.read(file);
}
}
大物件循環建立場景:Eden 區壓力測試
// 測試:顯式 null vs 依賴 GC
public class GCTest {
private static Object bigObject; // 靜態欄位 = GC Root,永不自動釋放
public static void testExplicitNull(int sizeMB) {
long start = System.currentTimeMillis();
int count = 0;
while (System.currentTimeMillis() - start < 10_000) { // 10 秒
bigObject = null; // 關鍵差異
bigObject = new byte[sizeMB * 1024 * 1024];
count++;
}
System.out.println("Explicit null: " + count + " objects created");
}
public static void testImplicitNull(int sizeMB) {
long start = System.currentTimeMillis();
int count = 0;
while (System.currentTimeMillis() - start < 10_000) {
// bigObject = null; // 註解掉
bigObject = new byte[sizeMB * 1024 * 1024];
count++;
}
System.out.println("Implicit null: " + count + " objects created");
}
}
實測結果:大物件 (超過 Eden 區) 時,顯式 null 可讓舊物件直接在 Young GC 回收,避免提升到 Old 區觸發昂貴的 Full GC。效能差距可達 5-10 倍。
四、記憶體洩漏診斷工具鏈
1. 堆轉儲分析 (Heap Dump)
# 獲取堆轉儲
jmap -dump:live,format=b,file=heap.hprof
# 或觸發 OOM 時自動
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/
# 分析工具
# Eclipse MAT (Memory Analyzer Tool) - 最強大
# VisualVM - 內建、易上手
# JProfiler / YourKit - 商業級、功能豐富
2. MAT 核心分析流程
- Histogram:依類別統計實例數/淺堆/保留堆,找佔用大戶
- Dominator Tree:支配者樹,找「誰持有大量物件不放」
- Path to GC Roots:右鍵可疑物件 →
Merge Shortest Paths to GC Roots → exclude weak/soft/phantom references
- Leak Suspects Report:自動生成洩漏嫌疑報告
3. 即時監控指標
# jstat 監控 GC 行為
jstat -gcutil 1000 10 # 每秒輸出一次,共 10 次
# 關鍵指標
S0/S1/E/O/M/CCS/YGC/YGCT/FGC/FGCT/GCT
# 重點關注:Old 區使用率持續上升、Full GC 頻率增加、GC 耗時變長
4. 生產環境線上診斷 (Arthas / async-profiler)
# Arthas (阿里開源)
java -arthas-boot.jar
# 進入後
dashboard # 系統總覽
heapdump /tmp/h.hprof # 線上堆轉儲
monitor -c 5 com.example.Service method # 方法監控
trace com.example.Service method # 鏈路追蹤
# async-profiler (低開銷 CPU/記憶體分析)
./profiler.sh -d 30 -e alloc -f flamegraph.html # 記憶體分配火焰圖
五、現代 GC 演進與選型指南
| GC | JDK 版本 | 特點 | 適用場景 |
| Serial | 1.0+ | 單執行緒、Stop-the-World | Client 模式、小堆、嵌入式 |
| Parallel (Throughput) | 1.4+ | 多執行緒、吞吐量優先、STW | 批次處理、科學計算、預設 (JDK 8) |
| CMS | 1.5+ | 並發標記、低延遲、碎片整理難、JDK 14 移除 | 舊版低延遲需求 |
| G1 | 9+ | 區域化、可預測停頓、混合 GC、JDK 9-20 預設 | 大堆 (4-32GB)、延遲敏感、通用首選 |
| ZGC | 11+ (實驗) / 15+ (正式) | 亞毫秒延遲、染色指標、多映射、JDK 21+ 預設 | 超大堆 (TB 級)、極低延遲、現代首選 |
| Shenandoah | 12+ (Red Hat) | 並發壓縮、Brooks 指標、低延遲 | 大堆、低延遲、OpenJDK 部署 |
生產環境推薦配置 (JDK 21+)
# ZGC 預設即優秀,大堆微調:
-XX:+UseZGC
-XX:+ZGenerational # 分代 ZGC (JDK 21+),Young/Old 分離
-XX:SoftMaxHeapSize=24g # 軟上限,觸發主動回收
-XX:ZCollectionInterval=30 # 最小 GC 間隔 (秒)
-XX:ZAllocationSpikeTolerance=2 # 分配尖峰容忍倍數
# G1 穩健配置 (JDK 8-20):
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目標停頓
-XX:G1HeapRegionSize=16m # Region 大小 (1-32MB, 2 的冪次)
-XX:+ParallelRefProcEnabled # 並行處理 Reference
-XX:+UnlockExperimentalVMOptions -XX:G1UseAdaptiveIHOP=true
六、生產級記憶體調優檢查清單
- 設定堆大小:
-Xms -Xmx 設相同值,避免動態擴縮容抖動。容器環境 -XX:InitialRAMPercentage=75 -XX:MaxRAMPercentage=75
- 啟用 GC 日誌:
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100m (JDK 9+ 統一日誌)
- 監控關鍵指標:Old 區使用率 < 70%、Full GC 頻率 < 1 次/小時、GC 佔 CPU < 5%
- 排查洩漏:定期比對 Histogram、關注類別載入/卸載 (Metaspace)、ThreadLocal 洩漏
- 避免終結器:
finalize() 已棄用,用 Cleaner 或 try-with-resources
- 直接記憶體:
-XX:MaxDirectMemorySize 限制 NIO/Netty 直接緩衝區
- 類別卸載:動態載入類別 (Groovy/腳本/熱部署) 需
-XX:+ClassUnloadingWithConcurrentMark (G1)
七、總結
Java 記憶體管理核心矛盾在於:開發者關注業務邏輯「何時不再需要」,而 GC 只能判斷「是否可達」。橋接這一鴻溝的關鍵在於:
- 設計階段:明確物件生命週期、所有權轉移、清理契約
- 編碼階段:優先縮小作用域、使用弱引用/工具類快取、ThreadLocal 必配
remove()
- 測試階段:壓力測試 + 堆轉儲分析、基準線建立
- 運行階段:GC 日誌監控、關鍵指標告警、Arthas 線上診斷
- 演進階段:升級 JDK 獲得更強 GC (ZGC/分代 ZGC)、持續優化配置
記住:沒有銀彈,只有持續的觀測、理解與優化。掌握物件引用與 GC 交互機制,是 Java 工程師進階為架構師的必修課。
- 上一篇文章: Java Swing 遊戲開發入門實戰:從視窗架構到事件驅動完整教學 (第一部分)
- 下一篇文章: Java 實作從指定 URL 抓取網頁原始碼完整方案
Copyright ©
程式碼之家 All Rights Reserved