Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

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 的真相〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。