Java heap、no-heap 和 off-heap 記憶體基礎與實踐

FunTester發表於2025-01-06

引言

在 Java 應用的記憶體管理中,Heap 、No-Heap 和 Off-Heap 是開發者最佳化效能和資源管理時不可忽視的關鍵組成部分。它們在 JVM 的執行中扮演著不同的角色,負責儲存不同型別的資料結構和物件。隨著現代應用程式的複雜性和規模不斷提升,合理地分配和管理這三類記憶體,不僅可以提高系統的效率,還能在高併發、大資料處理等場景下有效避免效能瓶頸。

Heap 是 Java 應用最常使用的記憶體區域,所有動態建立的物件都儲存在這裡。然而,頻繁的垃圾回收(GC)操作有時會帶來延遲,影響應用的響應時間。為此,No-Heap 提供了一個獨立的區域,用於儲存類的後設資料、執行緒棧和方法區資料,確保 JVM 穩定高效執行。而 Off-Heap 則是一個獨立於 JVM 的記憶體空間,適合儲存大資料和長生命週期的物件,減少垃圾回收的干擾。

理解和合理運用這三者之間的關係,能夠幫助開發者在不同的應用場景中充分發揮記憶體管理的優勢,實現高效的 Java 應用。

概覽

以下是對 Heap、No-Heap 和 Off-Heap 三者在常見屬性、功能和應用場景方面的對比:

屬性/功能 Heap No-Heap Off-Heap
定義 JVM 中儲存物件例項的記憶體區域 JVM 內的非堆記憶體區域(方法區、執行緒棧等) JVM 外部的記憶體,由開發者手動管理
管理方式 由 JVM 和 GC 自動管理 JVM 自行管理,開發者不可直接控制 手動分配和釋放,獨立於 JVM
GC 影響 受垃圾回收機制影響,可能導致效能抖動 不參與垃圾回收,減少 GC 開銷 不受 GC 影響,提高高併發和大資料處理效能
儲存內容 動態建立的物件例項 類的後設資料、方法位元組碼、靜態變數、執行緒棧等 大資料物件、長生命週期資料(如快取、IO 資料)
效能 受 GC 影響,可能引發 STW 事件 效能較高,無 GC 影響 一般與類載入和執行緒棧管理相關
分配方式 自動分配,程式碼中透過 new 關鍵字建立物件 JVM 啟動時分配,使用 JVM 內部機制 透過 ByteBuffer.allocateDirect() 或 JNI 分配
應用場景 普通 Java 物件儲存,適合大多數業務邏輯處理 類載入、靜態變數儲存、執行緒棧管理 高效能快取、I/O 操作、大資料處理,高併發系統

基礎知識

Heap(堆記憶體)

Heap(堆記憶體)是 Java 虛擬機器(JVM)用來儲存所有物件例項和陣列的記憶體區域。Java 中的物件在執行時透過 new 關鍵字動態建立,預設會存放在堆中。堆記憶體分為多個區域,用於管理物件的生命週期和垃圾回收機制。常見的區域包括:

  • Eden 區:新建立的物件首先分配在 Eden 區。
  • Survivor 區:存活過一次 GC 的物件會移動到 Survivor 區。
  • Old 區:生命週期較長的物件會被晉升到 Old 區,避免頻繁參與 GC。

堆記憶體的大小可以透過 JVM 引數 -Xms-Xmx 來手動配置,以適應不同的應用需求。

Heap 是 Java 中最常用的記憶體區域,適用於各種需要動態分配記憶體的場景。常見的使用場景包括:

  • 業務邏輯中的物件建立:在常規的 Java 應用中,業務物件、資料實體和服務類的例項化都發生在堆記憶體中。
  • 集合類儲存:ArrayListHashMapSet 等集合類的資料元素通常儲存在堆記憶體中。
  • 臨時快取:在短生命週期的資料快取場景中,堆記憶體常用於儲存臨時的資料結構,方便程式快速訪問。

heap 記憶體之所以這麼常用,因為以下優點:

  1. 自動管理記憶體:JVM 自動管理堆記憶體中的物件分配和釋放,開發者無需顯式釋放記憶體,避免了手動管理的複雜性。
  2. 方便物件共享:堆中的物件可以被多個執行緒訪問和共享,適合多執行緒環境。
  3. 垃圾回收機制:JVM 提供了自動垃圾回收(GC)機制,自動清理不再使用的物件,減少記憶體洩漏風險。

雖然 heap 記憶體有很多的優點,但也不可避免存在下面幾項缺點:

  1. GC 影響效能:當堆記憶體較大時,頻繁的垃圾回收會導致應用程式出現效能抖動,尤其在大資料處理和高併發場景中,GC 停頓可能影響系統響應速度。
  2. 延遲問題:在高負載環境下,GC 可能會帶來延遲,尤其是 Full GC 可能暫停整個應用的執行,造成卡頓。
  3. 記憶體碎片化:隨著物件頻繁分配和回收,堆記憶體可能出現碎片化,影響記憶體的高效利用。

Heap 記憶體在 Java 開發中佔據核心地位,其便捷的物件儲存方式和自動化記憶體管理非常適合大多數業務場景。然而,隨著系統規模的擴大和併發量的增加,堆記憶體的垃圾回收開銷可能成為效能瓶頸,需要結合 Off-Heap 等最佳化手段進行調整。

Off-Heap(堆外記憶體)

Off-Heap 是指 JVM 外部的記憶體,即不在 JVM 的堆區管理下的記憶體空間。通常由開發者手動管理,比如透過 DirectByteBufferUnsafe 類或使用第三方庫(如 Netty、RocksDB)來分配和釋放記憶體。

下面是 off-heap 的主要特性:

  • 手動管理:需要手動分配和釋放記憶體,類似於 C/C++ 中的記憶體管理方式。
  • 無需 GC 管理:堆外記憶體不受垃圾回收器的影響,因此可以避免 GC 造成的延遲。
  • 直接記憶體訪問:堆外記憶體可以透過 JNI(Java Native Interface)、NIO 等技術直接訪問,因此可以避免 Java 堆記憶體複製,提高 I/O 效能。
  • 配置:JVM 堆外記憶體的大小可以透過 JVM 引數 -XX:MaxDirectMemorySize 來配置,預設是最大堆記憶體大小。

off-heap 記憶體的主要使用場景如下:

  • 大資料處理:需要處理大量資料而又不希望受 GC 影響時,常使用堆外記憶體。例如,快取系統、資料流處理框架(如 Kafka、Flink)通常使用 Off-Heap 記憶體。
  • 網路程式設計:Java NIO 庫中的 DirectByteBuffer 允許程式設計師直接在作業系統的記憶體中進行資料操作,避免了從堆到堆外記憶體的多次複製。

Off-Heap 優點:

  • 減少 GC 壓力:因為 Off-Heap 記憶體不受垃圾回收管理,避免了頻繁 GC 引發的停頓,特別適合高併發和大資料處理場景。
  • 更大的記憶體空間:突破 JVM 堆記憶體限制,可以使用更多的記憶體,提升應用的可擴充套件性,像快取和資料庫這種場景特別有用。

Off-Heap 缺點:

  • 手動管理複雜:需要開發者手動分配和釋放記憶體,容易導致記憶體洩漏或管理錯誤。
  • 開發成本高:與直接使用 Heap 相比,增加了記憶體管理的複雜性,需要更多的編碼工作和除錯。

No-Heap(非堆記憶體)

No-Heap(非堆記憶體)是 JVM 之外的記憶體區域,主要用於儲存類後設資料、靜態變數、執行緒棧等資訊。在 Java 8 之後,元空間(Metaspace)取代了早期的永久代(PermGen),成為 No-Heap 的重要部分。

No-Heap(非堆記憶體)的主要使用場景涉及儲存 Java 虛擬機器執行所需的後設資料、執行緒棧和靜態變數。它的使用場景主要體現在以下幾方面:

  1. 類後設資料儲存:No-Heap 中的元空間(Metaspace)用來儲存類的定義、方法後設資料等資訊。適用於大規模類載入場景,如微服務架構或動態生成大量類的框架(如 Hibernate、Spring)。
  2. 多執行緒應用:每個執行緒都有獨立的棧空間儲存執行緒呼叫棧資訊,因此 No-Heap 對併發較高的應用至關重要,特別是在高併發場景下,如 Web 伺服器和大型分散式系統。
  3. 靜態變數管理:靜態變數不儲存在堆記憶體中,而是在 No-Heap 區域,適用於需要共享資料的應用場景,如全域性配置、快取類靜態資源等。
  4. JVM 效能調優:對於 JVM 調優,合理配置 No-Heap 區域的元空間和棧大小可以防止記憶體溢位,並提高系統效能。

No-Heap 優點:

  • 避免 OOM:由於類後設資料儲存在 No-Heap 中,記憶體分配更加靈活,減少了因類載入過多導致的 OutOfMemoryError
  • 不影響 GC:No-Heap 不受 JVM 垃圾回收器的直接管理,減少了 GC 停頓對服務效能的影響。

No-Heap 缺點:

  • 記憶體配置需精細:元空間等區域雖然靈活,但需要手動配置記憶體大小,配置不當可能導致元空間溢位或棧溢位。
  • 除錯複雜:相比 Heap,No-Heap 的除錯和監控更加複雜,特別是在涉及多執行緒時。

申請記憶體實踐

要向 Heap、Off-Heap 和 No-Heap 這三種記憶體區域申請記憶體,可以透過不同的方法來操作,以下是對應的具體程式碼示例:

Heap 記憶體申請

Heap 記憶體是 JVM 預設分配的記憶體區域,通常用於分配 Java 物件的記憶體。要向 Heap 申請記憶體,只需要建立 Java 物件即可,所有物件預設儲存在堆中,由 JVM 垃圾回收器(GC)管理。

下面是個使用案例:

public class HeapMemoryExample {
    public static void main(String[] args) {
        // 建立物件,分配在 Heap 記憶體中
        String[] largeArray = new String[1000000];  // 分配大量物件,佔用 heap
        for (int i = 0; i < largeArray.length; i++) {
            largeArray[i] = "String number " + i;  // 每個物件儲存在 heap 記憶體中
        }
        System.out.println("在堆中為物件申請記憶體");
    }
}

Off-Heap 記憶體申請

Off-Heap 記憶體指的是不在 JVM 堆記憶體中分配的記憶體,通常是透過 Java NIO 的 DirectByteBuffer 或使用 Unsafe 類進行手動管理。堆外記憶體通常用於需要高效能的 I/O 操作或避免垃圾回收影響的場景。

使用 DirectByteBuffer 分配 Off-Heap 記憶體:

import java.nio.ByteBuffer;

public class OffHeapMemoryExample {
    public static void main(String[] args) {
        // 分配 10 MB 的堆外記憶體
        ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
        // 向堆外記憶體中寫資料
        for (int i = 0; i < buffer.capacity(); i++) {
            buffer.put((byte) i);
        }
        System.out.println("在堆外記憶體中申請記憶體");
    }
}

tips

  • ByteBuffer.allocateDirect() 方法分配了一塊大小為 10 MB 的堆外記憶體。堆外記憶體不會受到 JVM 垃圾回收器的管理,適合需要大量資料緩衝和高效能 I/O 的場景。
  • 堆外記憶體需要手動管理,尤其是及時釋放資源(可以透過 sun.misc.Cleaner 來釋放)。

釋放堆外記憶體方式:

ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
sun.misc.Cleaner cleaner = ((DirectBuffer) buffer).cleaner();
cleaner.clean();  // 立即釋放堆外記憶體

還可以使用使用 Unsafe 類分配 Off-Heap 記憶體(不推薦用於生產環境):

import sun.misc.Unsafe;
import java.lang.reflect.Field;

public class OffHeapMemoryWithUnsafe {
    private static Unsafe getUnsafe() {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            return (Unsafe) field.get(null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) {
        Unsafe unsafe = getUnsafe();
        long memoryAddress = unsafe.allocateMemory(1024 * 1024);  // 分配 1 MB 堆外記憶體
        unsafe.setMemory(memoryAddress, 1024 * 1024, (byte) 0);   // 初始化為 0
        System.out.println("不安全分配的堆外記憶體。");

        // 釋放堆外記憶體
        unsafe.freeMemory(memoryAddress);
        System.out.println("已釋放不安全的堆外記憶體。");
    }
}

tips

  • 使用 Unsafe 類直接分配堆外記憶體。這是更底層的操作,提供了對原始記憶體的完全控制,但需要謹慎,因為如果不及時釋放,可能會導致記憶體洩漏。

No-Heap 記憶體申請

No-Heap 記憶體包括 Metaspace、執行緒棧(Thread Stack)和 程式碼快取(Code Cache)。Java 類的後設資料儲存在 Metaspace 中,而每個執行緒都有獨立的棧空間。No-Heap 記憶體通常由 JVM 在執行時自動管理,開發者不能直接控制其分配。

在 Java 8 及以上版本,Metaspace 用於儲存類的後設資料。當類載入器載入一個類時,會將該類的後設資料資訊存放到 Metaspace 中。要增加 Metaspace 的使用,可以載入大量類。

import java.util.ArrayList;
import javassist.ClassPool;

public class MetaspaceMemoryExample {
    public static void main(String[] args) {
        // 使用 Javassist 工具庫動態建立類,佔用 Metaspace 空間
        ClassPool classPool = ClassPool.getDefault();
        ArrayList<Class<?>> classes = new ArrayList<>();

        try {
            for (int i = 0; i < 10000; i++) {
                // 動態建立類
                Class<?> newClass = classPool.makeClass("Class" + i).toClass();
                classes.add(newClass);  // 將類載入到 JVM Metaspace 中
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        System.out.println("透過載入許多類來分配元空間記憶體。");
    }
}

tips

  • 這個例子使用 Javassist 工具庫動態生成大量類。每個類的後設資料會存放在 Metaspace 中,從而增加 Metaspace 的記憶體佔用。可以透過 JVM 引數 -XX:MaxMetaspaceSize 來限制 Metaspace 的最大大小。

使用執行緒棧來佔用 no-heap 記憶體:每個 Java 執行緒啟動時,JVM 會為其分配執行緒棧。執行緒棧大小可以透過 JVM 引數 -Xss 配置。增加執行緒棧的使用,可以透過建立大量執行緒來實現。

public class ThreadStackExample {
    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                try {
                    Thread.sleep(10000);  // 讓執行緒等待,消耗棧記憶體
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }

        System.out.println("執行緒啟動,堆疊記憶體分配。");
    }
}

tips

  • 每建立一個新執行緒,JVM 就會為其分配一個獨立的執行緒棧。在大量建立執行緒時,可以看到棧記憶體的增長。
FunTester 原創精華

【連載】從 Java 開始效能測試

  • 混沌工程、故障測試、Web 前端
  • 服務端功能測試
  • 效能測試專題
  • Java、Groovy、Go
  • 白盒、工具、爬蟲、UI 自動化
  • 理論、感悟、影片
如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章