前面已经讲了Selector
,SocketChannel
和DirectBuffer
, 这些是NIO网络编程中最核心的组件
接下来我们会再讲几点非核心的优化(不代表不重要, 只是API不占NIO设计的大头):
- 文件传输(File Transfer): 文件内容直接发送到网卡, 或者从网卡直接读到文件里
- 内存映射文件(Memory-mapped Files): 将文件的一块映射到内存
这两项本质上都基于零拷贝(zero copy)
技术。
1. 零拷贝?
1.1 简介
零拷贝(Zero-Copy)是指计算机在执行操作时,CPU不需要先将数据从某处内存复制到一个特定区域,从而节省CPU时钟周期和内存带宽 —-维基百科
拿常用的网络文件传输过程举个栗子:
- DMA
read
读取磁盘文件内容到内核缓冲区
- copy内核缓冲区数据到应用进程缓冲区
- 从应用进程缓冲区copy数据到socket缓冲区
DMA copy
给网卡发送
画个图:
可以清楚得看到,有2次copy是没必要的, 就是上面的2和3,还会平白增加2次用户态和内核态上下文切换, 在高并发场景下,这些会很致命。
1.2 Zero-Copy分类
解决上面这个问题有几个思路
- 直接I/O: 应用进程直接操作硬件存储
- 避免在用户空间和内核空间地址之间拷贝数据
- 优化
页缓存
和应用进程缓冲区
的传输
1和2都是避免应用程序地址空间和内核地址空间两者之间的缓冲区拷贝, 3是从传输的角度优化,因为DMA进行数据传输基本不需要CPU参与,但是用户地址空间的缓冲区和内核的页缓存
传输没有类似DMA的手段, 3就是从这个角度优化。
1.3 Linux的解决方案
直接I/O
和传输优化
都涉及到硬件层面我们暂且不讲,主要讲避免上下文切换和数据来回拷贝
这个思路, Linux内核提供了
- mmap: 内存映射文件, 即将文件的一段直接映射到内存,内核和应用进程共用一块内存地址,这样就不需要拷贝了
- sendfile: 从上图的内核缓冲区直接复制到socket缓冲区, 不需要向应用进程缓冲区拷贝
如图,mmap
将buffer映射到了用户空间,操作的是同一块内存,也不需要切换了, 但是mmap
有个缺点就是, 如果其他进程在向这个文件write
, 那么会被认为是一个错误的存储访问
而sendfile
则没有映射, 保留了mmap
的不需要来回拷贝优点,适用于应用进程不需要对读取的数据做任何处理的场景,如图:
2.6以后还提供了splice
, splice可以在内核态将数据整块的从A复制到B地址。
2. NIO中的零拷贝
NIO中通过FileChannel
来提供Zero-Copy
的支持,分别是
- FileChannel.map: 将文件的一部分映射到内存
- FileChannel.transferTo: 将本Channel的文件字节转移到指定的可写Channel
FileChannel.map
的基本用法如下:
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
|
/**
* 测试FileChannel的用法
*
* @author sound2gd
*
*/
public class FileChannnelTest {
public static void main(String[] args) {
File file = new File("src/com/cris/chapter15/f6/FileChannnelTest.java");
try (
// FileInputStream打开的FileChannel只能读取
FileChannel fc = new FileInputStream(file).getChannel();
// FileOutputStream打开的FileChannel只能写入
FileChannel fo = new FileOutputStream("src/com/cris/chapter15/f6/a.txt").getChannel();) {
// 将FileChannel的数据全部映射成ByteBuffer
MappedByteBuffer mbb = fc.map(MapMode.READ_ONLY, 0, file.length());
// 使用UTF-8的字符集来创建解码器
Charset charset = Charset.forName("UTF-8");
// 直接将buffer里的数据全部输出
fo.write(mbb);
mbb.clear();
// 创建解码器
CharsetDecoder decoder = charset.newDecoder();
// 使用解码器将byteBuffer转换为CharBuffer
CharBuffer decode = decoder.decode(mbb);
System.out.println(decode);
} catch (Exception e) {
}
}
}
|
这就是一个基本的例子,用于文件复制,可以看到fo.write(mbb)
的时候,是将mbb Buffer
的数据输出到另一个文件的,看起来就像是
在内存中,而不是在文件里, 这就是内存映射文件.
我们来看看map的实现
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
|
public MappedByteBuffer map(MapMode mode, long position, long size)
...省略非关键代码
try {
// 调用map0这个native方法
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted memory
// so force gc and re-attempt map
// gc下防止内存不够
System.gc();
try {
// 等待gc结束
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);
}
}
...
}
private native long map0(int prot, long position, long length)
throws IOException;
|
打开FileChannelImpl.c
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
|
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len)
{
void *mapAddress = 0;
jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
jint fd = fdval(env, fdo);
int protections = 0;
int flags = 0;
if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
protections = PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_PRIVATE;
}
// 所以还是使用的mmap这个API
mapAddress = mmap64(
0, /* Let OS decide location */
len, /* Number of bytes to map */
protections, /* File permissions */
flags, /* Changes are shared */
fd, /* File descriptor of mapped file */
off); /* Offset into file */
if (mapAddress == MAP_FAILED) {
if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "Map failed");
return IOS_THROWN;
}
return handle(env, -1, "Map failed");
}
return ((jlong) (unsigned long) mapAddress);
}
|
可以看到,还是使用的我们mmap
的api, 了解一些底层知识还是有必要的, JVM很多东西都是对底层的一层封装.
另一个API transferTo
同理,最后调用的是transferTo0
方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
jint srcFD,
jlong position, jlong count,
jint dstFD)
{
off64_t offset = (off64_t)position;
// 调用sendfile方法
jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
if (n < 0) {
if (errno == EAGAIN)
return IOS_UNAVAILABLE;
if ((errno == EINVAL) && ((ssize_t)count >= 0))
return IOS_UNSUPPORTED_CASE;
if (errno == EINTR) {
return IOS_INTERRUPTED;
}
JNU_ThrowIOExceptionWithLastError(env, "Transfer failed");
return IOS_THROWN;
}
return n;
}
|
可以看到封装的是sendfile
这个方法,这里看的是jvm在linux系统的的实现。
3. 总结
本文主要介绍了Linux中Zero-Copy零拷贝
的概念,分类和解决方案。
同时介绍了NIO对Zero-Copy
的支持, 分别是FileChannel.map
以及FileChannel.transferTo
.
在高并发场景下,这点提升是很关键的,著名框架Netty
, Kafka
都大量使用了零拷贝的API, 是其高性能的原因之一。
参考资料
- Zero Copy I: User-Mode Perspective
- wikipedia: zero copy
- Linux 中的零拷贝技术,第 1 部分