1. Java 行程的記憶體總覽
當你啟動一個 Java 程式時,JVM(Java Virtual Machine)會向作業系統索取一塊記憶體。有時很小,有時很大(尤其是你跑一個裝了很多模組的 Minecraft 時)。這塊記憶體被切成幾個關鍵區域,每一區都有自己的角色:
- 堆疊(Stack) — 用於區域變數與方法呼叫。
- 堆(Heap) — 用於所有透過 new 建立的物件。
- 服務區域(PermGen/MetaSpace)— 用於類別的中繼資料、靜態欄位與其他「魔法」內容。
大致如下:
┌───────────────────────────────┐
│ JVM 行程 │
│ ┌─────────────┐ │
│ │ Stack │ ← 每個執行緒都有自己的堆疊!
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Heap │ ← 所有執行緒共享
│ └─────────────┘ │
│ ┌───────────────┐ │
│ │ PermGen/ │ ← 類別中繼資料
│ │ MetaSpace │
│ └───────────────┘ │
└───────────────────────────────┘
為什麼這很重要?
- 理解記憶體結構有助於寫出更高效且安全的程式碼。
- 更容易診斷 StackOverflowError 或 OutOfMemoryError 之類的錯誤。
- 不再害怕「垃圾回收」與「記憶體洩漏」——你會知道該到哪裡找問題。
2. 堆疊(Stack):快速、區域,但不是永久
堆疊是為每個執行緒分別配置的特殊記憶體區域。堆疊就像一疊盤子:最後放上去的,要等上面所有的都拿走之後才能先拿。也就是說,堆疊遵循 LIFO(Last In, First Out,後進先出)原則。
堆疊有什麼用?
在堆疊中存放:
- 方法的區域變數(例如方法內的 int x = 5;)。
- 返回位址(方法呼叫結束後要返回到哪裡)。
每次你呼叫一個方法,堆疊中會加入新的框架(stack frame)——類似一個盒子,裡頭放著該方法的所有區域變數與一些輔助資訊。當方法執行完畢,它的框架會被移除——裡面的區域變數也隨之消失。
範例
public static void main(String[] args) {
int a = 10; // a 位於 main 的堆疊
int b = sum(a, 5); // 呼叫 sum
}
public static int sum(int x, int y) {
int result = x + y; // x、y、result 位於 sum 的堆疊
return result;
}
- 當呼叫 sum 時,會在堆疊中為它建立獨立的框架(frame)。
- sum 結束後,它的變數會消失。
變數的生命週期
區域變數只在其所屬的方法執行期間存在。一旦方法結束——它們就不再存在,記憶體會立刻被釋放。
堆疊溢位
如果你不小心(或故意)寫了無窮遞迴,每次方法呼叫都會在堆疊中加入新的框架。某個時刻堆疊空間會用盡,你會得到:
Exception in thread "main" java.lang.StackOverflowError
範例:
public static void main(String[] args) {
recurse();
}
public static void recurse() {
recurse(); // 無窮遞迴!
}
堆疊大小
堆疊的大小是有限的——通常每個執行緒只有幾個 MB(可用參數 -Xss 設定)。如果堆疊用盡——程式會因錯誤而終止。
3. 堆(Heap):放置你物件的地方
堆(Heap)是所有執行緒共享的記憶體區域,透過 new 建立的所有物件與陣列都住在這裡。物件導向的各種魔法基本上都發生在堆中。
物件如何進入堆?
String s = new String("Hello");
int[] arr = new int[10];
- 變數 s 是參考(reference),它位於堆疊。
- String 物件本身與陣列 arr 位於堆。
物件的生命週期
只要仍存在至少一個強參考(strong reference)指向某個物件,該物件就會活在堆中。一旦沒有人再指向它——它就成為「垃圾」,可以被垃圾回收器(GC)移除。
記憶體管理
不同於 C/C++ 需要你自行釋放記憶體(free、delete),在 Java 中由 GC 負責。你不能顯式釋放物件,但可以把所有指向它的參考設為 null——之後它就成為回收候選。
示意:哪裡放了什麼?
Stack (main)
└─ s ─┬────────────┐
│ │
▼ │
Heap │
┌─────────────┐ │
│ String "Hello"◄──┘
└─────────────┘
堆的特性
- 整個 JVM 行程只有一個堆。
- 可在啟動時設定堆的大小(-Xmx、-Xms)。
- 如果堆中沒有可用空間,且 GC 無法釋放記憶體——程式會拋出 OutOfMemoryError。
4. PermGen 與 MetaSpace:類別存在哪裡?
當你寫下 class MyClass { ... } 並執行程式時,JVM 需要有地方存放與該類別相關的一切——方法、欄位、位元組碼、靜態變數、常數,甚至字串常值。為此,JVM 有一個專門存放「類別」的記憶體區域。
在 Java 8 之前,這個區域稱為 PermGen(Permanent Generation)。但它有不少問題——例如大小是固定的,如果空間不足,應用程式就會直接以 OutOfMemoryError: PermGen space 錯誤終止。
自 Java 8 起出現了更彈性的區域——MetaSpace。它取代了舊的 PermGen,並且可以自動擴張,使用系統可用的實體記憶體(在可用範圍內)。
PermGen(Java 8 之前)
- PermGen 存放類別中繼資料、靜態欄位、字串常值。
- PermGen 大小受限(預設很小),可以用參數 -XX:MaxPermSize=256m 增加。
- 如果應用程式動態載入大量類別(例如在 web 伺服器中),PermGen 可能「用完」,你會看到錯誤:
java.lang.OutOfMemoryError: PermGen space
- 問題:當類別動態卸載時,PermGen 並不總是能正確清理(例如重新載入 web 應用時)。
MetaSpace(Java 8+)
- 從 Java 8 開始 PermGen 消失,改為 MetaSpace。
- MetaSpace 仍然存放類別的中繼資料,但現在位於原生記憶體(在 Java Heap 之外)。
- MetaSpace 預設不設上限(僅受系統記憶體限制),也可用 -XX:MaxMetaspaceSize=512m 設定上限。
- 記憶體不足時的錯誤現在長這樣:
java.lang.OutOfMemoryError: Metaspace
- MetaSpace 中也會放入靜態欄位、方法、類別資訊。
示意:整體是如何運作的
┌───────────────────────────────┐
│ JVM 行程 │
│ ┌─────────────┐ │
│ │ Stack │ ← 區域變數、方法呼叫
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Heap │ ← 物件、陣列、所有透過 new 的東西
│ └─────────────┘ │
│ ┌───────────────┐ │
│ │ MetaSpace │ ← 類別中繼資料、靜態欄位
│ └───────────────┘ │
└───────────────────────────────┘
為什麼這很重要?
如果你寫的是一般桌面或伺服器應用,多半不會遇到 PermGen 或 MetaSpace 的錯誤。但如果你使用動態載入類別(例如外掛、web 應用、或像 Spring 這種會載入/卸載大量類別的框架),那麼了解 MetaSpace 是必備知識!
5. 圖解:JVM 記憶體結構
flowchart TD
subgraph JVM
direction TB
Stack1["Stack (Thread 1)"]
Stack2["Stack (Thread 2)"]
Heap[Heap]
MetaSpace[MetaSpace]
end
Stack1 --引用--> Heap
Stack2 --引用--> Heap
Heap --使用類別自--> MetaSpace
- 每個執行緒都有自己的堆疊。
- 所有堆疊都可以參考堆中的物件。
- 堆中的物件「知道」自己的類別,而類別資訊位於 MetaSpace 中。
6. 範例:在實際程式碼中長什麼樣
public class MemoryDemo {
public static void main(String[] args) {
int x = 42; // x 位於 main 的堆疊
String s = "Hello!"; // s — 參考在堆疊,String 物件在堆,字串常值 "Hello!" 在 MetaSpace
Person p = new Person("Alice"); // p — 參考在堆疊,Person 物件在堆
// 呼叫方法以建立新的堆疊框架
printPerson(p);
}
public static void printPerson(Person person) {
// person — 位於 printPerson 堆疊中的參考
System.out.println(person.getName());
}
}
class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() { return name; }
}
解析:
- x — 區域變數,存在於 main 的堆疊。
- s — 參考在堆疊,String 物件在堆,而字串常值 "Hello!" 在 MetaSpace。
- p — 參考在堆疊,Person 物件在堆。
- Person 類別及其所有方法/欄位——在 MetaSpace(類別中繼資料)。
- 呼叫 printPerson(p) 會建立新的堆疊框架;在其中,區域參考 person 指向同一個堆中的物件。
7. JVM 如何管理記憶體:簡短 FAQ
我能管理堆疊嗎?
不行,堆疊完全由 JVM 控制。你只能在啟動時設定其大小(-Xss)。
我能管理堆嗎?
部分可以:堆的大小可在啟動時設定(-Xmx、-Xms)。清理由垃圾回收器(GC)負責。
我能管理 MetaSpace 嗎?
可以設定上限(-XX:MaxMetaspaceSize),但通常不需要。
記憶體不足時會發生什麼?
— 如果堆疊用完 — StackOverflowError。
— 如果堆用完 — OutOfMemoryError: Java heap space。
— 如果 MetaSpace 用完 — OutOfMemoryError: Metaspace。
8. 使用記憶體時的常見錯誤
錯誤 1:因無窮遞迴導致 StackOverflowError。 最常見的原因是忘了設計遞迴的終止條件。例如方法不停地呼叫自己。JVM 無法把堆疊無限制擴張,程式終將「倒下」。
錯誤 2:因堆滿了導致 OutOfMemoryError。 如果你建立了太多仍被變數/集合引用的物件(例如不斷往清單加入元素但從不移除),堆可能會用完。
錯誤 3:OutOfMemoryError: PermGen space / Metaspace。 如果你使用外掛或動態載入大量類別,而 MetaSpace 未被正確清理(例如類別未正確卸載),MetaSpace 的空間可能用盡。
錯誤 4:混淆了「參考」與「物件」。 許多新手會搞混:堆疊中的 Person 型別變數只是參考,真正的物件在堆中。
錯誤 5:期待垃圾回收器能立刻刪掉所有東西。 GC 是「看心情」運作的(其實是依內部演算法與記憶體壓力),不是在你把參考設為 null 的瞬間就執行。不要指望立即釋放記憶體。
GO TO FULL VERSION