Chapter 02

Java 深入(JVM、並行與 Spring Boot)

職缺寫的是「Python / Go / Java 至少一種精通」。如果你的履歷上有 Spring Boot 或 Java 後端經驗,面試官很可能直接從 JVM、集合、並行與 Spring 追問下去。本章從 bytecode、JIT、GC 講到 Java Memory Model、thread pool、virtual threads,再到 Spring Boot 的 proxy 與交易陷阱,以及線上 Java 服務的排查劇本。另外,Kafka、Spark、Flink、Hadoop 都跑在 JVM 上,懂 JVM 也等於懂了大數據工具鏈的執行環境。

預估閱讀 80 分鐘

JVM 執行模型:從 .java 到 bytecode 到 JIT 進階

Java 的口號是「write once, run anywhere」,靠的是中間層:javac 把原始碼編譯成與平台無關的 bytecode(.class),再由各平台的 JVM 執行。和 CPython 最大的不同是:HotSpot JVM 不只直譯,它會在執行期觀察熱點,把熱的方法編譯成機器碼(JIT),而且是基於實際執行資料做激進最佳化。這是 Java 服務「暖機後很快」的根本原因。

flowchart LR A["Hello.java"] --> B["javac"] B --> C["Hello.class (bytecode)"] C --> D["Class Loader"] D --> E["Bytecode Verifier"] E --> F["Interpreter"] F -- "熱點方法" --> G["C1 JIT (快速編譯)"] G -- "更熱 + profile" --> H["C2 JIT (激進最佳化)"] H -- "假設失效" --> F F --> I["Runtime: Heap / GC / Threads"] H --> I

類別載入(class loading)

  1. Loading:找到 .class 的位元組並建立 Class 物件。JDK 9 之後有三層 loader:Bootstrap(載入 java.base 等核心模組)、Platform(原 Extension)、Application(classpath / module path 上的程式)。
  2. Linking:驗證(verification,確保 bytecode 不會破壞 JVM 安全性)→ 準備(preparation,為 static 欄位配置記憶體並給預設值)→ 解析(resolution,把符號參考換成直接參考,通常延遲進行)。
  3. 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:一個跑很久的迴圈不用等方法結束,就能在迴圈中途切換到編譯後的版本。
面試官想聽到什麼:「Java 是編譯式還是直譯式?」

兩段式:javac 先編譯成 bytecode,JVM 執行時先直譯並收集 profile,熱點再由 JIT(C1 → C2)編譯成機器碼,且能依執行期資料做推測式最佳化、假設失效時 deoptimize。所以長時間執行的服務暖機後效能接近 C++,但冷啟動慢、暖機期間延遲較高。追問「怎麼解決冷啟動」:GraalVM native image(AOT 編譯,啟動毫秒級但失去部分動態特性與峰值最佳化)、CDS / AppCDS(類別資料共享,加速載入)、JDK 24 之後的 AOT cache(Project Leyden 系列 JEP),或在 readiness probe 前先打暖機流量。

Appier 情境:暖機與自動擴展

以廣告平台常見做法為例:流量高峰時 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 memoryOutOfMemoryError: Metaspace
JVM Stack每個執行緒一個每次方法呼叫一個 frame:local 變數、operand stack、回傳位址StackOverflowError(遞迴太深)
PC Register每個執行緒一個目前執行到哪條 bytecode不會 OOM
Native Method Stack每個執行緒一個JNI 呼叫的 native frameStackOverflowError
Direct / native memory共享ByteBuffer.allocateDirect、Netty buffer、JIT code cache、GC 自身資料結構、thread stackOutOfMemoryError: 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。

實戰提示:heap 不要剛好開到 32GB

超過約 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 記憶體管理最重要的觀察:

面試官想聽到什麼:弱分代假說(weak generational hypothesis)

大部分物件很快就死(例如一次 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 大小脫鉤。

flowchart LR subgraph Young["Young Generation"] E["Eden"] --> S0["Survivor 0"] S0 --- S1["Survivor 1"] end S1 -- "活過 N 次 young GC" --> O["Old Generation"] E -- "大物件 (G1: humongous)" --> O O --> M["Mixed / Full GC"]

主要收集器比較

收集器設計目標暫停時間適合
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 自動選了 Serial GC

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
  1. 先量測再調:開 GC log,看暫停時間分布、GC 頻率、每次回收後 old 區的佔用(如果回收後的佔用持續上升,是洩漏,不是調參能解決的)。
  2. 先調大小,再換收集器:heap 太小會頻繁 GC;young 區太小會讓中等壽命物件過早晉升到 old 區。多數情況下只設 heap 上限與收集器,其他交給 ergonomics。
  3. 減少配置才是根本:避免在熱路徑大量 boxing、字串串接、建立臨時集合;用 async-profiler 的 allocation 模式找出配置最多的地方。
  4. 留記憶體給 heap 以外的東西:容器記憶體 = heap + metaspace + thread stacks + direct buffers + code cache + GC 結構。heap 設成容器的 100% 一定會被 OOMKilled,通常設 60%~75%。
面試官想聽到什麼:「p99 偶爾飆高,懷疑是 GC,怎麼確認?」

(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) 的流程:

  1. 計算 hash:h = key.hashCode(); hash = h ^ (h >>> 16)。把高 16 位元混進低位元,因為 index 只用低位元。
  2. 算 index:(n - 1) & hash。table 長度 n 永遠是 2 的次方,所以這等於取模,但比 % 快。
  3. bucket 空的就直接放;否則沿著 bucket 比對 hash 與 equals,找到就覆蓋,找不到就接在尾端(Java 8 改為尾插法)。
  4. 樹化:單一 bucket 的節點數達到 8,而且 table 容量至少 64 時,該 bucket 轉成紅黑樹,最壞查詢從 O(n) 降到 O(log n);容量不到 64 時會先擴容。節點數降到 6 以下再轉回鏈結串列。
  5. 擴容:元素數超過 容量 × load factor(預設 16 × 0.75 = 12)就把 table 加倍。因為容量是 2 的次方,每個節點在新 table 中不是留在原 index,就是移到「原 index + 舊容量」,只要看 hash 多出來的那一位元,不必重新計算 hash。
flowchart TD A["put(key, value)"] --> B["hash:hashCode 高 16 位元 XOR 低 16 位元"] B --> C["index:hash 取低位元(n 為 2 的次方)"] C --> D{"bucket 是空的?"} D -- 是 --> E["直接放入"] D -- 否 --> F{"找到相同 key?"} F -- 是 --> G["覆蓋 value"] F -- 否 --> H["接到尾端"] H --> I{"bucket 節點數達 8?"} I -- "是,且容量至少 64" --> J["樹化成紅黑樹"] I -- "是,但容量未滿 64" --> K["改為擴容"] E --> L{"size 超過 容量 x 0.75?"} G --> L J --> L L -- 是 --> M["容量加倍並重新分配"]
面試官想聽到什麼:「HashMap 為什麼不是 thread-safe?」

多個執行緒同時 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);
常見陷阱:thread-safe 的容器不等於 thread-safe 的邏輯

每個方法單獨是原子的,不代表多個方法組合起來是原子的。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 / HashSethash table + 紅黑樹平均 O(1),最壞 O(log n)查找、去重、計數
LinkedHashMapHashMap + 雙向鏈結同 HashMap,保持插入或存取順序需要順序;覆寫 removeEldestEntry 可做簡易 LRU
TreeMap / TreeSet紅黑樹O(log n);有 floor/ceiling/range有序、範圍查詢、找最接近的 key
PriorityQueuebinary heapoffer/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

synchronizedReentrantLock
使用方式語法層級,自動釋放必須在 finally 中 unlock()
可中斷等待否lockInterruptibly()
逾時嘗試否tryLock(timeout),可用來避免死結
公平性非公平可選公平鎖(吞吐量較低)
條件變數一個(wait/notify)多個 Condition

J.U.C 的大多數同步器(ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock)都建立在 AQS(AbstractQueuedSynchronizer)上:一個 volatile 的 int state 加上 CAS 修改它,取不到的執行緒進入 FIFO 等待佇列並被 park。理解 AQS 就能講清楚這些工具的共同原理。

面試官想聽到什麼:死結(deadlock)

四個必要條件:互斥、持有並等待、不可搶佔、循環等待。實務上破壞「循環等待」最容易:所有執行緒依固定順序取鎖(例如依帳戶 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
);

提交任務時的判斷順序(常被問、也常被答錯):

flowchart TD A["submit(task)"] --> B{"執行緒數未達 core?"} B -- 是 --> C["建立新執行緒執行"] B -- 否 --> D{"佇列還沒滿?"} D -- 是 --> E["放進佇列等待"] D -- 否 --> F{"執行緒數未達 max?"} F -- 是 --> G["建立非核心執行緒執行"] F -- 否 --> H["執行拒絕策略"]

重點:佇列滿了才會擴到 max。所以搭配無界佇列時,maximumPoolSize 永遠不會生效。

拒絕策略行為適合
AbortPolicy(預設)拋 RejectedExecutionException呼叫端能處理失敗(回 503、重試)
CallerRunsPolicy由提交任務的執行緒自己執行自然降速、不想丟資料
DiscardPolicy默默丟棄可以接受遺失的任務(危險,至少要記 metric)
DiscardOldestPolicy丟掉佇列最舊的任務再重試只在乎最新資料
常見陷阱:Executors 工廠方法

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 繼續執行。寫起來是同步阻塞的程式碼,執行起來像非同步。

sequenceDiagram participant VT1 as Virtual thread A participant C as Carrier (OS thread) participant VT2 as Virtual thread B VT1->>C: mount,執行程式碼 VT1->>C: 呼叫 socket.read() 阻塞 C-->>VT1: unmount(stack 存回 heap) VT2->>C: mount,執行 Note over VT1: I/O 完成後重新排入 VT2->>C: 阻塞或結束後 unmount VT1->>C: 再次 mount,從 read() 之後繼續
// 每個任務一個 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
常見陷阱:pinning 與資源上限

(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 threadsJava virtual threadsPython asyncioGo goroutine
排程者OS kernelJVM(M:N)單執行緒 event loopGo runtime(M:N)
寫法同步阻塞同步阻塞async/await,會「傳染」整條呼叫鏈同步阻塞
可同時存在的數量數千數百萬數十萬(coroutine)數百萬
多核心利用是是否(單執行緒,需多 process)是
呼叫同步阻塞 library沒問題多數 JDK I/O 會自動卸載會卡住整個 loopruntime 會處理
面試官想聽到什麼:「virtual threads 出來後,還需要 WebFlux 嗎?」

對大多數 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)會加分。

版本代表特性
8Lambda、Stream API、Optional、java.time、interface default 方法
11var(10 起)、新的 HttpClient、String 新方法(isBlank、lines)、ZGC 實驗版
17record、sealed classes、text blocks、instanceof pattern matching
21Virtual threads、switch pattern matching、record patterns、sequenced collections、分代 ZGC
25Scoped 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

  1. 讀取設定與 component scan,建立 bean definition。
  2. 實例化(呼叫建構子)→ 注入依賴。
  3. BeanPostProcessor 的前置處理 → 初始化回呼(@PostConstruct、InitializingBean)→ 後置處理。AOP proxy 就是在後置處理這一步包上去的,所以容器裡拿到的常常是 proxy,不是你寫的那個類別本身。
  4. 使用中。
  5. 容器關閉時執行銷毀回呼(@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 經驗講成故事

如果你寫過長時間執行的 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
jstackthread 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 spaceheap 不夠:洩漏或 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 memoryNIO 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 拆解
實戰提示:一定要預先開 HeapDumpOnOutOfMemoryError

OOM 往往在半夜發生且無法重現。啟動參數預先加上 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=...,並確認路徑掛在 Pod 重啟後還在的 volume 上。heap dump 檔案大小約等於 heap 使用量,磁碟要夠。

劇本三:服務沒掛但不回應

  1. 抓 3 次間隔數秒的 thread dump,看大部分 Tomcat 工作執行緒卡在哪。
  2. 大量執行緒 WAITING 在 HikariPool.getConnection:連線池耗盡。查是否有慢查詢佔住連線、交易中呼叫外部服務、或連線洩漏(Hikari 的 leakDetectionThreshold)。
  3. 大量執行緒 RUNNABLE 在 SocketInputStream.read:在等下游回應,而且沒有設 read timeout。所有對外呼叫都要設 connect / read timeout。
  4. 大量執行緒 BLOCKED 在同一把鎖:鎖競爭,找出持有鎖的那個執行緒在做什麼。
  5. dump 結尾出現 Found one Java-level deadlock:死結。
Appier 情境:JVM 是大數據工具鏈的共同底層

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 會這樣」 進階

JavaPythonGo
型別靜態、名義型別動態(可選 type hints)靜態、結構型別(interface 隱式實作)
執行方式bytecode + JITbytecode 直譯(3.13+ 實驗性 JIT)AOT 編譯成原生執行檔
啟動 / 暖機慢,需要暖機快很快
峰值效能高低(熱點靠 C 擴充)高
記憶體用量較高(物件 header、JIT、heap 預留)中低
記憶體管理多種可選 GC,可調參數最多reference counting + 循環 GC並行標記清除 GC,參數很少
併發執行緒、J.U.C、virtual threadsGIL;asyncio、多 processgoroutine + 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。