static int write(FileDescriptor fd, ByteBuffer src, long position,
NativeDispatcher nd, Object lock)
throws IOException
{
//如果是堆外内存,则直接写
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd, lock);
// Substitute a native buffer
int pos = src.position();
int lim = src.limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
//创建一块堆外内存,并将数据赋值到堆外内存中去
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
bb.put(src);
bb.flip();
// Do not update src until we see how many bytes were written
src.position(pos);
int n = writeFromNativeBuffer(fd, bb, position, nd, lock);
if (n > 0) {
// now update src
src.position(pos + n);
}
return n;
} finally {
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
/**
* 分配一片堆外内存
*/
static ByteBuffer getTemporaryDirectBuffer(int size) {
BufferCache cache = bufferCache.get();
ByteBuffer buf = cache.get(size);
if (buf != null) {
return buf;
} else {
// No suitable buffer in the cache so we need to allocate a new
// one. To avoid the cache growing then we remove the first
// buffer from the cache and free it.
if (!cache.isEmpty()) {
buf = cache.removeFirst();
free(buf);
}
return ByteBuffer.allocateDirect(size);
}
}
Java中的对象都是在JVM堆中分配的,其好处在于开发者不用关心对象的回收。但有利必有弊,堆内内存主要有两个缺点:1.GC是有成本的,堆中的对象数量越多,GC的开销也会越大。2.使用堆内内存进行文件、网络的IO时,JVM会使用堆外内存做一次额外的中转,也就是会多一次内存拷贝。
和堆内内存相对应,堆外内存就是把内存对象分配在Java虚拟机堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。
我们先看下堆外内存的实现原理,再谈谈它的应用场景。
更多文章见个人博客:https://github.com/farmerjohngit/myblog
堆外内存的实现
Java中分配堆外内存的方式有两种,一是通过
ByteBuffer.java#allocateDirect
得到以一个DirectByteBuffer对象,二是直接调用Unsafe.java#allocateMemory
分配内存,但Unsafe只能在JDK的代码中调用,一般不会直接使用该方法分配内存。其中DirectByteBuffer也是用Unsafe去实现内存分配的,对堆内存的分配、读写、回收都做了封装。本篇文章的内容也是分析DirectByteBuffer的实现。
我们从堆外内存的分配回收、读写两个角度去分析DirectByteBuffer。
堆外内存的分配与回收
ByteBuffer#allocateDirect
中仅仅是创建了一个DirectByteBuffer对象,重点在DirectByteBuffer的构造方法中。DirectByteBuffer构造方法中还做了挺多事情的,总的来说分为几个步骤:
Java的堆外内存回收设计是这样的:当GC发现DirectByteBuffer对象变成垃圾时,会调用
Cleaner#clean
回收对应的堆外内存,一定程度上防止了内存泄露。当然,也可以手动的调用该方法,对堆外内存进行提前回收。Cleaner的实现
我们先看下
Cleaner#clean
的实现:Cleaner继承自PhantomReference,关于虚引用的知识,可以看我之前写的文章
简单的说,就是当字段referent(也就是DirectByteBuffer对象)被回收时,会调用到
Cleaner#clean
方法,最终会调用到Deallocator#run
进行堆外内存的回收。Cleaner是虚引用在JDK中的一个典型应用场景。
预分配内存
然后再看下DirectByteBuffer构造方法中的第二步,
reserveMemory
在创建一个新的DirecByteBuffer时,会先确认有没有足够的内存,如果没有的话,会通过一些手段回收一部分堆外内存,直到可用内存大于需要分配的内存。具体步骤如下:
tryHandlePendingReference
方法回收已经变成垃圾的DirectByteBuffer对象对应的堆外内存,直到可用内存足够,或目前没有垃圾DirectByteBuffer对象-XX:+DisableExplicitGC
,那System.gc();是无效的详细分析下第2步是如何回收垃圾的:
tryHandlePendingReference
最终调用到的是Reference#tryHandlePending
方法,在之前的文章中有介绍过该方法可以看到,
tryHandlePendingReference
的最终效果就是:如果有垃圾DirectBytebuffer对象,则调用对应的Cleaner#clean
方法进行回收。clean方法在上面已经分析过了。堆外内存的读写
读写的逻辑也比较简单,address就是构造方法中分配的native内存的起始地址。Unsafe的putByte/getByte都是native方法,就是写入值到某个地址/获取某个地址的值。
堆外内存的使用场景
适合长期存在或能复用的场景
堆外内存分配回收也是有开销的,所以适合长期存在的对象
适合注重稳定的场景
堆外内存能有效避免因GC导致的暂停问题。
适合简单对象的存储
因为堆外内存只能存储字节数组,所以对于复杂的DTO对象,每次存储/读取都需要序列化/反序列化,
适合注重IO效率的场景
用堆外内存读写文件性能更好
文件IO
关于堆外内存IO为什么有更好的性能这点展开一下。
BIO
BIO的文件写
FileOutputStream#write
最终会调用到native层的io_util.c#writeBytes
方法GetByteArrayRegion
其实就是对数组进行了一份拷贝,该函数的实现在jni.cpp宏定义中,找了很久才找到可以看到,传统的BIO,在native层真正写文件前,会在堆外内存(c分配的内存)中对字节数组拷贝一份,之后真正IO时,使用的是堆外的数组。要这样做的原因是
以上内容来自于 知乎 ETIN的回答 https://www.zhihu.com/question/60892134/answer/182225677
BIO的文件读也一样,这里就不分析了。
NIO
NIO的文件写最终会调用到
IOUtil#write
可以看到,NIO的文件写,对于堆内内存来说也是会有一次额外的内存拷贝的。
End
堆外内存的分析就到这里结束了,JVM为堆外内存做这么多处理,其主要原因也是因为Java毕竟不是像C这样的完全由开发者管理内存的语言。因此即使使用堆外内存了,JVM也希望能在合适的时候自动的对堆外内存进行回收。