java.nio.DirectByteBuffer的分配与回收源码剖析

直接内存简介

java NIO使用直接内存时,可以减少一次堆内内存到对外内存的转换,从而提高效率。在java中,语义上使用DirectByteBuffer对象表示一段直接内存,而本质上DirectByteBuffer对象是位于堆内的,它指向了堆外的一个内存块。直接内存不归jvm管理,所以使用时,需要小心它的回收问题。具体如何本文将详细分析。

以下代码展示了DirectByteBuffer的分配、读写和回收:

public static void main(String[] args) throws Exception {

	// 分配
	ByteBuffer buffer = ByteBuffer.allocateDirect(128);

	// 写入
	buffer.put("写入到直接内存".getBytes(Charset.forName("utf-8")));

	// 读取
	buffer.flip();
	byte[] bytes = new byte[buffer.remaining()];
	buffer.get(bytes);
	System.out.println(new String(bytes, Charset.forName("utf-8")));

	System.gc();	// 不是必须
}

以上一小段代码,涉及到了直接内存的分配、使用、回收,涉及到直接内存的各个相关对象的结构关系如下:

在这里插入图片描述

请求分配一个大小为capacity的直接内存时,分配返回的结果是一个内存地址address,对应一段内存的起始地址。
分配内存创建DirectByteBuffer对象时,会同时创建一个相关联的Cleaner对象和Deallocator对象,他们的关系如上图所示。Cleaner对象是个虚引用,继承自PhantomReference类,虚引用在其引用的对象被垃圾回收时,被放到一个pending队列,然后由一个引用处理线程处理这个队列、调用Cleaner对象的clean()方法,由clean方法调用Deallocator对象的run()方法,进行堆外内存的释放。

直接内存分配剖析

通常我们调用静态方法ByteBuffer.allocate(int capacity)分配一段直接内存,在allocate方法内部调用DirectByteBuffer的构造器,分配出一段直接内存缓冲,DirectByteBuffer的这个构造器如下:

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));	// 计算size,后面按size进行实际内存占用
    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;
}

该构造器的主要逻辑是:

1、通过unsafe.allocateMemory(size)分配一段大小为size的内存,这是个native方法,表明会通过JNI调用操作系统本地的系统调用接口。该方法最终会调用操作系统的malloc方法,进行内存的分配,分配成功后返回一个基地址,这个基地址最后转换为address,DirectByteBuffer对象就是通过address和size引用这段内存。

2、创建Cleaner对象,后续用于清理直接内存。

Cleaner在后面说释放时再细讲,这里进一步解释下直接内存分配的几个有趣的知识点。

内存对齐:

内存对齐有利于提高程序的移植性和内存的访问性能,原理可参考文章数据对齐:整理并向右对齐–为速度和正确性对齐你的数据。在分配直接内存时,使用了内存对齐的技术,首先通过boolean pa = VM.isDirectMemoryPageAligned()看当前虚拟机实例是否启用了内存对齐,这个可以通过-XX:[+|-]PageAlignDirectMemory选项或者sun.nio.PageAlignDirectMemory系统属性进行设置。

接着计算size:long size = Math.max(1L, (long)cap + (pa ? ps : 0)),可知如果不启用内存对齐,那么最终size=capacity;如果启用了内存对齐,那么size比capacity大一个内存页,而占用内存量是按size去占的,因此这种情况下实际占用的内存比buffer使用的内存会大。通过base = unsafe.allocateMemory(size)得到基地址后,如果未启用内存页对齐,那么赋值address=base;如果启用了,那么计算address = base + ps - (base & (ps - 1)),这个计算可以使得address指向内存页边界。

解释下这个二进制运算,把实际的数据缩小,比如假设base是7,对应二进制111,ps是4,对应二进制100,7除以4余3,对应的二进制操作是base & (ps - 1),即先把ps减去1,得到011,和base按位与得到余数011。接着base+ps减去余数,7+4-3=8,这时得到的内存地址是内存页大小4的整数倍,并且指向页边界。

控制直接内存的总大小:

通过Bits.reserveMemory(size, cap)记录当前已经使用了多少直接内存,本次是否还能分配。直接内存大小可以通过-XX:MaxDirectMemorySize=参数进行设置。该方法首先判断是否还有足够的内存以供本次分配,如果有则将使用量累加器往上加capacity。如果没有,显示调用System.gc()发起一次垃圾回收,以间接回收掉一些直接内存,然后再检查是否有足够的内存供分配,如果有则将使用量往上累加capacity,如果这时还没有足够的内存,则会抛出直接内存区域的OOM异常。

可以看到Bits类中,totalCapacity用于记录应用申请的直接内存总量,reservedMemory记录的才是实际的直接内存使用总量,在启用内存页对齐时,这两者可能是不相等的。而判断是否还剩余足够的内存可供分配时,是使用maxMemory-totalCapacity来判断的。所以当totalCapacity达到maxMemory大小时,实际的内存使用量reservedMemory可能已经超出不小。

直接内存释放剖析

Cleaner对象

在创建直接内存时,通过以下语句给DirectByteBuffer分配了清理对象:

cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

先来看看Deallocator对象,创建该对象时,把base/size/capacity作为构造器参数,送过去,因此Deallocator就指向了这块直接内存,构造方法源代码如下:

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

Deallocator实现了Runnable,通过执行其run方法释放直接内存,其run方法实现如下:

public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address);	
    address = 0;
    Bits.unreserveMemory(size, capacity);
}

首先,通过调用unsafe.freeMemory(address)释放内存,该方法被声明为native,最终会执行系统调用方法free,释放由之前malloc分配的内存。

然后调用Bits.unreserveMemory(size, capacity),从使用量控制累加器上减去当前直接内存的大小。

接下来看看Cleaner如何调用到Deallocator对象,Cleaner对象的创建过程如下:

private Cleaner next = null;  // 多个cleaner对象是以双向链表的方式保存的
private Cleaner prev = null;
private final Runnable thunk;	// Deallocator对象会被Cleaner对象持有

// 1 先调用静态的创建方法
public static Cleaner create(Object ob, Runnable thunk) {
    if (thunk == null)
        return null;
    return add(new Cleaner(ob, thunk));
}

// 2 调用构造器,这时把其引用的DirectByteBuffer对象referent传给其父类,即虚引用类
private Cleaner(Object referent, Runnable thunk) {
    super(referent, dummyQueue);
    this.thunk = thunk;
}

// 3 将当前cleaner对象加入到cleaner链表的表头
private static synchronized Cleaner add(Cleaner cl) {
    if (first != null) {
        cl.next = first;
        first.prev = cl;
    }
    first = cl;
    return cl;
}  

以上就是Cleaner对象的创建过程,当清除直接内存时,会调用Cleaner对象的clean()方法:

public void clean() {
    if(remove(this)) {
        try {
            this.thunk.run();
        } catch (final Throwable var2) {
            // 省略...
    }
}

比较简单,直接调用this.thunk.run()方法释放内存,从上文分析可知thunk是个Deallocator对象,即调用了Deallocator对象的run()方法释放了内存。

直接内存释放的触发时机

上文提到,Cleaner对象是个虚引用,当一个虚引用所引用的对象被垃圾回收器回收掉时,这个需引用将会被加入到pending链表中。另外有一个引用处理线程来处理pending列表里面的引用对象。如果这个引用对象是个Cleaner,则直接调用其clean()方法。

我们先来看看虚引用的定义:

public class PhantomReference<T> extends Reference<T> {
    public PhantomReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }
}

可见PhantomReference继承了Reference类,来看Reference类,其主要属性:

private T referent;         /* Treated specially by GC */
volatile ReferenceQueue<? super T> queue;
transient private Reference<T> discovered;  /* used by VM */
private static Reference<Object> pending = null;

被引用对象referent和引用队列queue由构造器传入,在创建Cleaner时由子类传上来。当其引用的对象referent被垃圾回收器回收掉时,该引用将会被加入pending单向链表。pending是static的,所以可以认为它是一个全局的数据结构。链表里的每个元素,通过discovered类变量指向到链表中的下一个元素。

接下来看看引用处理线程ReferenceHandler如何处理pending链表里的引用,ReferenceHandler是Reference类的静态内部类,继承自Thread类,覆盖了线程类的run方法:

public void run() {
    for (;;) {
        Reference<Object> r;
        synchronized (lock) {
            if (pending != null) {    // (1)
                r = pending;
                pending = r.discovered;
                r.discovered = null;
            } else {
                try {
                    try {
                        lock.wait();
                    } catch (OutOfMemoryError x) { }
                } catch (InterruptedException x) { }
                continue;
            }
        }

        // Fast path for cleaners
        if (r instanceof Cleaner) {   // (2)
            ((Cleaner)r).clean();
            continue;
        }

        ReferenceQueue<Object> q = r.queue;
        if (q != ReferenceQueue.NULL) q.enqueue(r);
    }
}

(1) 首先,从pending链表取出一个引用r,如果pending链表已经没有引用,则调用lock.wait()阻塞等待。

(2)如果该引用r是Cleaner实例,调动其clean()方法。Cleaner的clean()方法就是在这里被调用的。

继续往后看,可知如果引用r不是Cleaner实例,将被加入到引用所关联的队列里面,这个队列里面的引用是由应用代码去处理的。这里由于跟直接内存释放没有关系,不展开,不过可以参考netty的io.netty.util.internal.ObjectCleaner类,里面有精彩的使用案例。

而ReferenceHandler线程的创建和启动,是在Rererence类的静态代码块里。

一些最佳实践

最好主动地回收直接内存

这篇文章Netty之Java堆外内存扫盲贴里,对为什么最好要主动地回收直接内存有很好的描述,这里直接引用:

存在于堆内的DirectByteBuffer对象很小,只存着基地址和大小等几个属性,和一个Cleaner,但它代表着后面所分配的一大段内存,是所谓的冰山对象。通过前面说的Cleaner,堆内的DirectByteBuffer对象被GC时,它背后的堆外内存也会被回收。

快速回顾一下堆内的GC机制,当新生代满了,就会发生young gc;如果此时对象还没失效,就不会被回收;撑过几次young gc后,对象被迁移到老生代;当老生代也满了,就会发生full gc。

这里可以看到一种尴尬的情况,因为DirectByteBuffer本身的个头很小,只要熬过了young gc,即使已经失效了也能在老生代里舒服的呆着,不容易把老生代撑爆触发full gc,如果没有别的大块头进入老生代触发full gc,就一直在那耗着,占着一大片堆外内存不释放。

这时,就只能靠前面提到的申请额度超限时触发的system.gc()来救场了。但这道最后的保险其实也不很好,首先它会中断整个进程,然后它让当前线程睡了整整一百毫秒,而且如果gc没在一百毫秒内完成,它仍然会无情的抛出OOM异常。还有,万一,万一大家迷信某个调优指南设置了-DisableExplicitGC禁止了system.gc(),那就不好玩了。

所以,堆外内存还是自己主动点回收更好,比如Netty就是这么做的。

如何主动回收,该文章也有说明:

对于Sun的JDK这其实很简单,只要从DirectByteBuffer里取出那个sun.misc.Cleaner,然后调用它的clean()就行。

前面说的,clean()执行时实际调用的是被绑定的Deallocator类,这个类可被重复执行,释放过了就不再释放。所以GC时再被动执行一次clean()也没所谓。

在Netty里,因为不确定跑在Sun的JDK里(比如安卓),所以多废了些功夫来确定Cleaner的存在。

附加内容:直接内存的读写分析

了解完直接内存的分配和释放,来看看如何将数据写入到直接内存,以及如何从直接内存读取数据。

写入直接内存

以DirectByteBuffer.put(byte[] src, int offset, int length)为例,其主体逻辑在ByteBuffer类中:

public ByteBuffer put(byte[] src, int offset, int length) {
    // 省略
    int end = offset + length;
    for (int i = offset; i < end; i++)
        this.put(src[i]);
    return this;
}
public ByteBuffer put(byte x) {
    unsafe.putByte(ix(nextPutIndex()), ((x)));
    return this;
}

unsafe.putByte是个native方法,最后通过JNI来设置内存地址指定的内存区域。

这里看下nextPutIndex()方法,它将当前缓冲位置下标position加1,然后返回。

其次是ix(int i)方法,它只有一句代码:

return address + ((long)i << 0);

即在缓冲区起始内存地址的基础上加上position,得到要访问的内存地址,然后设置一个byte进去。

读取直接内存

public ByteBuffer get(byte[] dst, int offset, int length) {
    // 省略
    int end = offset + length;
    for (int i = offset; i < end; i++)
        dst[i] = get();
    return this;
}

可见,它是通过依次调动get()方法获取到一个字节的内容,然后设置进目标数组的指定位置的。get()方法的实现,也在DirectByteBuffer中:

public byte get() {
    return ((unsafe.getByte(ix(nextGetIndex()))));
}

最终,还是通过unsafe.getByte方法,来获取指定内存地址处的一个字节。

总结

1、直接内存的分配和释放是通过unsafe.allocateMemory/freeMemory来操作的,它们最终会对应到系统调用alloc/free。读写最终都通过unsafe.put/get实现。

2、最大可使用的直接内存在Bits类里控制。

3、由于DirectByteBuffer是典型的冰山对象,如果使用完了,最好主动回收。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值