2022-08-06  阅读(2)
原文作者:Chris 的博客 原文地址:http://sound2gd.wang/

前面已经讲了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给网卡发送

画个图:

202208060003016811.png

可以清楚得看到,有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, 那么会被认为是一个错误的存储访问

202208060003027102.png

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

202208060003036363.png

2.6以后还提供了splice, splice可以在内核态将数据整块的从A复制到B地址。

2. NIO中的零拷贝

NIO中通过FileChannel来提供Zero-Copy的支持,分别是

  • FileChannel.map: 将文件的一部分映射到内存
  • FileChannel.transferTo: 将本Channel的文件字节转移到指定的可写Channel

FileChannel.map的基本用法如下:

/**
 * 测试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的实现

    ...省略非关键代码
        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

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方法:

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, 是其高性能的原因之一。


参考资料

  1. Zero Copy I: User-Mode Perspective
  2. wikipedia: zero copy
  3. Linux 中的零拷贝技术,第 1 部分

Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。

它的内容包括:

  • 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
  • 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
  • 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
  • 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
  • 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
  • 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
  • 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
  • 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw

目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:

想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询

同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。

阅读全文