題幹與適用場景
這道程式與並發題適合 Java 後端、平台和效能工程職位。題目要求你比較 platform thread、virtual thread 和非同步回呼模型,並把並發數量、CPU、阻塞 I/O、下游連線池和診斷工具放進同一個推理鏈。重點是可複用的選擇規則,而非背誦 API 名稱。
面試官考察點
- 能否說清虛擬執行緒提升的是可擴展並發和吞吐,不是單一任務的執行速度。
- 能否識別
synchronized或 native 呼叫造成的 pinning,以及 CPU 密集任務的上限。 - 能否用每任務一個虛擬執行緒表達 fan-out,並用
Semaphore或資料庫連線池限制下游資源。 - 能否設計 JFR、執行緒轉儲、延遲、carrier 利用率和下游池等待的驗證方法。
回答前需要釐清的問題
先問請求主要等待網路、資料庫還是執行 CPU 計算;三個下游呼叫是否可以並行;下游每個服務的並發額度和資料庫連線數是多少。還要確認框架與驅動是否支援阻塞 API、是否存在長時間 synchronized 或 native 區段、目標是降低 p99 還是提高吞吐,以及是否允許在灰度期間保留舊執行器。
30 秒回答框架
虛擬執行緒讓每個任務保留同步、阻塞 I/O 的寫法,等待時釋放 carrier,因此適合大量 I/O 等待的請求;它不會讓 CPU 程式碼變快,也不會增加資料庫連線或下游配額。我會使用每任務一個虛擬執行緒做 fan-out,用 Semaphore 或連線池限制稀缺資源,檢查鎖和 native 呼叫造成的 pinning。最後透過 JFR、執行緒轉儲、下游等待和 p99/吞吐對比灰度驗證,而不是假設替換執行緒池就一定更快。
分步深入解答
1. 把收益定義成等待期間的並發能力
platform thread 長時間等待 I/O 時仍占用 OS 執行緒;virtual thread 在阻塞 I/O 時可被掛起,carrier 繼續執行其他虛擬執行緒。它適合請求大部分時間等待網路或資料庫的服務。CPU 密集程式碼仍受核心數和排程並行度限制,虛擬執行緒不會降低演算法複雜度或單請求 CPU 時間。
2. 每個任務建立虛擬執行緒,不要池化虛擬執行緒
虛擬執行緒是廉價的任務表示,應用程式可使用 Executors.newVirtualThreadPerTaskExecutor() 為每個提交任務建立新執行緒。共享固定虛擬執行緒池會重新引入排隊語義,卻沒有必要複用稀缺 carrier;需要限制的是外部資源,不是虛擬執行緒數量本身。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Profile> profile = executor.submit(() -> profileClient.fetch(id));
Future<Orders> orders = executor.submit(() -> orderClient.fetch(id));
Future<Quota> quota = executor.submit(() -> quotaClient.fetch(id));
return merge(profile.get(), orders.get(), quota.get());
}3. 用專門的信號量限制下游並發
虛擬執行緒數量可以很多,但資料庫連線、供應商 QPS 和檔案描述符仍然有限。用 Semaphore 包住受限呼叫,或直接依賴既有資料庫連線池作為並發邊界;不要用「固定執行緒池大小」同時承擔執行緒複用和資源限流。信號量必須在 finally 中釋放,並為等待設定逾時和取消策略。
4. 識別 pinning 和不可卸載場景
虛擬執行緒在 synchronized 區段執行阻塞操作,或呼叫 native/foreign 函式時可能固定在 carrier 上。短小的記憶體鎖通常無礙,長時間 I/O 鎖會占住 carrier,導致吞吐下降。把熱路徑中包住阻塞呼叫的監視器改為合適的 ReentrantLock,並透過 JFR 的 pinned 事件或診斷選項定位,而不是全域替換所有 synchronized。
5. 處理取消、逾時和例外傳播
fan-out 的三個呼叫必須有請求總截止時間;任一關鍵呼叫失敗時取消仍在等待的任務,避免客戶端已逾時後下游繼續消耗配額。虛擬執行緒只改變執行緒承載方式,不會自動取消 Future、關閉回應體或釋放連線。把 InterruptedException、逾時、下游錯誤映射到明確的降級結果,並在 finally 清理資源。
6. 用指標和對照實驗證明收益
灰度比較相同流量下的吞吐、p50/p99、CPU、carrier 並行度、虛擬執行緒數、下游連線池等待、信號量等待和錯誤率。用 JFR 記錄 jdk.VirtualThreadPinned、啟動結束事件,並用 jcmd 執行緒轉儲觀察堆疊。分別跑 I/O 等待、CPU 密集、下游限流和鎖競爭場景;只有 I/O 場景改善而 CPU 場景不改善,才符合預期。
高品質示範回答
我不會把虛擬執行緒當成更快的執行緒池。它適合每請求包含大量阻塞 I/O 的服務,因為等待時虛擬執行緒可掛起並釋放 carrier;CPU 密集計算仍受核心數限制。對於三個並行下游呼叫,我會用每任務虛擬執行緒的 executor,使用總截止時間和取消處理;資料庫連線和供應商額度用連線池或 Semaphore 限制。先檢查驅動、鎖和 native 呼叫,避免長 I/O 位於 synchronized 區段造成 pinning。灰度時比較吞吐、p99、carrier 利用率、下游等待、JFR pinned 事件和執行緒轉儲,再決定是否擴大遷移。
常見錯誤
- 說虛擬執行緒讓 CPU 程式碼更快 → 它主要提高等待型任務的並發,不增加 CPU 核心 → 把吞吐、延遲和 CPU 計算分開量測。
- 建立固定大小的虛擬執行緒池 → 把執行緒池誤當成資源限流器 → 每任務建立虛擬執行緒,用信號量或連線池限制下游。
- 看到
synchronized就全部替換 → 短記憶體臨界區不一定 pinning,盲目替換會增加複雜度 → 定位阻塞鎖路徑和 JFR 證據後再改。 - 忽略下游連線數 → 虛擬執行緒能同時發起更多請求,卻不能增加連線或 QPS 配額 → 明確預算、逾時和降級。
- 只做高並發壓測 → 可能掩蓋 pinning、取消洩漏或 CPU 飽和 → 分別測試 I/O、CPU、鎖、限流和例外恢復。
追問及應對
虛擬執行緒與 reactive 有什麼取捨?
虛擬執行緒保留順序、阻塞式程式碼和傳統診斷工具,適合大量 I/O 等待且呼叫堆疊需要易讀的服務。Reactive 在事件流組合、極高連線數或已有非阻塞生態中可能更合適。選擇依據是驅動支援、團隊維護成本、延遲目標和觀測能力,而非「新技術一定更快」。
如何限制一個供應商最多十個並發請求?
為該供應商建立容量為十的 Semaphore,取得許可後呼叫並在 finally 釋放;等待設定截止時間,逾時回傳降級或排隊結果。若已有連線池且每個請求必須持有連線,連線池本身就是更準確的邊界,避免重複加兩層限制。
為什麼仍然會出現 carrier 飢餓?
長時間 synchronized 阻塞、native 呼叫、CPU 密集任務或未受控的外部資源等待都可能占住 carrier 或耗盡並行度。查看 JFR pinned 事件、執行緒轉儲、CPU 火焰圖和下游等待時間,區分 pinning 與正常的任務積壓。
虛擬執行緒能取代資料庫連線池嗎?
不能。虛擬執行緒是執行任務的承載,資料庫連線是有限外部資源。仍需連線池、逾時、交易邊界和池等待指標;讓大量虛擬執行緒無限等待連線只會把壓力轉移到記憶體和請求截止時間。
如何證明遷移沒有讓延遲變差?
固定輸入、下游回應分布和錯誤率,比較舊執行器與虛擬執行緒灰度的 p50/p99、吞吐、CPU、carrier 利用率、池等待、取消完成率和 pinned 事件。覆蓋穩態、突發、下游慢、連線池耗盡和程序重啟,設定回滾閾值後再擴大流量。