學完本章你將能夠:

  • 描述 JVM 的整體架構與類別載入的三大階段。

  • 說明雙親委派模型的運作與存在理由。

  • 分辨執行期資料區各區塊,並解釋兩種 |Error| 的成因。

  • 說明可達性分析、GC Roots 與四種引用強度。

  • 比較分代收集與各種垃圾收集器的特性。

  • 使用 jstack 等診斷工具,並找出記憶體洩漏。

前五章我們專注在「怎麼寫 Java」,本章要往下鑽一層,看看「Java 程式如何被執行」。 理解 JVM 不是為了炫技,而是實務所需:當線上服務發生記憶體溢位、GC 停頓過長、 或詭異的 |ClassNotFoundException| 時,唯有懂 JVM 才能快速定位問題。

我們會依循程式的生命週期展開:先看類別如何被載入進 JVM,再看執行時 資料存放在哪些記憶體區域,接著深入垃圾回收如何自動管理記憶體, 最後用一組診斷工具把前面的理論落實到實戰。

JVM 整體架構

JVM 由四大子系統構成,資料在其中流動:

  • 類別載入子系統:負責把 |.class| 位元組碼載入記憶體, 完成連結與初始化。

  • 執行期資料區:JVM 執行時的記憶體,含堆積、堆疊、方法區等。

  • 執行引擎:解讀並執行位元組碼,內含直譯器、JIT 即時編譯器與 垃圾收集器。

  • 本地方法介面(JNI):讓 Java 呼叫以 C/C++ 撰寫的原生方法。

類別載入過程

一個類別從 |.class| 檔到可被使用,要經過載入、連結、初始化三大 階段,其中連結又細分為驗證、準備、解析:

  • 載入(Loading):讀取位元組碼,在方法區建立該類別的結構, 並產生代表它的 |Class| 物件。

  • 驗證(Verification):確認位元組碼符合規範、不會危害 JVM。

  • 準備(Preparation):為靜態變數配置記憶體並設為預設 值(如 |int| 為 0),尚未執行程式設定的初值。

  • 解析(Resolution):把常量池中的符號參考轉為直接參考。

  • 初始化(Initialization):執行靜態初始化區塊與靜態變數的 賦值,此時 |int| 才變成程式指定的值。

初始化的觸發時機

類別的初始化是惰性的:只有在「主動使用」時才觸發,例如 |new| 實體、呼叫靜態方法、存取靜態變數(常數除外)。而「被動使用」不會觸發初始化。 一個經典例子是:透過子類別存取父類別的靜態變數,只會初始化父類別;而存取 |static final| 的編譯期常數根本不會觸發任何初始化,因為常數在 編譯期就被內聯到使用處了。

/** * 觀察類別初始化的觸發時機。 * 存取編譯期常數不會觸發類別初始化(值已在編譯期內聯)。 */ public class ClassInit

static class Holder

// 編譯期常數:存取它不會初始化 Holder static final int CONST = 42; // 非常數靜態變數:存取它會觸發初始化 static int counter = compute();

static System.out.println(">> Holder 被初始化了"); static int compute() System.out.println(">> counter 正在計算"); return 100;

public static void main(String[] args) // 存取編譯期常數:不會看到任何初始化訊息 System.out.println("常數 CONST = " + Holder.CONST); System.out.println("— 分隔線 —"); // 存取非常數靜態變數:觸發初始化 System.out.println("counter = " + Holder.counter);

常數 CONST = 42 — 分隔線 — >> counter 正在計算 >> Holder 被初始化了 counter = 100

類別載入器與雙親委派

類別由「類別載入器」(class loader)負責載入,它們形成階層:

  • Bootstrap ClassLoader:載入 JDK 核心類別(如 |java.lang.*|),由 C++ 實作,在 Java 中顯示為 |null|。

  • Platform ClassLoader:載入 JDK 的擴充模組。

  • Application ClassLoader:載入 classpath 上我們自己的類別。

/** * 觀察不同類別由哪個載入器載入。 * 核心類別由 Bootstrap 載入,故顯示為 null。 */ public class ClassLoaderDemo public static void main(String[] args) // 自己寫的類別:由 Application ClassLoader 載入 System.out.println("本類別:" + ClassLoaderDemo.class.getClassLoader()); // 核心類別 String:由 Bootstrap 載入,顯示 null System.out.println("String:" + String.class.getClassLoader()); // 印出 Application ClassLoader 的父載入器 ClassLoader app = ClassLoaderDemo.class.getClassLoader(); System.out.println("父載入器:" + app.getParent());

本類別:jdk.internal.loader.ClassLoaders\(AppClassLoader@2437c6dc String:null 父載入器:jdk.internal.loader.ClassLoaders\)PlatformClassLoader@1b6d3586

雙親委派模型:當一個載入器收到載入請求時,它不會馬上自己動手,而是 先往上委派給父載入器,父載入器再往上,直到 Bootstrap。只有當上層都 表示「載不到」時,才由下層嘗試。這樣設計的理由是安全與一致:確保核心 類別(如 |java.lang.String|)永遠由 Bootstrap 載入,即使有人惡意寫了一個 同名的 |java.lang.String| 放進 classpath,也不會被載入,從而無法替換或 汙染核心類別。同時也保證同一類別在 JVM 中只有一份,不會重複載入。

執行期資料區

JVM 執行時把記憶體劃分成數個區域,有的執行緒私有、有的全體共享:

  • 程式計數器(PC Register,執行緒私有):記錄目前執行到哪條 位元組碼指令。

  • JVM 堆疊(Stack,執行緒私有):每呼叫一個方法就壓入一個 「堆疊框」(stack frame),內含區域變數表與運算元堆疊。

  • 堆積(Heap,全體共享):所有物件實體的家,也是 GC 的主戰場。

  • 方法區(Method Area,全體共享):存放類別結構、常數、靜態 變數等。

堆疊與 StackOverflowError

每次方法呼叫都會壓入一個堆疊框,方法返回就彈出。若遞迴沒有正確的終止條件, 堆疊框無限累積,就會撐爆堆疊:

/** * 無終止條件的遞迴會不斷壓入堆疊框,最終撐爆 JVM 堆疊。 */ public class StackOverflow

static int depth = 0;

/** 每次呼叫自己,堆疊框只增不減 */ static void recurse() depth++; recurse(); // 沒有終止條件

public static void main(String[] args) try recurse(); catch (StackOverflowError e) // Error 通常不該捕捉,這裡僅為教學觀察深度 System.out.println("堆疊溢位,遞迴深度約:" + depth);

堆疊溢位,遞迴深度約:21847 (註:實際深度視 -Xss 堆疊大小與方法框大小而定)

堆積與 OutOfMemoryError

堆積存放所有物件。若不斷建立物件又一直被參考(無法回收),堆積終將耗盡:

import java.util.ArrayList; import java.util.List;

/** * 不斷加入物件到 List,物件都被 List 參考而無法回收, * 最終耗盡堆積,拋出 OutOfMemoryError。 * 建議搭配 -Xmx64m 執行以快速觀察。 */ public class HeapOOM public static void main(String[] args) List<byte[]> list = new ArrayList<>(); int count = 0; try while (true) list.add(new byte[1024 * 1024]); // 每次 1 MB count++; catch (OutOfMemoryError e) System.out.println("堆積溢位,已配置約 " + count + " MB");

堆積溢位,已配置約 58 MB (註:實際數字取決於 -Xmx 設定)

方法區在 Java 8 有重大變化:舊版用「永久代」(PermGen)實作,容易因類別過多而 溢位;Java 8 起改用Metaspace(元空間),改存於本地記憶體,預設不設上限 (可用 |-XX:MaxMetaspaceSize| 限制)。此外,字串常量池在 Java 7 起已從 方法區移到堆積,因此 |String.intern()| 的行為與早期版本不同。

物件的建立與記憶體佈局

當 JVM 遇到 |new| 指令,它會先檢查該類別是否已載入,若否則先載入;接著 在堆積為物件配置記憶體、把實體欄位設為預設值、執行建構子完成初始化。一個物件 在堆積中的佈局分為三部分:物件頭(object header,含 mark word 與指向 類別的指標)、實體資料(各欄位的值)、對齊填充(padding,補齊 到 8 位元組的倍數以利存取)。

垃圾回收基礎

如何判斷垃圾

GC 的第一個問題是:哪些物件是垃圾?一種天真的想法是「引用計數」——記錄每個 物件被幾個地方參考,歸零就回收。但它有致命缺陷:循環引用。若 A 參考 B、B 也參考 A,即使外界都不再用它們,兩者的計數都不為零,永遠無法回收。

因此主流 JVM 採用可達性分析:從一組稱為「GC Roots」的根物件出發(如 堆疊中的區域變數、靜態變數、常數),沿參考鏈遍歷,凡是「走得到」的物件就存活, 「走不到」的就是垃圾。這樣循環引用但整體不可達的物件群,會被正確地一併回收。

四種引用強度

Java 提供四種強度的引用,讓我們對「物件何時該被回收」有更細緻的控制:

引用強度 回收時機
強引用(Strong) 一般的參考,只要可達就絕不回收
軟引用(Soft) 記憶體不足時才回收,適合做快取
弱引用(Weak) 只要 GC 發生就回收
虛引用(Phantom) 隨時可能被回收,用於追蹤回收事件

import java.lang.ref.WeakReference;

/** * 弱引用:GC 一旦執行,被弱引用指向且無強引用的物件即被回收。 */ public class ReferenceDemo

public static void main(String[] args)

// 建立物件,只用弱引用指向它(無強引用) WeakReference<byte[]> weak = new WeakReference<>(new byte[10 * 1024 * 1024]);

System.out.println("GC 前,物件存在?" + (weak.get() != null)); System.gc(); // 建議 JVM 執行 GC try Thread.sleep(100); // 給 GC 一點時間 catch (InterruptedException e) Thread.currentThread().interrupt(); System.out.println("GC 後,物件存在?" + (weak.get() != null));

GC 前,物件存在?true GC 後,物件存在?false

分代收集

主流 GC 基於一個經驗觀察——「大部分物件朝生暮死」(弱分代假說),把堆積分成 新生代與老年代。新生代又分為一個 Eden 區與兩個 Survivor 區。 新物件先進 Eden;一次 Minor GC 後,存活者被複製到一個 Survivor 區,如此在兩個 Survivor 間來回複製,每熬過一次 GC 年齡加一;年齡達到門檻(預設 15)就 晉升到老年代。老年代滿了才觸發成本較高的 Major/Full GC。

GC 演算法與收集器

不同區域適用不同的基礎演算法,各有取捨:

演算法 做法 適用
標記清除 標記垃圾後清除 會產生碎片
複製 存活者複製到另一半 新生代(存活少)
標記整理 標記後把存活者往一端壓縮 老年代(無碎片)

垃圾收集器則是這些演算法的具體實作,多年來不斷演進:

收集器 特性
Serial 單執行緒,簡單,適合小型應用
Parallel 多執行緒,追求高吞吐量
CMS 低停頓,但已於 Java 14 移除
G1 區域化設計,可預測停頓,Java 9 起的預設
ZGC 超低延遲,停頓不隨堆大小成長

常用 JVM 參數與 GC 日誌

調校 JVM 時,這些參數最常用:

參數 作用
|-Xms| / |-Xmx| 堆積初始 / 最大大小
|-Xss| 每個執行緒的堆疊大小
|-Xlog:gc| 輸出 GC 日誌(Java 9 起的統一格式)
|-XX:+HeapDumpOnOutOfMemoryError| OOM 時自動產生堆積傾印

正式環境通常把 |-Xms| 與 |-Xmx| 設為相同值。因為堆積動態 擴張與收縮本身要付出成本,且可能引發額外的 Full GC;設成相同大小可避免執行期 反覆調整堆積,讓效能更穩定、可預測。

以下是一段 |-Xlog:gc| 輸出的逐行解讀:

GC(0) Pause Young (G1 Evacuation Pause) 24M->6M(64M) 3.821ms

這行的意思是:程式啟動後 0.312 秒發生第 0 次 GC,類型是 G1 的年輕代回收; 回收前堆積用了 24 MB,回收後降到 6 MB(總堆 64 MB);本次停頓 3.821 毫秒。 從「24M 降到 6M」可知大量短命物件被回收,這是健康的年輕代 GC 表現。

診斷工具實戰

JDK 內建一組命令列工具,是排查線上問題的第一線武器:|jps| 列出 Java 程序、|jinfo| 看設定、|jstat| 看 GC 統計、|jmap| 產生堆積 傾印、|jstack| 印出執行緒堆疊。其中 |jstack| 最能立竿見影——它能 自動偵測死結。用第 3 章的死結程式示範:

\(jps 18244 DeadlockDemo 18255 Jps\) jstack 18244 ... Found one Java-level deadlock: ============================= "Thread-0": waiting to lock monitor 0x00007f... (object 0x000000071..., a Object), which is held by "Thread-1" "Thread-1": waiting to lock monitor 0x00007f... (object 0x000000072..., a Object), which is held by "Thread-0"

Found 1 deadlock.

|jstack| 直接點名「Found one Java-level deadlock」並列出互相等待的兩個 執行緒與鎖,讓死結無所遁形。圖形化的 VisualVM 與內建的 JFR(Java Flight Recorder)則能持續記錄 CPU、記憶體、GC 與鎖競爭,適合做長時間的效能剖析。

記憶體洩漏經典案例

Java 有 GC,為什麼還會記憶體洩漏?因為 GC 只回收「不可達」的物件,若你不小心 讓一個該丟棄的物件仍被某處參考,GC 就無能為力。最經典的案例是用 |static| 集合當快取卻只進不出:

import java.util.ArrayList; import java.util.List;

/** * 反面教材:static 集合持續加入卻從不移除,物件永遠可達, * 形同記憶體洩漏。修正方式見註解。 */ public class MemoryLeak

// static 欄位的生命週期與類別相同,幾乎等於「永遠不死」 static final List<byte[]> CACHE = new ArrayList<>();

static void handleRequest() // 每次請求都塞入資料,卻從未清理 → 洩漏 CACHE.add(new byte[1024 * 1024]);

public static void main(String[] args) for (int i = 1; i <= 5; i++) handleRequest(); System.out.println("處理第 " + i + " 個請求,快取筆數:" + CACHE.size()); // 正解:改用有淘汰機制的快取(如 LinkedHashMap 的 LRU、 // 或 WeakHashMap),並在適當時機移除不再需要的項目。

處理第 1 個請求,快取筆數:1 處理第 2 個請求,快取筆數:2 處理第 3 個請求,快取筆數:3 處理第 4 個請求,快取筆數:4 處理第 5 個請求,快取筆數:5

在真實系統中,這個 |CACHE| 會隨著請求量無限膨脹,最終導致 OOM。修正之道 是採用有上限與淘汰策略的快取(如 LRU),或用 |WeakHashMap| 讓 不再被別處強引用的項目能被 GC 自動清除。診斷這類問題時,就靠前一節的 |jmap| 產生堆積傾印,再用 VisualVM 分析「誰佔用了最多記憶體、又是誰在 參考它」。

本章小結

  • JVM 由類別載入子系統、執行期資料區、執行引擎、本地方法介面組成。

  • 類別載入歷經載入、連結(驗證/準備/解析)、初始化;初始化是惰性的, 存取編譯期常數不會觸發。

  • 雙親委派模型讓核心類別只由 Bootstrap 載入,確保安全與一致。

  • 堆疊為執行緒私有、堆積為全體共享;無窮遞迴致 |StackOverflowError|, 物件累積不放致 |OutOfMemoryError|。

  • Java 8 起方法區改用 Metaspace,字串常量池 Java 7 起移入堆積。

  • GC 用可達性分析(GC Roots)判斷垃圾,解決引用計數的循環引用缺陷。

  • 四種引用強度提供回收控制;分代收集依「朝生暮死」把堆分新生代與老年代。

  • G1 是現代預設收集器;|-Xms| 與 |-Xmx| 建議設為相同值。

  • |jstack| 可自動偵測死結;|static| 集合只增不減是經典的 記憶體洩漏。

本章練習

  1. 觀念題:為什麼引用計數無法處理循環引用?可達性分析如何解決這個問題? (提示:想想從 GC Roots 是否「走得到」。)

  2. 觀念題:雙親委派模型如何防止核心類別被惡意替換?若沒有它會有什麼風險? (提示:假設有人自寫一個 |java.lang.String|。)

  3. 實作題:寫一個會觸發 |StackOverflowError| 的程式與一個會觸發 |OutOfMemoryError| 的程式,分別用 |-Xss256k| 與 |-Xmx32m| 執行並觀察。 (提示:一個靠無窮遞迴,一個靠不斷 |new| 並保留參考。)

  4. 實作題:用 |SoftReference| 實作一個簡易圖片快取,在記憶體吃緊時 自動釋放,並驗證其行為。 (提示:快取的值用 |SoftReference| 包住。)

  5. 實作題:故意寫一個會死結的程式,用 |jps| 找到程序號,再用 |jstack| 印出堆疊,找出死結報告。 (提示:可直接用第 3 章的死結範例。)

  6. 觀念題:解讀這行 GC 日誌代表什麼—— |Pause Young 40M->8M(128M) 5.2ms|,並說明它是否健康。 (提示:關注回收前後的用量差與停頓時間。)