從HotSpot原始碼理解DirectByteBuffer

開翻挖掘機發表於2022-07-04

1. 前言

  自從java在1.4版本後有了NIO,direct memory就變得如此的常見。在NIO中,direct memory充當緩衝區,使用的是本機記憶體而不是堆記憶體。這種方式減少了資料在java堆和本機堆之間的複製操作,一定程度上提高了資料流轉的效率。但是direct memory的分配和回收效能不高,不建議頻繁的分配direct memory。

  通常我們都是通過allocateDirect()分配一塊直接記憶體。這實際上是在堆上新建了一個DirectByteBuffer的java物件,該物件引用了一塊直接記憶體的地址(jvm程式中的虛地址,通過缺頁異常分配實際的實體地址)。下面就會介紹通過allocateDirect()分配直接記憶體的過程以及DirectMemory在hotspot原始碼中的一些細節。

hotspot原始碼版本為:openjdk 11.0.14

2. DirectMemory記憶體分配流程

  通過如下方法可以分配1M的直接記憶體。

ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);

直接進allocateDirect()方法,可以看到實際上是新建了一個DirectByteBuffer物件。

public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
        }

核心內容在DirectByteBuffer類的構造方法中,原始碼如下:

DirectByteBuffer(int cap) {                   // package-private
        // 使用父類構造方法初始化ByteBuffer指標
        super(-1, 0, cap, cap);
        // 判斷是否設定了 記憶體對齊(預設false)
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        // 如果不設定記憶體對齊,size和cap值一樣
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        // 記憶體分配的一些檢查和回收操作
        Bits.reserveMemory(size, cap);

        long base = 0;
        try {
            // 分配記憶體,並返回直接記憶體地址
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        }
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;



    }

2.1 記憶體分配前的檢查

  核心方法就在Bits.reserveMemory(size, cap);內。原始碼如下:

    static void reserveMemory(long size, int cap) {


        if (!memoryLimitSet && VM.isBooted()) {
            // 獲取最大直接記憶體大小
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }

        // optimist!
        // 這個方法內檢查是否存在剩餘直接記憶體空間
        if (tryReserveMemory(size, cap)) {
            // 如果還有空間進行分配,直接返回
            return;
        }
        // 獲取Reference物件(這裡需要Reference的一些知識點)
        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        // retry while helping enqueue pending Reference objects
        // which includes executing pending Cleaner(s) which includes
        // Cleaner(s) that free direct buffer memory
        // 通過Cleaner嘗試釋放一部分直接記憶體
        while (jlra.tryHandlePendingReference()) {
            // 再次檢查剩餘直接記憶體容量
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }

        // trigger VM's Reference processing
        // 強制Full GC
        // 可以看到,如果直接記憶體餘量檢查不通過,就會觸發Full GC
        System.gc();

        // a retry loop with exponential back-off delays
        // (this gives VM some time to do it's job)
        // 在迴圈中多次檢查剩餘直接記憶體容量
        boolean interrupted = false;
        try {
            long sleepTime = 1;
            int sleeps = 0;
            while (true) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
                // MAX_SLEEPS為9
                if (sleeps >= MAX_SLEEPS) {
                    break;
                }
                if (!jlra.tryHandlePendingReference()) {
                    try {
                        // 每次迴圈 睡眠時間 * 2(單位:ms)
                        Thread.sleep(sleepTime);
                        sleepTime <<= 1;
                        sleeps++;
                    } catch (InterruptedException e) {
                        interrupted = true;
                    }
                }
            }

            // no luck
            throw new OutOfMemoryError("Direct buffer memory");

        } finally {
            if (interrupted) {
                // don't swallow interrupts
                Thread.currentThread().interrupt();
            }
        }
    }

tryReserveMemory(size, cap)進行直接記憶體餘量檢查的原始碼如下:

private static boolean tryReserveMemory(long size, int cap) {

        // -XX:MaxDirectMemorySize limits the total capacity rather than the
        // actual memory usage, which will differ when buffers are page
        // aligned.
        long totalCap;
        // totalCapacity記錄當前已使用直接記憶體大小
        // 需要分配的大小如果小於  最大直接記憶體和當前已使用的直接記憶體的差值,則為true
        // 否則,返回false
        while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
            // 通過CAS將當前已使用直接記憶體大小 更新為 當前新的值
            if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
                // 將已預留直接記憶體大小 更新 為當前新的值
                reservedMemory.addAndGet(size);
                // 計數器自增
                count.incrementAndGet();
                return true;
            }
        }

        return false;
    }

2.2 DirectMemory預設最大是多少

  在上面進行剩餘記憶體檢查的時候,最大直接記憶體用的是maxMemory變數的值,原始碼中它的取值如下:

private static volatile long maxMemory = VM.maxDirectMemory();
/**
* 這裡這個directMemory在VM.java中定義了個靜態變數,容易誤導人,讓人
* 以為maxDirectMemory的大小就是64M,其實不是的。
**/
public static long maxDirectMemory() {
        return directMemory;
    }

image.png

其實maxMemory的值並不是就是64M,預設如果不設定,maxMemory的值和最大堆記憶體大小(-Xmx設定的值)差不多。如果我們設定了JVM的執行時引數-XX:MaxDirectMemorySize=xxxmaxMemory就是我們自定義的值。直接看hotspot原始碼:

在jvm中會將-XX:MaxDirectMemorySize的屬性轉換成sun.nio.MaxDirectMemorySize屬性,如果不設定,則預設設定為-1
image.png

在jvm啟動的時候會讀取上面設定的值,如果是-1,則將directMemory設定為執行時的最大記憶體(即差不多-Xmx的值)。
image.png

maxMemory()也是個native方法,原始碼如下:
image.png

至於為什麼說 差不多等於最大堆記憶體的值,其實是少了一個survivor的空間大小。還是看hotspot原始碼(maxMemory是如何計算的):
hotspot中對應獲取執行時記憶體的方法是max_capacity(),這個方法的大小計算和垃圾收集器關係密切:

image.png

  // The particular choice of collected heap.
  static CollectedHeap* heap() { return _collectedHeap; }

可以看到這個版本的hotspot中有8種垃圾收集器
image.png

以下以CMS垃圾演算法為基礎
可以看到CMS垃圾演算法下的heap大小其實是:年輕代最大記憶體 ➕ 老年代最大記憶體
image.png

再看年輕代的最大記憶體,其實是減掉了一個survivor的大小,原始碼如下:
image.png

可以看下除錯過程中的max_capacity()方法的返回值【設定了-Xmx1g】:
image.png

  • 由此可見,DirectMemory預設最大是(Xmx - 1個survivor)的大小;
  • DirectMemory不足會導致Full GC;
G1垃圾收集器的話,max_capacity()的計算方法(高位地址 - 地位地址)就不存少一個survivor的說法,-Xmx設定了多少就是多少。

2.3 DirectByteBuffer的記憶體分配

  真正分配記憶體的方法其實是unsafe.allocateMemory(size),這是個native方法:
image.png

hotspot中的實現是在unsafe.cpp中,原始碼如下:
image.png

實際上底層是呼叫了作業系統的malloc函式進行記憶體分配,然後返回一個記憶體地址給java。

2.3.1 總結下direct memory大致的分配流程:

  1. new一個DirectByteBuffer物件;
  2. DirectByteBuffer物件在執行初始化執行構造方法的時候呼叫unsafe.allocateMemory(size)分配記憶體,以記憶體地址作為返回結果;
  3. jvm呼叫作業系統malloc函式分配虛擬記憶體(然後在實際使用中通過缺頁異常分配實際的實體記憶體),返回記憶體地址給java;
  4. 將記憶體地址儲存至DirectByteBuffer物件的成員變數address中進行引用;

因此DirectByteBuffer本身作為一個java物件存在於jvm堆中,但是持有一個本機記憶體的記憶體地址的引用。
DirectByteBuffer在堆中佔用的記憶體很小,但是很可能持有一塊很大的本機記憶體引用。

3. DirectMemory關聯的本機記憶體是如何清理的

  既然直接記憶體不屬於jvm堆記憶體的一部分,那GC肯定是無法直接管理這塊記憶體區域的,那direct memory是如何進行記憶體回收的呢?

  前面已經瞭解到,直接記憶體實際上是通過作業系統的malloc函式進行記憶體分配的,因此記憶體釋放也需要呼叫作業系統的free函式。java中可以通過unsafe.freeMemory()來呼叫底層的free函式。

基於這個思路,釋放直接記憶體大隻有兩種途徑:

  1. 手動呼叫unsafe.freeMemory()進行釋放,netty中ByteBuf.release()就是這種方式;
  2. 利用GC機制,在GC的過程中自動呼叫unsafe.freeMemory()釋放不再被引用的直接記憶體;

今天主要想分享第2種回收方式,也就是如何在GC的過程中釋放不再被引用的直接記憶體。

在開始之前,需要了解一些關於Reference的前置知識。因為通過GC間接回收direct memory的方式,完全基於PhantomReference虛引用來實現。

這裡我直接貼上一位大佬的部落格:《【java.lang.ref】PhantomReference & jdk.internal.ref.Cleaner》地址:https://blog.csdn.net/reliveI...

這篇文章很全面的介紹了PhantomReference虛引用的相關知識,並在DirectByteBuffer章節清晰的描述了通過與GC的互動聯動,實現direct memory的回收過程。給大佬點個贊?。

到這裡也就知道,為啥《2.1 記憶體分配前的檢查》在Bits.reserveMemory(size, cap)方法中要顯示呼叫System.gc()進行Full GC。這是為了儘可能回收不可達的DirectByteBuffer物件,也只有通過GC才會自動觸發unsafe.freeMemory()的呼叫,釋放直接記憶體。


相關文章