JVM 執行模型:從 .java 到 bytecode 到 JIT 進階
Java 的口號是「write once, run anywhere」,靠的是中間層:javac 把原始碼編譯成與平台無關的 bytecode(.class),再由各平台的 JVM 執行。和 CPython 最大的不同是:HotSpot JVM 不只直譯,它會在執行期觀察熱點,把熱的方法編譯成機器碼(JIT),而且是基於實際執行資料做激進最佳化。這是 Java 服務「暖機後很快」的根本原因。
類別載入(class loading)
- Loading:找到 .class 的位元組並建立
Class物件。JDK 9 之後有三層 loader:Bootstrap(載入java.base等核心模組)、Platform(原 Extension)、Application(classpath / module path 上的程式)。 - Linking:驗證(verification,確保 bytecode 不會破壞 JVM 安全性)→ 準備(preparation,為 static 欄位配置記憶體並給預設值)→ 解析(resolution,把符號參考換成直接參考,通常延遲進行)。
- Initialization:執行 static initializer 與 static 欄位的賦值。只在類別第一次被「主動使用」時發生,例如
new、呼叫 static 方法、存取非常數的 static 欄位。JVM 保證初始化是 thread-safe 的,這也是 holder pattern 單例可以不加鎖的原因。
雙親委派(parent delegation):loader 收到請求先交給父 loader,父找不到才自己載入。好處是核心類別(例如 java.lang.String)不會被應用程式偷換、同一個類別不會被重複載入。Tomcat 等容器會為每個 web app 建立獨立 loader 並部分打破委派,以隔離不同應用的依賴版本。
JVM 判斷兩個類別是否相同,看的是「全名 + 載入它的 class loader」。同一個 .class 被兩個 loader 載入,會變成兩個不同類別,互相轉型會拋 ClassCastException。熱部署、plugin 系統、Spring Boot DevTools 的 restart classloader 偶爾會遇到這種問題。
用 javap 看 bytecode
public class Add {
static int add(int a, int b) {
int c = a + b;
return c;
}
}
$ javac Add.java && javap -c Add
static int add(int, int);
Code:
0: iload_0 // 把 local 變數 0(a)推上 operand stack
1: iload_1 // 推 b
2: iadd // 彈出兩個 int 相加,結果推回
3: istore_2 // 存到 local 變數 2(c)
4: iload_2
5: ireturn
和 CPython 一樣,JVM 也是 stack-based 虛擬機,但 bytecode 是有型別的(iadd 是 int 相加、dadd 是 double),因為 Java 是靜態型別,型別在編譯期就決定了。這讓 JIT 不需要像動態語言那樣猜型別,能產生很緊湊的機器碼。
JIT 與分層編譯(tiered compilation)
HotSpot 預設啟用分層編譯:方法一開始由直譯器執行並收集 profile(呼叫次數、分支走向、實際型別);夠熱就交給 C1 快速編譯成還算快的機器碼;再更熱、profile 足夠時由 C2 做激進最佳化。C2 常見的最佳化有:
- Inlining
- 把小方法的本體直接嵌進呼叫端,省去呼叫成本,也讓後續最佳化看得到更大範圍。getter/setter 幾乎一定會被 inline,所以不用為了效能避免封裝。
- Escape analysis
- 判斷物件是否會「逃出」方法或執行緒。不逃逸的物件可做 scalar replacement(拆成 local 變數,完全不在 heap 配置),以及 lock elision(消除不必要的
synchronized)。 - Devirtualization
- 若 profile 顯示某個介面呼叫點永遠只看到一種實作(monomorphic),就直接呼叫該實作甚至 inline,並放一個型別檢查當護欄。
- Deoptimization
- 當最佳化的假設失效(例如載入了新的子類別、出現新型別),JVM 會丟棄機器碼退回直譯器,之後再重新編譯。這是「推測式最佳化」能安全的關鍵。
- OSR
- On-Stack Replacement:一個跑很久的迴圈不用等方法結束,就能在迴圈中途切換到編譯後的版本。
兩段式:javac 先編譯成 bytecode,JVM 執行時先直譯並收集 profile,熱點再由 JIT(C1 → C2)編譯成機器碼,且能依執行期資料做推測式最佳化、假設失效時 deoptimize。所以長時間執行的服務暖機後效能接近 C++,但冷啟動慢、暖機期間延遲較高。追問「怎麼解決冷啟動」:GraalVM native image(AOT 編譯,啟動毫秒級但失去部分動態特性與峰值最佳化)、CDS / AppCDS(類別資料共享,加速載入)、JDK 24 之後的 AOT cache(Project Leyden 系列 JEP),或在 readiness probe 前先打暖機流量。
以廣告平台常見做法為例:流量高峰時 HPA 擴出新的 Pod,如果新 Pod 一 ready 就接滿流量,JIT 還沒編譯熱點,p99 會短暫飆高,對 RTB 這種約 100ms 內要回應的場景是實際問題。常見對策:readiness probe 在暖機完成後才回成功、LB 端 slow start(新節點權重逐步上升)、啟動時先用錄製的流量自我暖機。
執行期記憶體區域與物件配置 高頻
「JVM 記憶體分哪幾塊」是 Java 面試的開場題之一。要分清楚哪些是執行緒私有、哪些是共享,以及每一塊滿了會拋什麼錯。
| 區域 | 共享? | 放什麼 | 出錯時 |
|---|---|---|---|
| Heap | 所有執行緒共享 | 所有物件實例與陣列;GC 管理的主戰場 | OutOfMemoryError: Java heap space |
| Metaspace | 共享 | 類別的 metadata(方法、欄位描述、常數池等);Java 8 起取代 PermGen,使用 native memory | OutOfMemoryError: Metaspace |
| JVM Stack | 每個執行緒一個 | 每次方法呼叫一個 frame:local 變數、operand stack、回傳位址 | StackOverflowError(遞迴太深) |
| PC Register | 每個執行緒一個 | 目前執行到哪條 bytecode | 不會 OOM |
| Native Method Stack | 每個執行緒一個 | JNI 呼叫的 native frame | StackOverflowError |
| Direct / native memory | 共享 | ByteBuffer.allocateDirect、Netty buffer、JIT code cache、GC 自身資料結構、thread stack | OutOfMemoryError: Direct buffer memory,或容器被 OOMKilled |
物件長什麼樣子
在 64 位元 HotSpot 上,一個物件 = object header(mark word 存 hash code、GC age、鎖狀態;klass pointer 指向類別 metadata)+ 欄位 + 對齊填充(8 bytes 對齊)。開啟 compressed oops(heap 小於約 32GB 時預設開啟)後,參考只佔 4 bytes。所以一個 new Object() 大約 16 bytes,一個 Integer 也要 16 bytes,比 4 bytes 的 int 大好幾倍,這就是大量 boxing 會吃記憶體、也會增加 GC 壓力的原因。JDK 24 起還有實驗性的 compact object headers(JEP 450),把 header 壓到 8 bytes。
超過約 32GB 時 compressed oops 會自動關閉,每個參考從 4 bytes 變 8 bytes。結果 heap 從 31GB 開到 33GB,實際可用空間可能反而變少。這是 Elasticsearch、Kafka broker 等 JVM 服務調校文件常提到的建議。
物件怎麼配置:TLAB 與分代
配置物件非常便宜:每個執行緒在 Eden 有一塊自己的 TLAB(Thread-Local Allocation Buffer),配置只是「把指標往前推」(bump-the-pointer),不需要加鎖。真正的成本在 GC:物件活得越久、被搬動越多次,成本越高。這引出 JVM 記憶體管理最重要的觀察:
大部分物件很快就死(例如一次 request 裡建立的 DTO、字串),少數物件活很久(快取、連線池)。所以把 heap 分成 young 與 old:young 區小、頻繁回收,而且只需要搬活下來的少數物件(copying 成本跟存活量成正比,不跟垃圾量成正比);活過幾次 young GC 的物件晉升到 old 區,old 區回收頻率低。這就是為什麼「短命物件很便宜,中等壽命的物件最貴」:它們撐過幾次 young GC、被搬來搬去,最後又在 old 區死掉。
四種參考強度
| 類型 | 何時被回收 | 典型用途 |
|---|---|---|
| Strong(一般參考) | 只要可達就不回收 | 一般物件 |
SoftReference | 記憶體不足時才回收 | 記憶體敏感的快取(但行為難預測,現在多改用 Caffeine 這類有容量上限的快取) |
WeakReference | 下次 GC 只要沒有強參考就回收 | WeakHashMap、canonicalizing map、避免 listener 洩漏 |
PhantomReference | 物件已被判定可回收,用來在回收後收到通知 | 搭配 Cleaner 釋放 native 資源(取代已棄用的 finalize()) |
Java 有 GC 為什麼還會記憶體洩漏?
GC 回收的是不可達的物件。只要還有一條參考鏈從 GC root(執行緒 stack 上的 local 變數、static 欄位、JNI 參考等)連到物件,它就不會被回收,即使你的程式再也不會用到它。常見原因:
- static 的
Map當快取,只放不清,沒有容量上限或 TTL。 ThreadLocal在 thread pool 中用完沒remove():執行緒會被重複使用,值一直掛在執行緒上(下一節詳述)。- 註冊了 listener / callback 卻沒有反註冊。
- 自訂 key 的
hashCode()會隨欄位變動:放進HashMap後改了欄位,就再也找不到也刪不掉。 - 未關閉的資源(連線、stream)持有 buffer;應一律使用 try-with-resources。
垃圾回收:演算法、收集器與怎麼選 深入
判斷物件死活用的是可達性分析(reachability analysis):從 GC roots 出發標記所有可達物件,其餘就是垃圾。Java 不用 reference counting,所以循環參考不是問題(這是和 CPython 的一大差異)。
基本演算法
Mark-Sweep
標記活物件,清掉其他。快,但會留下碎片,大物件可能找不到連續空間。
Copying
把活物件複製到另一塊空間,原空間整塊清空。沒有碎片、成本跟存活量成正比,適合 young 區;代價是要預留空間。
Mark-Compact
標記後把活物件往一端壓緊。沒有碎片也不需雙倍空間,但搬動與更新參考成本高,常用於 old 區。
Stop-the-world(STW):GC 的某些階段必須暫停所有應用執行緒,延遲敏感服務在意的就是這段暫停時間。現代收集器的演進方向,就是把越來越多工作改成和應用執行緒並行(concurrent)執行,讓暫停時間跟 heap 大小脫鉤。
主要收集器比較
| 收集器 | 設計目標 | 暫停時間 | 適合 |
|---|---|---|---|
| Serial | 單執行緒、最小開銷 | 長,與 heap 大小成正比 | 小 heap、單核容器、CLI 工具 |
| Parallel | 最大吞吐量(多執行緒 STW) | 中長 | 批次運算、離線處理,不在意單次暫停 |
| G1(JDK 9 起預設) | 在吞吐量與暫停時間之間平衡;heap 切成等大的 region,優先回收垃圾最多的 region | 可設目標,預設 MaxGCPauseMillis=200 | 大多數後端服務的預設選擇 |
| ZGC | 超低延遲;標記、搬移、參考更新幾乎全部並行(colored pointers + load barrier) | 通常在 1ms 以下,與 heap 大小幾乎無關 | 延遲敏感、大 heap(數十 GB 以上);JDK 21 加入分代模式(JEP 439),JDK 23 成為預設,JDK 24 移除非分代模式 |
| Shenandoah | 同樣以並行搬移達成低延遲(Red Hat 主導) | 低 | 與 ZGC 定位相近;JDK 25 起分代模式轉正(JEP 521),可用性看發行版 |
JVM 會依可用資源自動選擇設定(ergonomics):當偵測到少於 2 顆 CPU 或少於約 1.8GB 記憶體時,會判定不是「server class machine」而選用 Serial GC。K8s 上把 CPU limit 設成 1 的 Java 服務,常常不知不覺在用 Serial GC,暫停時間變長。建議明確指定 -XX:+UseG1GC(或 ZGC),並確認 CPU 配額合理。
調校與觀察
# 常用啟動參數(JDK 11+)
java \
-XX:+UseG1GC \
-XX:MaxRAMPercentage=75.0 \ # heap 上限 = 容器記憶體的 75%(預設只有 25%)
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dumps \
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
-jar app.jar
# 執行中的 JVM 看 GC 狀況:每 1 秒印一次各區使用率與 GC 次數/時間
jstat -gcutil <pid> 1000
- 先量測再調:開 GC log,看暫停時間分布、GC 頻率、每次回收後 old 區的佔用(如果回收後的佔用持續上升,是洩漏,不是調參能解決的)。
- 先調大小,再換收集器:heap 太小會頻繁 GC;young 區太小會讓中等壽命物件過早晉升到 old 區。多數情況下只設 heap 上限與收集器,其他交給 ergonomics。
- 減少配置才是根本:避免在熱路徑大量 boxing、字串串接、建立臨時集合;用 async-profiler 的 allocation 模式找出配置最多的地方。
- 留記憶體給 heap 以外的東西:容器記憶體 = heap + metaspace + thread stacks + direct buffers + code cache + GC 結構。heap 設成容器的 100% 一定會被 OOMKilled,通常設 60%~75%。
(1) 對齊時間軸:GC log 裡的暫停時間點是否與延遲尖峰重疊;(2) 看是哪種 GC:young GC 頻繁但短、還是偶發 Full GC 或 to-space exhausted(G1 搬移失敗);(3) 若是 Full GC,查 old 區為何被填滿:洩漏(回收後的佔用越來越高)、大物件(humongous allocation)、或 heap 本來就太小;(4) 對策依序是修洩漏、減少配置、調整 heap 與 region 大小、最後才考慮換 ZGC。也要排除不是 GC 的原因:safepoint 停頓、CPU throttling(容器 CPU limit)、下游依賴變慢。
語言核心高頻題 高頻
Java 只有 pass-by-value
Java 永遠是傳值。差別在於:基本型別傳的是值本身;物件傳的是參考的值(類似指標的複本)。所以在方法內修改物件的欄位,呼叫端看得到;但在方法內讓參數指向新物件,呼叫端看不到。
static void mutate(List<Integer> list) { list.add(1); } // 呼叫端看得到
static void rebind(List<Integer> list) { list = new ArrayList<>(); } // 呼叫端看不到
List<Integer> xs = new ArrayList<>();
mutate(xs); // xs = [1]
rebind(xs); // xs 仍然是 [1]
這和 Python 的 call by sharing 行為一樣,只是 Java 社群習慣稱之為 pass-by-value(傳的是參考的值)。
== 與 equals()、equals 與 hashCode 的契約
==:基本型別比較值;物件比較是否為同一個實例(參考相等)。equals():邏輯相等,預設實作就是==,需要自己覆寫。- 契約:
a.equals(b)為 true,則a.hashCode() == b.hashCode()必須成立;反之不必。只覆寫equals不覆寫hashCode,放進HashMap/HashSet會找不到(兩個相等的物件落在不同 bucket)。 - 作為 key 的物件最好是不可變的。Java 16 起的
record會自動產生依所有欄位計算的equals/hashCode/toString,是當 key 的好選擇。
Integer cache:為什麼 127 == 127 但 128 != 128
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true:autoboxing 呼叫 Integer.valueOf,-128~127 會重用快取物件
System.out.println(c == d); // false:超出快取範圍,是兩個不同物件
System.out.println(c.equals(d)); // true:比較值要用 equals
Map<String, Integer> m = new HashMap<>();
int n = m.get("missing"); // NullPointerException:null 自動 unboxing
快取上限可用 -XX:AutoBoxCacheMax 調大,但正確的做法是比較包裝型別一律用 equals。另一個 boxing 陷阱是 unboxing null 造成的 NPE,常出現在從 Map 取值或三元運算子混用基本型別與包裝型別時。
String:不可變、string pool 與串接
- 不可變的理由:可以安全地在執行緒間共享、可以快取 hash code(所以很適合當
HashMap的 key)、可以放進 string pool 重用、也避免當作參數(檔名、URL、類別名稱)傳遞後被竄改的安全問題。 - String pool:字串字面值會被 intern,Java 7 起 pool 移到 heap 中。
new String("a")一定建立新物件,所以new String("a") == "a"是 false。 - Compact Strings(Java 9):內部從
char[]改成byte[]+ 編碼旗標,純 Latin-1 字串只佔一半記憶體。 - 串接:Java 9 起
+串接改用invokedynamic(JEP 280)在執行期產生最佳化的串接策略。但在迴圈中反覆s += x仍然每次都建立新字串,是 O(n²),迴圈內請用StringBuilder。StringBuffer是同步版本,現在幾乎用不到。
final、static、abstract class 與 interface
- final 變數
- 只能賦值一次。
final List代表參考不能改,不代表內容不可變;要不可變請用List.of(...)或Collections.unmodifiableList。final 欄位在 JMM 中還有特殊的初始化安全保證(建構完成後其他執行緒必定看到正確的值)。 - final 方法 / 類別
- 不能被覆寫 / 繼承(例如
String)。 - static
- 屬於類別而非實例。static 可變狀態等於全域變數,多執行緒下要注意同步,也讓測試變難。
- abstract class vs interface
- Java 8 起 interface 可以有
default與static方法,Java 9 起可以有private方法。差別剩下:abstract class 可以有實例狀態(欄位)與建構子,且只能單一繼承;interface 沒有實例狀態,可以多重實作。實務原則:定義能力用 interface,共享實作骨架用 abstract class(或組合優於繼承)。
泛型與型別抹除(type erasure)
Java 泛型只存在於編譯期:編譯器檢查型別並插入轉型,產生的 bytecode 中 List<String> 和 List<Integer> 都是 List。這是為了與 Java 5 之前的程式碼相容。後果:
- 不能
new T()、不能new T[]、不能instanceof List<String>。 - 不能用
List<int>,只能用包裝型別(所以有 boxing 成本;Project Valhalla 的 value class 目標之一就是改善這點)。 - 執行期需要型別資訊時,要明確傳入
Class<T>,或像 Jackson 的TypeReference利用匿名子類別保留泛型資訊。
PECS(Producer Extends, Consumer Super):只從集合讀取用 ? extends T,只寫入用 ? super T。例如 Collections.copy(List<? super T> dest, List<? extends T> src)。
例外:checked vs unchecked、try-with-resources
Checked exception
繼承 Exception 但不是 RuntimeException,例如 IOException。編譯器強制要求 catch 或宣告 throws。用於呼叫端可以合理處理的狀況。
Unchecked exception
RuntimeException 及其子類別(NPE、IllegalArgumentException),以及 Error(OOM、StackOverflow)。通常代表程式錯誤或無法恢復的狀況。
// try-with-resources:任何實作 AutoCloseable 的資源都會自動關閉,
// 即使發生例外;關閉時的例外會以 suppressed 形式附加,不會蓋掉原本的例外
try (var conn = dataSource.getConnection();
var ps = conn.prepareStatement("SELECT id, name FROM users WHERE id = ?")) {
ps.setLong(1, userId);
try (var rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
} catch (SQLException e) {
throw new DataAccessException("query user failed: " + userId, e); // 包裝時保留 cause
}
(1) 吞掉例外(catch (Exception e) {})或只印 e.getMessage() 丟失 stack trace;(2) 包裝例外時沒有傳入 cause;(3) 在 finally 裡 return 會吞掉例外;(4) 用例外控制正常流程,建立例外要填 stack trace,成本不低;(5) Spring 的 @Transactional 預設遇到 checked exception 不會回滾(見 Spring 一節)。
集合框架內部:HashMap、ConcurrentHashMap 與時間複雜度 高頻
HashMap 怎麼運作(Java 8+)
底層是一個 Node[] table,每個 slot 是一個 bucket。put(k, v) 的流程:
- 計算 hash:
h = key.hashCode(); hash = h ^ (h >>> 16)。把高 16 位元混進低位元,因為 index 只用低位元。 - 算 index:
(n - 1) & hash。table 長度 n 永遠是 2 的次方,所以這等於取模,但比%快。 - bucket 空的就直接放;否則沿著 bucket 比對
hash與equals,找到就覆蓋,找不到就接在尾端(Java 8 改為尾插法)。 - 樹化:單一 bucket 的節點數達到 8,而且 table 容量至少 64 時,該 bucket 轉成紅黑樹,最壞查詢從 O(n) 降到 O(log n);容量不到 64 時會先擴容。節點數降到 6 以下再轉回鏈結串列。
- 擴容:元素數超過
容量 × load factor(預設 16 × 0.75 = 12)就把 table 加倍。因為容量是 2 的次方,每個節點在新 table 中不是留在原 index,就是移到「原 index + 舊容量」,只要看 hash 多出來的那一位元,不必重新計算 hash。
多個執行緒同時 put 可能互相覆蓋造成資料遺失、size 計數錯誤、擴容時讀到不一致的狀態。Java 7 以前用頭插法,併發擴容可能讓鏈結串列形成環,之後 get 會無窮迴圈把 CPU 打滿,這是經典面試題;Java 8 改成尾插法後不會成環,但仍然不是 thread-safe。需要併發存取請用 ConcurrentHashMap,不要用 Hashtable 或 Collections.synchronizedMap(整個 map 一把鎖,競爭激烈)。
ConcurrentHashMap(Java 8+)
- Java 7 是分段鎖(Segment,預設 16 段);Java 8 改成以 bucket 為單位:空 bucket 用 CAS 放入第一個節點,非空 bucket 對該 bucket 的頭節點
synchronized。鎖的粒度降到單一 bucket,不同 bucket 的寫入完全不互相阻擋。 - 讀取(
get)完全不加鎖,靠volatile讀保證可見性。 - 擴容時多個執行緒會協助搬移(transfer),已搬完的 bucket 放 forwarding node。
size()用類似LongAdder的分散計數器(counter cells),高併發下不會集中競爭同一個變數;回傳的是估計值。- 不允許 null key 或 null value:因為併發下
get回傳 null 時無法分辨「不存在」還是「值是 null」。
// 錯誤:check-then-act 不是原子操作,兩個執行緒可能都看到 null 而各自 put
if (!map.containsKey(key)) {
map.put(key, new ArrayList<>());
}
map.get(key).add(item);
// 正確:computeIfAbsent 在該 bucket 上原子地執行
map.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>()).add(item);
// 計數:merge 原子地更新;高併發計數也可用 LongAdder 當 value
map.merge(word, 1L, Long::sum);
每個方法單獨是原子的,不代表多個方法組合起來是原子的。containsKey + put、get + put 都有 race condition,要改用 putIfAbsent、computeIfAbsent、merge、compute。另外 computeIfAbsent 的 mapping function 執行期間會持有該 bucket 的鎖,不要在裡面做慢的 I/O,也不要在裡面再修改同一個 map。
常用集合的選擇與複雜度
| 集合 | 底層 | 關鍵操作複雜度 | 何時用 |
|---|---|---|---|
ArrayList | 動態陣列(擴容約 1.5 倍) | get O(1);尾端 add 均攤 O(1);中間插入/刪除 O(n) | 預設的 List 選擇 |
LinkedList | 雙向鏈結串列 | get O(n);已知節點時插入/刪除 O(1) | 幾乎不用;cache locality 差,當佇列請用 ArrayDeque |
ArrayDeque | 環狀陣列 | 兩端 add/remove 均攤 O(1) | stack、queue、BFS |
HashMap / HashSet | hash table + 紅黑樹 | 平均 O(1),最壞 O(log n) | 查找、去重、計數 |
LinkedHashMap | HashMap + 雙向鏈結 | 同 HashMap,保持插入或存取順序 | 需要順序;覆寫 removeEldestEntry 可做簡易 LRU |
TreeMap / TreeSet | 紅黑樹 | O(log n);有 floor/ceiling/range | 有序、範圍查詢、找最接近的 key |
PriorityQueue | binary heap | offer/poll O(log n),peek O(1) | Top-K、排程、Dijkstra |
ConcurrentHashMap | 見上 | 平均 O(1) | 併發讀寫 |
CopyOnWriteArrayList | 寫入時複製整個陣列 | 讀 O(1) 無鎖;寫 O(n) | 讀多寫極少,例如 listener 清單、設定 |
// LRU cache:LinkedHashMap 的 accessOrder=true 讓 get 把節點移到尾端
class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
LruCache(int capacity) {
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // 超過容量就淘汰最久未使用的
}
}
// 注意:這不是 thread-safe 的。正式服務請用 Caffeine(W-TinyLFU,命中率與併發效能都好)
fail-fast iterator:迭代 ArrayList / HashMap 時若集合被結構性修改(不是透過 iterator 自己的 remove),會拋 ConcurrentModificationException。這是盡力而為的錯誤偵測,不是同步機制。單執行緒中「邊迭代邊刪除」請用 iterator.remove() 或 removeIf。
Java Memory Model:可見性、有序性與 happens-before 深入
多執行緒的 bug 有三個來源:原子性(操作被打斷)、可見性(一個執行緒寫入的值,另一個執行緒看不到,因為值還在 CPU cache 或暫存器裡)、有序性(編譯器、JIT、CPU 為了效能重排指令)。JMM 定義了在什麼條件下,一個執行緒的寫入保證對另一個執行緒可見,核心概念是 happens-before。
happens-before 規則
- 程式順序:同一個執行緒內,前面的動作 happens-before 後面的動作。
- Monitor lock:對同一把鎖的 unlock happens-before 之後的 lock。
- volatile:對 volatile 變數的寫入 happens-before 之後對它的讀取。
- Thread start / join:
t.start()之前的動作 happens-before 執行緒 t 內的動作;t 內的動作 happens-before 其他執行緒從t.join()返回。 - 遞移性:A hb B、B hb C,則 A hb C。
沒有 happens-before 關係的兩個存取,如果其中一個是寫入,就是 data race,結果不受保證。
volatile 能做什麼、不能做什麼
class Worker implements Runnable {
private volatile boolean running = true; // 沒有 volatile,JIT 可能把讀取提到迴圈外,迴圈永遠不會停
public void stop() { running = false; }
public void run() {
while (running) {
doWork();
}
}
}
class Counter {
private volatile int count = 0;
void inc() { count++; } // 錯誤!count++ 是「讀、加、寫」三步,volatile 不保證原子性
}
volatile 保證可見性與有序性(寫入前的動作不會被重排到寫入之後),但不保證複合操作的原子性。適合「一個執行緒寫、其他執行緒讀」的旗標或狀態發佈。計數器請用 AtomicInteger / LongAdder 或加鎖。
雙重檢查鎖定(double-checked locking)為什麼需要 volatile
class Config {
private static volatile Config instance; // 必須是 volatile
static Config get() {
Config local = instance;
if (local == null) {
synchronized (Config.class) {
local = instance;
if (local == null) {
instance = local = new Config();
}
}
}
return local;
}
}
// 更簡單且一樣 lazy、thread-safe 的寫法:holder idiom,靠 JVM 類別初始化的鎖
class Config2 {
private static class Holder { static final Config2 INSTANCE = new Config2(); }
static Config2 get() { return Holder.INSTANCE; }
}
new Config() 不是原子的:配置記憶體、執行建構子、把參考寫入 instance。沒有 volatile 時,後兩步可能被重排,另一個執行緒在第一次檢查就看到非 null、但建構子還沒跑完的物件。volatile 禁止這種重排。
synchronized 與鎖的實作
synchronized方法鎖的是this(static 方法鎖的是 Class 物件);synchronized (obj)鎖指定物件。它同時提供互斥與可見性(進入時讀到最新值、離開時寫回)。- 可重入:同一個執行緒可以重複取得同一把鎖。
- HotSpot 的實作會依競爭程度調整:沒有競爭時用 CAS 在 object header 上標記(輕量級鎖),競爭激烈時才膨脹成 OS 層級的 monitor,讓等待的執行緒睡眠。舊版還有偏向鎖(biased locking),JDK 15 起預設關閉並棄用(JEP 374),JDK 18 移除。面試提到偏向鎖時,記得補充它已經被移除,以及原因:現代應用多用併發集合,偏向鎖撤銷的成本反而高。
CAS、Atomic 類別與 LongAdder
CAS(Compare-And-Swap)是 CPU 提供的原子指令:「如果記憶體中的值仍是 expected,就換成 new,否則失敗」。失敗就重試(自旋),不需要讓執行緒睡眠,低競爭時比鎖快很多。AtomicInteger、AtomicReference、ConcurrentHashMap 都建立在 CAS 上。
- ABA 問題
- 值從 A 變 B 又變回 A,CAS 會誤以為沒變過。對指標型的 lock-free 結構有影響;可用
AtomicStampedReference(加版本號)解決。 - 高競爭下的 CAS
- 大量執行緒同時 CAS 同一個變數,失敗重試會浪費 CPU,cache line 在核心之間來回搬(cache line bouncing)。
- LongAdder
- 把計數分散到多個 cell,每個執行緒更新不同的 cell,讀取時加總。寫入極快、讀取稍慢且不是瞬間精確值,非常適合 metrics 計數(QPS、錯誤數)。
ReentrantLock 與 AQS
synchronized | ReentrantLock | |
|---|---|---|
| 使用方式 | 語法層級,自動釋放 | 必須在 finally 中 unlock() |
| 可中斷等待 | 否 | lockInterruptibly() |
| 逾時嘗試 | 否 | tryLock(timeout),可用來避免死結 |
| 公平性 | 非公平 | 可選公平鎖(吞吐量較低) |
| 條件變數 | 一個(wait/notify) | 多個 Condition |
J.U.C 的大多數同步器(ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock)都建立在 AQS(AbstractQueuedSynchronizer)上:一個 volatile 的 int state 加上 CAS 修改它,取不到的執行緒進入 FIFO 等待佇列並被 park。理解 AQS 就能講清楚這些工具的共同原理。
四個必要條件:互斥、持有並等待、不可搶佔、循環等待。實務上破壞「循環等待」最容易:所有執行緒依固定順序取鎖(例如依帳戶 ID 排序後再鎖)。其他手段:tryLock 加逾時、縮小鎖的範圍、避免在持有鎖時呼叫外部程式碼。排查:jstack 或 jcmd <pid> Thread.print 會在輸出結尾直接列出偵測到的 Java-level deadlock 與各自持有、等待的鎖。
Thread pool、CompletableFuture 與 ThreadLocal 高頻
ThreadPoolExecutor 的參數與行為
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // corePoolSize
32, // maximumPoolSize
60, TimeUnit.SECONDS, // 超過 core 的閒置執行緒多久後回收
new ArrayBlockingQueue<>(1000), // 有界佇列
new ThreadFactoryBuilder().setNameFormat("report-worker-%d").build(), // Guava;有名字才好排查
new ThreadPoolExecutor.CallerRunsPolicy() // 滿了就讓呼叫端自己執行,形成 backpressure
);
提交任務時的判斷順序(常被問、也常被答錯):
重點:佇列滿了才會擴到 max。所以搭配無界佇列時,maximumPoolSize 永遠不會生效。
| 拒絕策略 | 行為 | 適合 |
|---|---|---|
AbortPolicy(預設) | 拋 RejectedExecutionException | 呼叫端能處理失敗(回 503、重試) |
CallerRunsPolicy | 由提交任務的執行緒自己執行 | 自然降速、不想丟資料 |
DiscardPolicy | 默默丟棄 | 可以接受遺失的任務(危險,至少要記 metric) |
DiscardOldestPolicy | 丟掉佇列最舊的任務再重試 | 只在乎最新資料 |
Executors.newFixedThreadPool(n) 與 newSingleThreadExecutor() 用的是無界的 LinkedBlockingQueue,下游變慢時任務無限堆積直到 OOM;newCachedThreadPool() 的最大執行緒數是 Integer.MAX_VALUE,突發流量可能建立上萬個執行緒耗盡記憶體。正式服務請直接 new ThreadPoolExecutor,明確設定有界佇列、執行緒名稱與拒絕策略,並把佇列長度、活躍執行緒數輸出成 metrics。
執行緒數怎麼設
- CPU-bound:約等於 CPU 核心數(或 +1)。再多只會增加 context switch。
- I/O-bound:常見估算是
核心數 × (1 + 等待時間 / 計算時間)。例如每個請求計算 10ms、等 DB 90ms,8 核約可設 80。 - 公式只是起點,最後以壓測為準,觀察 CPU 使用率、佇列長度、p99。也要注意下游容量:執行緒開到 200,但 DB 連線池只有 10,多出來的執行緒只是在等連線。
- 容器中
Runtime.getRuntime().availableProcessors()會依 CPU limit 回報(JDK 10+ 的容器感知),框架常用這個值決定預設執行緒數。
CompletableFuture:組合非同步呼叫
ExecutorService io = Executors.newFixedThreadPool(32); // 示意;正式請用有界的 ThreadPoolExecutor
CompletableFuture<Profile> profile =
CompletableFuture.supplyAsync(() -> profileClient.get(userId), io);
CompletableFuture<List<Segment>> segments =
CompletableFuture.supplyAsync(() -> segmentClient.get(userId), io);
UserView view = profile
.thenCombine(segments, UserView::new) // 兩個都完成後合併
.orTimeout(80, TimeUnit.MILLISECONDS) // Java 9+:逾時以 TimeoutException 完成
.exceptionally(ex -> UserView.fallback(userId)) // 降級:任一失敗或逾時就回預設值
.join();
thenApply(同步轉換)、thenCompose(接另一個非同步呼叫,類似 flatMap)、thenCombine(合併兩個)、allOf/anyOf。- 不指定 executor 的
*Async方法會用ForkJoinPool.commonPool(),它的大小約是核心數 - 1,設計給 CPU-bound 工作。把阻塞 I/O 丟進去會把 common pool 塞滿,連帶拖垮同樣使用 common pool 的 parallel stream。I/O 一定要傳自己的 executor。 orTimeout只是讓 future 以逾時完成,不會中斷正在執行的底層工作;HTTP client 本身也要設 timeout。
ThreadLocal 與它在 thread pool 中的陷阱
private static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>();
void handle(Request req) {
CTX.set(new RequestContext(req.traceId(), req.userId()));
try {
service.process(req); // 下游任何地方都能 CTX.get() 取得 trace id
} finally {
CTX.remove(); // 一定要清除!
}
}
每個 Thread 物件內有一個 ThreadLocalMap,key 是 ThreadLocal 的弱參考、value 是強參考。在 thread pool 裡,執行緒會被重複使用:
- 資料串到下一個請求:沒
remove(),下一個在同一執行緒上的請求會讀到上一個使用者的 context,是資安問題。 - 記憶體洩漏:value 被長壽的執行緒持有,無法回收。
- 非同步邊界會遺失:任務丟到另一個執行緒池後讀不到原本的值,需要手動傳遞(或用 MDC 的 decorator、OpenTelemetry 的 context propagation)。
Java 25 正式推出的 Scoped Values(JEP 506)是不可變、有明確作用範圍的替代方案,特別適合搭配 virtual threads。
Virtual threads 與並行模型比較 高頻
傳統 Java 執行緒是 OS 執行緒的一對一包裝(platform thread):每個預留約 1MB 的 stack、建立與切換都要經過 kernel,所以一台機器大概只能撐幾千個。這讓「一個請求一個執行緒」(thread-per-request)的模型在高併發 I/O 時撐不住,過去只能改寫成 reactive(WebFlux、RxJava),程式難寫難除錯。
Java 21 正式推出 virtual threads(JEP 444):由 JVM 排程的輕量執行緒,stack 存在 heap 上、按需增長,可以輕鬆建立數百萬個。當 virtual thread 執行阻塞 I/O 時,JVM 會把它從底下的 OS 執行緒(carrier thread)卸載(unmount),carrier 立刻去跑別的 virtual thread;I/O 完成後再掛回某個 carrier 繼續執行。寫起來是同步阻塞的程式碼,執行起來像非同步。
// 每個任務一個 virtual thread;不要 pool 化 virtual thread,它本來就便宜
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Bid>> futures = bidders.stream()
.map(b -> exec.submit(() -> b.requestBid(request))) // 每個呼叫都是阻塞式寫法
.toList();
for (Future<Bid> f : futures) {
results.add(f.get());
}
} // try-with-resources 結束時會等待所有任務完成
// Spring Boot 3.2+:一行設定讓 Tomcat 處理請求改用 virtual threads
// application.properties
// spring.threads.virtual.enabled=true
(1) Pinning:virtual thread 在某些情況下阻塞時無法卸載,會連 carrier 一起卡住。JDK 21–23 中最常見的是在 synchronized 區塊內阻塞,JDK 24(JEP 491)已解決這個情況;呼叫 native 方法時仍會 pin。在 JDK 21 上建議把長時間持有的 synchronized 改成 ReentrantLock。(2) Virtual thread 不會讓下游變快:以前 200 個 Tomcat 執行緒天然限制了打到 DB 的併發量;改成 virtual threads 後可能一次湧入上萬個請求,要用連線池大小或 Semaphore 明確限流。(3) 不適合 CPU-bound 工作,那種情況執行緒數本來就應該等於核心數。(4) 大量 virtual thread 搭配 ThreadLocal 存大物件會吃掉很多記憶體。
| Java platform threads | Java virtual threads | Python asyncio | Go goroutine | |
|---|---|---|---|---|
| 排程者 | OS kernel | JVM(M:N) | 單執行緒 event loop | Go runtime(M:N) |
| 寫法 | 同步阻塞 | 同步阻塞 | async/await,會「傳染」整條呼叫鏈 | 同步阻塞 |
| 可同時存在的數量 | 數千 | 數百萬 | 數十萬(coroutine) | 數百萬 |
| 多核心利用 | 是 | 是 | 否(單執行緒,需多 process) | 是 |
| 呼叫同步阻塞 library | 沒問題 | 多數 JDK I/O 會自動卸載 | 會卡住整個 loop | runtime 會處理 |
對大多數 thread-per-request 的 I/O 密集服務,virtual threads 能用簡單的同步寫法達到接近 reactive 的併發度,除錯和 stack trace 也清楚得多,所以新專案的預設選擇正在轉向它。Reactive 仍有優勢的地方:串流與 backpressure 語意(例如持續推送的資料流)、已經是 reactive 的生態系(R2DBC、Reactor Kafka)。結構化併發(structured concurrency,StructuredTaskScope)在 Java 25 仍是 preview,正式後能讓「同時呼叫多個下游、任一失敗就取消其他」寫得更安全。
現代 Java:Java 8 到 25 的重點語法 進階
LTS 版本:8、11、17、21、25(2025 年 9 月)。Spring Boot 3 要求 Java 17 以上。面試常問「你用哪一版、為什麼升級」,能講出升級帶來的具體好處(GC 改善、virtual threads、record)會加分。
| 版本 | 代表特性 |
|---|---|
| 8 | Lambda、Stream API、Optional、java.time、interface default 方法 |
| 11 | var(10 起)、新的 HttpClient、String 新方法(isBlank、lines)、ZGC 實驗版 |
| 17 | record、sealed classes、text blocks、instanceof pattern matching |
| 21 | Virtual threads、switch pattern matching、record patterns、sequenced collections、分代 ZGC |
| 25 | Scoped values、精簡的原始檔與 instance main 方法、module import、compact object headers 轉正 |
// record:不可變資料載體,自動產生建構子、accessor、equals/hashCode/toString
public record BidRequest(String requestId, String userId, List<String> slots, long deadlineMs) {
public BidRequest { // compact constructor:驗證與防禦性複製
Objects.requireNonNull(requestId);
slots = List.copyOf(slots);
}
}
// sealed + record + switch pattern matching:編譯器檢查是否涵蓋所有情況
sealed interface Event permits Impression, Click, Conversion {}
record Impression(String adId, long ts) implements Event {}
record Click(String adId, long ts, String url) implements Event {}
record Conversion(String adId, long ts, double value) implements Event {}
static double revenue(Event e) {
return switch (e) {
case Impression i -> 0.0;
case Click c -> 0.0;
case Conversion(var adId, var ts, var value) when value > 0 -> value;
case Conversion c -> 0.0;
}; // 新增 Event 的子型別卻沒處理時,這裡會編譯失敗
}
// text block
String sql = """
SELECT campaign_id, COUNT(*) AS clicks
FROM events
WHERE type = 'click' AND ts >= ?
GROUP BY campaign_id
""";
Stream API 與它的陷阱
// 每個 campaign 的點擊數,取前 10 名
Map<String, Long> clicksByCampaign = events.stream()
.filter(e -> e.type() == EventType.CLICK)
.collect(Collectors.groupingBy(Event::campaignId, Collectors.counting()));
List<Map.Entry<String, Long>> top10 = clicksByCampaign.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(10)
.toList(); // Java 16+;回傳不可修改的 List
// 合併兩個來源中相同 id 的使用者屬性(Appier 真題的 Java 版)
Map<String, Map<String, Object>> merged = Stream.concat(sourceA.stream(), sourceB.stream())
.collect(Collectors.toMap(
u -> (String) u.get("id"),
u -> new HashMap<>(u),
(left, right) -> { left.putAll(right); return left; } // key 重複時的合併策略
));
- Stream 是惰性的,沒有終端操作(
collect、forEach、count)就什麼都不會執行;而且只能消費一次。 Collectors.toMap遇到重複 key 預設拋IllegalStateException,要給第三個參數處理衝突;value 為 null 會拋 NPE。- 不要在 lambda 中修改外部狀態(例如往外部 list add),parallel 時會出錯;用 collector。
parallelStream()共用ForkJoinPool.commonPool(),在 web 服務中使用容易互相干擾;資料量小時拆分合併的成本比收益還高。只在大量、CPU-bound、無狀態的運算時考慮。- 熱路徑上 stream 有額外的物件配置成本,對延遲極度敏感的程式碼,簡單的 for 迴圈常常更快也更好讀。
Optional 的正確用法
Optional 設計給回傳值,表達「可能沒有結果」。不要用在欄位、方法參數或集合元素上(序列化與效能都不理想),也不要寫 if (opt.isPresent()) opt.get(),改用 map、orElse、orElseGet(預設值計算昂貴時用這個,orElse 的參數一定會被求值)、orElseThrow。
Spring Boot 內部:IoC、AOP proxy 與交易陷阱 高頻
如果履歷寫了 Spring Boot,面試官很少問「怎麼寫 controller」,而是問「它怎麼運作」與「你踩過什麼坑」。
IoC 與 DI
控制反轉:物件不自己 new 依賴,而是由容器(ApplicationContext)建立並注入。好處是解耦、容易替換實作與寫測試。注入方式優先用建構子注入:依賴可以是 final、物件建立後就是完整可用的狀態、測試時不需要 Spring 就能 new,也能讓循環依賴在啟動時就暴露(Spring Boot 2.6 起預設禁止循環依賴)。
@Service
public class ReportService {
private final EventRepository repo;
private final Clock clock;
// 單一建構子時不需要 @Autowired
public ReportService(EventRepository repo, Clock clock) {
this.repo = repo;
this.clock = clock;
}
}
Bean 的生命週期與 scope
- 讀取設定與 component scan,建立 bean definition。
- 實例化(呼叫建構子)→ 注入依賴。
BeanPostProcessor的前置處理 → 初始化回呼(@PostConstruct、InitializingBean)→ 後置處理。AOP proxy 就是在後置處理這一步包上去的,所以容器裡拿到的常常是 proxy,不是你寫的那個類別本身。- 使用中。
- 容器關閉時執行銷毀回呼(
@PreDestroy),例如關閉連線池、優雅停機。
預設 scope 是 singleton:整個容器只有一個實例,會被所有請求執行緒共用。所以 singleton bean 不能有可變的實例欄位來存放單一請求的狀態,這是 Spring 服務最常見的併發 bug。其他 scope:prototype(每次取都新建)、request、session。
AOP 與 proxy 的運作方式
@Transactional、@Cacheable、@Async、@Retryable 都是透過 proxy 實現的:呼叫先進到 proxy,proxy 在前後加上邏輯(開交易、查快取),再呼叫目標物件的方法。
JDK dynamic proxy
只能代理介面,產生一個實作相同介面的類別。
CGLIB proxy
在執行期產生目標類別的子類別,覆寫方法。Spring Boot 2.0 起預設使用(proxyTargetClass=true)。因為是子類別,final 類別與 final 方法無法被攔截。
@Transactional 的經典陷阱
@Service
public class OrderService {
public void placeOrder(Order o) {
save(o); // 陷阱 1:自我呼叫(self-invocation)
} // 走的是 this.save(),沒經過 proxy,交易根本沒開
@Transactional
public void save(Order o) { ... }
@Transactional
public void importFile(Path p) throws IOException {
repo.saveAll(parse(p));
throw new IOException("disk error"); // 陷阱 2:checked exception 預設不回滾!
} // 改用 @Transactional(rollbackFor = Exception.class)
@Transactional
public void process(Order o) {
try {
paymentService.charge(o); // 若 charge 也是 @Transactional(REQUIRED) 且拋出例外,
} catch (PaymentException e) { // 共用的交易已被標記 rollback-only;
log.warn("charge failed", e); // 陷阱 3:這裡吞掉例外後 commit 會拋
} // UnexpectedRollbackException
}
}
- 自我呼叫:同一個類別內部呼叫帶註解的方法不會經過 proxy。解法是把方法搬到另一個 bean、注入自己(不建議)、或改用
TransactionTemplate程式化交易。 - 回滾規則:預設只對
RuntimeException與Error回滾。 - 方法可見性:基於 proxy 攔截,
public方法最保險;private方法一定攔截不到。 - 傳播行為(propagation):預設
REQUIRED(有交易就加入,沒有就新開)。REQUIRES_NEW會暫停外層交易、另開新交易(例如稽核 log 無論如何都要寫入),但會多佔一條 DB 連線,巢狀太多可能耗盡連線池而死結。 - 交易裡不要做慢的外部呼叫:在交易中呼叫 HTTP API 會讓 DB 連線與鎖被長時間持有。先完成外部呼叫,再開短交易寫入;需要跨系統一致性時用 outbox pattern(見 第 11 章)。
@Async也有同樣的自我呼叫問題,而且預設的 executor 設定要確認(Spring Boot 會自動設定ThreadPoolTaskExecutor,正式環境仍建議明確設定佇列上限)。
Auto-configuration 怎麼運作
@SpringBootApplication = @Configuration + @ComponentScan + @EnableAutoConfiguration。Auto-configuration 類別列在依賴 jar 的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Boot 2.7 引入,3.0 起是唯一方式;更舊的版本用 spring.factories),每個都帶條件註解:@ConditionalOnClass(classpath 上有某個類別才生效)、@ConditionalOnMissingBean(使用者沒自己定義才建立預設的)、@ConditionalOnProperty。所以加入 spring-boot-starter-data-redis 就自動有 RedisTemplate,而你自己定義一個同型別的 bean 就會覆蓋預設。除錯時用 --debug 啟動,會印出 condition evaluation report,說明每個 auto-configuration 為何生效或沒生效。
執行期的關鍵數字與設定
| 元件 | 預設值 | 說明 |
|---|---|---|
| Tomcat 最大工作執行緒 | server.tomcat.threads.max=200 | 同時處理的請求上限(使用 platform threads 時) |
| HikariCP 連線池 | maximum-pool-size=10 | 常是真正的瓶頸;200 個執行緒搶 10 條連線,多數時間在等 |
| HikariCP 取得連線逾時 | connection-timeout=30000(30 秒) | 線上服務通常要調短,快速失敗比卡 30 秒好 |
| 優雅停機 | Spring Boot 3.4 起預設 server.shutdown=graceful | 收到 SIGTERM 後停止接新請求、等處理中的完成;要搭配 K8s 的 terminationGracePeriodSeconds 與 preStop |
| Actuator | /actuator/health、/actuator/prometheus | 健康檢查(liveness / readiness 分開)、Micrometer metrics |
如果你寫過長時間執行的 Spring Boot daemon(例如定期處理大量背景資料、接 MQ 訊息),準備好回答這些追問:排程怎麼避免同一份工作被多個 instance 重複執行(DB 鎖、ShedLock、leader election);訊息消費失敗怎麼重試、怎麼確保冪等;處理到一半服務重啟怎麼辦(checkpoint、交易邊界);批次大小與 commit 頻率怎麼取捨;怎麼監控(處理量、延遲、積壓數量)。這些比背框架 API 更能證明你真的做過。
線上排查:CPU 飆高、OOM、執行緒卡住 高頻
職缺特別寫了 profiling 與效能調校。Java 的工具鏈非常成熟,面試時能講出「用什麼工具、看什麼指標、怎麼縮小範圍」會很有說服力。
| 工具 | 用途 |
|---|---|
jcmd | 瑞士刀:Thread.print(thread dump)、GC.heap_info、GC.heap_dump、VM.flags、VM.native_memory、JFR.start |
jstack | thread dump:每個執行緒的狀態與 stack,排查卡住、死結、CPU 熱點 |
jstat | 即時看各區使用率與 GC 次數/時間 |
jmap / heap dump + Eclipse MAT | 分析記憶體被誰佔用:dominator tree、到 GC root 的參考鏈 |
| JFR(Java Flight Recorder)+ JMC | 內建的低開銷(通常約 1% 左右)持續錄製:CPU、配置、鎖競爭、GC、I/O 事件,可以在正式環境開 |
| async-profiler | 低開銷取樣 profiler,產生火焰圖;能看 CPU、配置、鎖、wall-clock,沒有 safepoint bias |
劇本一:CPU 飆高
# 1. 找出吃 CPU 的 Java process
top
# 2. 找出該 process 內吃 CPU 的執行緒(-H 顯示執行緒)
top -H -p <pid>
# 3. 執行緒 ID 轉十六進位(thread dump 中的 nid 是十六進位)
printf '%x\n' <tid>
# 4. 抓 thread dump,找到對應 nid 的 stack
jcmd <pid> Thread.print > dump.txt
grep -A 30 'nid=0x<hex>' dump.txt
# 5. 只看一次 dump 可能是巧合,隔幾秒抓 3 次比較;或直接用 async-profiler 錄 30 秒火焰圖
./asprof -d 30 -f cpu.html <pid>
常見原因:無窮迴圈或過於昂貴的演算法、正規表示式災難性回溯、頻繁 GC(GC 執行緒吃 CPU,看 GC log)、大量序列化/反序列化、自旋等待、log 等級設太低導致大量字串格式化。
劇本二:OutOfMemoryError 與容器被 OOMKilled
| 錯誤訊息 | 意義與方向 |
|---|---|
Java heap space | heap 不夠:洩漏或 heap 太小。看 heap dump 的 dominator tree |
GC overhead limit exceeded | 花了絕大部分時間在 GC 卻回收很少,通常是快滿了的洩漏 |
Metaspace | 類別太多:動態產生類別(大量 proxy、Groovy script)、classloader 洩漏(熱部署) |
unable to create native thread | 執行緒太多(無界的 thread pool)或碰到 OS / 容器的 pid 上限 |
Direct buffer memory | NIO direct buffer 用盡,常見於 Netty;檢查 buffer 是否有釋放 |
| 容器 exit code 137、沒有 Java 例外 | 被 kernel OOM killer 殺掉:整個 process 的 RSS 超過容器記憶體上限,heap 以外的部分(metaspace、thread stack、direct buffer、code cache)沒算進去。用 -XX:NativeMemoryTracking=summary 加 jcmd <pid> VM.native_memory 拆解 |
OOM 往往在半夜發生且無法重現。啟動參數預先加上 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=...,並確認路徑掛在 Pod 重啟後還在的 volume 上。heap dump 檔案大小約等於 heap 使用量,磁碟要夠。
劇本三:服務沒掛但不回應
- 抓 3 次間隔數秒的 thread dump,看大部分 Tomcat 工作執行緒卡在哪。
- 大量執行緒
WAITING在HikariPool.getConnection:連線池耗盡。查是否有慢查詢佔住連線、交易中呼叫外部服務、或連線洩漏(Hikari 的leakDetectionThreshold)。 - 大量執行緒
RUNNABLE在SocketInputStream.read:在等下游回應,而且沒有設 read timeout。所有對外呼叫都要設 connect / read timeout。 - 大量執行緒
BLOCKED在同一把鎖:鎖競爭,找出持有鎖的那個執行緒在做什麼。 - dump 結尾出現
Found one Java-level deadlock:死結。
Kafka broker、Spark executor、Flink TaskManager、Hadoop 都是 JVM process。Spark 任務因 executor OOM 失敗、Flink checkpoint 因 GC 暫停而逾時、Kafka consumer 因長時間 GC 錯過 heartbeat 觸發 rebalance,排查方法都和本節相同:GC log、heap 與 off-heap 的拆分、thread dump。即使服務本身是 Python 或 Go,這些知識在資料平台上一樣用得到。延伸閱讀見 第 12 章。
Java、Python、Go 的取捨與「為什麼 Java 會這樣」 進階
| Java | Python | Go | |
|---|---|---|---|
| 型別 | 靜態、名義型別 | 動態(可選 type hints) | 靜態、結構型別(interface 隱式實作) |
| 執行方式 | bytecode + JIT | bytecode 直譯(3.13+ 實驗性 JIT) | AOT 編譯成原生執行檔 |
| 啟動 / 暖機 | 慢,需要暖機 | 快 | 很快 |
| 峰值效能 | 高 | 低(熱點靠 C 擴充) | 高 |
| 記憶體用量 | 較高(物件 header、JIT、heap 預留) | 中 | 低 |
| 記憶體管理 | 多種可選 GC,可調參數最多 | reference counting + 循環 GC | 並行標記清除 GC,參數很少 |
| 併發 | 執行緒、J.U.C、virtual threads | GIL;asyncio、多 process | goroutine + channel |
| 生態系強項 | 企業後端、Spring、大數據(Kafka/Spark/Flink) | 資料科學、ML、腳本、快速開發 | 雲原生基礎設施、高併發網路服務 |
為什麼 Java 的泛型不能用基本型別?
因為型別抹除:泛型在執行期都變成 Object,而 int 不是物件,無法放進 Object 欄位。這是 Java 5 為了與既有 bytecode 相容所做的取捨。代價是 boxing 的記憶體與 GC 成本;效能敏感時可用基本型別特化的集合(例如 fastutil、Eclipse Collections 的 IntArrayList),長期解法是 Project Valhalla。
為什麼 Java 沒有 unsigned int、沒有運算子多載?
設計哲學是簡單與可讀性優先:運算子多載容易被濫用而降低可讀性(C++ 的經驗),unsigned 與 signed 混用是 C 的常見 bug 來源。需要時有替代方案:Integer.toUnsignedLong、Integer.divideUnsigned,大數用 BigInteger / BigDecimal(金額計算一律用 BigDecimal,不要用 double)。
為什麼 main 是 public static void?
JVM 啟動時還沒有任何實例,所以入口必須是 static;要讓 JVM 從類別外部呼叫,所以是 public;回傳值沒有意義(結束碼用 System.exit),所以是 void。Java 25 起允許更精簡的 instance main 方法與無類別宣告的原始檔,降低入門門檻,但傳統寫法仍然有效。
為什麼 Java 服務比 Go 服務吃更多記憶體?
JVM 本身有固定開銷(JIT 編譯器與 code cache、metaspace、GC 資料結構);物件有 header,boxing 普遍;GC 需要多餘的 heap 空間才能有效率地運作(heap 用滿時 GC 會非常頻繁);而且 JVM 傾向保留已配置的 heap 不歸還給 OS。對策:選對 GC 與 heap 大小、減少 boxing、或在需要極小記憶體與快速啟動的場景用 GraalVM native image。
面試自測題
JVM 的記憶體分成哪些區域?哪些是執行緒私有的?
共享的有 heap(物件實例,GC 主要管理區)與 metaspace(類別 metadata,Java 8 起取代 PermGen,使用 native memory);執行緒私有的有 JVM stack(每次方法呼叫一個 frame)、PC register、native method stack。另外還有 direct memory、code cache 等 native 記憶體。heap 不足拋 Java heap space,metaspace 不足拋 Metaspace,遞迴太深拋 StackOverflowError。追問容器環境:容器記憶體上限要大於 heap + 所有 native 部分,否則會被 OOMKilled(exit 137),通常 heap 設成容器記憶體的 60%~75%。
說明 HashMap 的 put 流程、擴容與樹化條件。
先算 hash = h ^ (h >>> 16) 把高位元混入低位元,再用 (n-1) & hash 算 index(容量是 2 的次方所以等同取模)。bucket 空就放入,否則沿著比對 hash 與 equals,相同就覆蓋,否則接到尾端(Java 8 尾插法)。單一 bucket 達 8 個節點且容量至少 64 時轉紅黑樹,否則先擴容;降到 6 個以下轉回串列。元素數超過容量 × 0.75 就容量加倍,每個節點依 hash 的新增位元決定留在原 index 或移到原 index + 舊容量。追問:為什麼 load factor 是 0.75(空間與碰撞機率的折衷);預知大小時用 new HashMap<>(expected / 0.75 + 1) 或 Java 19+ 的 HashMap.newHashMap(n) 避免反覆擴容。
ConcurrentHashMap 在 Java 7 與 Java 8 的實作差在哪?為什麼不允許 null?
Java 7 用 Segment 分段鎖,預設 16 段,最多 16 個執行緒同時寫。Java 8 改成以 bucket 為單位:空 bucket 用 CAS 插入,非空 bucket 鎖住頭節點(synchronized),讀取完全無鎖靠 volatile;擴容時多執行緒協助搬移;size 用分散的 counter cells。不允許 null 是因為併發下 get 回傳 null 無法分辨「key 不存在」還是「value 就是 null」,而單執行緒的 HashMap 可以再呼叫 containsKey 確認,併發下兩次呼叫之間狀態可能已改變。追問:組合操作要用 computeIfAbsent / merge 才是原子的。
volatile 和 synchronized 差在哪?volatile 能保證 count++ 正確嗎?
volatile 保證可見性(寫入立刻對其他執行緒可見)與有序性(禁止特定重排,建立 happens-before),但不提供互斥,所以不保證複合操作的原子性,count++ 是讀、加、寫三步,多執行緒下會遺失更新。synchronized 同時提供互斥、可見性與有序性。count++ 的正確做法:AtomicInteger.incrementAndGet()(CAS),高併發計數用 LongAdder,或加鎖。volatile 適合狀態旗標、一寫多讀的設定發佈、DCL 單例中的 instance 欄位。
什麼是 happens-before?舉出幾條規則。
JMM 用 happens-before 定義跨執行緒的記憶體可見性:若 A hb B,則 A 的結果對 B 可見且 A 排在 B 之前。規則包括:同一執行緒內的程式順序;同一把鎖的 unlock hb 之後的 lock;volatile 寫 hb 之後的讀;Thread.start() hb 新執行緒內的動作;執行緒內的動作 hb 其他執行緒從 join() 返回;遞移性。沒有 hb 關係且至少一方是寫入的併發存取就是 data race,結果不受保證,例如沒有 volatile 的停止旗標可能永遠看不到更新。
ThreadPoolExecutor 提交任務的流程?為什麼不建議用 Executors.newFixedThreadPool?
執行緒數小於 core 就建新執行緒;否則放入佇列;佇列滿了且執行緒數小於 max 才建非核心執行緒;都滿了就執行拒絕策略(預設拋 RejectedExecutionException,另有 CallerRuns、Discard、DiscardOldest)。newFixedThreadPool 與 newSingleThreadExecutor 用無界 LinkedBlockingQueue,下游一慢任務就無限堆積到 OOM,而且 max 永遠用不到;newCachedThreadPool 的 max 是 Integer.MAX_VALUE,可能建立過多執行緒。正確做法是自己建 ThreadPoolExecutor:有界佇列、具名的 ThreadFactory、明確的拒絕策略,並監控佇列長度與活躍執行緒數。執行緒數 CPU-bound 約等於核心數,I/O-bound 用 核心數 × (1 + 等待/計算) 估算再壓測。
什麼是 virtual thread?和 platform thread 差在哪?有什麼要注意的?
Java 21 正式推出的輕量執行緒,由 JVM 以 M:N 方式排程到少量 carrier(OS)執行緒上,stack 存在 heap 且按需增長,可以建立數百萬個。阻塞 I/O 時會從 carrier 卸載,讓 carrier 去跑其他 virtual thread,所以能用同步阻塞的寫法達到高併發。注意事項:不要 pool 化(每任務一個);pinning(JDK 21–23 在 synchronized 內阻塞會卡住 carrier,JDK 24 的 JEP 491 已解決,native 呼叫仍會 pin);它不增加下游容量,要用連線池或 Semaphore 限制對 DB 的併發;不適合 CPU-bound;大量 ThreadLocal 會吃記憶體。Spring Boot 3.2+ 可用 spring.threads.virtual.enabled=true 啟用。
說明 G1 和 ZGC 的差異,以及怎麼選。
G1 是 JDK 9 起的預設:heap 切成等大的 region,優先回收垃圾最多的 region,以可設定的暫停目標(預設 200ms)在吞吐量與延遲間平衡,適合大多數服務。ZGC 透過 colored pointers 與 load barrier,讓標記、搬移、參考更新幾乎都和應用並行,暫停時間通常在 1ms 以下且與 heap 大小幾乎無關,代價是較多的 CPU 與記憶體開銷;JDK 21 起支援分代模式,JDK 23 成為預設。選擇:預設 G1;p99 延遲要求嚴格或 heap 很大時評估 ZGC;純批次吞吐量考慮 Parallel。無論哪種,先開 GC log 量測,並優先處理洩漏與過量配置。
Java 有 GC,為什麼還會記憶體洩漏?怎麼排查?
GC 只回收不可達的物件,只要從 GC root 還有參考鏈連到,就算程式不再使用也不會被回收。常見原因:static 集合當快取只增不減、ThreadLocal 在 thread pool 中沒 remove、listener 沒反註冊、作為 key 的物件 hashCode 改變導致刪不掉、資源沒關閉。排查:GC log 看每次 GC 後 old 區的佔用是否持續上升;抓兩個時間點的 heap dump(jcmd <pid> GC.heap_dump),用 Eclipse MAT 看 dominator tree 與到 GC root 的路徑;JFR 的 Old Object Sample 事件也能找出長期存活物件的配置點。
線上 Java 服務 CPU 100%,你怎麼排查?
top 找到 process,top -H -p <pid> 找到吃 CPU 的執行緒,printf '%x' 把 tid 轉十六進位,抓 thread dump(jcmd <pid> Thread.print)找 nid 對應的 stack;隔幾秒抓多次確認不是巧合,或用 async-profiler 錄火焰圖。同時看 GC log,若 CPU 是被 GC 執行緒吃掉,問題其實是記憶體。常見根因:無窮迴圈、正規表示式回溯、過量序列化、頻繁 GC、自旋。修復後用壓測驗證並補上監控(CPU、GC 時間、執行緒狀態)。
@Transactional 在什麼情況下會失效?
(1) 自我呼叫:同類別內部呼叫不經過 proxy;(2) 方法不是 public(private 一定攔截不到),或類別/方法是 final(CGLIB 無法覆寫);(3) 拋出 checked exception 預設不回滾,需 rollbackFor;(4) 例外在方法內被 catch 吞掉,proxy 看不到;(5) 物件不是 Spring 管理的 bean(自己 new 的);(6) 在另一個執行緒中執行(交易綁定在 ThreadLocal 上,@Async 或自建執行緒都不在同一交易中)。追問 propagation:REQUIRED 共用交易時內層失敗會把整個交易標成 rollback-only,外層吞例外後 commit 會拋 UnexpectedRollbackException;REQUIRES_NEW 會另開交易並多佔一條連線。
Spring 的 singleton bean 是 thread-safe 的嗎?
Spring 不保證。singleton 只代表容器內只有一個實例,會被所有請求執行緒同時使用。如果 bean 是無狀態的(只有注入的依賴,且依賴本身 thread-safe),就是安全的;如果有可變的實例欄位存放單一請求的資料(例如把 userId 存在欄位上),就會有 race condition 與資料錯亂。解法:保持無狀態、請求資料用方法參數傳遞、必要時用 ThreadLocal(記得清除)或 request scope、共享可變狀態用併發容器或 Atomic 類別。
equals 和 hashCode 為什麼要一起覆寫?
HashMap / HashSet 先用 hashCode 找 bucket,再用 equals 在 bucket 內比對。契約要求 equals 相等的物件 hashCode 必須相同。只覆寫 equals 時,兩個邏輯相等的物件 hashCode 不同(預設依 identity),會落在不同 bucket,導致 set.contains 找不到、Set 中出現重複。兩者應使用同一組欄位,且作為 key 的物件在存放期間不應改變這些欄位。Java 16+ 的 record 會自動正確產生兩者。
CompletableFuture 的 supplyAsync 不傳 executor 會發生什麼事?
會使用 ForkJoinPool.commonPool(),大小約為核心數 - 1,整個 JVM 共用,設計給短小的 CPU-bound 任務。若把阻塞 I/O(HTTP、DB 呼叫)丟進去,少數慢呼叫就能佔滿所有 worker,讓其他使用 common pool 的程式(包括 parallel stream)全部排隊。正確做法:I/O 任務傳入專用的有界 executor(或 virtual thread executor),並在 HTTP client 本身設定 timeout;orTimeout 只會讓 future 逾時完成,不會中斷底層呼叫。
Java 是 pass-by-value 還是 pass-by-reference?
永遠是 pass-by-value。基本型別傳值本身;物件傳的是參考的複本。因此方法內透過參考修改物件內容(list.add)呼叫端看得到,但讓參數指向新物件(list = new ArrayList<>())只改變方法內的區域變數,呼叫端不受影響。可以用 swap 兩個物件參數無法成功的例子說明。
Integer a = 128, b = 128; a == b 的結果?為什麼?
false。autoboxing 呼叫 Integer.valueOf,只有 -128 到 127 會回傳快取的同一個物件,128 會建立兩個不同物件,== 比較的是參考。比較包裝型別一律用 equals,或先 unbox。相關陷阱:從 Map 取出 null 再自動 unboxing 成 int 會拋 NPE;三元運算子混用 int 與 Integer 也可能因 unboxing 拋 NPE。
Spring Boot 的 auto-configuration 是怎麼運作的?怎麼覆蓋預設設定?
@EnableAutoConfiguration 讀取各 jar 中 META-INF/spring/...AutoConfiguration.imports 列出的設定類別(Boot 2.7 引入,3.0 起是唯一方式;更舊的版本用 spring.factories),每個類別用條件註解決定是否生效:@ConditionalOnClass(classpath 上有相關類別)、@ConditionalOnMissingBean(使用者沒自己定義)、@ConditionalOnProperty。所以加入 starter 就自動有對應的 bean;自己定義同型別的 bean,或用 properties 調整,就能覆蓋預設。除錯用 --debug 看 condition evaluation report。
Java 服務在 K8s 上被 OOMKilled,但 heap dump 看起來 heap 用量正常,可能是什麼原因?
OOMKilled 是 kernel 依整個 process 的 RSS 判定的,heap 只是其中一部分。可能原因:heap 上限設得太接近容器上限(例如 -Xmx 等於容器記憶體),沒有留空間給 metaspace、thread stack(每個執行緒預設約 1MB 保留)、direct buffer(Netty)、code cache、GC 自身的資料結構;執行緒數暴增;native 函式庫洩漏。排查:開 -XX:NativeMemoryTracking=summary 並用 jcmd <pid> VM.native_memory summary 拆解各區;改用 -XX:MaxRAMPercentage 讓 heap 依容器記憶體比例設定並留足餘裕;限制 direct memory(-XX:MaxDirectMemorySize)。
為什麼 JIT 編譯的 Java 有時候比 AOT 編譯的程式還快?又有什麼代價?
JIT 在執行期擁有 AOT 編譯器沒有的資訊:實際的熱點、分支走向、呼叫點實際出現的型別。因此能做推測式最佳化,例如把只看過一種實作的介面呼叫直接 inline、依實際分支機率排列程式碼、escape analysis 後完全省略配置;假設被打破時再 deoptimize。代價是暖機時間(剛啟動時以直譯或低階編譯執行,延遲較高)、JIT 本身佔用 CPU 與記憶體、效能較難預測。冷啟動敏感的場景(serverless、快速擴展)可考慮 GraalVM native image、CDS 或 JDK 的 AOT cache。