詳述DirectByteBuffer直接內存

什麼是直接內存

我們都知道Java程序是運行在Java虛擬機中的,Java對象的分配一般情況下是在虛擬機的堆內存空間,俗稱堆內內存。這一塊的內存垃圾回收是受JVM控制的,程序員無需爲此處的內存回收而操心。Java對象除了能分配在堆中,也能分配在堆外,這部分內存叫堆外內存,也就是直接內存。

直接內存和堆內內存的比較

堆內內存的分配是在JVM中,因此分配速度很快,但是堆內內存在進行網絡I/O的時候,需要先將內存從堆內複製到native堆。堆外內存的分配是直接調用的C的malloc函數在堆外空間分配的,因此分配速度相對較慢,但是在進行網絡I/O的時候,由於沒有將內存從堆內複製到native堆這一步,因此較快。下圖是發起一個網絡請求時的內存數據流向圖(網絡響應是原路返回,我就不畫了):
在這裏插入圖片描述

直接內存的回收

直接內存的回收受JVM控制嗎?答案是YES!很多人肯定覺得不可思議,直接內存在堆外,爲什麼還會受JVM控制呢?其實道理是這樣的,當我們使用代碼ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024)來分配1MB的直接內存的時候,byteBuffer對象仍然是在堆內的,它持有了直接內存的地址,可以肯定的是它的大小遠遠小於1MB,我們把它叫做冰山對象。當冰山對象被GC時,它所關聯的直接內存也會被釋放。口說無憑,下面用代碼來證明:

  • 示例一(YGC回收直接內存)
import java.nio.ByteBuffer;

/**
 * -Xms120m
 * -Xmx120m
 * -XX:+UseParNewGC
 * -XX:+PrintCommandLineFlags
 * -XX:+PrintGCDetails
 * -XX:+PrintHeapAtGC
 * -XX:+DisableExplicitGC
 * -XX:MaxDirectMemorySize=15m
 *
 * @author debo
 * @date 2020-02-07
 */
public class DirectByteBufferTest {

    public static final int _1K = 1024;

    public static void main(String[] args) {
        // 分配20MB直接內存
        for (int i = 0; i < 20 * _1K; i++) {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1K);
        }
    }
}

在類註釋上的JVM參數中,-XX:+PrintGCDetails表示打印GC詳細信息,-XX:+PrintHeapAtGC表示在GC時分別打印GC前和GC後的堆內存信息,XX:+DisableExplicitGC表示禁用System.gc() 的顯式Full GC調用,-XX:MaxDirectMemorySize=15m表示最大可分配15MB直接內存。

使用類註釋上的JVM參數運行程序,輸出信息如下

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:658)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)
	at DirectByteBufferTest.main(DirectByteBufferTest.java:15)
Heap
 par new generation   total 36864K, used 5450K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
  eden space 32768K,  16% used [0x00000000f3600000, 0x00000000f3b52858, 0x00000000f5600000)
  from space 4096K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
  to   space 4096K,   0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
 tenured generation   total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
   the space 81920K,   0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 3105K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  14% used [0x00000000fae00000, 0x00000000fb108740, 0x00000000fb108800, 0x00000000fc2c0000)
No shared spaces configured.

很明顯出現了直接內存溢出,且這時候新生代和老年代尚有足夠的內存空間。

修改程序如下:

import java.nio.ByteBuffer;

/**
 * -Xms120m
 * -Xmx120m
 * -XX:+UseParNewGC
 * -XX:+PrintCommandLineFlags
 * -XX:+PrintGCDetails
 * -XX:+PrintHeapAtGC
 * -XX:+DisableExplicitGC
 * -XX:MaxDirectMemorySize=15m
 *
 * @author debo
 * @date 2020-02-07
 */
public class DirectByteBufferTest {

    public static final int _1K = 1024;

    public static void main(String[] args) {
        // 分配28MB堆內存
        for (int i = 0; i < 28 * _1K; i++) {
            byte[] bytes = new byte[_1K];
        }
        // 分配20MB直接內存
        for (int i = 0; i < 20 * _1K; i++) {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1K);
        }
    }
}

使用相同的JVM參數運行程序,輸出如下

{Heap before GC invocations=0 (full 0):
 par new generation   total 36864K, used 32768K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
  eden space 32768K, 100% used [0x00000000f3600000, 0x00000000f5600000, 0x00000000f5600000)
  from space 4096K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
  to   space 4096K,   0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
 tenured generation   total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
   the space 81920K,   0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 3074K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  14% used [0x00000000fae00000, 0x00000000fb100a60, 0x00000000fb100c00, 0x00000000fc2c0000)
No shared spaces configured.
[GC[ParNew: 32768K->1141K(36864K), 0.0047660 secs] 32768K->1141K(118784K), 0.0047850 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
Heap after GC invocations=1 (full 0):
 par new generation   total 36864K, used 1141K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
  eden space 32768K,   0% used [0x00000000f3600000, 0x00000000f3600000, 0x00000000f5600000)
  from space 4096K,  27% used [0x00000000f5a00000, 0x00000000f5b1d760, 0x00000000f5e00000)
  to   space 4096K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
 tenured generation   total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
   the space 81920K,   0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 3074K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  14% used [0x00000000fae00000, 0x00000000fb100a60, 0x00000000fb100c00, 0x00000000fc2c0000)
No shared spaces configured.
}
Heap
 par new generation   total 36864K, used 2948K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
  eden space 32768K,   5% used [0x00000000f3600000, 0x00000000f37c3960, 0x00000000f5600000)
  from space 4096K,  27% used [0x00000000f5a00000, 0x00000000f5b1d760, 0x00000000f5e00000)
  to   space 4096K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
 tenured generation   total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
   the space 81920K,   0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 3083K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  14% used [0x00000000fae00000, 0x00000000fb102e40, 0x00000000fb103000, 0x00000000fc2c0000)
No shared spaces configured.

發現並沒有出現直接內存溢出,且出現了一次YGC,下面解讀這次的現象:根據JVM參數得知,最大堆內存分配了120M,根據默認的比例,新生代佔用1/3(40M),老年代佔用2/3(80M),新生代中,S區(from+to)分別佔用1/10(4M),E區佔用8/10(32M)。根據代碼得知,一開始分配了28M堆內存,雖然是28M,但我在自己機器上測試發現,分配了28M堆內存以後,E區就已經被佔用98%了,後面繼續分配20M的直接內存,我用了循環分配的方法,每次循環只分配1K直接內存,這樣做的目的是爲了產生儘可能多的冰山對象。在分配直接內存的過程中,冰山對象逐漸填充完E區剩餘的2%,於是觸發YGC,且此時直接內存的分配還沒有達到最大值15M。YGC回收的過程中,會釋放冰山對象所引用的直接內存,於是經過一次YGC後,又有15M的空閒直接內存可供分配,因此後面的分配得以順利進行。

不僅YGC,FGC的時候也會回收直接內存,代碼如下

  • 示例二(FGC回收直接內存)
import java.nio.ByteBuffer;

/**
 * -Xms120m
 * -Xmx120m
 * -XX:+UseParNewGC
 * -XX:+PrintCommandLineFlags
 * -XX:+PrintGCDetails
 * -XX:+PrintHeapAtGC
 * -XX:MaxDirectMemorySize=15m
 *
 * @author debo
 * @date 2020-02-07
 */
public class DirectByteBufferTest {

    public static final int _1K = 1024;

    public static void main(String[] args) {
        // 分配10MB直接內存
        for (int i = 0; i < 10 * _1K; i++) {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1K);
        }
        // 觸發FGC
        System.gc();
        // 分配10MB直接內存
        for (int i = 0; i < 10 * _1K; i++) {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1K);
        }
    }
}

JVM參數中,去掉了-XX:+DisableExplicitGC,輸出如下

{Heap before GC invocations=0 (full 0):
 par new generation   total 36864K, used 4139K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
  eden space 32768K,  12% used [0x00000000f3600000, 0x00000000f3a0ad58, 0x00000000f5600000)
  from space 4096K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
  to   space 4096K,   0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
 tenured generation   total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
   the space 81920K,   0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 3074K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  14% used [0x00000000fae00000, 0x00000000fb100ad8, 0x00000000fb100c00, 0x00000000fc2c0000)
No shared spaces configured.
[Full GC[Tenured: 0K->1042K(81920K), 0.0048550 secs] 4139K->1042K(118784K), [Perm : 3074K->3074K(21248K)], 0.0048750 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
Heap after GC invocations=1 (full 1):
 par new generation   total 36864K, used 0K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
  eden space 32768K,   0% used [0x00000000f3600000, 0x00000000f3600000, 0x00000000f5600000)
  from space 4096K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
  to   space 4096K,   0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
 tenured generation   total 81920K, used 1042K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
   the space 81920K,   1% used [0x00000000f5e00000, 0x00000000f5f04850, 0x00000000f5f04a00, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 3074K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  14% used [0x00000000fae00000, 0x00000000fb100ad8, 0x00000000fb100c00, 0x00000000fc2c0000)
No shared spaces configured.
}
Heap
 par new generation   total 36864K, used 1947K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
  eden space 32768K,   5% used [0x00000000f3600000, 0x00000000f37e6d08, 0x00000000f5600000)
  from space 4096K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
  to   space 4096K,   0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
 tenured generation   total 81920K, used 1042K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
   the space 81920K,   1% used [0x00000000f5e00000, 0x00000000f5f04850, 0x00000000f5f04a00, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 3083K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  14% used [0x00000000fae00000, 0x00000000fb102eb8, 0x00000000fb103000, 0x00000000fc2c0000)
No shared spaces configured.

前後各分配10M的直接內存,沒有發生直接內存溢出,說明FGC時回收掉了第一次分配的10M直接內存。

從源碼層面分析直接內存的回收

ByteBuffer.allocateDirect()源碼如下:

    DirectByteBuffer(int cap) {                 
    super(-1, 0, cap, cap);
    //內存是否按頁分配對齊
    boolean pa = VM.isDirectMemoryPageAligned();
    //獲取每頁內存大小
    int ps = Bits.pageSize();
    //分配內存的大小,如果是按頁對齊方式,需要再加一頁內存的容量
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    //用Bits類保存總分配內存(按頁分配)的大小和實際內存的大小
    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;
}
static void reserveMemory(long size, int cap) {
        synchronized (Bits.class) {
            if (!memoryLimitSet && VM.isBooted()) {
                maxMemory = VM.maxDirectMemory();
                memoryLimitSet = true;
            }
            // -XX:MaxDirectMemorySize limits the total capacity rather than the
            // actual memory usage, which will differ when buffers are page
            // aligned.
            if (cap <= maxMemory - totalCapacity) {
                reservedMemory += size;
                totalCapacity += cap;
                count++;
                return;
            }
        }

        System.gc();
        try {
            Thread.sleep(100);
        } catch (InterruptedException x) {
            // Restore interrupt status
            Thread.currentThread().interrupt();
        }
        synchronized (Bits.class) {
            if (totalCapacity + cap > maxMemory)
                throw new OutOfMemoryError("Direct buffer memory");
            reservedMemory += size;
            totalCapacity += cap;
            count++;
        }

    }

通過查看Bits.reserveMemory方法發現,默認情況下,當分配的直接內存超過指定的最大值時,源碼中會調用System.gc()觸發FGC,從而回收直接內存。回收有可能會失效,比如JVM參數中禁用了顯式GC,再比如直接內存的冰山對象引用鏈可達,不滿足回收條件等。

爲什麼YGC或者FGC時,回收了冰山對象就能回收直接內存呢?看第一段源碼,倒數第二行創建了Cleaner對象,Cleaner類繼承了PhantomReference類,在這裏Cleaner對象就是DirectByteBuffer對象的虛引用。虛引用有什麼用呢?當DirectByteBuffer對象被回收了以後,Cleaner對象自己會被放入到引用隊列,JVM中的Reference Handler線程負責處理這個隊列,從隊列裏面拿到Cleaner對象,然後調用該對象的clean()方法,這個方法就只幹一件事——釋放Cleaner對象相關的DirectByteBuffer對象所引用的直接內存。

完全繞開JVM的直接內存

通常我們會調用ByteBuffer.allocateDirect()方法進行直接內存的分配,這種直接內存會被GC回收,但是有些高級的網絡編程框架(比如netty)會使用如下方式分配直接內存:

import sun.misc.Unsafe;

import java.lang.reflect.Field;

/**
 * @author debo
 * @date 2020-02-07
 */
public class DirectByteBufferTest {

    public static final int _1K = 1024;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        // 分配1KB直接內存並返回內存地址
        long address = unsafe.allocateMemory(_1K);
    }
}

通過反射拿到Unsafe對象,然後用來分配直接內存。通過這種方式來分配的直接內存就完全不受JVM控制了,你必須主動釋放。

結束語

直接內存是把雙刃劍,用好了,它可以大大提升網絡I/O的性能,用的不好,就會造成猝不及防的內存溢出。試想,當大部分冰山對象進入了老年代而遲遲等不到FGC時,程序卻還在繼續分配直接內存,那麼就很可能會造成直接內存溢出。同時,直接內存的分配速度要比堆內存慢得多,因此在程序中大量使用直接內存的時候,請務必用內存池去緩存。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章