我们知道, 在使用IO传输数据时, 首先会将数据传输到堆外直接内存中, 然后才通过网络发送出去。这样的话, 数据多了次中间copy, 能否不经过copy而直接将数据发送出去呢, 其实是可以的, 存放的位置就是本文要讲的主角:DirectByteBuffer 。JVM内存主要分为heap内存和堆外内存(一般我们也会称呼为直接内存), heap内存我们不用care, jvm能自动帮我们管理, 而堆外内存回收不受JVM GC控制, 因此, 堆外内存使用必须小心。本文就主要讲jvm中堆外内存的实现及原理。
DirectByteBuffer使用
在程序中, 我们可以通过如下方式获取到DirectByteBuffer, 并且直接作为IO的缓存:
1 | public void sendAndRecv(String words) throws IOException |
DirectByteBuffer是不能直接被外界引用的, 类成员变量如下:
1 | DirectByteBuffer(int cap) { |
可以看到, DirectByteBuffer通过直接调用base=unsafe.allocateMemory(size)操作堆外内存, 返回的是该堆外内存的直接地址, 存放在address中, 以便通过address进行堆外数据的读取与写入。 unsafe的使用可以参考:LockSupport原理分析
我们需要了解下, Bits.reserveMemory()如何判断堆外内存是否可用的:
1 | static void reserveMemory(long size, int cap) { ////对分配的直接内存做一个记录 |
可以看到:
- 首先检查堆外内存是否够分
- 若不够分的话, 再进行一次full gc显示推动对堆外内存的回收, 再次尝试分配堆外内存, 不够分的话, 则抛出OOM异常。
堆外内存的回收
在DirectByteBuffer的构造函数中, 我们可以看到这样的一行代码cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
, 没错, 直接内存释放主要由cleaner来完成。 我们知道JVM GC并不能直接释放直接内存, 但是GC可以释放管理直接内存的DirectByteBuffer对象。 我们需要注意下cleaner的类型:
1 | public class Cleaner extends PhantomReference<Object> |
PhantomReference并不会对对象的垃圾回收产生任何影响, 当进行gc完成后, 当发现某个对象只剩下虚引用后, 会将该引用迁移至Reference类的pending队列进行回收. 这里可以看到DirectByteBuffer被Cleaner引用着。Reference操作回收代码如下:
1 | static private class Lock { }; |
可以看出来, JVM会新建名为Reference Handler
的线程, 时刻回收被挂到pending上面的虚拟引用(该线程在JVM启动时就会产生)。 当DirectByteBuff对象仅被Cleaner引用时, Cleaner被放入pending队列, 之后调用Cleaner.clean()队列
1 | public void clean() { //这里的clean()会在Reference回收时显示调用 |
可以看到, 此时完成了DirectByteBuff直接内存的释放(虚引用中保存有堆外内存的直接地址,来达到释放堆外地址的效果)。
可能有些人会好奇: 为什么IO操作不直接使用堆内内存? 这是因为堆内内存会发生GC移动操作, 对象移动后, 其绝对内存地址也会发生改变, 而gc时对象移动操作很频繁, 不可能每次移动堆内数据, IO时缓存的buffer也跟着一起移动。这样也是不合理的。 而IO操作直接使用堆外内存则没有了这一限制。同时jvm中IO操作的Buffer必须是DirectBuffer(可查看IO.write/read函数)。
堆外内存的检测
- 通过DirectByteBuffer申请的堆外内存, 我们可以通过如下的方式获取到:
1
2
3
4List<BufferPoolMXBean> bufferPools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
for (BufferPoolMXBean bufferPool : bufferPools) {
System.out.println("name: " + bufferPool.getName() + ", getCount: " + bufferPool.getCount() + ", getTotalCapacity: " + bufferPool.getTotalCapacity() + ", getMemoryUsed: " + bufferPool.getMemoryUsed());
}
将会打印如下结果:
1 | name: direct, getCount: 1, getTotalCapacity: 1073741824, getMemoryUsed: 1073741824 |
direct部分内存就是了。
2. 若我们直接通过base = unsafe.allocateMemory(size)申请内存, 申请内存大小(申请大小jvm参数都控制不了)及内存管理都已不受JVM控制的了, 我们不能再通过上面那种方式检测出来了,但是大小可以通过jcmd方式检测出来。此时jvm总的使用量(堆内+元数据+DirectByteBuff+unsafe申请内存空间)可以在top命令中RES可查看到。
总结
在JVM中, 一般只有通过DirectByteBuffer这一种方式操作堆外内存, 平时说的堆外内存泄漏, 也就是指的DirectByteBuffer里面的堆外内存发生泄漏。合理使用DirectByteBuffer对通信框架有着很重要的帮助, 比如netty大量的IO数据传输, 都是通过DirectByteBuffer完成的。 直接内存的申请与释放比较代价比较大, 一般都会辅助对象池来尽量高效的利用申请的对象。