Java IO系列 | NIO-1.0拾遗、NIO-2.0 & 零拷贝必吹的牛皮

前言

上一篇系列文章中,我们已经对NIO中的 Buffer、Channel、Selector 做了较为系统的梳理,凭借其内容,Android的同学应该能跨过侃大山的门槛了。

在 NIO-1.0 中,仍有两块儿内容值得展开:

  • Scatter/Gather
  • 零拷贝 Zero Copy

而NIO-2.0中的内容,往底层深挖时确实量不少,但Android同学能拿来侃大山的知识相对很少,我们合并成一篇。

JDK中的Scatter&Gather

作者按:读者诸君务必注意,本章节中讨论的内容,均为JDK中体现 Scatter&Gather 特性的内容,并非是操作系统层面的内容

Scatter 译为 分散Gather 译为 聚集

Scatter 在NIO-1.0中的应用是 Scattering Reads ,是指数据从一个Channel读取到多个 Buffer 中:

一种典型的应用方向是实现数据协议,从应用编写角度看,编码思路更加简单。

例如,约定一个数据分包协议进行数据传输,每一个包包含 "10byte的Header" 和 "50byte的Body(不足进行填充)"

java 复制代码
//ignore imports

public class ScatterExample {
    public static void main(String[] args) {

        try (SocketChannel channel = SocketChannel.open()) {
            channel.connect(new InetSocketAddress("localhost", 8080));

            ByteBuffer headerBuffer = ByteBuffer.allocate(10);
            ByteBuffer bodyBuffer = ByteBuffer.allocate(50);

            ByteBuffer[] buffers = {headerBuffer, bodyBuffer};

            long bytesRead = channel.read(buffers);

            headerBuffer.flip();
            bodyBuffer.flip();

            // Process the data in buffers, hexString代指16进制两位补齐的字符串
            System.out.println("Header: " + hexString(headerBuffer.array()));
            System.out.println("Body: " + hexString(bodyBuffer.array()));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

不难想象,我们可以比较容易地实现:Header信息识别、内容拼包。

当然,一个健壮的协议不会如此简单,仅作为示意。

值得注意的是:Scattering Reads适合 "定长" 的读取情况。

相应的,Gather 在NIO-1.0中的应用是 Gathering Writes,指数据从多个Buffer按序写入同一个Channel中:

以下是一个简单使用Demo

java 复制代码
//ignore imports
public class GatherExample {
    public static void main(String[] args) {
        try {
            // 创建SocketChannel并连接到服务器
            SocketChannel socketChannel = SocketChannel.open();
            socketChannel.connect(new InetSocketAddress("localhost", 8080));

            // 准备多个缓冲区
            ByteBuffer buffer1 = ByteBuffer.wrap("Hello,".getBytes());
            ByteBuffer buffer2 = ByteBuffer.wrap(" World!".getBytes());

            // 将多个缓冲区的数据写入到通道中
            ByteBuffer[] buffers = {buffer1, buffer2};
            socketChannel.write(buffers);

            // 关闭SocketChannel
            socketChannel.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Scatter 不同的是,Gather 擅长 动态长度

OS中的零拷贝

作者按:诸君请注意,本文中讨论零拷贝、Zero-Copy时,均指操作系统中的相关内容,如与Java间存在关联,会单独说明

首先需要记住,零拷贝中并非没有拷贝,而是指新增各种机制,以减少主内存中不必要的拷贝,例如免去从内核态到用户态的拷贝

发展历程中涉及到的技术:

  • mmap
  • sendfile
  • splice 等

我们以"将文件系统中的文件通过网卡发出"为例,简单讨论。

传统IO

在JAVA中使用传统IO实现该需求时,即前文中所述经典IO,需要将文件系统中的文件内容,拷贝到应用内部,继而通过 Socket 从网卡发送.

包含两个关键操作:

scss 复制代码
read()
write()

流程图和数据拷贝过程如下图:
DMA: Direct Memory Access, 直接内存访问, 计算机总线架构提供的功能,它能使数据从附加设备(如磁盘驱动器)直接发送到计算机主板的内存上。

整个过程中,发生两次系统调用,共发生了 4次用户态与内核态的 上下文切换,和4次数据拷贝:

  • 第一次拷贝 ,把磁盘上的数据拷贝到操作系统内核的缓冲区里,通过 DMA 搬运。
  • 第二次拷贝 ,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,由 CPU 完成。
  • 第三次拷贝 ,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,由 CPU 完成。
  • 第四次拷贝 ,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,通过 DMA 搬运。

很显然,这一过程中,文件数据进入用户缓存区再离开,并没有附加必不可少的操作,上下文切换也比较多,存在改进的空间。

mmap取代read

使用 mmap 取代 read 后,整个过程包含两个关键操作:

scss 复制代码
mmap()
write()

先补充一张 虚拟内存 的原理示意图,如下:

使用虚拟地址取代物理地址后,多个虚拟内存可以指向同一个物理地址,虚拟内存表示的空间可以大于实际物理内存空间。

用户空间缓存区 中的部分虚拟内存 和 内核空间缓存区 中的部分虚拟内存,映射到同一物理内存区域时,可以减少不必要的拷贝。

在Linux中,mmap 将一个文件或一块设备内存(如设备寄存器)映射到进程的地址空间,实现 文件磁盘地址设备io地址 与进程虚拟地址空间中一段虚拟地址建立映射,ioremap 实现向内核空间映射 。

使用该技术后,可减少一次CPU拷贝,但上下文切换次数不变,流程图和数据流示意图如下:

3次 数据拷贝,系统调用次数不变,4次 上下文切换

java中使用Demo,从上层编码也能体现一二:

java 复制代码
class Demo {
    public static void main(String[] args) {
        try {
            // 获取文件
            FileChannel readChannel = FileChannel.open(Paths.get("/..../test1.txt"), StandardOpenOption.READ);
            MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, readChannel.size());
            FileChannel writeChannel = FileChannel.open(Paths.get("/..../test2.txt"),
                    StandardOpenOption.WRITE, StandardOpenOption.CREATE);
            //数据传输
            writeChannel.write(data);
            readChannel.close();
            writeChannel.close();
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

sendfile 取代 mmap+write

上文提到,将文件数据读入用户空间内存后并没有附加必不可少的操作,那么就存在减少系统调用的优化空间。

Linux 2.1 版本开始,Linux 引入了 sendfile 替换 mmap+write方式,简化流程。

流程图和数据流示意图如下:

共发生 3次 数据拷贝 ,1次 系统调用, 即2次 上下文切换

scatter/gather 优化的 sendfile方式

sendfile 中,还有CPU拷贝的过程,能不能进一步优化呢?

Linux 2.4 内核进行了优化,提供了带有 scatter/gathersendfile 操作,可以减少拷贝的内容,注意,仍然有描述信息需要拷贝。

原理为:

  • 目标:内核空间 Read Buffer 和 Socket Buffer 之间不做数据复制
  • 将 Read Buffer 的内存地址、偏移量信息等拷贝到 Socket Buffer 中。参考虚拟内存的解决思路实现目标。

Read Buffer 的内存地址、偏移量信息等,即所谓描述信息

流程图和数据流示意图如下:

从内核缓冲区到网卡的DMA拷贝,为 Gather Copy

sendfile只适用于将数据从文件拷贝到套接字上,限定了它的使用范围。

Linux在2.6.17版本引入splice,用于在两个文件描述符中移动数据:

c 复制代码
#define _GNU_SOURCE         /* See feature_test_macros(7) */
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

splice 在两个文件描述符之间移动数据,从 fd_in 拷贝长度为 len 的数据到 fd_out,有一方必须是管道设备。

以java中的transferTo为例

在Java中,transferTo 底层使用零拷贝技术,但从上层编码并不能体现出来:

java 复制代码
class Demo {
    public static void main(String[] args) {
        try {
            FileChannel readChannel = FileChannel.open(Paths.get("/..../test1.txt"), StandardOpenOption.READ);
            long len = readChannel.size();
            long position = readChannel.position();
            FileChannel writeChannel = FileChannel.open(Paths.get("/..../test2.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
            readChannel.transferTo(position, len, writeChannel);
            readChannel.close();
            writeChannel.close();
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

在 zulu版本的实现中:

java 复制代码
class FileChannelImpl {
    public long transferTo(long position, long count,
                           WritableByteChannel target)
            throws IOException {
        ensureOpen();
        //ignore

        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);
    }
}

通过注释与方法名可以看出端倪,感兴趣的读者可继续追溯源码,本文不再展开。

NIO-2.0

操作系统中的AIO

还请读者诸君回忆一下 总纲 中提到的AIO,

很显然,这是操作系统中的AIO,例如,Windows 中提供了 IOCP(I/O CompletionPort,I/O完成端口)

Java中的NIO-2.0

回想一下Java中经典IO(BIO),和NIO-1.0,并没有在JDK层面提供开箱即用的异步IO编程框架。当然,这和Java的多线程编程、异步编程发展有关。

而在JDK1.7中,配套提供了异步IO的编程框架,同样置于nio包下,惯称为NIO-2.0,也有人称之为AIO。

注意,阅读其他文章时,对于 异步阻塞 的讨论,要界定清楚讨论的对象和范围

在应用程序部分,发起IO调用和执行IO操作是异步的,但在JVM中,是否使用了操作系统异步IO则需要看操作系统平台,像Linux是通过 epoll,模拟了AIO。

Java.nio.channels 包下增加了四个异步通道:

  • AsynchronousSocketChannel
  • AsynchronousServerSocketChannel
  • AsynchronousFileChannel
  • AsynchronousDatagramChannel

结合 Future 进行异步编程,例如:

java 复制代码
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.ByteBuffer;
import java.util.concurrent.Future;

class Demo {
    public static void main(String[] args) {
        Path file = Paths.get("/path/to/file.txt");
        AsynchronousFileChannel channel = AsynchronousFileChannel.open(file, StandardOpenOption.READ);
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        Future<Integer> operation = channel.read(buffer, 0);
        while (!operation.isDone()) {
            // can do other work here while reading is in progress asynchronously  
        }
        buffer.flip();
        byte[] data = new byte[buffer.limit()];
        buffer.get(data);
        System.out.println(new String(data));
        channel.close();
    }
}

当然,也可以使用 Callback

这些异步通道,通过 Future + Callback + 线程池 + Native API 实现了 文件异步非阻塞 IO

  • 其中 Native API 的部分,对应到操作系统中的AIO。
  • FutureCallback线程池 为异步程序编写提供了框架支持。

结语

至此,Java IO系列已告一段落,作为一个Android程序员,会再写一篇关于 Okio 的文章,毕竟 OKHttp 几乎是Android程序员吃饭的家伙了。

前段时间因为工作内容的变化,尚未适应过来,这篇文章的草稿攒了月余时间,期间也进行了多次思考,基础系列的文章确实相当枯燥,后面可能会靠好玩系列、三思系列进行调节。

相关推荐
Dcs16 分钟前
VSCode等多款主流 IDE 爆出安全漏洞!插件“伪装认证”可执行恶意命令!
java
车载应用猿19 分钟前
基于Android14的CarService 启动流程分析
android
保持学习ing22 分钟前
day1--项目搭建and内容管理模块
java·数据库·后端·docker·虚拟机
京东云开发者33 分钟前
Java的SPI机制详解
java
超级小忍1 小时前
服务端向客户端主动推送数据的几种方法(Spring Boot 环境)
java·spring boot·后端
没有了遇见1 小时前
Android 渐变色实现总结
android
程序无bug1 小时前
Spring IoC注解式开发无敌详细(细节丰富)
java·后端
小莫分享1 小时前
Java Lombok 入门
java
程序无bug1 小时前
Spring 对于事务上的应用的详细说明
java·后端
食亨技术团队1 小时前
被忽略的 SAAS 生命线:操作日志有多重要
java·后端