Java中的NIO

本文章主要讨论以下几个问题:

  1. 什么是NIO?NIO与IO的区别与联系?
  2. 为什么要使用NIO,它有哪些优点?
  3. NIO中的关键类,例如 BufferChannelSelector 的介绍?
  4. NIO的具体使用。

什么是NIO?NIO与普通IO的区别与联系?

  • NIO指的是Non-Block IO,也即非阻塞的IO。传统的IO是堵塞的,也就是说当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。
  • Java NIO和IO之间第一个最大的区别是,IO是面向流的NIO是面向缓冲区的
  • 面向流 的 I/O 系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是,面向流的 I/O 通常相当慢。
  • 一个 面向缓冲区 的 I/O 系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
  • Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。

为什么要使用NIO,它有哪些优点?

  • NIO 的创建目的是为了让 Java 程序员可以实现高速 I/O 而无需编写自定义的本机代码。NIO 将最耗时的 I/O 操作(即填充和提取缓冲区)转移回操作系统,因而可以极大地提高速度。
  • 第二个使用NIO的原因是它可以实现异步的IO模式。通过向 Selector进行注册事件,如监听、读操作等,这样的话当一个事件到来时,Selector就会通知该Channel来处理该事件。

异步 I/O 是一种 没有阻塞地 读写数据的方法。通常,在代码进行 read() 调用时,代码会阻塞直至有可供读取的数据。同样, write() 调用将会阻塞直至数据能够写入。
另一方面,异步 I/O 调用不会阻塞。相反,您将注册对特定 I/O 事件的兴趣 ― 可读的数据的到达、新的套接字连接,等等,而在发生这样的事件时,系统将会告诉您。
异步 I/O 的一个优势在于,它允许您同时根据大量的输入和输出执行 I/O。同步程序常常要求助于轮询,或者创建许许多多的线程以处理大量的连接。使用异步 I/O,您可以监听任何数量的通道上的事件,不用轮询,也不用额外的线程。

NIO中的关键类,例如Buffer、Channel、Selector的介绍

NIO是面向块的,也就是说写操作和读操作都是需要经过缓冲区(Buffer)。例如从一个文件中读取数据,需要首先获取到文件的Channel,然后将文件中的数据读入Buffer,最后再对数据进行处理;向一个文件中进行写的操作,先把数据写入缓冲区,然后将缓冲区的数据write到文件中。

Buffer对象

Buffer 是一个对象, 它包含一些要写入或者刚读出的数据。 在 NIO 中加入 Buffer 对象,体现了新库与原 I/O 的一个重要区别。在面向流的 I/O 中,您将数据直接写入或者将数据直接读到 Stream 对象中。
缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不 仅仅 是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

Buffer的类型

最常用的就是ByteBuffer,他按照字节来处理数据。当然对于特定的数据类型也有不同的Buffer,但是其主要操作还是一样的。如CharBufferShortBufferIntBufferLongBufferFloatBuffer、DoubleBuffer。

Buffer中的三个属性position、limit以及capacity

  • capacity。这个Buffer的容量,是一个不变的值。
  • limit。这个值代表的是可读可写的范围。对于读或者写操作,范围就是[position, limit]。
  • position。当前操作的位置。

三者之间的关系,具体可以看下面这段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 // 分配一个缓存区,此时position=0,limit = capacity = 100;
ByteBuffer buffer = ByteBuffer.allocate(100);
// 往buffer里面放了两个int,每个4字节
//因此,position = 8, limit = capacity = 100
buffer.putInt(1);
buffer.putInt(2);
//flip操作就是将buffer由写模式转为读模式
//limit 置位position = 8, 然后position = 0,capacity = 100
buffer.flip();
while(buffer.hasRemaining()){
buffer.getInt();
}
// clear操作,将postion置为0, limit = capacity = 100,这样就可以继续往这个buffer中写数据了
buffer.clear();

Channel对象

  • Channel是一个对象,可以通过它读取和写入数据。拿 NIO 与原来的 I/O 做个比较,通道就像是流。通道与流的不同之处在于通道是双向的。而流只是在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类), 而 通道 可以用于读、写或者同时用于读写。
  • 一个Channel 总是 往一个Buffer里面读(read to) ,或者从一个buffer里面写(write from)。

Channel的种类

  • FileChannel 。从一个文件中进行读写。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
          // 创建一个FileChannel, 使用getChannel方法。
    FileChannel inChannel = new FileInputStream("hello.txt").getChannel();
    FileChannel outChannel = new FileOutputStream("world.txt").getChannel();
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    while (true)
    {
    buffer.clear();

    int count = inchannel.read(buffer);
    if (count < 0)
    break;

    buffer.flip();

    outChannel.write(buffer);
    }
  • DatagramChannel 。通过UDP协议进行读写的操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    DatagramChannel channel = DatagramChannel.open();
    channel.socket().bind(new InetSocketAddress(9999));//在9999监听UDP连接

    ByteBuffer buf = ByteBuffer.allocate(48);
    buf.clear();
    // 接收数据并存到buf中
    channel.receive(buf);
    // 发送数据
    int bytesSent = channel.send(buf, new InetSocketAddress(host, 80));
  • SocketChannel。通过TCP协议进行读写的操作。

    1
    2
    3
    SocketChannel socketChannel = SocketChannel.open();
    // 连接到服务器
    socketChannel.connect(new InetSocketAddress(host, 80));
  • ServerSocketChannel。允许你监听TCP连接,并且针对每个连接创建一个SocketChannel。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 在9999建立监听
    serverSocketChannel.socket().bind(new InetSocketAddress(9999));

    while(true){
    SocketChannel socketChannel =
    serverSocketChannel.accept();
    //do something with socketChannel...
    }

Selector

Selector 是一个Java NIO组件,它可以检查一个或多个NIO通道,并确定哪些通道已经准备好了,例如读或写。通过这种方式,单个线程可以管理多个通道,从而实现多个网络连接。、

为什么使用Selector

使用单个线程来处理多个通道的优点是,您需要较少的线程来处理通道。实际上,您可以使用一个线程来处理所有的通道。对于操作系统来说,在线程之间切换是很昂贵的,而且每个线程在操作系统中也会占用一些资源(内存)。因此,使用的线程越少越好。

一个selector管理多个channel

创建一个Selector

1
Selector selector = Selector.open();

向Selector注册Channel

为了使用选择器的通道,您必须使用选择器注册通道。这是通过使用SelectableChannel.register() 方法,如下:

1
2
3
channel.configureBlocking(false);

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

FileChannel 是不可以注册的,因为它不能切换为 non-block 模式。

NIO的具体使用

下面这个例子展示的是Selector的使用,首先开启Selector,然后channel向Selector注册,Selector负责监听。

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
41
//1. 开启一个Selector
Selector selector = Selector.open();

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

//3. channel向Selector进行注册,它将处理读事件
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);


while(true) {
//4. select() 方法将会阻塞,直到有准备好的channel
int readyChannels = selector.select();

if(readyChannels == 0) continue;

//5. 进行相应的处理
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();
}
}

参考资料