Java 并发基础之 Java NIO 详解

2022-08-11  本文已影响0人  you的日常

Java NIO(Java New IO)是从 Java 1.4 版本开始引入的一个新的 IO API,可以替代标准的 Java IO API。本文将有助于你学习和理解 Java NIO。

Java NIO 提供了与标准 IO 不同的 IO工作方式:

下面就来详细介绍 Java NIO 的相关知识。

Java NIO 概述

Java NIO 由以下几个核心部分组成:

虽然Java NIO 中除此之外还有很多类和组件,但在我看来,Channel,Buffer 和 Selector 构成了核心的 API。其它组件,如 Pipe 和 FileLock,只不过是与三个核心组件共同使用的工具类。因此,在概述中我将集中在这三个组件上。其它组件会在单独的章节中讲到。

Channel 和 Buffer

基本上,所有的 IO 在NIO 中都从一个Channel 开始。Channel 有点象流。 数据可以从 Channel 读到 Buffer 中,也可以从 Buffer 写到 Channel 中。这里有个图示:

image.png

Channel 和 Buffer 有好几种类型。下面是 Java NIO 中的一些主要 Channel 的实现:

正如你所看到的,这些通道涵盖了 UDP 和 TCP 网络 IO,以及文件 IO。

与这些类一起的有一些有趣的接口,但为简单起见,我尽量在概述中不提到它们。

以下是 Java NIO 里关键的 Buffer 实现:

这些 Buffer 覆盖了你能通过 IO 发送的基本数据类型:byte, short, int, long, float, double 和 char。

Java NIO 还有个 Mappedyteuffer,用于表示内存映射文件。

Selector

Selector 允许单线程处理多个 Channel。如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用 Selector 就会很方便。例如,在一个聊天服务器中。

这是在一个单线程中使用一个 Selector 处理 3 个 Channel 的图示:


image.png

要使用 Selector,得向 Selector 注册 Channel,然后调用它的 select() 方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新连接进来,数据接收等。

Java NIO vs. IO

当学习了 Java NIO 和 IO 的 API 后,一个问题马上涌入脑海:

我应该何时使用 IO,何时使用 NIO 呢?在本文中,我会尽量清晰地解析 Java NIO 和 IO 的差异、它们的使用场景,以及它们如何影响您的代码设计。

Java NIO 和 IO 的主要区别

下表总结了 Java NIO 和 IO 之间的主要差别,我会更详细地描述表中每部分的差异。

IO NIO
Stream oriented Buffer oriented
Blocking IO Non blocking IO
Selectors

面向流与面向缓冲

Java NIO 和 IO 之间第一个最大的区别是,IO 是面向流的,NIO 是面向缓冲区的。 Java IO 面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。

Java NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。

阻塞与非阻塞IO

Java IO 的各种流是阻塞的。这意味着,当一个线程调用 read()write() 时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。 Java NIO 的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞 IO 的空闲时间用于在其它通道上执行 IO 操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。

选择器(Selectors)

Java NIO 的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。

NIO 和 IO 如何影响应用程序的设计

无论您选择 IO 或 NIO 工具箱,可能会影响您应用程序设计的以下几个方面:

API 调用

当然,使用 NIO 的 API 调用时看起来与使用 IO 时有所不同,但这并不意外,因为并不是仅从一个 InputStream 逐字节读取,而是数据必须先读入缓冲区再处理。

数据处理

使用纯粹的 NIO 设计相较 IO 设计,数据处理也受到影响。

在 IO 设计中,我们从 InputStream 或 Reader 逐字节读取数据。假设你正在处理一基于行的文本数据流,例如:

Name: Anna  
Age: 25  
Email: an12na@mailserver.com  
Phone: 1234567890  

该文本行的流可以这样处理:

 // get the InputStream from the client socket  
InputStream input = … ;
BufferedReader reader = new BufferedReader(new InputStreamReader(input));  

String nameLine   = reader.readLine();  
String ageLine    = reader.readLine();  
String emailLine  = reader.readLine();  
String phoneLine  = reader.readLine();  

请注意处理状态由程序执行多久决定。换句话说,一旦 reader.readLine() 方法返回,你就知道肯定文本行就已读完, readline() 阻塞直到整行读完,这就是原因。你也知道此行包含名称;同样,第二个 readline() 调用返回的时候,你知道这行包含年龄等。 正如你可以看到,该处理程序仅在有新数据读入时运行,并知道每步的数据是什么。一旦正在运行的线程已处理过读入的某些数据,该线程不会再回退数据(大多如此)。下图也说明了这条原则:

image.png

从一个阻塞的流中读数据

而一个NIO的实现会有所不同,下面是一个简单的例子:

ByteBuffer buffer = ByteBuffer.allocate(48);  

int bytesRead = inChannel.read(buffer);  

注意第二行,从通道读取字节到 ByteBuffer。当这个方法调用返回时,你不知道你所需的所有数据是否在缓冲区内。你所知道的是,该缓冲区包含一些字节,这使得处理有点困难。
假设第一次 read(buffer) 调用后,读入缓冲区的数据只有半行,例如,“Name:An”,你能处理数据吗?显然不能,需要等待,直到整行数据读入缓存,在此之前,对数据的任何处理毫无意义。

所以,你怎么知道是否该缓冲区包含足够的数据可以处理呢?好了,你不知道。发现的方法只能查看缓冲区中的数据。其结果是,在你知道所有数据都在缓冲区里之前,你必须检查几次缓冲区的数据。这不仅效率低下,而且可以使程序设计方案杂乱不堪。例如:

ByteBuffer buffer = ByteBuffer.allocate(48);  
int bytesRead = inChannel.read(buffer);  
while(! bufferFull(bytesRead) ) {  
  bytesRead = inChannel.read(buffer);  
}  

bufferFull() 方法必须跟踪有多少数据读入缓冲区,并返回真或假,这取决于缓冲区是否已满。换句话说,如果缓冲区准备好被处理,那么表示缓冲区满了。

bufferFull()方法扫描缓冲区,但必须保持在 bufferFull() 方法被调用之前状态相同。如果没有,下一个读入缓冲区的数据可能无法读到正确的位置。这是不可能的,但却是需要注意的又一问题。

如果缓冲区已满,它可以被处理。如果它不满,并且在你的实际案例中有意义,你或许能处理其中的部分数据。但是许多情况下并非如此。下图展示了“缓冲区数据循环就绪”:


image.png

从一个通道里读数据,直到所有的数据都读到缓冲区里

总结

NIO 可让您只使用一个(或几个)单线程管理多个通道(网络连接或文件),但付出的代价是解析数据可能会比从一个阻塞流中读取数据更复杂。

如果需要管理同时打开的成千上万个连接,这些连接每次只是发送少量的数据,例如聊天服务器,实现 NIO 的服务器可能是一个优势。同样,如果你需要维持许多打开的连接到其他计算机上,如 P2P 网络中,使用一个单独的线程来管理你所有出站连接,可能是一个优势。一个线程多个连接的设计方案如下图所示:

单线程管理多个连接.png

如果你有少量的连接使用非常高的带宽,一次发送大量的数据,也许典型的IO服务器实现可能非常契合。下图说明了一个典型的 IO 服务器设计:


一个典型的IO服务器设计:一个连接通过一个线程处理.png

通道(Channel)

Java NIO 的通道类似流,但又有些不同:

正如上面所说,从通道读取数据到缓冲区,从缓冲区写入数据到通道。如下图所示:


image.png

Channel 的实现

这些是 Java NIO 中最重要的通道的实现:

基本的 Channel 示例

下面是一个使用 FileChannel 读取数据到 Buffer 中的示例:

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");  
FileChannel inChannel = aFile.getChannel();  

ByteBuffer buf = ByteBuffer.allocate(48);  

int bytesRead = inChannel.read(buf);  
while (bytesRead != -1) {  

  System.out.println("Read " + bytesRead);  
  buf.flip();  

  while(buf.hasRemaining()){  
      System.out.print((char) buf.get());  
  }  

  buf.clear();  
  bytesRead = inChannel.read(buf);  
}  
aFile.close();  

注意 buf.flip() 的调用,首先读取数据到 Buffer,然后反转 Buffer , 接着再从 Buffer 中读取数据。下一节会深入讲解 Buffer 的更多细节。

缓冲区(Buffer)

Java NIO 中的 Buffer 用于和 NIO 通道进行交互。如你所知,数据是从通道读入缓冲区,从缓冲区写入到通道中的。

缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。

Buffer 的基本用法

使用 Buffer 读写数据一般遵循以下四个步骤:

当向 buffer 写入数据时,buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip() 方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 buffer 的所有数据。

一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用 clear()compact() 方法。clear() 方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

下面是一个使用 Buffer 的例子:

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");  
FileChannel inChannel = aFile.getChannel();  

//create buffer with capacity of 48 bytes  
ByteBuffer buf = ByteBuffer.allocate(48);  

int bytesRead = inChannel.read(buf); //read into buffer.  
  while (bytesRead != -1) {  

    buf.flip();  //make buffer ready for read  

    while(buf.hasRemaining()){  
        System.out.print((char) buf.get()); // read 1 byte at a time  
    }  

    buf.clear(); //make buffer ready for writing  
    bytesRead = inChannel.read(buf);  
} 
 
aFile.close();  

Buffer的capacity,position和limit

缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。

为了理解 Buffer 的工作原理,需要熟悉它的三个属性:

position和limit的含义取决于 Buffer 处在读模式还是写模式。不管 Buffer 处在什么模式,capacity 的含义总是一样的。

这里有一个关于 capacity,position 和 limit 在读写模式中的说明。


image.png

capacity

作为一个内存块,Buffer 有一个固定的大小值,也叫 “capacity” .你只能往里写 capacity 个 byte、long,char 等类型。一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。

position

当你写数据到 Buffer 中时,position 表示当前的位置。初始的 position 值为 0.当一个 byte、long 等数据写到 Buffer 后, position 会向前移动到下一个可插入数据的 Buffer 单元。position 最大可为 capacity – 1。

当读取数据时,也是从某个特定位置读。当将 Buffer 从写模式切换到读模式,position 会被重置为 0。当从 Buffer 的 position 处读取数据时,position 向前移动到下一个可读的位置。

limit

在写模式下,Buffer 的 limit 表示你最多能往 Buffer 里写多少数据。 写模式下,limit 等于 Buffer 的 capacity。

当切换 Buffer 到读模式时, limit表示你最多能读到多少数据。因此,当切换 Buffer 到读模式时,limit 会被设置成写模式下的position 值。换句话说,你能读到之前写入的所有数据( limit 被设置成已写数据的数量,这个值在写模式下就是 position)

Buffer 的类型

Java NIO 有以下 Buffer 类型:

如你所见,这些 Buffer 类型代表了不同的数据类型。换句话说,就是可以通过 char,short,int,long,float 或 double 类型来操作缓冲区中的字节。

MappedByteBuffer 有些特别,在涉及它的专门章节中再讲。

Buffer的分配

要想获得一个 Buffer 对象首先要进行分配。 每一个 Buffer 类都有一个 allocate 方法。下面是一个分配 48 字节 capacity 的 ByteBuffer 的例子。

ByteBuffer buf = ByteBuffer.allocate(48);  

这是分配一个可存储 1024 个字符的 CharBuffer:

CharBuffer buf = CharBuffer.allocate(1024);  

向Buffer中写数据

写数据到 Buffer 有两种方式:

从 Channel 写到 Buffer 的例子

int bytesRead = inChannel.read(buf); //read into buffer.  

通过put方法写Buffer的例子:

buf.put(127);  

put 方法有很多版本,允许你以不同的方式把数据写入到 Buffer 中。例如, 写到一个指定的位置,或者把一个字节数组写入到Buffer。 更多 Buffer 实现的细节参考 JavaDoc。

flip()方法

flip 方法将 Buffer 从写模式切换到读模式。调用 flip() 方法会将 position 设回 0,并将 limit 设置成之前 position 的值。

换句话说,position 现在用于标记读的位置,limit 表示之前写进了多少个 byte、char 等 —— 现在能读取多少个 byte、char 等。

从 Buffer 中读取数据

从 Buffer 中读取数据有两种方式:

从 Buffer 读取数据到 Channel 的例子:

//read from buffer into channel.  
int bytesWritten = inChannel.write(buf);  

使用 get() 方法从 Buffer 中读取数据的例子

byte aByte = buf.get();  

get 方法有很多版本,允许你以不同的方式从 Buffer 中读取数据。例如,从指定 position 读取,或者从 Buffer 中读取数据到字节数组。更多 Buffer 实现的细节参考 JavaDoc。

rewind()方法

Buffer.rewind() 将 position 设回 0,所以你可以重读 Buffer 中的所有数据。limit保持不变,仍然表示能从 Buffer 中读取多少个元素( byte、char 等)。

clear() 与 compact() 方法

一旦读完 Buffer 中的数据,需要让 Buffer 准备好再次被写入。可以通过 clear()compact() 方法来完成。

如果调用的是 clear() 方法, position 将被设回 0,limit 被设置成 capacity 的值。换句话说,Buffer 被清空了。Buffer 中的数据并未清除,只是这些标记告诉我们可以从哪里开始往 Buffer 里写数据。

如果 Buffer 中有一些未读的数据,调用 clear() 方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。

如果 Buffer 中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用 compact() 方法。

compact() 方法将所有未读的数据拷贝到 Buffer 起始处。然后将 position 设到最后一个未读元素正后面。limit 属性依然像clear() 方法一样,设置成 capacity。现在 Buffer 准备好写数据了,但是不会覆盖未读的数据。

mark() 与 reset() 方法

通过调用 Buffer.mark() 方法,可以标记 Buffer 中的一个特定 position。之后可以通过调用 Buffer.reset() 方法恢复到这个 position 。例如:

buffer.mark();  

//call buffer.get() a couple of times, e.g. during parsing.  

buffer.reset();  //set position back to mark.  

equals()与compareTo()方法

可以使用 equals()compareTo() 方法两个 Buffer。

equals()

当满足下列条件时,表示两个Buffer相等:

如你所见,equals 只是比较 Buffer 的一部分,不是每一个在它里面的元素都比较。实际上,它只比较 Buffer 中的剩余元素。

compareTo()方法

compareTo() 方法比较两个 Buffer 的剩余元素( byte、char 等), 如果满足下列条件,则认为一个 Buffer “小于”另一个 Buffer :

(译注:剩余元素是从 position 到 limit 之间的元素)

分散(Scatter)/聚集(Gather)

Java NIO开始支持 scatter / gather,scatter / gather 用于描述从 Channel 中读取或者写入到 Channel 的操作。

分散(scatter)从 Channel 中读取是指在读操作时将读取的数据写入多个 buffer 中。因此,Channel 将从 Channel 中读取的数据“分散(scatter)”到多个 Buffer 中。

聚集(gather)写入Channel是指在写操作时将多个buffer的数据写入同一个 Channel,因此,Channel 将多个 Buffer 中的数据“聚集(gather)”后发送到 Channel。

scatter / gather 经常用于需要将传输的数据分开处理的场合,例如传输一个由消息头和消息体组成的消息,你可能会将消息体和消息头分散到不同的 buffer 中,这样你可以方便的处理消息头和消息体。

Scattering Reads

Scattering Reads 是指数据从一个 channel 读取到多个 buffer 中。如下图描述:


image.png

代码示例如下:

上一篇下一篇

猜你喜欢

热点阅读