Java 後端底層 ch04 JVM 記憶體與 GC:從分代假說到容器裡的 OOMKilled
下一章→
CH 04 JVM

JVM 記憶體與 GC:從分代假說到容器裡的 OOMKilled

記憶體分成哪幾塊為什麼要分代一個物件的一生STW 停頓怎麼傳染四種引用與 ThreadLocal 洩漏OOM 圖鑑容器裡的 137

前三章都在資料庫那一側。這一章換到應用程式這一側,看的是同一件事的另一半:記憶體是怎麼被配置、被回收,以及當它不夠用的時候,你的服務會用哪種方式死掉。

先看兩個現象,它們的成因完全不同,但很多人會混在一起:

現象 A:java.lang.OutOfMemoryError: Java heap space
        → 有例外堆疊、有 heap dump、應用程式知道自己死了

現象 B:容器被殺掉,exit code 137
        → 沒有任何 Java 例外、沒有 heap dump、log 裡什麼都沒有
        → kubectl describe pod 顯示 OOMKilled

**現象 B 才是容器化之後最常見、也最難查的一種。**它不是 JVM 丟出來的錯誤—— 是作業系統在 JVM 還沒察覺之前,直接把整個 process 殺掉了。

這一章要處理的東西:

  • JVM 的記憶體到底分成哪幾塊,哪些受 -Xmx 管、哪些不受——這是理解現象 B 的關鍵。
  • 為什麼要分代:從「弱世代假說」推導出 Eden、Survivor 的比例,而不是背下 8:1:1。
  • 一個物件的一生:從 Eden 出生,到晉升老年代的完整路徑。
  • STW 停頓怎麼傳染:一次 2 秒的 Full GC,如何變成整個系統的雪崩——這裡會接回 ch03 的連線池。
  • 四種引用,以及 ThreadLocal 記憶體洩漏的真正成因。
  • OOM 圖鑑:五種不同的 OutOfMemoryError,各自代表什麼、怎麼診斷。
  • 容器裡的 JVM:為什麼 -Xmx 設對了還是會被 OOMKilled。

容器記憶體限制的部分會用到 容器化技術 ch06 資料持久化 與 ch10 映像瘦身與安全強化 的背景,但沒讀過也能理解這一章。

很多人以為 -Xmx 就是 JVM 的記憶體上限。它只管其中一塊。這個誤解是後面「容器被 OOMKilled」的根源,所以先把地圖攤開。

受 -Xmx 管的:只有 Heap

  • Heap(堆)——所有 new 出來的物件都在這裡,也是 GC 的主戰場。分成新生代與老年代。

不受 -Xmx 管的(但一樣佔實體記憶體)

  • Metaspace——類別的中繼資料(Class 結構、方法資訊)。JDK 8 之後放在原生記憶體,預設沒有上限,由 -XX:MaxMetaspaceSize 控制。
  • 執行緒堆疊(Thread Stack)——每條執行緒各一份,預設 512KB~1MB(-Xss)。1000 條執行緒就是接近 1GB。
  • Direct Memory(堆外記憶體)——NIO 的 ByteBuffer.allocateDirect()、Netty、部分 JDBC 驅動都會用。由 -XX:MaxDirectMemorySize 控制,預設等於 -Xmx。
  • Code Cache——JIT 編譯後的機器碼。
  • GC 自身的資料結構——記憶集、標記位圖等,G1 大約要 heap 的 5~10%。
所以 JVM 實際佔用的實體記憶體(RSS)永遠大於 -Xmx。
一個 -Xmx2g 的服務,RSS 到 2.6~3GB 是完全正常的。如果你的容器記憶體限制剛好設 2g,它必死無疑。

快速估算

RSS ≈ Xmx
     + Metaspace(一般 Spring Boot 約 100~256MB)
     + 執行緒數 × Xss(200 條 × 1MB = 200MB)
     + Direct Memory(視使用情況)
     + Code Cache(約 50~100MB)
     + GC 結構(Xmx 的 5~10%)
💡 實用規則:容器記憶體限制至少要是 -Xmx 的 1.3~1.5 倍。反過來說,容器給 2GB 的話,-Xmx 大概只能設 1200~1400MB。

「新生代、老年代、Eden、Survivor、8:1:1」——這些名詞如果用背的,很快就會忘。它們全部都是從一個觀察推導出來的。

那個觀察:弱世代假說(Weak Generational Hypothesis)

絕大多數物件都是「朝生暮死」的——建立之後很快就沒人用了。
實測上,一般 Java 應用大約有 90~98% 的物件在第一次 GC 就可以被回收。

想想你寫的程式碼就知道:一個 HTTP 請求進來,建了 DTO、建了字串、建了一堆中間集合,回應送出去之後這些全部都沒用了。真正需要長期存活的東西(快取、連線池、單例 Bean)非常少。

從這個觀察能推出什麼

如果 98% 的物件都會死,那麼「找出還活著的那 2%」遠比「找出死掉的那 98%」便宜。

這正是複製演算法(Copying)的前提:

把記憶體切兩半,只用其中一半。
GC 時:把「還活著的物件」複製到另一半,然後整塊清空原來那半。

成本 ∝ 存活物件的數量(很少)
而不是 ∝ 垃圾的數量(很多)
  • ✅ 不會產生記憶體碎片(複製過去時直接緊密排列)
  • ✅ 配置新物件時只要移動一個指標(bump the pointer),極快
  • ❌ 浪費一半空間

8:1:1 是怎麼來的

「浪費一半」太奢侈了。既然只有 2% 會活下來,根本不需要留 50% 當作複製的目的地——留 10% 就夠了。

新生代 = Eden 80%  +  Survivor 0 (10%)  +  Survivor 1 (10%)

平常只用 Eden + 一個 Survivor(共 90%)
GC 時把活著的複製到另一個 Survivor(10%)
✅ 8:1:1 不是魔術數字,是「98% 的物件會死」這個觀察的直接結果。知道由來之後,你也能判斷什麼情況下該調整它(例如物件存活率異常高的應用)。

那老年代呢

能活過好幾輪 GC 的物件,很可能會活很久。對這些物件用複製演算法就不划算了(存活率高=複製成本高),所以老年代改用標記-整理(Mark-Compact):標記出活的,然後把它們往一端推擠、整理碎片。

把上一張卡的推導接起來,看一個物件實際會走過的路徑。

第 1 步:在 Eden 出生

幾乎所有物件都在 Eden 配置。配置的動作只是移動一個指標,所以極快(比 C 的 malloc 還快)。

第 2 步:Eden 滿了 → 觸發 Young GC(Minor GC)

Eden 中還活著的物件  →  複製到 Survivor 0,年齡 +1
Eden 整塊清空(不需要逐一釋放)

第 3 步:之後的每次 Young GC

Eden 活著的 + Survivor 0 活著的  →  一起複製到 Survivor 1,年齡 +1
然後 Eden 與 Survivor 0 整塊清空

下一輪換方向:複製到 Survivor 0
(所以兩個 Survivor 永遠有一個是空的)

第 4 步:晉升老年代(Promotion)

有四種情況會讓物件搬進老年代:

  1. 年齡達標——熬過 -XX:MaxTenuringThreshold 次 GC(預設 15)
  2. 動態年齡判定——如果同一年齡的物件總大小超過 Survivor 的一半,那個年齡以上的全部直接晉升,不用等到 15
  3. 大物件直接晉升——超過 -XX:PretenureSizeThreshold 的物件直接進老年代(避免在 Survivor 之間反覆複製)
  4. Survivor 放不下——空間不足時直接進老年代(稱為「分配擔保」)
常見的效能問題:過早晉升(Premature Promotion)
如果 Survivor 太小,本來該死在新生代的物件被迫提早進老年代。老年代填滿的速度變快 → Full GC 變頻繁 → 停頓變長。

徵狀:Young GC 很頻繁但每次回收的量不大,老年代使用量呈階梯狀持續上升,最後 Full GC 一次清掉一大半——這代表那些物件其實是垃圾,只是死錯地方了。

第 5 步:老年代滿了 → Full GC

Full GC 會同時整理新生代與老年代,停頓時間比 Young GC 長一到兩個數量級。這就是下一張卡的主題。

GC 最重要的指標不是「回收了多少記憶體」,而是它讓你的程式停了多久。

什麼是 STW(Stop-The-World)

GC 需要在某些階段暫停所有應用執行緒——因為它要移動物件、更新參照,過程中不能有人偷改。這段時間你的服務完全沒有回應:不處理請求、不回心跳、不讀 socket。

  • Young GC:通常 1~50 毫秒,頻繁但可接受
  • Full GC:舊的收集器可能好幾秒,heap 越大越久

一次 2 秒的 Full GC 會發生什麼(連鎖反應)

① GC 開始,所有應用執行緒暫停 2 秒

② 已經借出去的資料庫連線「被凍在半路」
   → 連線沒有還回池裡(ch03)
   → 新進來的請求全部卡在等連線

③ Tomcat 執行緒池被塞滿
   → 新的 HTTP 請求開始排隊,甚至被拒絕

④ 上游服務的呼叫逾時 → 觸發重試
   → 重試讓流量瞬間放大 2~3 倍

⑤ GC 結束,服務恢復——但此時面對的是「原本的流量 + 累積的積壓 + 重試放大」
   → 記憶體壓力更大 → 觸發下一次 Full GC

⑥ 進入 GC 死亡螺旋
還有一個常被忽略的殺手:健康檢查。
Kubernetes 的 liveness probe 在 STW 期間收不到回應。如果 timeoutSeconds 和 failureThreshold 設得太緊,k8s 會直接重啟這個 Pod——把一次可恢復的 GC 停頓變成一次服務中斷。而重啟後流量轉到其他 Pod,可能讓它們也開始 Full GC。

現代收集器怎麼解決

  • G1(JDK 9 起的預設)——把 heap 切成數千個 region,每次只回收「垃圾最多的幾個 region」(Garbage First 的由來),用 -XX:MaxGCPauseMillis 給一個停頓目標,G1 自己決定這次收幾個 region。
  • ZGC / Shenandoah——標記與整理幾乎全部並行執行,停頓時間與 heap 大小無關,通常在 1 毫秒以內。代價是吞吐量略低、CPU 用得多一些。
✅ 判斷準則:如果你的服務對延遲敏感(線上 API),選低停頓的收集器;如果是批次處理(在意總處理時間),吞吐優先的反而更好。沒有一個收集器在所有面向都最好——這是取捨不是排名。

GC 判斷「這個物件還要不要」的依據是「還有沒有人參照它」。但參照有強弱之分——這是 Java 少數幾個「你可以直接干預 GC」的地方。

強引用(Strong)——預設的那種

User user = new User();   // 強引用

只要強引用還在,GC 絕對不會回收它——寧可拋 OutOfMemoryError 也不動。

軟引用(Soft)——記憶體不夠時才回收

SoftReference<byte[]> cache = new SoftReference<>(bigData);

記憶體充足時保留,快要 OOM 之前才回收。天生適合做快取——記憶體夠就留著,不夠就自動讓出。

但實務上很少人手寫軟引用快取,因為回收時機不可控、也沒有淘汰策略。用 Caffeine 或 Guava Cache(可以設定大小上限與過期時間)通常更好。

弱引用(Weak)——下一次 GC 就回收

WeakReference<User> ref = new WeakReference<>(user);

只要發生 GC 就會被回收,不管記憶體夠不夠。ThreadLocalMap 的 key 就是弱引用——這是下面那個經典洩漏的關鍵。

虛引用(Phantom)——只用來收屍

完全拿不到物件本身,唯一用途是在物件被回收時收到通知,用來管理堆外資源(例如 DirectByteBuffer 的釋放)。一般業務程式碼不會用到。

ThreadLocal 記憶體洩漏:真正的原因

ThreadLocalMap 的結構:
  key   = ThreadLocal 物件的「弱引用」
  value = 你放進去的東西的「強引用」   ← 問題在這
  1. ThreadLocal 變數本身沒人參照了 → key 被 GC 回收,變成 null
  2. 但 value 是強引用,還掛在 ThreadLocalMap 上
  3. ThreadLocalMap 屬於 Thread → 只要那條執行緒還活著,value 就永遠不會被回收
為什麼在 Web 應用特別致命:Tomcat 的執行緒是重複使用的。一條執行緒可能活好幾天、處理幾百萬個請求。每個請求留下一個沒清掉的 value,累積起來就是穩定成長的記憶體洩漏。

徵狀:老年代使用量隨執行時間單調上升,Full GC 之後也降不下來,最後 Java heap space。
修法:try { ... } finally { threadLocal.remove(); }——永遠在 finally 裡 remove。Spring 的 RequestContextHolder 等元件都有做這件事,自己寫的要記得。

字串是 Java 裡最常被建立的物件,所以 JVM 為它做了特別處理——也因此產生了最多的誤解。

常量池在哪裡

  • JDK 6 以前:在永久代(PermGen),空間小,intern() 太多會 OutOfMemoryError: PermGen space
  • JDK 7 之後:移到 Heap 裡,所以可以被 GC 回收,也受 -Xmx 管
  • JDK 8:永久代整個廢除,換成 Metaspace(放類別中繼資料),字串常量池仍在 Heap

字面量 vs new String()

String a = "hello";              // 直接指向常量池裡的物件
String b = "hello";              // 指向「同一個」常量池物件
String c = new String("hello");  // 在 heap 新建一個物件,內容相同但位址不同

a == b        // true   ← 同一個物件
a == c        // false  ← 不同物件
a.equals(c)   // true   ← 內容相同
== 比的是「是不是同一個物件」,equals() 比的是「內容一不一樣」。字串會讓人混淆,正是因為常量池讓字面量共用同一個物件,使得 == 有時候「剛好」是對的。

編譯期常量折疊

String a = "hello";
String b = "hel" + "lo";        // 編譯器直接算成 "hello" → a == b 為 true

String hel = "hel";
String c = hel + "lo";          // 執行期才拼接 → 新物件 → a == c 為 false

迴圈裡拼接字串為什麼慢

// ❌ 每一圈都產生一個新的 String 物件
String result = "";
for (String s : list) { result += s; }

// ✅ 只有一個可變的緩衝區
StringBuilder sb = new StringBuilder();
for (String s : list) { sb.append(s); }

String 是不可變的,+= 實際上是建新物件再丟掉舊的。一萬圈就產生一萬個垃圾物件——這正是新生代 GC 的主要來源之一。

💡 不要濫用 intern()。它會把字串放進常量池,聽起來很省記憶體,但常量池的雜湊表本身也有成本,而且放進去的字串生命週期會被拉長。只有在確定有大量重複字串(例如解析固定格式的資料)時才值得。

OutOfMemoryError 後面那串字才是重點——五種訊息代表五個完全不同的問題,修法也完全不同。

1. Java heap space

最常見。物件塞不進 heap 了。

  • 可能是真的不夠:流量成長、快取設太大 → 調 -Xmx
  • 可能是記憶體洩漏:某個集合只進不出、ThreadLocal 沒清、監聽器沒移除 → 調大只是延後爆炸時間
  • 診斷:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump,用 MAT 或 VisualVM 開 dump,看「誰佔最多、誰參照著它」

2. GC overhead limit exceeded

JVM 花了超過 98% 的時間在 GC,卻只回收到不到 2% 的記憶體。本質上就是 heap space 的前兆——記憶體幾乎全是活的物件,GC 一直做白工。診斷方式相同。

3. Metaspace

類別中繼資料放不下。正常應用不會發生,發生了通常代表:

  • 動態產生類別(CGLIB 代理、Groovy 腳本、大量反射)沒有節制
  • 應用程式重複熱部署,舊的 ClassLoader 沒被回收

4. Direct buffer memory

堆外記憶體用完。Netty、NIO、某些 JDBC 驅動會用。-Xmx 調大完全沒用,要調的是 -XX:MaxDirectMemorySize,或者找出沒有釋放 ByteBuffer 的地方。

5. unable to create new native thread

這個根本不是記憶體不足,是執行緒數量到頂了。可能是 OS 的 ulimit -u 限制,也可能是執行緒洩漏(每次請求都 new Thread() 而沒有用執行緒池)。

診斷的第一步永遠是:看完整的錯誤訊息,不要只看到「OutOfMemoryError」就直接加 -Xmx。五種裡面有三種調 -Xmx 完全沒有用,其中一種還會讓情況更糟(heap 變大 → GC 停頓更長)。

這是容器化之後最典型、也最難查的一種死法——因為它不留下任何 Java 層面的證據。

徵狀

  • Pod 突然重啟,kubectl describe pod 顯示 Reason: OOMKilled、Exit Code: 137
  • 應用程式 log 裡什麼都沒有——沒有例外、沒有 heap dump、沒有 GC log 的異常
  • 最後一行 log 可能只是一個正常的請求處理完成
137 = 128 + 9,代表 process 收到 SIGKILL。是 Linux 的 cgroup 記憶體控制器在 JVM 還沒察覺之前,直接把整個 process 殺掉了。SIGKILL 無法被攔截,所以 JVM 沒有機會做任何事——這就是為什麼什麼證據都沒有。

原因一:RSS 超過容器限制(最常見)

回到第一張卡:JVM 佔用的實體記憶體遠大於 -Xmx。

容器 memory limit: 2Gi
-Xmx2g                     ← 看起來剛剛好

實際 RSS:
  Heap          2.0 GB
+ Metaspace     0.2 GB
+ 執行緒堆疊     0.2 GB   (200 條 × 1MB)
+ Code Cache    0.1 GB
+ GC 結構       0.15 GB
= 約 2.65 GB   ← 超過 2Gi,被殺

原因二:JVM 看不到容器的限制(舊版本)

JVM 預設會把 heap 設成「實體記憶體的 1/4」。JDK 8u191 以前的版本讀到的是「宿主機」的記憶體,不是容器的 limit:

宿主機 64GB,容器 limit 2GB
舊 JDK:預設 -Xmx = 64GB / 4 = 16GB
→ JVM 開心地一直配置到 16GB → 早就被 cgroup 殺掉了

修法

# ① 用百分比而不是絕對值(JDK 10+,會讀容器 limit)
-XX:MaxRAMPercentage=70.0

# ② 明確限制非 heap 的部分
-XX:MaxMetaspaceSize=256m
-Xss512k                      # 執行緒多的話特別有效
-XX:MaxDirectMemorySize=256m

# ③ 確認 JVM 有看到容器限制(JDK 10+ 預設開啟)
-XX:+UseContainerSupport

怎麼確認 JVM 看到的是什麼

java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage'

# 在容器裡直接問 JVM
jcmd <pid> VM.flags
jcmd <pid> VM.native_memory summary   # 需要 -XX:NativeMemoryTracking=summary
🚨 規則:容器 memory limit ≈ -Xmx × 1.3~1.5,或直接用 MaxRAMPercentage=70 讓 JVM 自己算。不要把 -Xmx 設成跟 limit 一樣——那等於保證會被殺。

GC 調校是最容易「看起來很專業,實際上在瞎忙」的領域。先講一個殘酷的事實:

絕大多數的記憶體問題,根因是記憶體洩漏或設定錯誤,而不是「GC 演算法選錯」。換收集器、加一堆參數,通常只是把爆炸時間往後延幾小時。

先量再調,而且要量對東西

# 開 GC log(JDK 9+ 統一格式)
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=10M

# 要看的三個數字:
#   ① Full GC 的「頻率」——一小時幾次?
#   ② 每次停頓「多久」——P99 是多少?
#   ③ Full GC 之後老年代「降到多少」——降不下來就是洩漏
💡 第 ③ 點是判斷洩漏最快的方法:如果每次 Full GC 之後老年代的使用量都比上一次高,而且呈單調上升——那是洩漏,不是 GC 問題。這時調任何 GC 參數都沒有用。

不要做的事

  • 不要抄別人的參數。網路上那些「JVM 調優黃金參數」是為特定的硬體、heap 大小、應用型態調的。抄過來很可能更差。
  • 不要一次改多個參數。改了三個東西之後變快了,你不知道是哪一個有效,也不知道另外兩個是不是反而有害。
  • 不要在沒有 GC log 的情況下調參數。那是猜,不是調。
  • 不要因為「Full GC 很可怕」就把 heap 開很大。heap 越大,單次 Full GC 的停頓越長。

真正常用的參數其實只有幾個

  • -Xmx / -XX:MaxRAMPercentage——最重要的一個
  • -XX:MaxGCPauseMillis——給 G1 一個停頓目標(不保證達成,但它會朝這個方向調)
  • -XX:+HeapDumpOnOutOfMemoryError——這個一定要開,出事時才有證據
  • 對延遲極度敏感時:換 ZGC(-XX:+UseZGC)

正確的排查順序

  1. 看完整的 OOM 訊息 → 確認是哪一種 OOM(五種修法完全不同)
  2. 看 GC log → Full GC 後老年代降不下來?那是洩漏,去抓 heap dump
  3. heap dump 裡看 dominator tree → 誰佔最多、誰參照著它
  4. 修掉洩漏或不合理的快取
  5. 以上都做完了、確認記憶體用量合理,才輪到調 GC 參數

這個順序跟 ch03 的「先看 SQL → 再看交易邊界 → 最後才調池」是同一個道理:先找根因,再調旋鈕。

JVM 記憶體區域:誰管得到、爆掉會怎樣
區域存什麼受哪個參數控制溢出時的錯誤
Heap所有 new 出來的物件、字串常量池(JDK7+)-Xmx / -XmsOutOfMemoryError: Java heap space
Metaspace類別中繼資料(Class 結構、方法資訊)-XX:MaxMetaspaceSize(預設無上限)OutOfMemoryError: Metaspace
執行緒堆疊區域變數、方法呼叫堆疊;每條執行緒各一份-Xss(預設 512KB~1MB)StackOverflowError;執行緒開太多則是 unable to create new native thread
Direct MemoryNIO ByteBuffer、Netty 緩衝區-XX:MaxDirectMemorySize(預設 = -Xmx)OutOfMemoryError: Direct buffer memory(調 -Xmx 沒用)
Code CacheJIT 編譯後的機器碼-XX:ReservedCodeCacheSizeCodeCache is full(效能大幅下降但不會崩)
四種引用:什麼時候被回收、拿來做什麼
類型什麼時候被回收典型用途注意
強引用 Strong永不(只要引用還在)一般程式碼的預設寧可 OOM 也不回收——洩漏都是它造成的
軟引用 Soft記憶體不足時(OOM 前)快取回收時機不可控、沒有淘汰策略;實務多改用 Caffeine
弱引用 Weak下一次 GC 就回收ThreadLocalMap 的 key、WeakHashMapThreadLocal 的 value 是強引用——洩漏的根源
虛引用 Phantom物件被回收時收到通知管理堆外資源(DirectByteBuffer 釋放)拿不到物件本身,業務程式碼幾乎用不到
OOM 診斷表:訊息 → 原因 → 修法
錯誤訊息真正的問題調 -Xmx 有用嗎正確修法
Java heap spaceheap 不夠,或有記憶體洩漏看情況先抓 heap dump 確認是洩漏還是真的不夠;洩漏的話調大只是延後爆炸
GC overhead limit exceeded98% 時間在 GC 但回收不到 2%——heap space 的前兆看情況同上,本質是同一個問題
Metaspace動態產生類別失控、ClassLoader 洩漏❌ 完全沒用調 -XX:MaxMetaspaceSize;找出動態產生類別的地方
Direct buffer memory堆外記憶體用完(Netty、NIO)❌ 完全沒用調 -XX:MaxDirectMemorySize;找出沒釋放 ByteBuffer 的地方
unable to create new native thread執行緒數到頂——根本不是記憶體問題❌ 反而更糟檢查 ulimit -u;找出沒用執行緒池而直接 new Thread() 的地方
(沒有訊息)exit code 137RSS 超過容器限制,被 cgroup SIGKILL❌ 要調小容器 limit ≈ Xmx × 1.3~1.5,或用 -XX:MaxRAMPercentage=70

練習題 點選選項查看解析

0 / 10
01 / 10
為什麼新生代要分成 Eden、Survivor 0、Survivor 1,比例是 8:1:1?
A 這是 JVM 規範強制規定的比例
B 因為約 98% 的物件朝生暮死,複製演算法只需要少量空間當複製目的地,留 10% 就夠
C 為了讓三個區域的 GC 可以並行執行
D 因為 CPU 快取行的大小剛好是 8:1:1
解析
弱世代假說:絕大多數物件很快就死。所以「找出活的」比「找出死的」便宜,適合複製演算法。而既然只有 2% 會活下來,就不需要留 50% 當複製目的地,10% 就夠——8:1:1 是這個觀察的直接結果,不是魔術數字。
02 / 10
-Xmx2g 的 Java 服務,容器 memory limit 也設 2Gi,會發生什麼?
A 剛剛好,是最佳設定
B JVM 會自動把 heap 縮小以留出空間
C RSS 會超過 2Gi(還有 Metaspace、執行緒堆疊、Code Cache、GC 結構),Pod 會被 OOMKilled
D 會出現 OutOfMemoryError: Java heap space
解析
-Xmx 只管 heap。Metaspace、執行緒堆疊(200 條 × 1MB)、Direct Memory、Code Cache、GC 自身結構都在 heap 之外,加起來可能多出 30~50%。容器 limit 至少要是 -Xmx 的 1.3~1.5 倍,或改用 -XX:MaxRAMPercentage=70 讓 JVM 自己算。
03 / 10
Pod 重啟、exit code 137、應用 log 裡什麼都沒有。為什麼沒有任何 Java 例外或 heap dump?
A 因為 log 被截斷了,要去看更早的日誌
B 137 = 128 + 9 代表 SIGKILL,是 cgroup 直接殺掉 process,SIGKILL 無法被攔截,JVM 沒有機會做任何事
C 因為 JVM 崩潰時會清空 log
D 因為 heap dump 需要額外設定,跟 137 無關
解析
OOMKilled 不是 JVM 丟出來的錯誤——是 Linux cgroup 記憶體控制器在 JVM 察覺之前把整個 process 殺掉。SIGKILL 無法被攔截或處理,所以不會有例外堆疊、不會觸發 HeapDumpOnOutOfMemoryError。這正是它難查的原因。
04 / 10
ThreadLocal 記憶體洩漏的真正成因是什麼?
A ThreadLocal 物件本身沒有被回收
B ThreadLocalMap 的 key 是弱引用會被回收,但 value 是強引用,只要執行緒還活著就不會被回收
C ThreadLocal 使用了堆外記憶體
D ThreadLocal 會阻止整條執行緒被回收
解析
key 是弱引用(GC 後變 null),但 value 是強引用且掛在 Thread 的 ThreadLocalMap 上。Tomcat 的執行緒是重複使用的,一條執行緒可能活好幾天處理幾百萬個請求,每個請求留下一個沒清的 value 就是穩定成長的洩漏。修法是永遠在 finally 裡 remove()。
05 / 10
出現 OutOfMemoryError: Direct buffer memory,調大 -Xmx 有用嗎?
A 有用,heap 變大之後堆外記憶體也會跟著變大
B 沒用。Direct Memory 在 heap 之外,要調的是 -XX:MaxDirectMemorySize 或找出沒釋放 ByteBuffer 的地方
C 有用,但要同時調 -Xms
D 沒用,只能換成 heap ByteBuffer
解析
五種 OOM 裡有三種調 -Xmx 完全沒用(Metaspace、Direct buffer、native thread)。所以診斷第一步永遠是看完整訊息,而不是看到 OutOfMemoryError 就加 -Xmx。順帶一提 MaxDirectMemorySize 的預設值等於 -Xmx,這也是容易誤判的地方。
06 / 10
一次 2 秒的 Full GC 為什麼可能引發整個系統雪崩?
A 因為 GC 會清掉連線池裡的連線
B 所有執行緒暫停 → 借出的連線沒還回池 → 新請求卡在等連線 → 上游逾時重試放大流量 → GC 結束後壓力更大 → 下一次 Full GC
C 因為 Full GC 會導致資料庫連線中斷
D 因為 GC 期間會大量寫入磁碟
解析
STW 讓已借出的連線「凍在半路」(ch03),新請求卡住、Tomcat 執行緒塞滿、上游逾時重試把流量放大 2~3 倍。GC 結束後面對的是原本流量+積壓+重試放大,於是進入死亡螺旋。另一個殺手是 k8s liveness probe 在 STW 期間收不到回應而重啟 Pod。
07 / 10
怎麼從 GC log 判斷「這是記憶體洩漏,不是 GC 參數問題」?
A 看 Young GC 的頻率有沒有變高
B 看每次 Full GC 之後老年代的使用量——如果降不下來且單調上升,就是洩漏
C 看 GC 總耗時佔比是否超過 5%
D 看 Eden 區的回收率是否低於 90%
解析
Full GC 已經是最徹底的回收,如果之後老年代還降不下來、而且每次都比上一次高,代表那些物件全都還被強引用著——那是洩漏。這時調任何 GC 參數都沒有用,該做的是抓 heap dump 看 dominator tree。
08 / 10
String a = "hello"; String c = new String("hello"); 則 a == c 的結果與原因?
A true,因為內容相同
B false,因為 a 指向常量池的物件,c 是 heap 上新建的另一個物件
C true,因為 JVM 會自動 intern
D 編譯錯誤
解析
== 比的是「是不是同一個物件」,equals() 比的是「內容一不一樣」。字面量會共用常量池裡的同一個物件(所以兩個字面量 == 是 true),但 new String() 一定會在 heap 上建新物件。字串之所以容易混淆,正是因為常量池讓 == 有時候「剛好」是對的。
09 / 10
「過早晉升(Premature Promotion)」的徵狀是什麼?
A Young GC 很少發生,但每次都很久
B Young GC 頻繁但每次回收量不大,老年代呈階梯狀持續上升,最後 Full GC 一次清掉一大半
C Metaspace 使用量持續上升
D Direct Memory 用量異常
解析
Survivor 太小時,本該死在新生代的物件被迫提早進老年代。老年代填滿變快 → Full GC 變頻繁。關鍵線索是「Full GC 一次清掉一大半」——代表那些物件其實是垃圾,只是死錯地方了。
10 / 10
JDK 8u191 以前的 JVM 跑在容器裡,為什麼容易被 OOMKilled?
A 因為舊版 JVM 有記憶體洩漏的 bug
B 因為 JVM 讀到的是「宿主機」的記憶體而不是容器 limit,預設 heap 設成宿主機記憶體的 1/4
C 因為舊版 JVM 不支援 G1 收集器
D 因為容器的檔案系統是唯讀的
解析
宿主機 64GB、容器 limit 2GB 時,舊 JVM 會把預設 -Xmx 設成 16GB,然後開心地一直配置到早就超過容器限制。JDK 10+ 預設開啟 UseContainerSupport 會讀 cgroup 的 limit,搭配 -XX:MaxRAMPercentage 使用最安全。

點擊卡片翻面查看答案,共 15 張。

QUESTION
-Xmx 管的是哪一塊?哪些不受它管?
點擊翻面
ANSWER
只管 Heap。不受管的有:Metaspace、執行緒堆疊(每條 512KB~1MB)、Direct Memory、Code Cache、GC 自身結構。所以 RSS 永遠大於 -Xmx,通常多 30~50%。
點擊翻回
QUESTION
弱世代假說是什麼?能推出什麼?
點擊翻面
ANSWER
絕大多數物件朝生暮死(實測 90~98% 第一次 GC 就能回收)。推論:「找出活的」比「找出死的」便宜 → 新生代適合複製演算法 → 只需留 10% 當複製目的地 → 8:1:1。
點擊翻回
QUESTION
為什麼老年代不用複製演算法?
點擊翻面
ANSWER
複製演算法的成本正比於「存活物件數量」。老年代裡的物件存活率高,複製成本就高,所以改用標記-整理(Mark-Compact):標記出活的,往一端推擠、順便整理碎片。
點擊翻回
QUESTION
物件晉升老年代的四種情況?
點擊翻面
ANSWER
① 年齡達標(預設熬過 15 次 GC)② 動態年齡判定(同齡物件超過 Survivor 一半,該年齡以上全部晉升)③ 大物件直接晉升 ④ Survivor 放不下(分配擔保)。
點擊翻回
QUESTION
過早晉升的徵狀?
點擊翻面
ANSWER
Young GC 頻繁但回收量不大,老年代階梯狀上升,最後 Full GC 一次清掉一大半——代表那些物件其實是垃圾,只是因為 Survivor 太小而死錯地方。
點擊翻回
QUESTION
一次 2 秒 Full GC 的連鎖反應?
點擊翻面
ANSWER
執行緒全停 → 借出的連線凍在半路沒還池(ch03)→ 新請求卡在等連線 → Tomcat 執行緒塞滿 → 上游逾時重試放大流量 2~3 倍 → GC 結束後壓力更大 → 死亡螺旋。另外 k8s liveness probe 收不到回應可能直接重啟 Pod。
點擊翻回
QUESTION
G1 的「Garbage First」是什麼意思?
點擊翻面
ANSWER
把 heap 切成數千個 region,每次只回收「垃圾最多的那幾個 region」。用 -XX:MaxGCPauseMillis 給停頓目標,G1 自己決定這次收幾個。JDK 9 起是預設收集器。
點擊翻回
QUESTION
四種引用各自何時被回收?
點擊翻面
ANSWER
強:永不(寧可 OOM)。軟:記憶體不足時(OOM 前)。弱:下一次 GC。虛:拿不到物件,只在被回收時收到通知。
點擊翻回
QUESTION
ThreadLocal 洩漏的機制與修法?
點擊翻面
ANSWER
ThreadLocalMap 的 key 是弱引用(會變 null)但 value 是強引用,掛在 Thread 上。Tomcat 執行緒重複使用、可能活好幾天,累積就是洩漏。修法:try { ... } finally { threadLocal.remove(); }。
點擊翻回
QUESTION
== 和 equals 對字串的差別?
點擊翻面
ANSWER
== 比「是不是同一個物件」,equals 比「內容一不一樣」。字面量共用常量池的同一物件(== 為 true),new String() 一定是新物件(== 為 false)。容易混淆正是因為 == 有時候「剛好」對。
點擊翻回
QUESTION
五種 OOM 裡,哪幾種調 -Xmx 沒用?
點擊翻面
ANSWER
三種完全沒用:Metaspace(調 MaxMetaspaceSize)、Direct buffer memory(調 MaxDirectMemorySize)、unable to create new native thread(根本不是記憶體問題,是執行緒數到頂)。所以第一步永遠是看完整訊息。
點擊翻回
QUESTION
exit code 137 代表什麼?為什麼沒有任何證據?
點擊翻面
ANSWER
137 = 128 + 9 = SIGKILL。cgroup 在 JVM 察覺之前直接殺掉 process,SIGKILL 無法被攔截,所以沒有例外、沒有 heap dump、log 停在最後一個正常請求。
點擊翻回
QUESTION
容器裡 -Xmx 該怎麼設?
點擊翻面
ANSWER
容器 limit ≈ -Xmx × 1.3~1.5,或直接用 -XX:MaxRAMPercentage=70 讓 JVM 讀 cgroup limit 自己算。絕對不要把 -Xmx 設成跟 limit 一樣——那等於保證被殺。
點擊翻回
QUESTION
怎麼判斷「是洩漏」而不是「GC 參數沒調好」?
點擊翻面
ANSWER
看 GC log:每次 Full GC 之後老年代的使用量如果降不下來、而且單調上升,就是洩漏。Full GC 已是最徹底的回收,降不下來代表全被強引用著。這時調任何 GC 參數都沒用,該去抓 heap dump。
點擊翻回
QUESTION
GC 排查的正確順序?
點擊翻面
ANSWER
① 看完整 OOM 訊息(五種修法不同)② 看 GC log,Full GC 後老年代降不下來就是洩漏 ③ 抓 heap dump 看 dominator tree ④ 修洩漏或不合理的快取 ⑤ 確認用量合理之後,才輪到調 GC 參數。跟 ch03「先看 SQL 再調池」是同一個道理。
點擊翻回