前三章都在資料庫那一側。這一章換到應用程式這一側,看的是同一件事的另一半:記憶體是怎麼被配置、被回收,以及當它不夠用的時候,你的服務會用哪種方式死掉。
先看兩個現象,它們的成因完全不同,但很多人會混在一起:
現象 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 殺掉了。
這一章要處理的東西:
-Xmx 管、哪些不受——這是理解現象 B 的關鍵。ThreadLocal 記憶體洩漏的真正成因。OutOfMemoryError,各自代表什麼、怎麼診斷。-Xmx 設對了還是會被 OOMKilled。容器記憶體限制的部分會用到 容器化技術 ch06 資料持久化 與 ch10 映像瘦身與安全強化 的背景,但沒讀過也能理解這一章。
很多人以為 -Xmx 就是 JVM 的記憶體上限。它只管其中一塊。這個誤解是後面「容器被 OOMKilled」的根源,所以先把地圖攤開。
new 出來的物件都在這裡,也是 GC 的主戰場。分成新生代與老年代。-XX:MaxMetaspaceSize 控制。-Xss)。1000 條執行緒就是接近 1GB。ByteBuffer.allocateDirect()、Netty、部分 JDBC 驅動都會用。由 -XX:MaxDirectMemorySize 控制,預設等於 -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 大概只能設 1200~1400MB。「新生代、老年代、Eden、Survivor、8:1:1」——這些名詞如果用背的,很快就會忘。它們全部都是從一個觀察推導出來的。
想想你寫的程式碼就知道:一個 HTTP 請求進來,建了 DTO、建了字串、建了一堆中間集合,回應送出去之後這些全部都沒用了。真正需要長期存活的東西(快取、連線池、單例 Bean)非常少。
如果 98% 的物件都會死,那麼「找出還活著的那 2%」遠比「找出死掉的那 98%」便宜。
這正是複製演算法(Copying)的前提:
把記憶體切兩半,只用其中一半。
GC 時:把「還活著的物件」複製到另一半,然後整塊清空原來那半。
成本 ∝ 存活物件的數量(很少)
而不是 ∝ 垃圾的數量(很多)「浪費一半」太奢侈了。既然只有 2% 會活下來,根本不需要留 50% 當作複製的目的地——留 10% 就夠了。
新生代 = Eden 80% + Survivor 0 (10%) + Survivor 1 (10%)
平常只用 Eden + 一個 Survivor(共 90%)
GC 時把活著的複製到另一個 Survivor(10%)能活過好幾輪 GC 的物件,很可能會活很久。對這些物件用複製演算法就不划算了(存活率高=複製成本高),所以老年代改用標記-整理(Mark-Compact):標記出活的,然後把它們往一端推擠、整理碎片。
把上一張卡的推導接起來,看一個物件實際會走過的路徑。
幾乎所有物件都在 Eden 配置。配置的動作只是移動一個指標,所以極快(比 C 的 malloc 還快)。
Eden 中還活著的物件 → 複製到 Survivor 0,年齡 +1
Eden 整塊清空(不需要逐一釋放)Eden 活著的 + Survivor 0 活著的 → 一起複製到 Survivor 1,年齡 +1
然後 Eden 與 Survivor 0 整塊清空
下一輪換方向:複製到 Survivor 0
(所以兩個 Survivor 永遠有一個是空的)有四種情況會讓物件搬進老年代:
-XX:MaxTenuringThreshold 次 GC(預設 15)-XX:PretenureSizeThreshold 的物件直接進老年代(避免在 Survivor 之間反覆複製)Full GC 會同時整理新生代與老年代,停頓時間比 Young GC 長一到兩個數量級。這就是下一張卡的主題。
GC 最重要的指標不是「回收了多少記憶體」,而是它讓你的程式停了多久。
GC 需要在某些階段暫停所有應用執行緒——因為它要移動物件、更新參照,過程中不能有人偷改。這段時間你的服務完全沒有回應:不處理請求、不回心跳、不讀 socket。
① GC 開始,所有應用執行緒暫停 2 秒
② 已經借出去的資料庫連線「被凍在半路」
→ 連線沒有還回池裡(ch03)
→ 新進來的請求全部卡在等連線
③ Tomcat 執行緒池被塞滿
→ 新的 HTTP 請求開始排隊,甚至被拒絕
④ 上游服務的呼叫逾時 → 觸發重試
→ 重試讓流量瞬間放大 2~3 倍
⑤ GC 結束,服務恢復——但此時面對的是「原本的流量 + 累積的積壓 + 重試放大」
→ 記憶體壓力更大 → 觸發下一次 Full GC
⑥ 進入 GC 死亡螺旋timeoutSeconds 和 failureThreshold 設得太緊,k8s 會直接重啟這個 Pod——把一次可恢復的 GC 停頓變成一次服務中斷。而重啟後流量轉到其他 Pod,可能讓它們也開始 Full GC。-XX:MaxGCPauseMillis 給一個停頓目標,G1 自己決定這次收幾個 region。GC 判斷「這個物件還要不要」的依據是「還有沒有人參照它」。但參照有強弱之分——這是 Java 少數幾個「你可以直接干預 GC」的地方。
User user = new User(); // 強引用只要強引用還在,GC 絕對不會回收它——寧可拋 OutOfMemoryError 也不動。
SoftReference<byte[]> cache = new SoftReference<>(bigData);記憶體充足時保留,快要 OOM 之前才回收。天生適合做快取——記憶體夠就留著,不夠就自動讓出。
WeakReference<User> ref = new WeakReference<>(user);只要發生 GC 就會被回收,不管記憶體夠不夠。ThreadLocalMap 的 key 就是弱引用——這是下面那個經典洩漏的關鍵。
完全拿不到物件本身,唯一用途是在物件被回收時收到通知,用來管理堆外資源(例如 DirectByteBuffer 的釋放)。一般業務程式碼不會用到。
ThreadLocalMap 的結構:
key = ThreadLocal 物件的「弱引用」
value = 你放進去的東西的「強引用」 ← 問題在這Java heap space。try { ... } finally { threadLocal.remove(); }——永遠在 finally 裡 remove。Spring 的 RequestContextHolder 等元件都有做這件事,自己寫的要記得。字串是 Java 裡最常被建立的物件,所以 JVM 為它做了特別處理——也因此產生了最多的誤解。
intern() 太多會 OutOfMemoryError: PermGen space-Xmx 管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 後面那串字才是重點——五種訊息代表五個完全不同的問題,修法也完全不同。
最常見。物件塞不進 heap 了。
-Xmx-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump,用 MAT 或 VisualVM 開 dump,看「誰佔最多、誰參照著它」JVM 花了超過 98% 的時間在 GC,卻只回收到不到 2% 的記憶體。本質上就是 heap space 的前兆——記憶體幾乎全是活的物件,GC 一直做白工。診斷方式相同。
類別中繼資料放不下。正常應用不會發生,發生了通常代表:
堆外記憶體用完。Netty、NIO、某些 JDBC 驅動會用。-Xmx 調大完全沒用,要調的是 -XX:MaxDirectMemorySize,或者找出沒有釋放 ByteBuffer 的地方。
這個根本不是記憶體不足,是執行緒數量到頂了。可能是 OS 的 ulimit -u 限制,也可能是執行緒洩漏(每次請求都 new Thread() 而沒有用執行緒池)。
-Xmx 完全沒有用,其中一種還會讓情況更糟(heap 變大 → GC 停頓更長)。這是容器化之後最典型、也最難查的一種死法——因為它不留下任何 Java 層面的證據。
kubectl describe pod 顯示 Reason: OOMKilled、Exit Code: 137回到第一張卡: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 預設會把 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:+UseContainerSupportjava -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage'
# 在容器裡直接問 JVM
jcmd <pid> VM.flags
jcmd <pid> VM.native_memory summary # 需要 -XX:NativeMemoryTracking=summary-Xmx 設成跟 limit 一樣——那等於保證會被殺。GC 調校是最容易「看起來很專業,實際上在瞎忙」的領域。先講一個殘酷的事實:
# 開 GC log(JDK 9+ 統一格式)
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=10M
# 要看的三個數字:
# ① Full GC 的「頻率」——一小時幾次?
# ② 每次停頓「多久」——P99 是多少?
# ③ Full GC 之後老年代「降到多少」——降不下來就是洩漏-Xmx / -XX:MaxRAMPercentage——最重要的一個-XX:MaxGCPauseMillis——給 G1 一個停頓目標(不保證達成,但它會朝這個方向調)-XX:+HeapDumpOnOutOfMemoryError——這個一定要開,出事時才有證據-XX:+UseZGC)這個順序跟 ch03 的「先看 SQL → 再看交易邊界 → 最後才調池」是同一個道理:先找根因,再調旋鈕。
| 區域 | 存什麼 | 受哪個參數控制 | 溢出時的錯誤 |
|---|---|---|---|
| Heap | 所有 new 出來的物件、字串常量池(JDK7+) | -Xmx / -Xms | OutOfMemoryError: Java heap space |
| Metaspace | 類別中繼資料(Class 結構、方法資訊) | -XX:MaxMetaspaceSize(預設無上限) | OutOfMemoryError: Metaspace |
| 執行緒堆疊 | 區域變數、方法呼叫堆疊;每條執行緒各一份 | -Xss(預設 512KB~1MB) | StackOverflowError;執行緒開太多則是 unable to create new native thread |
| Direct Memory | NIO ByteBuffer、Netty 緩衝區 | -XX:MaxDirectMemorySize(預設 = -Xmx) | OutOfMemoryError: Direct buffer memory(調 -Xmx 沒用) |
| Code Cache | JIT 編譯後的機器碼 | -XX:ReservedCodeCacheSize | CodeCache is full(效能大幅下降但不會崩) |
| 類型 | 什麼時候被回收 | 典型用途 | 注意 |
|---|---|---|---|
| 強引用 Strong | 永不(只要引用還在) | 一般程式碼的預設 | 寧可 OOM 也不回收——洩漏都是它造成的 |
| 軟引用 Soft | 記憶體不足時(OOM 前) | 快取 | 回收時機不可控、沒有淘汰策略;實務多改用 Caffeine |
| 弱引用 Weak | 下一次 GC 就回收 | ThreadLocalMap 的 key、WeakHashMap | ThreadLocal 的 value 是強引用——洩漏的根源 |
| 虛引用 Phantom | 物件被回收時收到通知 | 管理堆外資源(DirectByteBuffer 釋放) | 拿不到物件本身,業務程式碼幾乎用不到 |
| 錯誤訊息 | 真正的問題 | 調 -Xmx 有用嗎 | 正確修法 |
|---|---|---|---|
| Java heap space | heap 不夠,或有記憶體洩漏 | 看情況 | 先抓 heap dump 確認是洩漏還是真的不夠;洩漏的話調大只是延後爆炸 |
| GC overhead limit exceeded | 98% 時間在 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 137 | RSS 超過容器限制,被 cgroup SIGKILL | ❌ 要調小 | 容器 limit ≈ Xmx × 1.3~1.5,或用 -XX:MaxRAMPercentage=70 |
點擊卡片翻面查看答案,共 15 張。