1. 前言
最近研究ByteBuffer和DirectByteBuffer。堆外内存是相对于堆内内存的一个概念。堆内内存是由JVM所管控的Java进程内存,我们平时在Java中创建的对象都处于堆内内存中,并且它们遵循JVM的内存管理机制,JVM会采用垃圾回收机制统一管理它们的内存。那么堆外内存就是存在于JVM管控之外的一块内存区域,因此它是不受JVM的管控。下面本博客就来详细介绍以下Java NIO 中的DirectByteBuffer。
2. Linux 内核态和用户态
- 内核态:控制计算机的硬件资源,并提供上层应用程序运行的环境。比如socket I/0操作或者文件的读写操作等
- 用户态:上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源
- 系统调用:为了使上层应用能够访问到这些资源,内核为上层应用提供访问的接口
内核态由操作系统控制计算机的硬件资源,用户态的程序可以通过一些系统调用,通过上下文切换,由用户态切换到内核态,然后进行相应的操作系统底层系统调用。因此我们可以得知当我们通过JNI调用的native方法实际上就是从用户态切换到了内核态的一种方式。并且通过该系统调用使用操作系统所提供的功能。
2.DirectByteBuffer ———— 直接缓冲
堆内内存存在的问题:由于HeapByteBuffer是存储在JVM 堆中的,所以我们在使用ByteBufffer的时候,如果创建了一个很大的ByteBuffer的时候,那么过大的内存Buffer对于垃圾回收来说造成的影响会很大,JVM新生带会频繁进行Minor GC,进而对程序的性能造成影响。在某些I/O操作下,FilChannelImpl需要通过堆外内存进行数据传输,如果使用HeapByteBuffer的话,FilChannelImpl需要通过将HeapByteBuffer复制到堆外内存,然后进行数据传输。
DirectByteBuffer解决了上述产生的问题:
- 垃圾回收停顿改善:由于DirectByteBuffer是堆外内存,不受垃圾回收机制控制,它直接受操作系统管理。所以减少了大内存Buffer在新生代中,造成JVM新生代进行频繁的垃圾回收。
- 在某些场景下可以提升程序I/O操纵的性能。少去了将数据从堆内内存拷贝到堆外内存的步骤。
DirectByteBuffer是Java用于实现堆外内存的一个重要类,我们可以通过该类实现堆外内存的创建、使用和销毁。
DirectByteBuffer该类本身还是位于Java内存模型的堆中。堆内内存是JVM可以直接管控、操纵。 而DirectByteBuffer中的unsafe.allocateMemory(size);是个一个native方法,这个方法分配的是堆外内存,通过C的malloc来进行分配的。分配的内存是系统本地的内存,并不在Java的内存中,也不属于JVM管控范围,所以在DirectByteBuffer一定会存在某种方式来操纵堆外内存。 下面我们就来分析DirectByteBuffer的创建过程和回收过程。
2. 源码分析 DirectByteBuffer内存分配和回收
2.1 DirectByteBuffer内存分配
在DirectByteBuffer的父类Buffer中有个address属性,address只会被Directbuffer给使用到。之所以将address属性升级放在Buffer中,是为了在JNI调用GetDirectBufferAddress时提升它调用的速率。
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
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;
}
unsafe.allocateMemory(size);分配完堆外内存后就会返回分配的堆外内存基地址,并将这个地址赋值给了address属性。这样我们后面通过JNI对这个堆外内存操作时都是通过这个address来实现的了。
2.2 DirectByteBuffer回收
前面我们说到了DirectByteBuffer是堆外内存,它不由JVM 垃圾回收机制控制。所以JVM 垃圾回收不了DirectByteBuffer分配的内存,那么DirectByteBuffer是如何进行回收的呢?
DirectBuffer内存回收主要有两种方式,一种是通过System.gc来回收,另一种是通过构造函数里创建的Cleaner对象来回收。
System.gc回收
在DirectBuffer的构造函数中,用到了Bit.reserveMemory这个方法,该方法如下
static void reserveMemory(long size, int cap) {
······
if (tryReserveMemory(size, cap)) {
return;
}
······
while (jlra.tryHandlePendingReference()) {
if (tryReserveMemory(size, cap)) {
return;
}
}
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;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
if (!jlra.tryHandlePendingReference()) {
try {
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();
}
}
}
reserveMemory方法首先尝试分配内存,如果分配成功的话,那么就直接退出。如果分配失败那么就通过调用tryHandlePendingReference来尝试清理堆外内存(最终调用的是Cleaner的clean方法,其实就是unsafe.freeMemory然后释放内存),清理完内存之后再尝试分配内存。如果还是失败,调用System.gc()来触发一次FullGC进行回收(前提是没有加-XX:-+DisableExplicitGC参数)。
Cleaner对象回收
另个触发堆外内存回收的时机是通过Cleaner对象的clean方法进行回收。在每次新建一个DirectBuffer对象的时候,会同时创建一个Cleaner对象,同一个进程创建的所有的DirectBuffer对象跟Cleaner对象的个数是一样的,并且所有的Cleaner对象会组成一个链表,前后相连。
3. 源码分析 FilChannelImpl的read调用
下面我们来看一下为什么在某些I/O操作下,使用DirectBuffer对比HeapByteBuffer的性能会更好。在FileChannelImpl的read方法中进行read操作的时候,会调用IOUtil.read(this.fd, var1, -1L, this.nd) 的read 方法。
public class FileChannelImpl{
public int read(ByteBuffer var1) throws IOException {
this.ensureOpen();
if (!this.readable) {
throw new NonReadableChannelException();
} else {
Object var2 = this.positionLock;
synchronized(this.positionLock) {
int var3 = 0;
int var4 = -1;
byte var5;
try {
this.begin();
var4 = this.threads.add();
if (this.isOpen()) {
do {
var3 = IOUtil.read(this.fd, var1, -1L, this.nd);
} while(var3 == -3 && this.isOpen());
int var12 = IOStatus.normalize(var3);
return var12;
}
var5 = 0;
} finally {
this.threads.remove(var4);
this.end(var3 > 0);
assert IOStatus.check(var3);
}
return var5;
}
}
}
}
接下来我们继续跟踪源码,找到为什么I/O操作下,使用DirectBuffer对比HeapByteBuffer的性能会更好。
public class IOUtil{
static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if (var1.isReadOnly()) {
throw new IllegalArgumentException("Read-only buffer");
} else if (var1 instanceof DirectBuffer) {
return readIntoNativeBuffer(var0, var1, var2, var4);
} else {
ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());
int var7;
try {
int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
var5.flip();
if (var6 > 0) {
var1.put(var5);
}
var7 = var6;
} finally {
Util.offerFirstTemporaryDirectBuffer(var5);
}
return var7;
}
}
}
从这段代码中,我们可以看出来如果FileDescriptor操作的ByteBuffer是堆外内存的话,我们直接调用readIntoNativeBuffer将文件从内核缓冲区直接读取到DirectBuffer中。但是如果我们操作的是HeapBytebuffer的话,我们首先构造一个新的DirectBuffer,然后调用readIntoNativeBuffer将文件从内核缓冲区直接读取到DirectBuffer中,最后将这个DirectBuffer复制到HeapByteBuffer之中。所以说,如果我们直接使用DirectBuffer,在进行数据读取的话,就不用将构造出来新的DirectBuffer复制到HeapByteBuffer之中。所以对于大文件来说,我们使用DirectBuffer性能更好。
Q:小伙伴可能回文,那为什么操作系统不直接访问Java堆内的内存区域了?
A:这是因为JNI方法访问的内存区域是一个已经确定了的内存区域地址,那么该内存地址指向的是Java堆内内存的话,那么如果在操作系统正在访问这个内存地址的时候,Java在这个时候进行了GC操作,而GC操作会涉及到数据的移动操作[GC经常会进行先标志-整理算法的操作。即,将可回收的空间做标志,然后清空标志位置的内存,然后会进行一个整理,整理就会涉及到对象的移动,移动的目的是为了腾出一块更加完整、连续的内存空间,以容纳更大的新对象],数据的移动会使JNI调用的数据错乱。所以JNI调用的内存是不能进行GC操作的。
Q:如上面所说,JNI调用的内存是不能进行GC操作的,那该如何解决了?
A:①堆内内存与堆外内存之间数据拷贝的方式(并且在将堆内内存拷贝到堆外内存的过程JVM会保证不会进行GC操作):比如我们要完成一个从文件中读数据到堆内内存的操作,即FileChannelImpl.read(HeapByteBuffer)。这里实际上File I/O会将数据读到堆外内存中,然后堆外内存再讲数据拷贝到堆内内存,这样我们就读到了文件中的内存。