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

从HotSpot源码理解DirectByteBuffer原创

205704

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

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

为你推荐

【全网首发】(大表小技巧)有时候 2 小时的 SQL 操作,可能只要 1 分钟

【全网首发】(大表小技巧)有时候 2 小时的 SQL 操作,可能只要 1 分钟

【全网首发】JVM性能问题的自动分析

【全网首发】JVM性能问题的自动分析

【全网首发】揭密Java常用性能调优工具的底层实现原理

【全网首发】揭密Java常用性能调优工具的底层实现原理

干货!Java代码优化必知的30个小技巧!

干货!Java代码优化必知的30个小技巧!

JVM 优化踩坑记

JVM 优化踩坑记

Java 异步调用原理与实战

Java 异步调用原理与实战

4
0