深入理解 JVM 記憶體模型是 Java 效能調校的基礎。本章將詳細介紹 JVM 的記憶體區域劃分、物件記憶體佈局,以及各區域可能發生的記憶體溢位問題。

執行時資料區域概覽#

graph TB
    subgraph JVM["JVM 執行時資料區域"]
        subgraph Shared["執行緒共享區域"]
            subgraph Heap["堆積區 (Heap)"]
                Young["新生代<br/>Young"]
                Old["老年代<br/>Old"]
            end
            subgraph Method["方法區 (Method Area)"]
                Meta["類別資訊、常數池<br/>靜態變數、JIT 程式碼<br/><i>(Java 8+ = Metaspace)</i>"]
            end
        end
        subgraph Private["執行緒私有區域"]
            Stack["VM 堆疊<br/>(VM Stack)<br/>堆疊幀、區域變數<br/>運算元堆疊"]
            Native["本地方法堆疊<br/>(Native Stack)<br/>JNI 方法"]
            PC["程式計數器<br/>(PC Register)<br/>目前執行指令位址"]
        end
    end

    style Shared fill:#e3f2fd
    style Private fill:#fff3e0
    style Heap fill:#c8e6c9
    style Method fill:#ffccbc

堆積區(Heap)#

堆積區是 JVM 管理的最大記憶體區域,用於存放物件實體。

堆積區結構#

graph LR
    subgraph Heap["堆積區 (Heap)"]
        subgraph Young["新生代 (Young) - 1/3"]
            Eden["Eden<br/>(8)"]
            S0["S0<br/>(1)"]
            S1["S1<br/>(1)"]
        end
        subgraph Old["老年代 (Old) - 2/3"]
            LongLived["長期存活的物件"]
        end
    end

    style Young fill:#c8e6c9
    style Old fill:#fff9c4
    style Eden fill:#a5d6a7

預設比例:

  • 新生代 : 老年代 = 1 : 2 (-XX:NewRatio=2)
  • Eden : S0 : S1 = 8 : 1 : 1 (-XX:SurvivorRatio=8)

大部分物件都在 Eden 區分配。當 Eden 區滿時觸發 Minor GC,存活的物件會被複製到 Survivor 區。經過多次 GC 仍存活的物件(預設 15 次)會晉升到老年代。

物件分配策略#

// 1. 優先在 Eden 區分配
Object obj = new Object();  // 小物件,分配在 Eden

// 2. 大物件直接進入老年代
// -XX:PretenureSizeThreshold=1048576 (1MB)
byte[] bigArray = new byte[2 * 1024 * 1024];  // 2MB,直接到老年代

// 3. 長期存活的物件進入老年代
// 物件在 Survivor 區每熬過一次 Minor GC,年齡 +1
// 達到 MaxTenuringThreshold(預設 15)後晉升老年代

// 4. 動態物件年齡判定
// 如果 Survivor 區中相同年齡物件大小總和 > Survivor 空間的一半
// 則年齡 >= 該年齡的物件直接進入老年代

TLAB(Thread Local Allocation Buffer)#

graph LR
    subgraph Eden["Eden 區"]
        T1["TLAB-1<br/>Thread-1"]
        T2["TLAB-2<br/>Thread-2"]
        T3["TLAB-3<br/>Thread-3"]
        Shared["共用空間"]
    end

    style T1 fill:#bbdefb
    style T2 fill:#c8e6c9
    style T3 fill:#fff9c4
    style Shared fill:#f5f5f5

TLAB 是每個執行緒在 Eden 區的私有分配區域,可以避免多執行緒分配記憶體時的競爭,提升分配效率。預設開啟(-XX:+UseTLAB)。

方法區(Method Area)#

方法區存放類別的元資訊,在 Java 8 之後由 Metaspace 實現。

Java 7 vs Java 8+ 的變化#

graph TB
    subgraph Java7["Java 7 (永久代 PermGen)"]
        subgraph JVM7["JVM 堆積記憶體"]
            H7["堆積區<br/>(Heap)"]
            P7["永久代 (PermGen)<br/>類別資訊<br/>常數池<br/>靜態變數"]
        end
    end

    subgraph Java8["Java 8+ (元空間 Metaspace)"]
        subgraph JVM8["JVM 堆積記憶體"]
            H8["堆積區 (Heap)<br/>字串常數池<br/>靜態變數"]
        end
        subgraph Native["本地記憶體"]
            M8["Metaspace<br/>類別資訊"]
        end
    end

    style Java7 fill:#ffccbc
    style Java8 fill:#c8e6c9
    style P7 fill:#ffcdd2
    style M8 fill:#bbdefb

Java 8 將永久代移除,改用 Metaspace。主要變化:

  • Metaspace 使用本地記憶體,預設不限制大小
  • 字串常數池和靜態變數移到堆積區
  • 減少了 PermGen 記憶體溢位的問題

Metaspace 參數組態#

# 設定 Metaspace 初始大小
-XX:MetaspaceSize=256m

# 設定 Metaspace 最大大小
-XX:MaxMetaspaceSize=512m

# 類別中繼資料的壓縮空間大小
-XX:CompressedClassSpaceSize=256m

虛擬機器堆疊(VM Stack)#

每個執行緒都有自己的虛擬機器堆疊,用於存放堆疊幀。

堆疊幀結構#

flowchart TB
    subgraph VMStack["虛擬機器堆疊 (VM Stack)"]
        subgraph Frame1["堆疊幀 (Stack Frame)"]
            direction TB
            LV["區域變數表<br/>(Local Variables)<br/>slot 0: this<br/>slot 1~n: 參數/變數"]
            OS["運算元堆疊<br/>(Operand Stack)<br/>執行運算的臨時儲存"]
            DL["動態連結<br/>(Dynamic Linking)<br/>指向常數池方法參照"]
            RA["方法回傳位址<br/>(Return Address)<br/>退出後的回傳位置"]
        end
        Frame2["上一個堆疊幀"]
        Frame3["..."]
    end

    LV --> OS --> DL --> RA
    Frame1 --> Frame2 --> Frame3

    style VMStack fill:#fff3e0
    style Frame1 fill:#e3f2fd
    style LV fill:#c8e6c9
    style OS fill:#fff9c4
    style DL fill:#ffccbc
    style RA fill:#e1bee7

區域變數表的 Slot 複用#

public void example() {
    {
        int a = 100;      // slot 1
        System.out.println(a);
    }
    // a 的作用域結束,slot 1 可以被複用

    int b = 200;          // 複用 slot 1
    System.out.println(b);
}
區域變數表大小計算
資料型別佔用 Slot 數
boolean, byte, char, short, int, float1
long, double2
reference1
returnAddress1

實體方法的 slot 0 固定存放 this 參照。

物件記憶體佈局#

物件頭(Object Header)#

flowchart TB
    subgraph Layout["物件記憶體佈局"]
        direction TB
        subgraph Header["物件頭 (Object Header)"]
            direction TB
            MW["Mark Word (標記字)<br/>64 位元:HashCode、GC 年齡、鎖狀態"]
            CP["類型指標 (Class Pointer)<br/>指向方法區中的類別元資料<br/>(壓縮指標:32 位元)"]
            AL["陣列長度 (Array Length)<br/>只有陣列物件才有,4 位元組"]
        end
        ID["實體資料 (Instance Data)<br/>物件的欄位值,按分配策略排列"]
        PD["對齊填充 (Padding)<br/>確保物件大小是 8 位元組的倍數"]
    end

    MW --> CP --> AL
    Header --> ID --> PD

    style Layout fill:#fff3e0
    style Header fill:#e3f2fd
    style MW fill:#ffcdd2
    style CP fill:#c8e6c9
    style AL fill:#fff9c4
    style ID fill:#bbdefb
    style PD fill:#f5f5f5

壓縮指標(Compressed Oops)#

# 預設開啟(堆積 < 32GB 時)
-XX:+UseCompressedOops

# 壓縮類型指標(Java 8+)
-XX:+UseCompressedClassPointers

壓縮指標將 64 位元指標壓縮為 32 位元,可以顯著減少記憶體佔用。當堆積超過 32GB 時會自動關閉。建議堆積大小設定在 32GB 以內以享受壓縮指標的好處。

使用 JOL 分析物件佈局#

// 添加依賴
// <dependency>
//     <groupId>org.openjdk.jol</groupId>
//     <artifactId>jol-core</artifactId>
//     <version>0.16</version>
// </dependency>

import org.openjdk.jol.info.ClassLayout;

public class ObjectLayoutDemo {
    public static void main(String[] args) {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

輸出範例(開啟壓縮指標):

java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0x00000001
 12   4        (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

字段重排列#

class FieldReorder {
    byte b;      // 1 byte
    long l;      // 8 bytes
    int i;       // 4 bytes
    char c;      // 2 bytes
}

// JVM 會重排列欄位以減少 padding:
// 實際佈局可能是:
// long l (8) -> int i (4) -> char c (2) -> byte b (1) -> padding (1)
// 而非聲明順序

記憶體溢位場景分析#

1. 堆積區溢位(OutOfMemoryError: Java heap space)#

// 模擬堆積區溢位
// java -Xmx20m HeapOOM
public class HeapOOM {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            list.add(new byte[1024 * 1024]);  // 每次分配 1MB
        }
    }
}

解決方案:

# 1. 增加堆積大小
-Xmx4g -Xms4g

# 2. 檢查記憶體洩漏
# 使用 jmap 產生堆積傾印
jmap -dump:format=b,file=heap.hprof <pid>

# 3. 使用 MAT 或 VisualVM 分析

2. Metaspace 溢位(OutOfMemoryError: Metaspace)#

// 模擬 Metaspace 溢位
// java -XX:MaxMetaspaceSize=20m MetaspaceOOM
public class MetaspaceOOM {
    public static void main(String[] args) {
        int i = 0;
        while (true) {
            // 使用 CGLib 動態產生類別
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(MetaspaceOOM.class);
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) ->
                proxy.invokeSuper(obj, args1));
            enhancer.create();
            System.out.println("已建立 " + (++i) + " 個類別");
        }
    }
}

解決方案:

# 1. 增加 Metaspace 大小
-XX:MaxMetaspaceSize=512m

# 2. 檢查是否有大量動態類別產生
# 框架如 CGLib、ASM、Groovy 等可能產生大量類別

# 3. 監控 Metaspace 使用情況
jstat -gc <pid> 1000

3. 堆疊溢位(StackOverflowError)#

// 模擬堆疊溢位
public class StackOOM {
    private int stackDepth = 0;

    public void recursiveCall() {
        stackDepth++;
        recursiveCall();
    }

    public static void main(String[] args) {
        StackOOM oom = new StackOOM();
        try {
            oom.recursiveCall();
        } catch (StackOverflowError e) {
            System.out.println("堆疊深度:" + oom.stackDepth);
        }
    }
}

解決方案:

# 1. 增加堆疊大小
-Xss2m

# 2. 檢查遞迴呼叫是否有終止條件
# 3. 考慮將遞迴改為迭代

4. 直接記憶體溢位(OutOfMemoryError: Direct buffer memory)#

// 模擬直接記憶體溢位
// java -XX:MaxDirectMemorySize=20m DirectMemoryOOM
public class DirectMemoryOOM {
    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        while (true) {
            // 分配直接記憶體
            ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
            list.add(buffer);
        }
    }
}

解決方案:

# 1. 增加直接記憶體大小
-XX:MaxDirectMemorySize=256m

# 2. 手動呼叫 System.gc() 觸發直接記憶體回收
# 3. 使用 Cleaner 或反射呼叫 DirectByteBuffer.cleaner().clean()
直接記憶體的 GC 問題

直接記憶體不在 JVM 堆積中,Full GC 時才會觸發其回收。如果直接記憶體用完但堆積還充足,可能不會觸發 GC,導致 OOM。

解決方法:

// 手動清理直接記憶體
public static void cleanDirectBuffer(ByteBuffer buffer) {
    if (buffer.isDirect()) {
        try {
            Field cleanerField = buffer.getClass().getDeclaredField("cleaner");
            cleanerField.setAccessible(true);
            Cleaner cleaner = (Cleaner) cleanerField.get(buffer);
            cleaner.clean();
        } catch (Exception e) {
            // 處理例外
        }
    }
}

記憶體參數組態建議#

常用記憶體參數#

# 堆積設定
-Xms4g                    # 初始堆積大小
-Xmx4g                    # 最大堆積大小
-Xmn2g                    # 新生代大小
-XX:NewRatio=2            # 老年代:新生代 = 2:1
-XX:SurvivorRatio=8       # Eden:Survivor = 8:1

# Metaspace 設定
-XX:MetaspaceSize=256m    # 初始 Metaspace
-XX:MaxMetaspaceSize=512m # 最大 Metaspace

# 堆疊設定
-Xss1m                    # 執行緒堆疊大小

# 直接記憶體
-XX:MaxDirectMemorySize=256m

不同場景的組態建議#

場景堆積組態新生代組態說明
Web 應用4-8GB1/3 堆積物件生命週期短
批次處理視資料量較小大量長生命週期物件
微服務1-2GB預設容器環境注意限制
大資料處理16GB+較小大量快取物件

建議將 -Xms-Xmx 設為相同值,避免堆積動態調整帶來的效能開銷。在容器環境中,注意使用 -XX:+UseContainerSupport(Java 10+)讓 JVM 正確識別容器記憶體限制。

總結#

JVM 記憶體模型的核心要點:

  1. 執行緒共享:堆積區存放物件,方法區(Metaspace)存放類別資訊
  2. 執行緒私有:VM 堆疊、本地方法堆疊、程式計數器
  3. 物件佈局:物件頭 + 實體資料 + 對齊填充
  4. 壓縮指標:堆積 < 32GB 時有效,顯著減少記憶體佔用
  5. OOM 排查:根據錯誤類型定位問題區域,使用對應工具分析