Java BIO NIO 与 AIO

回顾

上一章我们介绍了操作系统层面的 IO 模型。

  • 阻塞 IO 模型。
  • 非阻塞 IO 模型。
  • IO 复用模型。
  • 信号驱动 IO 模型(用的不多,知道个概念就行)。
  • 异步 IO 模型。

并且介绍了 IO 多路复用的底层实现中,select,poll 和 epoll 的区别。

几个概念

我们在这里在强调一下几个概念。

一个 IO 操作的具体步骤:

对于操作系统来说,进程是没有直接操作硬件的权限的,所以必须请求内核来帮忙完成。

  • 等待数据准备好,对于一个套接字上得操作,这一步骤关系到数据从网络到达,并将其复制到内核某个缓冲区。
  • 将数据从内核缓冲区复制到进程缓冲区。

同步和异步的区别在于第二个步骤是否阻塞,如果从内核缓冲区复制到用户缓冲区的过程阻塞,那么就是同步 IO,否则就是异步 IO。所以上面提到的前四种 IO 模型都是同步 IO,最后一种是异步 IO。

阻塞和非阻塞的区别在于第一步,发起 IO 请求是否会被阻塞,如果阻塞直到完成那么就是传统的阻塞 IO,否则就是非阻塞 IO。所以上面提到的第一种 IO 模型是阻塞 IO,其余的都是非阻塞 IO。

Java IO API

介绍完操作系统层面的 IO 模型,我们来看看,Java 提供的 IO 相关的 API。

Java 中提供三种 IO 操作的 API,阻塞 IO(BIO,同步阻塞),非阻塞 IO(NIO,同步非阻塞)和异步 IO (AIO,异步非阻塞)。

Java 中提供的 IO 有关的 API,在文件处理的时候,其实是依赖操作系统层面的 IO 操作实现的。比如在 Linux 2.6 以后,Java 中的 NIO 和 AIO 都是通过 epoll(前面讲过的,IO 多路复用) 来实现的。而在 windows 上,AIO 是通过 IOCP 来实现的。

可以把 Java 中的 BIO,NIO 和 AIO 理解为是 Java 语言对操作系统的各种 IO 模型的封装。程序员在使用这些 API 的时候,不需要关心操作系统层面的知识,只需要使用 Java API 就可以了。

Java BIO NIO 与 AIO

  1. BIO 就是传统的 java.io 包,它是基于流模型实现的,交互方式是同步阻塞,也就是在读取或者写入输入输出流的时候,在读写动作完成之前,线程会一直阻塞在那里。它的效率比较低,容易成为性能瓶颈。

  2. NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel,Buffer,Selector 等工具类,底层依赖与 IO 多路复用模型,基于 epoll 实现(根据操作系统来看)。同步非阻塞模式。

  3. AIO 是 Java 1.7 引入的包,是 NIO 的升级版本,提供了异步非阻塞的 IO 操作方式,所以人们叫它 AIO,异步 IO 是基于事件回调机制实现的,也就是应用操作之后会直接返回,不会阻塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续操作。底层也是依赖于 IO 多路复用模型,基于 epoll 实现,异步非阻塞模式。

从代码看 BIO NIO 于 AIO 的区别

  • 传统的 Socket 实现

    //服务端
    ServerSocket serverSocket = ......
    serverSocket.bind(8899);
    
    while(true){
      Socket sokcet = serverSocket.accept(); //阻塞方法
      new Thread(socket);
       run(){
         socket.getInputStream();
         ....
         ....
       }
    }
    
    //客户端
    Socket socket  = new Socket("localhost",8899);
    socket.connect();
    
    8899 是用于客户端向服务端发起连接的端口号,并不是传递数据的端口号,服务端会根据每个连接也就是 Socket 选择一个端口与客户端进行通信。
    

    在 Java 中,线程的实现是比较重量级的,所以线程的启动和销毁是很消耗服务器资源的,即使使用线程池来实现,使用上述传统的 Socket 方式,当连接数急剧上升也会带来性能瓶颈,原因是线程的上下文切换开销会在高并发的时候体现的很明显,并且以上方式是同步阻塞,性能问题在高并发的时候会体现的尤为明显。

  • NIO 多路复用

    Java new IO 底层是基于 IO 多路复用模型实现的。NIO 是利用了单线程轮训事件的机制,通过高效地地位就绪的 Channel,来决定做什么,仅仅 select 阶段是阻塞的,可以避免大量的客户端连接时,频繁切换线程带来的问题,应用的扩展能力有了非常大的提高。

    // NIO 多路复用
    ThreadPoolExecutor threadPool = new ThreadPoolExecutor(4, 4,
            60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
    threadPool.execute(new Runnable() {
        @Override
        public void run() {
            try (Selector selector = Selector.open();
                 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();) {
                serverSocketChannel.bind(new InetSocketAddress(InetAddress.getLocalHost(), port));
                serverSocketChannel.configureBlocking(false);
                serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
                while (true) {
                    selector.select(); // 阻塞等待就绪的Channel
                    Set<SelectionKey> selectionKeys = selector.selectedKeys();
                    Iterator<SelectionKey> iterator = selectionKeys.iterator();
                    while (iterator.hasNext()) {
                        SelectionKey key = iterator.next();
                        try (SocketChannel channel = ((ServerSocketChannel) key.channel()).accept()) {
                            channel.write(Charset.defaultCharset().encode("你好,世界"));
                        }
                        iterator.remove();
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    });
    
    // Socket 客户端(接收信息并打印)
    try (Socket cSocket = new Socket(InetAddress.getLocalHost(), port)) {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(cSocket.getInputStream()));
        bufferedReader.lines().forEach(s -> System.out.println("NIO 客户端:" + s));
    } catch (IOException e) {
        e.printStackTrace();
    }
    
    1. 通过 Selector.open() 创建一个 selector,作为类似调度员的角色。
    2. 创建一个 ServerSocketChannel,并且像 selector 注册,通过指定 SelectionKey.OP_ACCEPT,告诉调度员,它关注的是新的连接请求。
    3. Selector 阻塞在 select 操作,当有 channel 发生接入请求,就会被唤醒。

  • AIO 版的 Socket 实现

    // AIO线程复用版
    Thread sThread = new Thread(new Runnable() {
        @Override
        public void run() {
            AsynchronousChannelGroup group = null;
            try {
                group = AsynchronousChannelGroup.withThreadPool(Executors.newFixedThreadPool(4));
                AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(group).bind(new InetSocketAddress(InetAddress.getLocalHost(), port));
                server.accept(null, new CompletionHandler<AsynchronousSocketChannel, AsynchronousServerSocketChannel>() {
                    @Override
                    public void completed(AsynchronousSocketChannel result, AsynchronousServerSocketChannel attachment) {
                        server.accept(null, this); // 接收下一个请求
                        try {
                            Future<Integer> f = result.write(Charset.defaultCharset().encode("你好,世界"));
                            f.get();
                            System.out.println("服务端发送时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
                            result.close();
                        } catch (InterruptedException | ExecutionException | IOException e) {
                            e.printStackTrace();
                        }
                    }
    
                    @Override
                    public void failed(Throwable exc, AsynchronousServerSocketChannel attachment) {
                    }
                });
                group.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    sThread.start();
    
    // Socket 客户端
    AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
    Future<Void> future = client.connect(new InetSocketAddress(InetAddress.getLocalHost(), port));
    future.get();
    ByteBuffer buffer = ByteBuffer.allocate(100);
    client.read(buffer, null, new CompletionHandler<Integer, Void>() {
        @Override
        public void completed(Integer result, Void attachment) {
            System.out.println("客户端打印:" + new String(buffer.array()));
        }
    
        @Override
        public void failed(Throwable exc, Void attachment) {
            exc.printStackTrace();
            try {
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    });
    Thread.sleep(10 * 1000);
    

    AIO 就是在 NIO 的基础上提供了回调函数。

NIO 中的重要概念

零拷贝

我们读取磁盘文件读取到内存中,以流的形式发送或者传输,这种形式我们使用的太多,太多了。我们可以 new InputStream 指向一个文件,读取完毕后在写到目标中,这样整个流程就结束了。

一个从磁盘文件读取并且通过socket写出的过程,对应的系统调用如下:

File.read(file, buf, len);
Socket.send(socket, buf, len);

  1. 程序使用read()系统调用。系统由用户态转换为内核态(第一次上线文切换),磁盘中的数据有DMA(Direct Memory Access)的方式读取到内核缓冲区(kernel buffer)。DMA过程中CPU不需要参与数据的读写,而是DMA处理器直接将硬盘数据通过总线传输到内存中。
  2. 由于应用程序无法读取内核地址空间的数据,如果应用程序要操作这些数据,必须把这些内容从读取缓冲区拷贝到用户缓冲区。系统由内核态转换为用户态(第二次上下文切换),当程序要读取的数据已经完成写入内核缓冲区以后,程序会将数据由内核缓存区,写入用户缓存区,这个过程需要CPU参与数据的读写。
  3. 程序使用write()系统调用。系统由用户态切换到内核态(第三次上下文切换),数据从用户态缓冲区写入到网络缓冲区(Socket Buffer),这个过程需要CPU参与数据的读写。
  4. 系统由内核态切换到用户态(第四次上下文切换),网络缓冲区的数据通过DMA的方式传输到网卡的驱动(存储缓冲区)中(protocol engine)

传统的I/O方式会经过4次用户态和内核态的切换(上下文切换),两次CPU中内存中进行数据读写的过程。这种拷贝过程相对来说比较消耗资源。

在整个过程中,过程1和4是由DMA负责,并不会消耗CPU,只有过程2和3的拷贝需要CPU参与。

我们思考一个问题,如果在应用程序中,不需要操作内容,过程2和3就是多余的,如果可以直接把内核态读取缓存冲区数据直接拷贝到套接字相关的缓存区,是不是可以达到优化的目的?

在Java中,正好FileChannel的transferTo() 方法可以实现这个过程,该方法将数据从文件通道传输到给定的可写字节通道, 上面的file.read()socket.send()调用动作可以替换为 transferTo()调用。

public void transferTo(long position, long count, WritableByteChannel target);

在 UNIX 和各种 Linux 系统中,此调用被传递到 sendfile() 系统调用中,最终实现将数据从一个文件描述符传输到了另一个文件描述符。

NIO 的零拷贝依赖于操作系统的支持,我们来看看操作系统意义上的零拷贝的流程(没有内核空间和用户空间数据拷贝)。相比于传统 IO,减少了两次上下文切换和数据拷贝,从操作系统角度称为零拷贝。如果熟悉 JVM 的同学应该知道,NIO 会使用一块 JVM 之外的内存区域,直接在该区域进行操作。

这种方式的I/O原理就是将用户缓冲区(user buffer)的内存地址和内核缓冲区(kernel buffer)的内存地址做一个映射,也就是说系统在用户态可以直接读取并操作内核空间的数据。

  1. sendfile()系统调用也会引起用户态到内核态的切换,与内存映射方式不同的是,用户空间此时是无法看到或修改数据内容,也就是说这是一次完全意义上的数据传输过程。

  2. 从磁盘读取到内存是DMA的方式,从内核读缓冲区读取到网络发送缓冲区,依旧需要CPU参与拷贝,而从网络发送缓冲区到网卡中的缓冲区依旧是DMA方式。

从上面我们可以看出,零拷贝的是指在操作过程中,CPU 不需要为数据在内存之间拷贝消耗资源,传统的 IO 操作需用从用户态转为内核态,内核拿到数据后还需要由内核态转为用户态将数据拷贝到用户空间,而零拷贝不需要将文件拷贝到用户空间,而直接在内核空间中传输到网络的方式。

内核空间操作文件的过程对用户来说是不透明的,用户只能请求和接受结果,如果用户想要参与这个过称怎么办?这时候就需要一个内存映射文件(将磁盘上的文件映射到内存之中,修改内存就可以修改磁盘上的文件),直接操作内核空间。

MappedByteBuffer,文件在内存中的映射,Java 程序不用和磁盘打交道,应用程序只需要对内存进行操作,这块内存是一个堆外内存。操作系统负责将我们对内存映射文件的修改更新到磁盘。

Java 中的实现

  File file = new File("test.zip");
  RandomAccessFile raf = new RandomAccessFile(file, "rw");
  FileChannel fileChannel = raf.getChannel();
  SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("", 1234));
  // 直接使用了transferTo()进行通道间的数据传输
  fileChannel.transferTo(0, fileChannel.size(), socketChannel);

NIO 的零拷贝由 transferTo() 方法实现。transferTo() 方法将数据从 FileChannel 对象传送到可写的字节通道(如Socket Channel等)。在内部实现中,由 native 方法 transferTo0() 来实现,它依赖底层操作系统的支持。在UNIX 和 Linux 系统中,调用这个方法将会引起 sendfile() 系统调用。

我们上面也说过,内核空间操作文件的过程对用户来说是不透明的,用户只能请求和接受结果,如果用户想要参与这个过称怎么办?

  File file = new File("test.zip");
  RandomAccessFile raf = new RandomAccessFile(file, "rw");
  FileChannel fileChannel = raf.getChannel();
  MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());

首先,它的作用位置处于传统IO(BIO)与零拷贝之间,为何这么说?

  • IO,可以把磁盘的文件经过内核空间,读到 JVM 空间(用户空间),然后进行各种操作,最后再写到磁盘或是发送到网络,效率较慢但支持数据文件操作。
  • 零拷贝则是直接在内核空间完成文件读取并转到磁盘(或发送到网络)。由于它没有读取文件数据到JVM这一环,因此程序无法操作该文件数据,尽管效率很高!

MappedByteBuffer 使用的是 JVM 之外的一块直接内存。

posted @ 2020-01-15 22:12  当年明月123  阅读(455)  评论(0编辑  收藏  举报