我们知道,I/O 操作分为磁盘 I/O 操作和网络 I/O 操作。前者是从磁盘中读取数据源输入到内存中,之后将读取的信息持久化输出在物理磁盘上;后者是从网络中读取信息输入到内存,最终将信息输出到网络中。
在 NIO 中我们接触了 ByteBuffer,通过 ByteBuffer 可以分配直接内存以提高数据读写的性能,下面我们就来分析下 DirectByteBuffer 在内存复制方面所做的优化措施?
传统 IO 的内存复制过程
在传统 I/O 中,我们可以通过 InputStream 从源数据中读取数据流输入到缓冲区里,通过 OutputStream 将数据输出到外部设备(磁盘、网络)。下图展示了输入操作在操作系统中的具体流程:
- JVM 会发出 read() 系统调用,并通过 read 系统调用向内核发起读请求;
- 内核向硬件发送读指令,并等待读就绪;
- 内核把将要读取的数据复制到指向的内核缓存中;
- 操作系统内核将数据复制到用户空间缓冲区,然后 read 系统调用返回。
在这个过程中,数据先从外部设备复制到内核空间,再从内核空间复制到用户空间,这个过程中发生了两次内存复制操作。这种操作会导致不必要的数据拷贝和上下文切换,从而降低 I/O 的性能。
NIO 中的内存复制过程
在 NIO 中,Channel 代表了一个与设备的连接的抽象,我们可以通过 Buffer 对 Channel 进行数据读写。在传统 IO 中,数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备。在 Java NIO 的实现中用户空间中又存在一个拷贝,那就是从 Java 堆内存中拷贝到临时的直接内存中,通过临时的直接内存再拷贝到内核内存空间中去,此时的直接内存和堆内存都属于用户空间。
1. 数据复制过程
查看 SocketChannel、FileChannel 等可以进行数据读写的 Channel 的实现类代码,我们会发现 Channel 的 read 和 write 方法的底层实现都依赖 IOUtil 类提供的 read 和 write 方法:
以下是 JDK 源码中 IOUtil.java 类中的 write 方法:
static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd) throws IOException {
if (src instanceof DirectBuffer) {
return writeFromNativeBuffer(fd, src, position, nd);
} else {
// 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();
src.position(pos);
int n = writeFromNativeBuffer(fd, bb, position, nd);
......
} finally {
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
}
我们知道,NIO 中的 Buffer 提供了一个可以直接访问物理内存的类 DirectBuffer。普通 Buffer 分配的是 JVM 的堆内存,而 DirectBuffer 则是直接分配物理内存 (非堆内存)。使用 DirectBuffer 可以将数据直接保存到非堆内存,避免再复制到堆内存中,从而减少了一次数据拷贝,因此可以提升性能。
从代码中也可以看到,如果是 DirectBuffer 则直接操作非堆内存,如果是普通 Buffer 则需要先申请一个临时的直接内存,将普通 Buffer 中的数据写入到临时直接内存中,然后再通过临时直接内存进行系统调用,这个复制过程也对应了我们上图中的三次数据复制过程。
为了测试 HeapByteBuffer 的这个性质,我们可以利用 Visual VM 工具来测试,我们需额外安装 VisualVM-BufferMonitor 插件。测试代码如下:
public static void main(String[] args) throws InterruptedException, IOException {
ByteBuffer heapByteBuffer = ByteBuffer.allocate(1024 * 1024);
Path path = FileSystems.getDefault().getPath("/Users/xulei/test.txt");
FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ,StandardOpenOption.WRITE);
//block on purpose
System.in.read();
fileChannel.write(heapByteBuffer);
new CountDownLatch(1).await();
}
我们可以在 VisualVM-BufferMonitor 插件对应的页面中看到如下结果:
2. 临时直接内存的用处
为什么 Java 需要通过一个临时的非堆内存来复制数据呢?这里其实是在迁就 OpenJDK 里的 HotSpot VM 的一点实现细节。因为 HotSpot VM 里提供的 GC 算法有的采用的是标记-整理的算法,在整理的过程中是需要移动对象的,即所谓的 Compacting GC。
普通的 Buffer 对象分配的是 JVM 堆内存,内部通过 byte[] 来存储数据,当进行数据读写时会把这个 byte[] 通过 JNI 调用传给 native 代码,让 native 代码通过操作系统 API 来读取 Socket 并把数据填充到 Buffer 中去。如果让 native 代码直接访问数组内容的话,就必须要保证 native 代码在访问这个 byte[] 时不能被移动,也就是要被 pin(钉)住,但可惜 HotSpot VM 出于一些取舍而决定不实现单个对象层面的 Object Pinning,要 pin 的话就得暂时禁用 GC,也就等于把整个 Java 堆都给 pin 住,这用起来就不那么顺手了。
所以 Oracle/Sun JDK/Open JDK 的这个地方就用了点绕弯的做法。它假设把 HeapByteBuffer 背后的 byte[] 里的内容拷贝一次是一个时间开销可以接受的操作,同时假设真正的 I/O 可能是一个很慢的操作。
于是它就先把 HeapByteBuffer 内部的 byte[] 的内容拷贝到一个 DirectByteBuffer 背后的 native memory 中去,这个拷贝操作会涉及 Unsafe.copyMemory() 的调用,该方法能够保证在整个拷贝过程中是不会发生 GC 的,因为 Unsafe.copyMemory() 是 HotSpot VM 的一个 intrinsic 方法,中间没有 safepoint 所以 GC 无法在调用过程中发生。
当把堆内存中的数据被拷贝到 native memory 之后就好办了,此时我们就可以去做真正的 I/O 操作,把 DirectByteBuffer 背后的 native memory 地址传给真正做 I/O 的函数。之后就不需要再去访问 Java 对象去读写要做 I/O 的数据了。
DirectByteBuffer
DirectByteBuffer 在网络 IO 和文件 IO 中都有它的身影,继承关系如下图所示,其在文件 IO 中提供了零拷贝的机制(FileChannel 的 transferTo),在网络 IO 中减少了一次堆内存的复制(堆内存 -> 临时直接内存):
Java NIO 中的 DirectByteBuffer 其实是分两部分的:
Java | native
|
DirectByteBuffer | malloc'd
[ address ] -+-> [ data ]
其中 DirectByteBuffer 对象实例本身还是在 Java 堆中的,但它并不实际存储数据,DirectByteBuffer 内部会通过 Unsafe 调用本地方法来申请一块直接内存(底层通过 C 语言的 malloc 函数分配)用来存储数据。这两者之间通过一个 long 类型的 address 字段来记录 DirectByteBuffer 分配的直接内存的地址,后面我们对其进行读写操作都是通过 address 来间接操作直接内存。
在构造方法的最后还创建了一个 Deallocator 实例,并利用这个实例构造了 Cleaner 实例,前面我们说过堆外内存的回收都不受 JVM 管控的,所以这个 Cleaner 就是负责 DirectByteBuffer 的回收工作的。
1. MappedByteBuffer
DirectBuffer 只优化了用户空间内部的拷贝(堆内存 -> 临时直接内存),而之前我们是说优化用户空间和内核空间的拷贝,那 Java NIO 中的 DirectByteBuffer 是否能做到减少用户空间和内核空间的拷贝优化呢?
答案是可以的,DirectByteBuffer 通过 unsafe.allocateMemory(size) 方法分配内存,也就是基于本地类 Unsafe 类调用 native 方法进行内存分配的。而在 NIO 中,还存在另外一个 Buffer 类:MappedByteBuffer。它是 Java 提供给开发人员对文件映射内存访问和操作的统一视图,适用于访问磁盘文件的场景。
MappedByteBuffer 底层通过本地类调用 mmap 系统方法来进行文件内存映射的,Linux 内核中的 mmap 函数可以代替 read、write 的文件 I/O 读写操作,实现用户空间和内核空间共享一个缓存数据。
mmap 会将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址,不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理内存地址。这种方式避免了内核空间与用户空间的数据交换。I/O 复用中的 epoll 函数中就是使用了 mmap 减少了文件描述符的内存拷贝。
常用方法:
FileChannel 提供的 map() 方法
2. DirectByteBuffer 回收
DirectByteBuffer 在构造时申请的是非 JVM 的物理内存,所以创建和销毁的代价很高,并且申请的这块内存并不是直接由 JVM 负责进行垃圾回收的,但 DirectByteBuffer 在构造时会构造一个 Cleaner 对象来跟踪分配的直接内存的回收,当 DirectByteBuffer 这个对象本身被 JVM 回收时,会通过 Java Reference 机制来释放由该对象分配出去的直接内存。
当通过 ByteBuffer 的 allocateDirect() 方法申请堆外内存时会首先调用 Bits.reservedMemory() 方法,在该方法中会显示调用 System.gc() 通知 JVM 进行一次内存回收。
可以看到,DirectByteBuffer 分配出去的直接内存其实也是由 GC 负责回收的,只是虚拟机不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收,除了在分配直接内存时会显示调用 gc 进行堆外内存的垃圾收集外,直接内存的回收就只能等老年代满了后触发 Full GC 时,顺便扫描堆中 DirectByteBuffer 对象是否还有引用,如果没有则会回收通过该 DirectByteBuffer 指向的堆外内存。
如果已经达到堆外内存的最大使用空间,但还没有触发 Full GC 或显示 gc,那就只能抛出 OOM 异常了。如果虚拟机开启了 -XX:+DisableExplicitGC,该参数会禁用掉 System.gc() 方法,使其在分配直接内存时不会进行回收,这增大了 OOM 的风险。
实际上 DirectByteBuffer 内部是通过 Unsafe 分配的直接内存,而 Unsafe 分配的直接内存不会被虚拟机进行回收而必须由自己进行管理(手动释放),那 GC 是如何回收通过 Unsafe 分配的直接内存的呢?
2.1 Cleaner 清理机制
实际上,在 DirectByteBuffer 的构造函数中会构建一个 Cleaner 对象用于跟踪 DirectByteBuffer 对象的垃圾回收以实现当 DirectByteBuffer 被垃圾回收时,分配的堆外内存一起被释放。
首先看一下 Deallocator 这个类,它是 DirectByteBuffer 的一个内部类,这个类实现了 Runnable 接口并在 run() 方法中使用 Unsafe 释放了内存。
那这个 Deallocator 线程是在哪里被调用的呢?我们继续跟踪一下 Cleaner 类:
public class Cleaner extends PhantomReference<Object> {
public static Cleaner create(Object var0, Runnable var1) {
return var1 == null ? null : add(new Cleaner(var0, var1));
}
}
可以看到 Cleaner 继承了 PhantomReference。而 PhantomReference 与其他的 Refenrence 引用不同。它并不会决定对象的生命周期,也不会对对象的垃圾回收产生任何影响。通常 PhantomReference 与引用队列 ReferenceQueue 结合使用,可以实现虚引用关联的对象被垃圾回收时能够进行系统通知、资源清理等功能。即 DirectByteBuffer 对象本身被回收时,会触发对应的 Cleaner 执行清理动作。
当 DirectByteBuffer 被 GC 之前 Cleaner 对象会被放入一个引用队列,JVM 会启动一个低优先级线程循环不断的扫描这个队列中的对象引用,并执行 Cleaner 的 clean 方法来进行相关清理工作。而 Cleaner 在自己的 clean 方法中启动了清理线程,该清理线程会执行 Deallocator 的 run 方法,此时就会通过 Unsafe 释放直接内存来达到回收的目的。
2.2 代码验证
下面我们通过代码来验证下直接内存也受到 GC 的管理:
public static void main(String[] args) throws Exception {
ByteBuffer directBytebuffer = ByteBuffer.allocateDirect(1024 * 1024);
directBytebuffer = null;
System.gc();
new CountDownLatch(1).await();
}
测试结果如下:
除了通过 System.gc() 来显式触发 GC,还可以直接操作对象的 Cleaner 对象手动释放关联的直接内存:
public static void main(String[] args) throws IOException, InterruptedException {
ByteBuffer directBytebuffer = ByteBuffer.allocateDirect(1024 * 1024);
System.in.read();
((DirectBuffer)directBytebuffer).cleaner().clean();
new CountDownLatch(1).await();
}