1. 程式人生 > >JDK 原始碼閱讀 : DirectByteBuffer

JDK 原始碼閱讀 : DirectByteBuffer

在文章JDK原始碼閱讀-ByteBuffer中,我們學習了ByteBuffer的設計。但是他是一個抽象類,真正的實現分為兩類:HeapByteBufferDirectByteBufferHeapByteBuffer是堆內ByteBuffer,使用byte[]儲存資料,是對陣列的封裝,比較簡單。DirectByteBuffer是堆外ByteBuffer,直接使用堆外記憶體空間儲存資料,是NIO高效能的核心設計之一。本文來分析一下DirectByteBuffer的實現。

如何使用DirectByteBuffer

如果需要例項化一個DirectByteBuffer,可以使用java.nio.ByteBuffer#allocateDirect

這個方法:

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

DirectByteBuffer例項化流程

我們來看一下DirectByteBuffer是如何構造,如何申請與釋放記憶體的。先看看DirectByteBuffer的建構函式:

DirectByteBuffer(int cap) {                   // package-private
	// 初始化Buffer的四個核心屬性
    super(-1, 0, cap, cap);
    // 判斷是否需要頁面對齊,通過引數-XX:+PageAlignDirectMemory控制,預設為false
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    // 確保有足夠記憶體
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 呼叫unsafe方法分配記憶體
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        // 分配失敗,釋放記憶體
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    // 初始化記憶體空間為0
    unsafe.setMemory(base, size, (byte) 0);
    // 設定記憶體起始地址
    if (pa && (base % ps != 0)) {
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    // 使用Cleaner機制註冊記憶體回收處理函式
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

申請記憶體前會呼叫java.nio.Bits#reserveMemory判斷是否有足夠的空間可供申請:

// 該方法主要用於判斷申請的堆外記憶體是否超過了用例指定的最大值
// 如果還有足夠空間可以申請,則更新對應的變數
// 如果已經沒有空間可以申請,則丟擲OOME
// 引數解釋:
//     size:根據是否按頁對齊,得到的真實需要申請的記憶體大小
//     cap:使用者指定需要的記憶體大小(<=size)
static void reserveMemory(long size, int cap) {
    // 因為涉及到更新多個靜態統計變數,這裡需要Bits類鎖
    synchronized (Bits.class) {
        // 獲取最大可以申請的對外記憶體大小,預設值是64MB
        // 可以通過引數-XX:MaxDirectMemorySize=<size>設定這個大小
        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }
        // -XX:MaxDirectMemorySize限制的是使用者申請的大小,而不考慮對齊情況
        // 所以使用兩個變數來統計:
        //     reservedMemory:真實的目前保留的空間
        //     totalCapacity:目前使用者申請的空間
        if (cap <= maxMemory - totalCapacity) {
            reservedMemory += size;
            totalCapacity += cap;
            count++;
            return; // 如果空間足夠,更新統計變數後直接返回
        }
    }

    // 如果已經沒有足夠空間,則嘗試GC
    System.gc();
    try {
        Thread.sleep(100);
    } catch (InterruptedException x) {
        // Restore interrupt status
        Thread.currentThread().interrupt();
    }
    synchronized (Bits.class) {
        // GC後再次判斷,如果還是沒有足夠空間,則丟擲OOME
        if (totalCapacity + cap > maxMemory)
            throw new OutOfMemoryError("Direct buffer memory");
        reservedMemory += size;
        totalCapacity += cap;
        count++;
    }
}

java.nio.Bits#reserveMemory方法中,如果空間不足,會呼叫System.gc()嘗試釋放記憶體,然後再進行判斷,如果還是沒有足夠的空間,丟擲OOME。

如果分配失敗,則需要把預留的統計變數更新回去:

static synchronized void unreserveMemory(long size, int cap) {
    if (reservedMemory > 0) {
        reservedMemory -= size;
        totalCapacity -= cap;
        count--;
        assert (reservedMemory > -1);
    }
}

從上面幾個函式中我們可以得到資訊:

  1. 可以通過-XX:+PageAlignDirectMemor引數控制堆外記憶體分配是否需要按頁對齊,預設不對齊。
  2. 每次申請和釋放需要呼叫呼叫Bits的reserveMemoryunreserveMemory方法,這兩個方法根據內部維護的統計變數判斷當前是否還有足夠的空間可供申請,如果有足夠的空間,更新統計變數,如果沒有足夠的空間,呼叫System.gc()嘗試進行垃圾回收,回收後再次進行判斷,如果還是沒有足夠的空間,丟擲OOME。
  3. Bits的reserveMemory方法判斷是否有足夠記憶體不是判斷物理機是否有足夠記憶體,而是判斷JVM啟動時,指定的堆外記憶體空間大小是否有剩餘的空間。這個大小由引數-XX:MaxDirectMemorySize=<size>設定。
  4. 確定有足夠的空間後,使用sun.misc.Unsafe#allocateMemory申請記憶體
  5. 申請後的記憶體空間會被清零
  6. DirectByteBuffer使用Cleaner機制進行空間回收

可以看出除了判斷是否有足夠的空間的邏輯外,核心的邏輯是呼叫sun.misc.Unsafe#allocateMemory申請記憶體,我們看一下這個函式是如何申請對外記憶體的:

// 申請一塊本地記憶體。記憶體空間是未初始化的,其內容是無法預期的。
// 使用freeMemory釋放記憶體,使用reallocateMemory修改記憶體大小
public native long allocateMemory(long bytes);
// openjdk8/hotspot/src/share/vm/prims/unsafe.cpp
UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory(JNIEnv *env, jobject unsafe, jlong size))
  UnsafeWrapper("Unsafe_AllocateMemory");
  size_t sz = (size_t)size;
  if (sz != (julong)size || size < 0) {
    THROW_0(vmSymbols::java_lang_IllegalArgumentException());
  }
  if (sz == 0) {
    return 0;
  }
  sz = round_to(sz, HeapWordSize);
  // 呼叫os::malloc申請記憶體,內部使用malloc函式申請記憶體
  void* x = os::malloc(sz, mtInternal);
  if (x == NULL) {
    THROW_0(vmSymbols::java_lang_OutOfMemoryError());
  }
  //Copy::fill_to_words((HeapWord*)x, sz / HeapWordSize);
  return addr_to_java(x);
UNSAFE_END

可以看出sun.misc.Unsafe#allocateMemory使用malloc這個C標準庫的函式來申請記憶體。

DirectByteBuffer回收流程

在DirectByteBuffer的建構函式的最後,我們看到了這樣的語句:

// 使用Cleaner機制註冊記憶體回收處理函式
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

這是使用Cleaner機制進行記憶體回收。因為DirectByteBuffer申請的記憶體是在堆外,DirectByteBuffer本身支援儲存了記憶體的起始地址而已,所以DirectByteBuffer的記憶體佔用是由堆內的DirectByteBuffer物件與堆外的對應記憶體空間共同構成。堆內的佔用只是很小的一部分,這種物件被稱為冰山物件。

堆內的DirectByteBuffer物件本身會被垃圾回收正常的處理,但是對外的記憶體就不會被GC回收了,所以需要一個機制,在DirectByteBuffer回收時,同時回收其堆外申請的記憶體。

Java中可選的特性有finalize函式,但是finalize機制是Java官方不推薦的,官方推薦的做法是使用虛引用來處理物件被回收時的後續處理工作,可以參考JDK原始碼閱讀-Reference。同時Java提供了Cleaner類來簡化這個實現,Cleaner是PhantomReference的子類,可以在PhantomReference被加入ReferenceQueue時觸發對應的Runnable回撥。

DirectByteBuffer就是使用Cleaner機制來實現本身被GC時,回收堆外記憶體的能力。我們來看一下其回收處理函式是如何實現的:

private static class Deallocator
    implements Runnable
    {

        private static Unsafe unsafe = Unsafe.getUnsafe();

        private long address;
        private long size;
        private int capacity;

        private Deallocator(long address, long size, int capacity) {
            assert (address != 0);
            this.address = address;
            this.size = size;
            this.capacity = capacity;
        }

        public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            // 使用unsafe方法釋放記憶體
            unsafe.freeMemory(address);
            address = 0;
            // 更新統計變數
            Bits.unreserveMemory(size, capacity);
        }

    }

sun.misc.Unsafe#freeMemory方法使用C標準庫的free函式釋放記憶體空間。同時更新Bits類中的統計變數。

DirectByteBuffer讀寫邏輯

public ByteBuffer put(int i, byte x) {
    unsafe.putByte(ix(checkIndex(i)), ((x)));
    return this;
}

public byte get(int i) {
    return ((unsafe.getByte(ix(checkIndex(i)))));
}

private long ix(int i) {
    return address + (i << 0);
}

DirectByteBuffer使用sun.misc.Unsafe#getByte(long)sun.misc.Unsafe#putByte(long, byte)這兩個方法來讀寫堆外記憶體空間的指定位置的位元組資料。不過這兩個方法本地實現比較複雜,這裡就不分析了。

預設可以申請的堆外記憶體大小

上文提到了DirectByteBuffer申請記憶體前會判斷是否有足夠的空間可供申請,這個是在一個指定的堆外大小限制的前提下。使用者可以通過-XX:MaxDirectMemorySize=<size>這個引數來控制可以申請多大的DirectByteBuffer記憶體。但是預設情況下這個大小是多少呢?

DirectByteBuffer通過sun.misc.VM#maxDirectMemory來獲取這個值,可以看一下對應的程式碼:

// A user-settable upper limit on the maximum amount of allocatable direct
// buffer memory.  This value may be changed during VM initialization if
// "java" is launched with "-XX:MaxDirectMemorySize=<size>".
//
// The initial value of this field is arbitrary; during JRE initialization
// it will be reset to the value specified on the command line, if any,
// otherwise to Runtime.getRuntime().maxMemory().
//
private static long directMemory = 64 * 1024 * 1024;

// Returns the maximum amount of allocatable direct buffer memory.
// The directMemory variable is initialized during system initialization
// in the saveAndRemoveProperties method.
//
public static long maxDirectMemory() {
    return directMemory;
}

這裡directMemory預設賦值為64MB,那對外記憶體的預設大小是64MB嗎?不是,仔細看註釋,註釋中說,這個值會在JRE啟動過程中被重新設定為使用者指定的值,如果使用者沒有指定,則會設定為Runtime.getRuntime().maxMemory()

這個過程發生在sun.misc.VM#saveAndRemoveProperties函式中,這個函式會被java.lang.System#initializeSystemClass呼叫:

public static void saveAndRemoveProperties(Properties props) {
    if (booted)
        throw new IllegalStateException("System initialization has completed");

    savedProps.putAll(props);

    // Set the maximum amount of direct memory.  This value is controlled
    // by the vm option -XX:MaxDirectMemorySize=<size>.
    // The maximum amount of allocatable direct buffer memory (in bytes)
    // from the system property sun.nio.MaxDirectMemorySize set by the VM.
    // The system property will be removed.
    String s = (String)props.remove("sun.nio.MaxDirectMemorySize");
    if (s != null) {
        if (s.equals("-1")) {
            // -XX:MaxDirectMemorySize not given, take default
            directMemory = Runtime.getRuntime().maxMemory();
        } else {
            long l = Long.parseLong(s);
            if (l > -1)
                directMemory = l;
        }
    }

    //...
}

所以預設情況下,可以申請的DirectByteBuffer大小為Runtime.getRuntime().maxMemory(),而這個值等於可用的最大Java堆大小,也就是我們-Xmx引數指定的值。

所以最終結論是:預設情況下,可以申請的最大DirectByteBuffer空間為Java最大堆大小的值。

和DirectByteBuffer有關的JVM選項

根據上文的分析,有兩個JVM引數與DirectByteBuffer直接相關:

  • -XX:+PageAlignDirectMemory:指定申請的記憶體是否需要按頁對齊,預設不對其
  • -XX:MaxDirectMemorySize=<size>,可以申請的最大DirectByteBuffer大小,預設與-Xmx相等

參考資料