NIO

NIO 与 IO 的区别,以及基本用法

Posted by Allen Vork on January 22, 2019

NIO 即 New IO,也叫 Non-blocking IO,非阻塞 IO。程序员无需写自定义的 native 代码就能实现高速的 I/O。NIO 将最耗时的操作(如往缓存中填充和从缓存中读出数据)移到操作系统中,从而达到极大的提速效果。它通过定义类来 hold 数据,并以块为单位来处理数据。

Identifying differences between IO and NIO

IO streams versus NIO blocks

和原始的 I/O 最重要的区别就是 NIO 的数据打包和传输方式。原始的 I/O 是以流的方式处理数据,而 NIO 则是以块来处理的。

  • stream-oriented I/O:一次处理一个或多个字节,输入流产生一个字节,输出流消耗一个字节的数据。它很容易为流数据创建过滤器。它也可以用相对简单的方式来链接几个过滤器。但问题是字节并不会被缓存,那么你就无法在流中前后移动数据,如果要移动的话,则必须要将字节都缓存起来。
  • block-oriented I/O:每一个操作都会产生或消耗一块数据,按块来处理数据比按字节流要快得多。在处理数据过程中,你可以在缓存区中前后移动,比原始的 I/O 要灵活。处理数据时,你需要检查缓存中是否包含了你需要的所有数据,并且当读取更多数据到缓存时,还要保证不会覆盖你还没有处理的数据。它相对于原始的 IO 丢掉了优雅和简洁。

NIO 和 IO 在文件拷贝上的性能差异:
以 FileInputStream 为例,它读取数据最终会调用一个 readBytes() 的 native 方法,该方法会调用系统内核的 read 方法。它是通过磁盘控制器通过 DMA 操作将数据从磁盘中拷贝到内核缓冲区中,该操作不依赖 CPU。然后用户进程会将数据从内核缓冲区拷贝到用户缓冲区。最后进程再从用户缓冲区中读取数据。由于用户进程不能直接访问硬件,所以它就通过内核来充当中间人来实现文件读取。整个过程如下图所示:

Java 1.4以后,在 NIO 中引入了文件内存映射(MappedByteBuffer)的概念,将内核空间地址和用户空间的虚拟地址映射到同一个物理地址,这样 DMA 硬件(只能访问物理内存地址)就可以填充对内核和用户空间进程同时可见的缓冲区了。如下图所示:

这样就省去了从内核缓冲区中拷贝数据到用户进程缓冲区的操作了。操作系统会通过一些页面调度算法来将磁盘文件载入对分区页进行高速缓存的物理内存。我们就可以通过映射后物理内存来读取磁盘文件了。

Synchronous vs. Asynchronous IO

传统的 I/O 流在读写的时候会一直阻塞,直到有数据可读或者数据已经完全写入。而在异步的 I/O 中,线程可以将数据写到 Channel 而不必等待写入完成。这样该线程就能同时做别的事了。这就意味着在一个线程里能够管理多个输入输出 Channel。
同步 IO 中,通常都是通过创建多个线程来处理多个连接。在异步 I/O 中,你只需要监听各个 Channel 的 I/O 事件即可。异步 I/O 的核心是Selector。你可以用 Selector 来注册你感兴趣的 I/O 事件,他能告诉你这些事件什么时候发生。

小结

NIO 是以块来处理数据,比传统的流速度快很多,并且内部会有缓存,方便前后移动来读取数据。NIO 是非阻塞的,它不必等待写入完成,一个线程里可以同时管理多个 channel。
NIO 能让你在一个线程里管理多个 channels,代价是数据的处理会相对复杂些。如果你需要同时管理成千上万的连接,每一个只会发送一点数据,那么 NIO 是很好的选择。如果连接比较少,但占用的带宽很大,同时会发送大量的数据,那么就应选择传统的 I/O。

Path

Path 用于表示文件系统中的路径,它类似 File,可以使用 Path 替换 File 来使用,使用很简单。

创建绝对路径

通过调用Paths.get()工厂方法,给定绝对路径就创建好了一个绝对路径的实例。

Path path = Paths.get("c:\\data\\myfile.txt");

创建一个相对路径

使用Paths.get(basePath, relativePath)方法创建一个相对路径:

// d:\data\projects
Path directory = Paths.get("d:\\data", "projects");

// d:\data\projects\a-project\myfile.txt
Path file      = Paths.get("d:\\data", "projects\\a-project\\myfile.txt");

相对路径可以使用连个特殊字符:

  • .:当前目录
  • ..:上级目录
Path currentDir = Paths.get("."); // 当前目录的路径
Path currentDir = Paths.get("d:\\data\\projects\.\a-project"); // 对应 d:\data\projects\a-project

Path parentDir = Paths.get(".."); // 上级目录的路径

String path = "d:\\data\\projects\\a-project\\..\\another-project";
Path parentDir2 = Paths.get(path); // 对应 d:\data\projects\another-project

Path path1 = Paths.get("d:\\data\\projects", ".\\a-project");
Path path2 = Paths.get("d:\\data\\projects\\a-project",
                       "..\\another-project");

Channel

类似流,可以从中读取数据到 buffer,或从 buffer 中些数据到 channel 中。

  • 流的读写是单向,而 channel 是双向。
  • channel 可以异步读写。
  • channel 的数据总是要先读到 buffer 中,或者是从 buffer 中写入。

Channel 有一些比较重要的类:

  • FileChannel:从文件中读写数据。
  • DatagramChannel:能通过UDP读写网络中的数据。
  • SocketChannel:能通过TCP读写网络中的数据。
  • ServerSocketChannel:可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。

sample

    private static void useNio(){
        RandomAccessFile aFile = null;
        try {
            aFile = new RandomAccessFile("/Users/sschen/Documents/SerialVersion.txt", "rw");
            FileChannel inChannel = aFile.getChannel();

            ByteBuffer byteBuffer = ByteBuffer.allocate(48); // 分配 48 个字节
            int byteReader = inChannel.read(byteBuffer); // channel 写数据到 buffer

            while (byteReader != -1) {
                System.out.println("Read:" + byteReader);
                byteBuffer.flip(); // flip 会将 position 设为 0,这样就可以从 0 开始读

                while (byteBuffer.hasRemaining()) {
                    System.out.println((char)byteBuffer.get());
                }

                byteBuffer.clear();

                byteReader = inChannel.read(byteBuffer);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        finally {
            try {
                aFile.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

Buffer

Buffer 是用于将数据从 Channel 中读到缓冲区,或从缓冲区写到 channel 中。使用 buffer 一般遵循4个步骤:

  1. 通过 channel 写数据到 buffer
  2. 调用 flip() 方法切换到读操作
  3. 从 buffer 中读取数据
  4. 调用 clear 清空缓冲区所有数据,或调用 compact 清除已读数据,方便写入新数据。

上面已经用 ByteBuffer 举例了,就不再赘述了。 Buffer 有如下几种类型:

  • ByteBuffer
  • MappedByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

Selector

他是用来检测一个或多个 Channel 实例的,并决定哪个 channel 可以读或写。这样,一个单线程就能管理多个 channels。它的优势就在于你只需要一个线程来管理所有的 channels。操作系统中线程切换是很昂贵的,并且每一个线程都会占用一些资源(内存)。因此线程使用的越少越好。

// 将 channel 设置为非阻塞模式
channel.configureBlocking(false);

// 创建 Selector
Selector selector = Selector.open(); 
//使用 selector 注册 channel
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

使用 Selector 的话,要求 channel 处于非阻塞模式。这意味着你不能对 FileChannel 使用 Selector,因为 FileChannel 不能切换到非阻塞模式。Selector 适用于 SocketChannels 之类的。register 的第二个参数表明 Selector 想要监听的事件,它有4种:

  • Connect:SelectionKey.OP_CONNECT
  • Accept:SelectionKey.OP_ACCEPT
  • Read:SelectionKey.OP_READ
  • Write:SelectionKey.OP_WRITE
看名字就能知道是什么意思,你也可以组合多个感兴趣的事件:int interestSet = SelectionKey.OP_READ SelectionKey.OP_WRITE;

SelectionKey

前面调用 register 方法会返回一个 SelectionKey,它包含了一些属性:

  • The interest set
  • The ready set
  • The Channel
  • The Selector
  • An attached object (optional)

下面一个个介绍:

Interest Set

它其实就是前面介绍的想监听的事件,你可以通过以下方式获取是否监听了哪些事件:

int interestSet = selectionKey.interestOps();

boolean isInterestedInAccept  = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;   

Ready Set

它表示 channel 已经准备好了的操作,你可以通过下面的方法来获取准备好了的集合:

int readySet = selectionKey.readyOps();

你也可以向上面的 interest set 那样来测试什么事件或者操作已经准备好了。你也可以用下面4个方法:

selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();

Channel + Selector

我们也可以通过 SelectionKey 来获取 Channel 和 Selector:

Channel  channel  = selectionKey.channel();

Selector selector = selectionKey.selector();    

Attaching Objects

你也可以关联一个 object 到 SelectionKey,这样就可以很方便的区分 Channel,也可以存放一些额外的信息。例如你可以管理 Buffer 到 Channel 之类的。

selectionKey.attach(theObject); // 关联 object

Object attachedObj = selectionKey.attachment(); // 获取 object

你也可以在 register 的时候关联 object:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

Selecting Channels via a Selector

当使用 Selector 注册了 channel 后,你可以调用 select() 方法来返回已经准备好了的 channel。 select 有几种方法:

  • int select():该方法会一直阻塞,直到至少有一个 Channel 准备好了
  • int select(long timeout):增加了设置超时的功能
  • int selectNow():不会阻塞,立马返回

返回值表示准备好了的 Channel 的个数。当有 Channel 准备好了的话,可以调用 selectedKeys() 来回去准备好了的 Channel:

Set<SelectionKey> selectedKeys = selector.selectedKeys();    

wakeUp()

前面说过 select() 会阻塞到至少有一个 Channel 准备好为止。我们也可与i在另一个线程调用 Selector.wakeup(),那么 select() 种等待的线程就会立刻返回。

close()

Selector 执行完成后就可以调用 close() 方法来使所有的 SelectionKey 无效。Channel 姿势使不会关闭的。

Full Selector Example

Selector selector = Selector.open();

channel.configureBlocking(false);

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

while(true) {

  int readyChannels = selector.selectNow();

  if(readyChannels == 0) continue;


  Set<SelectionKey> selectedKeys = selector.selectedKeys();

  Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

  while(keyIterator.hasNext()) {

    SelectionKey key = keyIterator.next();

    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.

    } else if (key.isConnectable()) {
        // a connection was established with a remote server.

    } else if (key.isReadable()) {
        // a channel is ready for reading

    } else if (key.isWritable()) {
        // a channel is ready for writing
    }

    keyIterator.remove();
  }
}

参考文献