性能文章>从HotSpot源码理解DirectByteBuffer>

从HotSpot源码理解DirectByteBuffer原创

237205

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;
    }

270818538762c1a888bc267.jpg

其实maxMemory的值并不是就是64M,默认如果不设置,maxMemory的值和最大堆内存大小(-Xmx设置的值)差不多。如果我们设置了JVM的运行时参数-XX:MaxDirectMemorySize=xxxmaxMemory就是我们自定义的值。直接看hotspot源码:

在jvm中会将-XX:MaxDirectMemorySize的属性转换成sun.nio.MaxDirectMemorySize属性,如果不设置,则默认设置为**-1**。
388054935362c1b4fc80d59.jpg

在jvm启动的时候会读取上面设置的值,如果是**-1**,则将directMemory设置为运行时的最大内存(即差不多-Xmx的值)。
252278298462c1b372a901f.jpg

maxMemory()也是个native方法,源码如下:
61051495462c1b8cde7e65.jpg

至于为什么说 差不多等于最大堆内存的值,其实是少了一个survivor的空间大小。还是看hotspot源码(maxMemory是如何计算的):
hotspot中对应获取运行时内存的方法是max_capacity(),这个方法的大小计算和垃圾收集器关系密切:

296262729262c1b8756b4d2.jpg

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

可以看到这个版本的hotspot中有8种垃圾收集器
183426335162c1c35a63d11_fix732.jpg

以下以CMS垃圾算法为基础
可以看到CMS垃圾算法下的heap大小其实是:年轻代最大内存 ➕ 老年代最大内存
131568697962c1b9631dbb2_fix732.jpg

再看年轻代的最大内存,其实是减掉了一个survivor的大小,源码如下:
199223542862c1b9e30580e_fix732.jpg

可以看下调试过程中的max_capacity()方法的返回值【设置了-Xmx1g】:
202884363662c28703f0789_fix732.jpg

  • 由此可见,DirectMemory默认最大是(Xmx - 1个survivor)的大小;
  • DirectMemory不足会导致Full GC;

G1垃圾收集器的话,max_capacity()的计算方法(高位地址 - 地位地址)就不存少一个survivor的说法,-Xmx设置了多少就是多少。

2.3 DirectByteBuffer的内存分配

  真正分配内存的方法其实是unsafe.allocateMemory(size),这是个native方法:
304142496062c1a6c628ba0_fix732.jpg

hotspot中的实现是在unsafe.cpp中,源码如下:
384730874162c24d93ecfa3.jpg

实际上底层是调用了操作系统的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/reliveIT/article/details/116157523

这篇文章很全面的介绍了PhantomReference虚引用的相关知识,并在DirectByteBuffer章节清晰的描述了通过与GC的交互联动,实现direct memory的回收过程。给大佬点个赞👍。

到这里也就知道,为啥《2.1 内存分配前的检查》在Bits.reserveMemory(size, cap)方法中要显示调用System.gc()进行Full GC。这是为了尽可能回收不可达的DirectByteBuffer对象,也只有通过GC才会自动触发unsafe.freeMemory()的调用,释放直接内存。


原文链接:https://segmentfault.com/a/1190000042065874#item-2-1

点赞收藏
开翻挖掘机
请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

【全网首发】一次想不到的 Bootstrap 类加载器带来的 Native 内存泄露分析

【全网首发】一次想不到的 Bootstrap 类加载器带来的 Native 内存泄露分析

记一次“雪花算法”造成的生产事故的排查记录

记一次“雪花算法”造成的生产事故的排查记录

【全网首发】一次疑似 JVM Native 内存泄露的问题分析

【全网首发】一次疑似 JVM Native 内存泄露的问题分析

解读JVM级别本地缓存Caffeine青出于蓝的要诀2 —— 弄清楚Caffeine的同步、异步回源方式

解读JVM级别本地缓存Caffeine青出于蓝的要诀2 —— 弄清楚Caffeine的同步、异步回源方式

单服务并发出票实践

单服务并发出票实践

刺激,线程池的一个BUG直接把CPU干到100%了。

刺激,线程池的一个BUG直接把CPU干到100%了。

5
0