0%

NIO与零拷贝

零拷贝是网络编程的关键,很多性能优化都离不开它。

  • 零拷贝可以减少用户态与内核态的上下文切换
  • 零拷贝可以减少内存拷贝的次数

前置知识

DMA技术

在没有 DMA 技术前,IO 的过程是这样的:

  1. 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回;
  2. CPU 在接收到指令以后对磁盘发起 IO 请求,将磁盘数据先放入磁盘控制器缓冲区;
  3. 数据准备完成以后,磁盘向 CPU 发起 IO 中断;
  4. CPU 收到 IO 中断以后将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区;
  5. 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟;

整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) ,简单理解就是,在进行 IO 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务

使用 DMA 控制器进行数据传输的过程是这样的:

  1. 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。
  2. CPU 在接收到指令以后对 DMA 磁盘控制器发起调度指令。
  3. DMA 磁盘控制器对磁盘发起 IO 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不参与此过程。
  4. 数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区。
  5. DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 负责将数据从内核缓冲区拷贝到用户缓冲区。
  6. 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。

整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

PageCache

PageCache,也叫内核缓冲区。读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把读写磁盘替换成读写内存,但内存空间比较小,所以 PageCache 用来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存

读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。

预读

考虑到磁盘IO是非常高昂的操作,操作系统做了预读的优化,当一次 IO 时,不光把当前磁盘地址的数据,而是把相邻的数据也都读取到内存缓冲区内,因为当计算机访问一个地址的数据的时候,与其相邻的数据也会很快被访问到。

后写

将很多小的逻辑写操作合并起来组成一个大的物理写操作。

预读和后写大大提高读写磁盘的性能。

缓存IO和直接IO

  • 缓存IO:数据从磁盘先通过DMA copy到内核空间,再从内核空间通过cpu copy到用户空间
  • 直接IO:数据从磁盘通过DMA copy到用户空间

绕开 PageCache 的 IO 叫直接 IO,使用 PageCache 的 IO 则叫缓存 IO。

通常,对于磁盘,异步 IO 只支持直接 IO

缓存 IO 又被称为标准 IO,大多数文件系统的默认IO操作都是缓存IO。这样可以直接利用 PageCache 的优化。

缓存 IO 的缺点:

  • 不适用大文件传输,大文件传输会导致热点小文件无法利用到 PageCache;

针对大文件的传输的方式,应该使用 异步 IO + 直接 IO 来替代零拷贝技术。

直接IO 的应用场景:

  • 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 IO,默认是不开启;
  • 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致热点文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 IO。

传输文件的时候,我们可以根据文件的大小来使用不同的方式:

  • 传输大文件的时候,使用 异步 IO + 直接 IO(直接IO也可认为是一种零拷贝技术);
  • 传输小文件的时候,则使用 零拷贝技术

传统IO方式

在 Linux 系统中,传统的访问方式是通过 write() 和 read() 两个系统调用实现的,通过 read() 函数读取文件到到缓存区中,然后通过 write() 方法把缓存中的数据输出到网络端口。如下图所示:

这两个系统调用,会发生 4 次用户态与内核态的上下文切换,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:

  1. 把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  2. 把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  3. 把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  4. 把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。

要想提高文件传输的性能,就需要减少 用户态与内核态的上下文切换 和 内存拷贝 的次数

一次系统调用必然会发生 2 次上下文切换,所以要想减少上下文切换次数,就要减少系统调用次数

传统的文件传输方式会经历 4 次数据拷贝,从内核的读缓冲区拷贝到用户的缓冲区后,并没有对数据进行再加工,就从用户的缓冲区里又拷贝到 socket 的缓冲区里,因此将数据拷贝到用户的缓冲区是没有必要的,可以直接从内核的读缓冲区拷贝到socket的缓冲区

零拷贝方式

零拷贝技术实现的方式通常有:

  • mmap + write
  • sendfile
  • splice

mmap + write

在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

mmap() 系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。如下图

  1. 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核会共享这个缓冲区;
  2. 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
  3. 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。

mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。

mmap 的拷贝虽然减少了 1 次拷贝,提升了效率,但也存在一些隐藏的问题。当 mmap 一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,服务器可能因此被终止

sendfile

在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:

1
2
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销

其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

sendfile 存在的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。

sendfile + SG-DMA

上面还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术,我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:

1
ethtool -k eth0 | grep scatter-gather

于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

  1. 通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  2. 缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

所以,这个过程之中,只进行了 2 次数据拷贝,如下图:

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

sendfile + SG-DMA 的方式同样存在用户程序不能对数据进行修改的问题而且本身需要硬件的支持,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。

splice

Linux 在 2.6.17 版本引入 splice 系统调用,splice 去掉 sendfile 的使用范围限制,可以用于任意两个文件描述符中传输数据。

但是 splice 也有局限,它使用了 Linux 的管道缓冲机制,所以,它的两个文件描述符参数中至少有一个必须是管道设备。

基于 splice 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 splice() 函数向内核发起系统调用,上下文从用户态切换为内核态。
  2. DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区(read buffer)。
  3. CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline)。
  4. DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
  5. 上下文从内核态切换回用户态,splice 系统调用执行返回。

小结

CPU拷贝 DMA拷贝 系统调用 上下文切换 是否可修改数据
read + write 2 2 read + write 4
mmap + wirte 1 2 mmap + write 4
sendfile 1 2 sendfile 2
sendfile + SG-DMA 0 2 sendfile 2
splice 0 2 splice 2
directio + aio 0 2 read + write 4
  • 大文件传输,使用 directio + aio
  • 小文件传输,需要修改数据使用 mmap + wirte
  • 小文件传输,无需修改数据使用 sendfile
  • 小文件传输,无需修改数据,且不支持 SG-DMA,有一个文件描述符是管道设备,使用 splice

NIO零拷贝实现

NIO 的核心是 Channel,Buffer 和 Selector,它们的关系如下:

FileChannel

FileChannel 是NIO中一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的。

FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它们是 NIO 基于 sendfile 这种零拷贝方式的一种实现

1
2
3
4
// 把文件里面的源数据写入一个 WritableByteChannel 的目的通道
public abstract long transferTo(long position, long count, WritableByteChannel target);
// 把一个源通道 ReadableByteChannel 中的数据读取到当前 FileChannel 的文件里面
public abstract long transferFrom(ReadableByteChannel src, long position, long count);

transferTo使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void transferTo() throws Exception {
String sourceFile = "C:\\Users\\Administrator\\Desktop\\source.txt";
String targetFile = "C:\\Users\\Administrator\\Desktop\\target.txt";
try (FileChannel sourceCh = new RandomAccessFile(sourceFile, "rw").getChannel();
FileChannel targetCh = new RandomAccessFile(targetFile, "rw").getChannel()) {
long position = 0L;
long offset = sourceCh.size();
sourceCh.transferTo(position, offset, targetCh);
targetCh.force(true);
}
}

transferFrom使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void transferFrom() throws Exception {
String sourceFile = "C:\\Users\\Administrator\\Desktop\\source.txt";
String targetFile = "C:\\Users\\Administrator\\Desktop\\target.txt";
try (FileChannel sourceCh = new RandomAccessFile(sourceFile, "rw").getChannel();
FileChannel targetCh = new RandomAccessFile(targetFile, "rw").getChannel()) {
long position = 0L;
long offset = sourceCh.size();
targetCh.transferFrom(sourceCh, position, offset);
targetCh.force(true);
}
}

force方法:将通道里尚未写入磁盘的数据强制写到磁盘上。

出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入到FileChannel里的数据一定会即时写到磁盘上。要保证这一点,需要调用force()方法,例如:targetCh.force(true)。

transferTo源码实现(sun.nio.ch.FileChannelImpl类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public long transferTo(long position, long count,
WritableByteChannel target)
throws IOException{
ensureOpen();
if (!target.isOpen())
throw new ClosedChannelException();
if (!readable)
throw new NonReadableChannelException();
if (target instanceof FileChannelImpl &&
!((FileChannelImpl)target).writable)
throw new NonWritableChannelException();
if ((position < 0) || (count < 0))
throw new IllegalArgumentException();
long sz = size();
if (position > sz)
return 0;
int icount = (int)Math.min(count, Integer.MAX_VALUE);
if ((sz - position) < icount)
icount = (int)(sz - position);

long n;

// Attempt a direct transfer, if the kernel supports it
if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;
// Attempt a mapped transfer, but only to trusted channel types
if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;
// Slow path for untrusted targets
return transferToArbitraryChannel(position, icount, target);
}
  1. 首先执行 transferToDirectly 方法,以 sendfile 的零拷贝方式尝试数据拷贝。
  2. 如果系统内核不支持 sendfile,进一步执行 transferToTrustedChannel() 方法,以 mmap 的零拷贝方式进行内存映射,这种情况下目的通道必须是 FileChannelImpl或者 SelChImpl 类型。
  3. 如果都失败了,则执行 transferToArbitraryChannel 方法,基于传统的 I/O 方式完成读写。

MappedByteBuffer

MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的一种实现。它继承自 ByteBuffer,通过 FileChannel 的 map 方法创建,可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。

1
public abstract MappedByteBuffer map(MapMode mode, long position, long size)
  • mode:限定内存映射区域(MappedByteBuffer)对内存映像文件的访问模式,包括只可读(READ_ONLY)、可读可写(READ_WRITE)和写时拷贝(PRIVATE)三种模式。
  • position:文件映射的起始地址,对应内存映射区域(MappedByteBuffer)的首地址。
  • size:文件映射的字节长度,从 position 往后的字节数,对应内存映射区域(MappedByteBuffer)的大小。

MappedByteBuffer 相比 ByteBuffer 新增了三个重要方法:

  • force():对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到本地文件。
  • load():将缓冲区的内容载入物理内存中,并返回这个缓冲区的引用。
  • isLoaded():如果缓冲区的内容在物理内存中,则返回 true,否则返回 false。

MappedByteBuffer 使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void writeToFileByMappedByteBuffer() throws Exception {
String sourceFile = "C:\\Users\\Administrator\\Desktop\\source.txt";
String targetFile = "C:\\Users\\Administrator\\Desktop\\target.txt";
try (FileChannel sourceCh = new RandomAccessFile(sourceFile, "rw").getChannel();
FileChannel targetCh = new RandomAccessFile(targetFile, "rw").getChannel()) {
ByteBuffer sourceBuffer = ByteBuffer.allocate((int) sourceCh.size());
sourceCh.read(sourceBuffer);
byte[] bytes = sourceBuffer.array();
MappedByteBuffer mappedByteBuffer = targetCh.map(FileChannel.MapMode.READ_WRITE,
0, bytes.length);
mappedByteBuffer.put(bytes);
mappedByteBuffer.force();
// 释放内存
Cleaner cl = ((DirectBuffer)mappedByteBuffer).cleaner();
if (cl != null)
cl.clean();
}
}

map 方法源码实现(sun.nio.ch.FileChannelImpl类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException{
// 由于代码较长,只保留核心
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
// If no exception was thrown from map0, the address is valid
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted memory
// so force gc and re-attempt map
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}

// On Windows, and potentially other platforms, we need an open
// file descriptor for some mapping operations.
FileDescriptor mfd;
try {
mfd = nd.duplicateForMapping(fd);
} catch (IOException ioe) {
unmap0(addr, mapSize);
throw ioe;
}

assert (IOStatus.checkAll(addr));
assert (addr % allocationGranularity == 0);
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize,
addr + pagePosition,
mfd,
um);
} else {
return Util.newMappedByteBuffer(isize,
addr + pagePosition,
mfd,
um);
}
} finally {
threads.remove(ti);
end(IOStatus.checkAll(addr));
}
}
  1. map() 方法通过本地方法 map0() 为文件分配一块虚拟内存,作为它的内存映射区域,然后返回这块内存映射区域的起始地址。
  2. 如果第一次文件映射导致 OOM,则触发垃圾回收,休眠 100ms 后再尝试映射,如果失败则抛出异常。
  3. 通过 Util 类使用反射创建一个 DirectByteBuffer 实例,DirectByteBuffer 是 MappedByteBuffer 的子类。

DirectByteBuffer

DirectByteBuffer 的对象引用位于 Java 内存模型的堆里面,但内部的字节缓冲区位在于堆外的直接内存。

DirectByteBuffer 初始化时通过 Unsafe 的本地方法 allocateMemory() 进行内存分配,底层调用的是操作系统的 malloc() 函数,除此之外,初始化时还会创建一个 Deallocator 线程,并通过 Cleaner 机制来调用 freeMemory() 方法来对直接内存进行回收操作,freeMemory() 底层调用的是操作系统的 free() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 关键构造方法
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;
}

在 DirectByteBuffer 中,首先向 Bits 类申请额度,Bits类有一个全局的 totalCapacity 变量,记录着全部DirectByteBuffer 的总大小,每次申请,都先看看是否超限,可用 -XX:MaxDirectMemorySize 重新设定。如果不指定,该参数的默认值为Xmx的值减去1个Survior区的值。 如设置启动参数-Xmx20M -Xmn10M -XX:SurvivorRatio=8,那么申请20M-1M=19M的DirectMemory。

如果已经超限,会主动执行Sytem.gc(),期待能主动回收一点堆外内存。System.gc() 会触发一个full gc,当然前提是没有显示的设置 -XX:+DisableExplicitGC 来禁用显式GC。不过,调用 System.gc() 并不能够保证 full gc 马上就能被执行。然后休眠一百毫秒,看看totalCapacity降下来没有,如果内存还是不足,就抛出OOM异常。如果额度被批准,就调用大名鼎鼎的 sun.misc.Unsafe 去分配内存,返回内存基地址。

最后,创建一个Cleaner,并把代表清理动作的Deallocator类绑定 – 降低 Bits 里的totalCapacity,并调用 Unsafe free() 去释放内存。

内存回收策略

Cleaner类继承了 java.lang.ref.Reference,GC线程会通过设置 Reference 的内部变量(pending变量为链表头部节点,discovered变量为下一个链表节点),将可被回收的不可达的Reference对象以链表的方式组织起来Reference的内部守护线程从链表的头部(head)消费数据,如果消费到的Reference对象同时也是Cleaner类型,线程会调用clean()方法。

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

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

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

1
2
ByteBuffer buf = ByteBuffer.allocateDirect(1);
((DirectBuffer) byteBuffer).cleaner().clean();

读写流程

假设从网络中读入数据,再发送数据,采用 Non-direct ByteBuffer 的简化流程是这样的:

1
网络 –> 临时的DirectByteBuffer –> 应用 Non-direct ByteBuffer –> 临时的Direct ByteBuffer –> 网络

采用 Direct ByteBuffer 的流程是这样的:

1
网络 –> 应用 Direct ByteBuffer –> 网络

第二种方式是直接在堆外分配一个内存来存储数据, 程序通过 JNI 直接将数据读/写到堆外内存中。因为数据直接写入到了堆外内存中,所以这种方式就不会再在 JVM 管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。

从上面的流程可以发现,执行 read() 系统调用之后,无论是采取DirectByteBuffer还是Non-direct ByteBuffer,数据都是从内核缓冲区拷贝到DirectByteBuffer。

那么采用 Non-direct ByteBuffer 时,为什么不直接将数据拷贝到Non-direct ByteBuffer中呢?

原因是执行native方法的线程,被认为是处于SafePoint,所以会发生 NIO,如果不复制到 DirectByteBuffer,就会有 GC 发生重排列对象内存的情况。

传统 BIO 是面向 Stream 的,底层实现可以理解为写入的是 byte 数组,调用 native 方法写入 IO,传的参数是这个数组,就算GC改变了内存地址,但是拿这个数组的引用照样能找到最新的地址,对应的方法时是:FileOutputStream.write

1
private native void writeBytes(byte b[], int off, int len, boolean append)

但是NIO,为了提升效率,传的是内存地址,省去了一次间接应用,但是就必须用 DirectByteBuffer 防止内存地址改变,对应的是 NativeDispatcher.write

1
abstract int write(FileDescriptor fd, long address, int len)

那为何内存地址会改变呢?GC会回收无用对象,同时还会进行碎片整理,移动对象在内存中的位置,来减少内存碎片。DirectByteBuffer不受GC控制。如果不用 DirectByteBuffer 而是用 HeapByteBuffer,如果在调用系统调用时,发生了GC,导致 HeapByteBuffer 内存位置发生了变化,但是内核态并不能感知到这个变化导致系统调用读取或者写入错误的数据。所以一定要通过不受GC影响的DirectByteBuffer来进行IO系统调用

除了构造和析构临时 Direct ByteBuffer 的时间外,起码还能节约两次内存拷贝的时间。那么是否在任何情况下都采用Direct Buffer呢?对于大部分应用而言,两次内存拷贝的时间几乎可以忽略不计,而构造和析构DirectBuffer的时间却相对较长。

所以,一般的框架里面,会在启动时申请一大块DirectByteBuffer,然后自己做内存管理。

Netty noCleaner策略

带有Cleaner对象的 DirectByteBuffer 在初始化时:

  1. 只有在DirectByteBuffer(int cap)构造方法中才会初始化Cleaner对象,方法中检查当前内存是否超过允许的最大堆外内存(可由-XX:MaxDirectMemorySize配置)
  2. 如果超出,则会先尝试将不可达的Reference对象加入Reference链表中,依赖Reference的内部守护线程触发可以被回收DirectByteBuffer关联的Cleaner的run()方法
  3. 如果内存还是不足, 则执行 System.gc(),触发full gc,来回收堆内存中的DirectByteBuffer对象来触发堆外内存回收,如果还是超过限制,则抛出java.lang.OutOfMemoryError(代码位java.nio.Bits#reserveMemory()方法)

Netty 在4.1引入可以 noCleaner 策略:创建不带Cleaner的DirectByteBuffer对象,这样做的好处是绕开带Cleaner的DirectByteBuffer执行构造方法和执行Cleaner的clean()方法中一些额外开销,当堆外内存不够的时候,不会触发 System.gc(),提高性能。

hasCleaner 的 DirectByteBuffer 和 noCleaner的 DirectByteBuffer 主要区别如下:

  • 创建方式不同
    • 由反射调用 private DirectByteBuffer(long addr, int cap)创建;
    • 由 ByteBuffer.allocateDirect() -> new DirectByteBuffer(int cap)创建;
  • 释放内存的方式不同
    • noCleaner对象:使用 UnSafe.freeMemory(address);
    • hasCleaner对象:使用 DirectByteBuffer 的 Cleaner 的 clean() 方法;

Netty在启动时需要判断检查当前环境、环境配置参数是否允许noCleaner策略(具体逻辑位于PlatformDependent的static代码块,也可以调用PlatformDependent.useDirectBufferNoCleaner()方法查看当前Netty程序是否使用noClaner策略),例如运行在Android下时,是没有Unsafe类的,不允许使用noCleaner策略,如果不允许,则使用hasCleaner策略。

查看DirectBuffer使用情况

  1. 进程内获取:

    1
    2
    3
    4
    5
    6
    MBeanServer mbs = ManagementFactory. getPlatformMBeanServer() ;
    ObjectName objectName = new ObjectName("java.nio:type=BufferPool,name=direct" ) ;
    MBeanInfo info = mbs.getMBeanInfo(objectName) ;
    for(MBeanAttributeInfo i : info.getAttributes()) {
    System.out .println(i.getName() + ":" + mbs.getAttribute(objectName , i.getName()));
    }
  2. 远程进程

    JMX获取 如果目标机器没有启动JMX,那么添加jvm参数:

    1
    2
    3
    -Dcom.sun.management.jmxremote.port=9999 
    -Dcom.sun.management.jmxremote.authenticate=false
    -Dcom.sun.management.jmxremotAe.ssl=false

    重启进程,通过 JConsole 等工具查看

Netty 零拷贝实现

Netty中零拷贝机制主要体现在以下方面:

  1. DefaultFileRegion 类对 java.nio.channels.FileChannel 的 tranferTo() 方法进行包装,在文件传输时可以将文件缓冲区的数据直接发送到目的通道(Channel)
  2. ByteBuf 可以通过 wrap 操作把字节数组、ByteBuf、ByteBuffer 包装成一个 ByteBuf 对象,使用堆外内存。
  3. ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免内存的拷贝。
  4. Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。

其中第 1 条属于操作系统层面的零拷贝操作,后面 3 条只能算用户层面的数据操作优化。

坚持原创技术分享,您的支持将鼓励我继续创作!