Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

Java 生態與應用

Java 生態與應用(進階):抹除、JIT、記憶體模型與 GC 的真相

拆開 JVM 黑盒子——從型別抹除的代價、逃逸分析、happens-before 到 ZGC 的亞毫秒停頓,看懂那些你以為是魔法的工程取捨。

當一行 new ArrayList<>() 在 JVM 裡發生了什麼?

你已經知道 Java 程式碼會先編譯成 bytecode、由 JVM 解譯執行,也知道 Spring 幫你把物件兜在一起。但這裡有個問題,可能是你從沒被逼著面對的:當你寫下一行平凡的 List<String> names = new ArrayList<>();,從原始碼到 CPU 真正搬動記憶體之間,至少經過了四層轉譯——javac 編譯、class 載入與驗證、JIT 即時編譯、以及 garbage collector 在背景默默替你回收記憶體。入門篇把 JVM 當成一個黑盒子;進階篇要做的,是把這個黑盒子拆開,看看裡面那些決定你程式效能與正確性的真正機制。

這一篇我們不再重複「JVM 是什麼」。我們聚焦四個入門篇刻意略過、卻是中高階 Java 工程師每天在打交道的主題:型別抹除(type erasure)的代價JIT 編譯與逃逸分析(escape analysis)記憶體模型與並行(Java Memory Model)、以及現代 GC 的分代假說與 ZGC。讀完你會發現,很多你以為是「魔法」的行為,其實都是可以推導的工程取捨。

Java 生態與應用進階概念示意圖

泛型的真相:型別抹除與它的幽靈

入門篇告訴你 List<String> 是「裝字串的清單」。但 JVM 從來不知道這件事。Java 的泛型(generics)是編譯期的把戲——編譯完成後,所有型別參數都被「抹除」(erasure)成它們的上界,預設就是 Object

換句話說,下面這兩個欄位在 runtime 是完全相同的型別

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true!

兩者的 getClass() 都回傳 java.util.ArrayList,因為 <String><Integer> 在 bytecode 裡都不存在了。編譯器只是在「你存取資料的那一刻」偷偷插入了型別轉換(cast)。

看一個例子

考慮這段看似合理、卻無法編譯的程式碼:

public <T> T createInstance() {
    return new T();      // 編譯錯誤:Cannot instantiate the type T
}

為什麼不行?因為 runtime 根本不知道 T 是誰——這個資訊在抹除後就消失了。new T() 需要呼叫某個具體類別的建構子,但 JVM 拿不到那個類別。解法是把型別資訊「補回來」,用 Class<T> 當作執行期的見證(witness):

public <T> T createInstance(Class<T> clazz)
        throws ReflectiveOperationException {
    return clazz.getDeclaredConstructor().newInstance();
}

// 呼叫端把型別 token 顯式傳進去
String s = createInstance(String.class);

這就是為什麼許多 Java 框架(包含你熟悉的 Spring 與 Jackson)的 API 到處都是 Class<?> 參數或 TypeReference 這種「型別 token」——它們是在用一個物件,把編譯期被抹除掉的型別資訊,人工搬運到執行期。

抹除還會產生一個「幽靈」:陣列與泛型不相容。new List<String>[10] 無法編譯,因為陣列在 runtime 會做 store check(型別檢查),但泛型 runtime 沒有型別,兩種設計理念直接衝突。這也是 Java 集合框架大量回傳 Object[] 再 cast 的根本原因。理解抹除,你才能看懂那些「為什麼非得這樣寫」的 API 設計。

JIT 編譯:解譯器與機器碼之間的賭注

入門篇說「JVM 解譯 bytecode」。這只說對了一半。現代 HotSpot JVM 是一個混合執行引擎:它先解譯,同時統計每個方法被呼叫的次數與迴圈次數,一旦某段程式碼「夠熱」(hot),就由 JIT(Just-In-Time)編譯器把它編成原生機器碼。

HotSpot 有兩個編譯器,採用分層編譯(tiered compilation)

  • C1(client compiler):快速編譯,做基本最佳化,先讓熱點動起來。
  • C2(server compiler):慢但激進,做深度最佳化,給真正的熱點長期服役。

關鍵在於:JIT 做的是投機式最佳化(speculative optimization)。它會根據目前觀察到的執行 profile「賭」一些假設,例如「這個方法呼叫永遠落在同一個實作」,據此把虛擬呼叫(virtual call)直接 inline 進去。萬一假設後來被打破(載入了新類別、出現新的型別),JVM 會觸發 deoptimization,退回解譯模式重新來過。這種「先賭、賭錯再退回」的策略,正是 JVM 能在動態語言特性下還跑得飛快的核心。

動手算一下:逃逸分析如何讓堆積配置消失

C2 有一項威力強大的最佳化叫逃逸分析(escape analysis):如果它能證明一個物件「不會逃出當前方法的作用域」(沒被回傳、沒被存進別人看得到的欄位),那這個物件根本不需要配置在堆積(heap)上——可以做純量替換(scalar replacement),把物件的欄位直接攤平成 CPU 暫存器或堆疊變數。

看這個迴圈:

public double distanceSum(double[] xs, double[] ys) {
    double sum = 0;
    for (int i = 0; i < xs.length; i++) {
        Point p = new Point(xs[i], ys[i]);  // 每圈都 new 一個物件
        sum += p.magnitude();
    }
    return sum;
}

天真地看,這個迴圈跑 $n$ 次就配置 $n$ 個 Point 物件,會給 GC 帶來壓力。但 p 從未逃出迴圈本體——逃逸分析能證明這點,於是 C2 把 p.xp.y 直接變成兩個堆疊上的 double,那行 new Point(...)堆積配置次數變成 0

這帶來一個重要的工程啟示:在 JIT 預熱(warm-up)之後,「短命的小物件」的配置成本可能趨近於零。這就是為什麼有經驗的 Java 工程師不會為了「避免配置」而過早把程式碼寫得醜陋——很多時候 JIT 已經替你免費處理掉了。但這也解釋了為什麼 Java 的 microbenchmark 必須用 JMH(Java Microbenchmark Harness)並做足 warm-up:冷啟動時量到的是解譯器的速度,跟正式服役時的機器碼天差地遠。

並行的暗礁:Java Memory Model

如果你寫過多執行緒程式,你可能寫過這種「旗標」模式:

class Worker {
    boolean stop = false;          // 注意:沒有 volatile

    void run() {
        while (!stop) {
            // 做事...
        }
    }
    void shutdown() { stop = true; }
}

直覺上,一條執行緒呼叫 shutdown() 後,跑迴圈的執行緒應該會看到 stoptrue 然後停下來。但這在 Java 裡不保證會發生,迴圈可能永遠跑下去。

原因是 Java Memory Model(JMM)。JMM 是一份規範,定義了「一條執行緒對記憶體的寫入,在什麼條件下能保證被另一條執行緒看見」。在沒有同步的情況下,編譯器、JIT 與 CPU 都被允許重排指令、把變數快取進暫存器。上面那個迴圈,JIT 完全可以把 !stop 提到迴圈外只讀一次(這叫 hoisting),於是 shutdown() 的寫入永遠不被看見。

核心概念是 happens-before:只有當寫入動作 happens-before 讀取動作時,可見性才被保證。建立 happens-before 關係的工具包括:

  • volatile:對 volatile 變數的寫,happens-before 後續對它的讀。
  • synchronized:解鎖 happens-before 後續對同一鎖的加鎖。
  • final 欄位的安全發布、Thread.start() / join()、以及 java.util.concurrent 的各種工具。

修正版只要一個關鍵字:

volatile boolean stop = false;

看一個更微妙的例子:雙重檢查鎖定

經典的「雙重檢查鎖定」(double-checked locking)單例模式,在缺少 volatile 時是壞的:

class Singleton {
    private static volatile Singleton instance;  // volatile 不可省

    static Singleton get() {
        if (instance == null) {                  // 第一次檢查(不加鎖)
            synchronized (Singleton.class) {
                if (instance == null) {          // 第二次檢查(加鎖)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

為什麼 instance 一定要 volatile?因為 instance = new Singleton() 不是原子操作,它在底層是三步:(1) 配置記憶體、(2) 呼叫建構子初始化、(3) 把參考指給 instance。CPU 與編譯器允許把 (2) 與 (3) 重排!若重排成 1→3→2,另一條執行緒在第一次檢查時會看到一個「非 null 但還沒初始化完成」的半成品物件,讀到一堆預設值。volatile 透過插入記憶體屏障(memory barrier)禁止這種重排,是這個模式正確的唯一保證。

這就是 JMM 最值得記住的一課:並行的 bug 大多不是「邏輯錯」,而是「可見性與順序性」錯——它們在你的開發機上跑一萬次都對,卻在正式環境的多核心 CPU 上偶發地壞掉。

GC 不是垃圾:分代假說與 ZGC

入門篇可能只說「Java 有自動垃圾回收,不用手動 free」。但 GC 的設計細節,直接決定了你的服務在尖峰流量下會不會「卡頓」(stop-the-world pause)。

現代 GC 建立在一個經驗觀察上,稱為弱分代假說(weak generational hypothesis)

絕大多數物件「朝生暮死」——出生後很快就沒人用了;而活得夠久的物件,往往會繼續活很久。

基於此,HotSpot 把堆積分成 young generation(新生代)與 old generation(老年代)。新物件先進新生代,這裡用「複製式回收」(copying collection):因為大部分物件已死,只要把少數存活者複製出來,剩下的整塊清空即可,效率極高。撐過幾輪的物件才「晉升」(promote)到老年代。這個分代設計,讓常見情況下的 GC 成本與「存活物件數」成正比,而非與「總配置量」成正比。

不同 GC 是針對不同目標的取捨:

GC 目標 典型 pause 適用
Parallel GC 吞吐量(throughput) 數百 ms 批次運算
G1 GC 平衡(JDK 9+ 預設) 數十~百 ms 一般服務
ZGC 超低延遲 < 1 ms 大堆積、延遲敏感

ZGC 是現代 Java 的明星。它能在數百 GB 的堆積上把停頓壓到亞毫秒級,靠的是把「找出垃圾」與「搬移物件」幾乎全程與你的應用程式執行緒並行(concurrent)。它的核心技巧之一是著色指標(colored pointers)讀屏障(load barrier):當應用程式讀取一個物件參考時,ZGC 在指標的高位 bit 裡編碼了 GC 狀態,讀屏障會在你存取的瞬間檢查並(必要時)即時把參考「修正」到搬移後的新位置。換句話說,物件搬家這件事不再需要「全世界停下來等」,而是邊跑邊搬。

對你的工程決策而言,重點是:GC 不是越「先進」越好,而是要對齊你的 SLA。做高吞吐批次運算,Parallel GC 的吞吐量可能勝過 ZGC;做使用者面向、p99 延遲敏感的 API,ZGC 的亞毫秒停頓才是關鍵。會挑 GC、會讀 GC log,是 Java 工程師從「會寫」到「會調校」的分水嶺。

重點回顧

  • 泛型是編譯期的:型別抹除後 runtime 沒有 <T>,所以 new T()new List<String>[] 都不行;框架用 Class<T> token 把型別資訊搬到執行期。
  • JIT 是投機式的:分層編譯(C1/C2)+ 逃逸分析能讓短命物件的堆積配置「消失」,但量測效能必須做足 warm-up(用 JMH),否則量到的是解譯器。
  • 並行 bug 多是可見性錯:沒有 happens-before 關係,一條執行緒的寫入不保證被另一條看見;volatile/synchronized 是建立可見性與禁止重排的工具。
  • 雙重檢查鎖定的 volatile 不可省:物件初始化的三步可能被重排,導致看到半成品。
  • GC 對齊 SLA:分代假說讓新生代回收極快;ZGC 用著色指標 + 讀屏障達成亞毫秒停頓,但選 GC 要看吞吐 vs 延遲的取捨。

深入探討(研究所視角)

若你想往更深處走,這四個主題各自連著一片研究與系統設計的天地。

型別系統與 reification。 Java 選擇 erasure 是為了向後相容(pre-generics 的 bytecode 仍要能跑),代價是放棄了「reified generics」——C# 的 CLR 選了另一條路,泛型型別在 runtime 是具體存在的。這是一個經典的語言設計取捨:相容性 vs 表達力。值得延伸閱讀 Project Valhalla 對 value types 與 specialized generics 的探索,它試圖在不破壞相容的前提下,讓 List<int> 能避開 autoboxing 的裝箱成本,這牽涉到 JVM 物件模型的根本重構。

編譯器最佳化理論。 JIT 的投機最佳化背後是「profile-guided optimization」與「speculative execution + deoptimization」的形式化。可以從資料流分析(data-flow analysis)、SSA(static single assignment)形式、以及 Graal 編譯器(用 Java 寫的 JIT,可做 partial escape analysis 等更激進的最佳化)切入。一個好問題是:deoptimization 的正確性如何保證?答案牽涉到在編譯碼與解譯狀態之間維護一致的「deopt map」。

記憶體模型的形式語意。 JMM 是少數被嚴謹形式化的 memory model 之一(JSR-133)。它定義在「allowed executions」的集合上,並用 happens-before 與 causality requirements 來排除憑空捏造的值(out-of-thin-air values)。這與 C++11 的 memory model、以及硬體層的 TSO(x86)/弱序(ARM)模型形成有趣對照。研究所層級可探討 acquire-release 語意如何對映到不同 ISA 的屏障指令。

並行 GC 的演算法。 ZGC 與 Shenandoah 都屬於「concurrent compaction」家族,理論根基是如何在 mutator(你的程式)持續修改物件圖時,安全地搬移物件並維持參考一致——這是 read barrier / write barrier、SATB(snapshot-at-the-beginning)或 incremental update 等技術要解的問題。可延伸到 Dijkstra 經典的「on-the-fly garbage collection」三色標記不變式(tri-color invariant),那是所有並行 GC 正確性論證的共同語言。

這四條線索的共同主題是:Java 的「易用」,是建立在一層又一層精巧的執行期機制之上的。 當你開始把這些機制當成可推導、可量測、可調校的工程對象,而不是黑魔法,你就從 Java 的使用者,變成了能駕馭 JVM 的工程師。

AI 共讀助教正在陪你讀:Java 生態與應用(進階):抹除、JIT、記憶體模型與 GC 的真相
嗨!我是這篇文章的共讀助教,只根據〈Java 生態與應用(進階):抹除、JIT、記憶體模型與 GC 的真相〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。