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 入門與 JVM

Java 與 JVM 進階:記憶體模型、垃圾回收與類別載入的真相

當多執行緒共享同一片堆積,「順序」與「可見性」不再理所當然——深入 JMM、GC 演算法權衡與延遲初始化

兩個執行緒讀寫同一個變數,為什麼結果可能違反你對「順序」的直覺?

入門篇把 JVM 當成一個忠實的翻譯機:位元碼(bytecode)丟進去,機器碼吐出來,垃圾回收(Garbage Collection,GC)自動把死掉的物件收走。這個圖像在單執行緒的世界裡夠用了。但現代企業系統幾乎都是多執行緒的——一台伺服器同時服務上千個請求,這些請求的執行緒共享同一片堆積(Heap)裡的物件。一旦多個執行緒同時讀寫同一塊記憶體,事情就變得反直覺到令人不安。

看這段程式:一個執行緒不斷把 ready 設成 true、把 value 設成 42;另一個執行緒等到看見 readytrue 時,把 value 印出來。

public class Surprise {
    static int value = 0;
    static boolean ready = false;

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            value = 42;       // (1)
            ready = true;     // (2)
        });
        Thread reader = new Thread(() -> {
            while (!ready) { }   // (3) 等待
            System.out.println(value);   // (4)
        });
        reader.start();
        writer.start();
    }
}

憑直覺,讀取執行緒看到 readytrue(離開迴圈)後,理應印出 42。但在沒有適當同步的情況下,這段程式可能印出 0,甚至可能永遠卡在迴圈裡跑不完。寫入執行緒明明先設 value 再設 ready,為什麼另一個執行緒會看到一個「ready 已變、value 未變」的矛盾世界?

答案藏在入門篇刻意沒打開的盒子裡:Java 記憶體模型(Java Memory Model,JMM)。這篇文章我們要深入 JVM 在多執行緒下的真實行為、GC 演算法的內部權衡,以及類別載入的精細機制——這些都是把 Java 從「會寫」推向「懂得為什麼」的關鍵。

Java 入門與 JVM進階概念示意圖

重排序與可見性:編譯器和 CPU 都在「動手腳」

上面那段程式之所以詭異,是因為在你和硬體之間,有兩層都會偷偷重排你的指令順序。

第一層是 JIT 編譯器。 入門篇談過即時編譯(Just-In-Time,JIT)會把熱點程式碼最佳化成機器碼。最佳化的手段之一就是指令重排序(reordering):只要在單一執行緒看來結果不變,編譯器就有權調換指令順序。對寫入執行緒而言,先設 value 還是先設 ready,在「它自己」看來毫無差別,所以編譯器完全可以把 (1) 和 (2) 對調。

第二層是 CPU 與快取。 即使編譯器不動,現代多核心 CPU 每顆核心有自己的快取(cache)。寫入執行緒把 value = 42 寫進的可能只是它那顆核心的快取,還沒同步到主記憶體;而讀取執行緒在另一顆核心上,看到的是它自己快取裡的舊值。兩顆核心對「現在記憶體長什麼樣」的認知可以暫時不一致——這就是可見性(visibility)問題。

所以「value = 42 先發生,所以另一個執行緒一定看得到」這個推論是錯的。在多執行緒下,沒有同步機制,一個執行緒的寫入何時、是否會被另一個執行緒看見,完全沒有保證。這不是 JVM 的 bug,而是所有現代語言(C++、Go 都一樣)為了榨出效能必須付出的代價。

JMM 的存在,就是為了在「效能(允許重排)」與「正確(程式設計師能推理)」之間,劃出一條明確的契約。

happens-before:JMM 給你的唯一保證

JMM 的核心是一個叫 happens-before(先行發生) 的偏序關係。它的意義非常精確:

若動作 A happens-before 動作 B,則 A 的所有記憶體寫入,對 B 都是可見的,且 A 不會被重排到 B 之後。

注意「happens-before」講的不是時間先後,而是可見性與順序的保證。兩個動作在時間上有先後、卻沒有 happens-before 關係,那麼後發生的那個也不保證看得見前一個的結果。JMM 規定了幾條會自動建立 happens-before 的規則,最常用的是:

  1. 程式順序規則:同一個執行緒內,前面的動作 happens-before 後面的動作。
  2. 監視鎖規則:對一把鎖的解鎖 happens-before 後續對同一把鎖的加鎖。
  3. volatile 規則:對 volatile 變數的寫入 happens-before 後續對同一變數的讀取。
  4. 執行緒啟動規則thread.start() happens-before 該執行緒內的任何動作。
  5. 傳遞性:A happens-before B、B happens-before C,則 A happens-before C。

回到開頭的問題:寫入執行緒裡 (1) happens-before (2)(程式順序規則),但 (2) 和讀取執行緒的 (3) 之間沒有任何 happens-before 關係——ready 只是個普通變數,跨執行緒的讀寫不在規則涵蓋內。於是讀取執行緒看到 (2) 的結果時,並不保證能看到 (1) 的結果。整條推理鏈在這裡斷掉。

看一個例子:用 volatile 修好它

最小的修法是把 ready 宣告成 volatile

public class Fixed {
    static int value = 0;
    static volatile boolean ready = false;   // 關鍵:volatile

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            value = 42;       // (1)
            ready = true;     // (2) 對 volatile 變數寫入
        });
        Thread reader = new Thread(() -> {
            while (!ready) { }           // (3) 對 volatile 變數讀取
            System.out.println(value);   // (4)  → 保證印出 42
        });
        reader.start();
        writer.start();
    }
}

關鍵推理鏈是這樣串起來的:

  • (1) happens-before (2):同執行緒程式順序。
  • (2) happens-before (3):volatile 規則——對 ready 的寫入 happens-before 後續對 ready 的讀取。
  • (3) happens-before (4):同執行緒程式順序。
  • 由傳遞性,(1) happens-before (4):所以讀取執行緒在 (4) 一定看得見 value = 42

值得玩味的是:value 自己並不是 volatile,卻因為「搭上了」ready 這個 volatile 變數建立的 happens-before 邊,它的寫入也被保證可見。volatile 不只保證該變數自己的可見性,還像一道記憶體屏障(memory barrier):寫入 volatile 之前的所有寫入,都不能被重排到它之後,並會一起被刷新到主記憶體。這就是它能「順便」修好 value 的原因。

一個常見迷思要在這裡澄清:volatile 不能取代 synchronized。它只保證可見性與禁止重排,不保證原子性(atomicity)count++ 即使 count 是 volatile,仍是「讀—改—寫」三步,兩個執行緒同時做會互相覆蓋。要原子遞增,得用 synchronizedAtomicInteger,或 java.util.concurrent 裡的工具。

GC 不是魔法:分代、停頓與吞吐量的權衡

入門篇說 GC 會「在適當時機自動回收」。研究這個「適當時機」與「怎麼回收」,會發現 GC 是 JVM 工程裡權衡最深的一塊。

所有現代 JVM 的 GC 都建立在一個經驗觀察上,叫弱分代假說(weak generational hypothesis)絕大多數物件出生後很快就死,活很久的物件相對稀少。 想想我們寫的程式,迴圈裡 new 出來的臨時字串、方法內的暫存物件,往往用完即丟。

基於這個假說,堆積被切成新生代(Young Generation)老年代(Old Generation)

  • 新物件先配置在新生代。新生代的回收(Minor GC)很頻繁,但因為大部分物件已死,只要把少數倖存者複製出來、整片清空即可,非常快。
  • 在新生代熬過數次回收的「老兵」會被晉升(promote)到老年代。老年代的回收(Major / Full GC)較少發生,但範圍大、較慢。

GC 怎麼判斷一個物件「死了」?不是用引用計數(reference counting,那會被循環引用騙過),而是可達性分析(reachability analysis):從一組 GC Roots(執行緒堆疊上的區域變數、靜態欄位等)出發,沿引用走訪,走得到的物件就是活的,走不到的就是垃圾。這天生不怕循環引用——兩個互相指、但沒人從 root 指得到的物件,照樣被判定為垃圾回收掉。

三個你必須懂的指標

評價一個 GC 策略,看三個彼此拉扯的指標:

指標 意義 誰最在乎
吞吐量(throughput) 單位時間能做多少有用工作(非 GC 時間佔比) 批次運算、離線分析
延遲 / 停頓(latency / pause) 單次 GC 讓應用「卡住」多久 線上交易、即時服務
記憶體足跡(footprint) 為了達成上述兩者要付出多少額外記憶體 容器、邊緣裝置

這三者是不可能三角:想要停頓短,往往得犧牲吞吐量或多吃記憶體。所以 JVM 提供多種 GC 演算法讓你依場景選擇,而不是一招打天下。

動手算一下:停頓時間怎麼估

假設一個服務的堆積有 4 GB,採用會「停止全世界(Stop-The-World,STW)」的回收器,標記與整理的速度約為每秒處理 2 GB 存活物件,而某次 Full GC 時老年代有 1.5 GB 存活物件。粗估這次的停頓:

$$ \text{停頓} \approx \frac{1.5\ \text{GB}}{2\ \text{GB/s}} = 0.75\ \text{秒} $$

750 毫秒對一個要求「99 分位延遲低於 100 毫秒」的線上交易系統是災難——這 0.75 秒內,所有應用執行緒全部凍結。這正是為什麼大堆積、低延遲場景不能用傳統 STW 回收器。現代的 G1(Garbage-First) 把堆積切成許多小區塊(region),優先回收垃圾最多的區塊,並讓大部分標記工作與應用執行緒並行(concurrent)進行,把單次停頓壓到可預測的數十毫秒。再進一步的 ZGCShenandoah 透過讀屏障(load barrier)與並行整理,能在 TB 級堆積上把停頓壓到毫秒以下,代價是吞吐量略降、實作複雜度高。

實務上你可以這樣指定與觀察:

# 選用 G1(較新的 JDK 預設就是 G1)
java -XX:+UseG1GC -Xmx4g -Xlog:gc MyApp

# 想要極低延遲,TB 級堆積,選 ZGC
java -XX:+UseZGC -Xmx16g -Xlog:gc MyApp

-Xlog:gc 會把每次 GC 的類型、回收前後堆積大小、停頓時間印出來。調 GC 的第一守則是先量再調——憑感覺亂加參數,常常讓事情更糟。

類別載入:執行期才綁定的延遲魔法

入門篇提過類別載入子系統與雙親委派模型(parent delegation model)。這裡我們看一個它帶來的、會直接影響你寫程式判斷的具體行為:類別在「第一次被主動使用」時才載入與初始化,而不是程式一啟動就全部載入。

看一個例子:靜態初始化的時機

public class LazyInit {
    static class Heavy {
        static {
            System.out.println(">> Heavy 的 static 區塊執行了");
        }
        static final int X = 100;
        static int y = compute();
        static int compute() {
            System.out.println(">> compute() 被呼叫");
            return 42;
        }
    }

    public static void main(String[] args) {
        System.out.println("程式開始");
        System.out.println("常數 X = " + Heavy.X);   // (A)
        System.out.println("欄位 y = " + Heavy.y);   // (B)
    }
}

輸出是:

程式開始
常數 X = 100
>> Heavy 的 static 區塊執行了
>> compute() 被呼叫
欄位 y = 42

兩個反直覺的點:

  1. 存取 Heavy.X 並沒有觸發 Heavy 的初始化(static 區塊沒印)。因為 Xstatic final 的編譯期常數(compile-time constant),它在編譯時就被內聯(inline)成字面值 100 直接嵌進 main 裡,根本不需要碰 Heavy 這個類別。
  2. 直到存取 Heavy.y(非常數的靜態欄位)才真正初始化 Heavy:這時 static 區塊與 compute() 才依序執行。

這個「延遲初始化」不是小細節。它解釋了為什麼有時你以為某段 static 初始化早就跑了、實際卻還沒跑;也是許多單例(singleton)模式與設定載入 bug 的根源。理解「主動使用才初始化」這條規則,你才能精準預測程式的初始化順序。

順帶一提,「初始化的執行緒安全」是 JVM 免費送你的:JVM 保證一個類別的初始化過程是執行緒安全的,多執行緒同時首次使用同一個類別,只有一個會執行初始化,其餘會等它完成。這讓「靜態持有者(holder)」成為實作執行緒安全延遲單例最優雅的方式之一,不需要任何顯式鎖。

重點回顧

  • 在多執行緒下,沒有同步就沒有可見性與順序保證:JIT 重排序與 CPU 快取會讓一個執行緒看到另一個執行緒「不一致的世界」。
  • JMM 用 happens-before 提供唯一的推理依據;volatilesynchronized、執行緒啟動等會建立 happens-before 邊。volatile 保證可見性與禁止重排,但不保證原子性
  • GC 建立在弱分代假說之上,用可達性分析(而非引用計數)判定垃圾;吞吐量、延遲、記憶體足跡構成不可能三角,G1/ZGC 用並行與分區換取可預測的低停頓。
  • 類別第一次被主動使用時才初始化static final 編譯期常數會被內聯,不觸發初始化。JVM 保證類別初始化的執行緒安全。

深入探討(研究所視角)

把上面三條線拉到研究所層次,會看到它們背後共同的理論骨架。

JMM 是一套「公理化的記憶體模型」。 它並非描述某顆 CPU 的行為,而是定義一個抽象的、允許重排的執行語意,再用 happens-before 限制哪些執行是「合法(well-formed)」的。形式化的核心問題是資料競爭(data race):當兩個動作存取同一變數、至少一個是寫入、且彼此沒有 happens-before 關係時,就構成資料競爭。JMM 提供著名的 DRF-SC 保證(Data-Race-Free implies Sequential Consistency):只要你的程式沒有資料競爭,JVM 就保證它的行為等同於某種「循序一致(sequentially consistent)」的執行——也就是符合你天真的直覺。換句話說,正確使用同步原語的程式,可以完全不必去想重排序;重排只會在有競爭的程式裡咬你。這是一條極其優雅的設計:把「允許激進最佳化」與「程式設計師可推理」用一條定理連起來。實作上,volatile 寫入會被編譯成適當的記憶體屏障(如 x86 的 store-load fence),不同硬體記憶體模型(x86 的 TSO、ARM 的弱序)需要插入的屏障強度不同,JMM 正是抹平這層硬體差異的抽象。

GC 的本質是並行圖追蹤問題。 並行回收器最難的點在於:應用執行緒(mutator)在回收器標記存活物件的同時,還在不斷修改物件間的引用。若處理不當,會發生「標記遺漏」——一個本來活的物件被誤判為垃圾而回收,導致記憶體損毀。理論上的判準是三色標記不變式(tri-color invariant):把物件染成白(未訪問)、灰(已訪問但引用尚未掃完)、黑(已完全掃完)三色,危險情境是「一個黑物件指向白物件,且通往該白物件的所有灰路徑都被切斷」。回收器必須用寫屏障(write barrier) 攔截引用更新來維持不變式,分為 SATB(snapshot-at-the-beginning,G1 採用)與 incremental-update 兩大流派。ZGC 更進一步用著色指標(colored pointers) 加 load barrier,把物件搬移與引用修正攤平到每次讀取,達成並行整理。理解這層,你才明白為什麼「低停頓 GC」不是把演算法改快,而是把工作從一次性的 STW 重新分配到 mutator 的每次讀寫上——天下沒有白吃的午餐,停頓變短的代價是 barrier 帶來的常態化吞吐量損耗。

類別載入是 JVM 動態性與安全性的交會點。 雙親委派模型本質上是一套命名空間隔離機制:類別的執行期身份是 (全限定名, 定義它的 ClassLoader) 這個二元組,而非僅僅是名字。這意味著同一個 com.example.Foo,被兩個不同的 ClassLoader 載入,會是兩個互不相容的型別——這正是應用伺服器能讓多個 web 應用各自隔離、OSGi 能做模組熱抽換、外掛系統能動態載卸的底層機理,但也是 ClassCastException: Foo cannot be cast to Foo 這種看似荒謬錯誤的來源。配合執行期的反射(reflection)、MethodHandleinvokedynamic 指令(Lambda 與許多 JVM 語言的實作基礎),Java 在「靜態強型別的編譯期安全」之外,保有了一套強大的執行期動態能力。整個 JVM 的設計哲學,到這裡可以收束成一句話:用一層精心設計的虛擬機器抽象,在編譯期安全、執行期彈性、跨平台可移植、與並行高效能之間,同時拿到原本看似互斥的多個目標。 這正是它能支撐三十年企業級系統的根本理由。

AI 共讀助教正在陪你讀:Java 與 JVM 進階:記憶體模型、垃圾回收與類別載入的真相
嗨!我是這篇文章的共讀助教,只根據〈Java 與 JVM 進階:記憶體模型、垃圾回收與類別載入的真相〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。