Java NIO分析: 零拷贝技术以及NIO的支持

Java NIO分析(11): 零拷贝技术以及NIO的支持

 2018-07-24

 JAVA

 NIO LINUX

前面已经讲了Selector,SocketChannelDirectBuffer, 这些是NIO网络编程中最核心的组件
接下来我们会再讲几点非核心的优化(不代表不重要, 只是API不占NIO设计的大头):

  • 文件传输(File Transfer): 文件内容直接发送到网卡, 或者从网卡直接读到文件里
  • 内存映射文件(Memory-mapped Files): 将文件的一块映射到内存

这两项本质上都基于零拷贝(zero copy)技术。

1. 零拷贝?

1.1 简介

零拷贝(Zero-Copy)是指计算机在执行操作时,CPU不需要先将数据从某处内存复制到一个特定区域,从而节省CPU时钟周期和内存带宽 —-维基百科

拿常用的网络文件传输过程举个栗子:

  1. DMAread读取磁盘文件内容到内核缓冲区
  2. copy内核缓冲区数据到应用进程缓冲区
  3. 从应用进程缓冲区copy数据到socket缓冲区
  4. DMA copy给网卡发送

画个图:

Java NIO分析: 零拷贝技术以及NIO的支持_第1张图片

可以清楚得看到,有2次copy是没必要的, 就是上面的2和3,还会平白增加2次用户态和内核态上下文切换, 在高并发场景下,这些会很致命。

1.2 Zero-Copy分类

解决上面这个问题有几个思路

  1. 直接I/O: 应用进程直接操作硬件存储
  2. 避免在用户空间和内核空间地址之间拷贝数据
  3. 优化页缓存应用进程缓冲区的传输

1和2都是避免应用程序地址空间和内核地址空间两者之间的缓冲区拷贝, 3是从传输的角度优化,因为DMA进行数据传输基本不需要CPU参与,但是用户地址空间的缓冲区和内核的页缓存传输没有类似DMA的手段, 3就是从这个角度优化。

1.3 Linux的解决方案

直接I/O传输优化都涉及到硬件层面我们暂且不讲,主要讲避免上下文切换和数据来回拷贝这个思路, Linux内核提供了

  • mmap: 内存映射文件, 即将文件的一段直接映射到内存,内核和应用进程共用一块内存地址,这样就不需要拷贝了
  • sendfile: 从上图的内核缓冲区直接复制到socket缓冲区, 不需要向应用进程缓冲区拷贝

如图,mmap将buffer映射到了用户空间,操作的是同一块内存,也不需要切换了, 但是mmap有个缺点就是, 如果其他进程在向这个文件write, 那么会被认为是一个错误的存储访问

Java NIO分析: 零拷贝技术以及NIO的支持_第2张图片

sendfile没有映射, 保留了mmap不需要来回拷贝优点,适用于应用进程不需要对读取的数据做任何处理的场景,如图:

Java NIO分析: 零拷贝技术以及NIO的支持_第3张图片

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.

在高并发场景下,这点提升是很关键的,著名框架NettyKafka都大量使用了零拷贝的API, 是其高性能的原因之一。

你可能感兴趣的:(Java NIO分析: 零拷贝技术以及NIO的支持)