详述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时,程序却还在继续分配直接内存,那么就很可能会造成直接内存溢出。同时,直接内存的分配速度要比堆内存慢得多,因此在程序中大量使用直接内存的时候,请务必用内存池去缓存。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值