原创

Java IO与NIO的区别

1. 概览

处理输入和输出是Java程序员的常见任务,本教程中,我们将介绍 原始的 java.io (IO) 库和较新的 java.nio (NIO) 库 以及它们在通过网络进行通信时的区别。.

2. 关键特性

让我们先来看看这两个包的关键特性。

2.1. IO – java.io

java.io 包是在Java 1.0引入的,而Reader 则是在 Java 1.1中引入。它提供:

  • InputStreamOutputStream – 一次提供一个字节的数据。
  • ReaderWriter – 包装流
  • 阻塞模式(blocking mode) – 等待完整的消息

2.2. NIO – java.nio

java.nio 包在Java 1.4中被引入 并在Java 1.7 (NIO.2)更新了,其中包含 增强的文件操作ASynchronousSocketChannel*。 它提供 :

  • Buffer 一个读取数据块
  • CharsetDecoder – 用于将原始字节映射到可读字符/从可读字符映射原始字节
  • Channel – 与外界沟通
  • Selector – 在 SelectableChannel 上启用多路复用,并提供对任何准备好进行I/O的 Channels 的访问
  • 非阻塞模式(non-blocking mode) – 读取任何准备好的东西

现在,让我们看看在向服务器发送数据或读取其响应时如何使用这些包。

3. 配置测试服务器

在这里,我们将使用 WireMock 来模拟另一台服务器,以便我们可以独立运行测试。

配置这台服务器来监听请求,并像真正的web服务器一样向我们发送响应。同时我们还将使用动态端口,这样就不会与本地计算机上的任何服务冲突。

让我们添加WireMock Maven依赖项到 test scope:

Let's add the Maven dependency for WireMock with test scope:

<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock-jre8</artifactId>
    <version>2.26.3</version>
    <scope>test</scope>
</dependency>

在测试类中,让我们定义一个 JUnit@Rule 来在空闲端口上启动 WireMock 。然后,我们将对其进行配置,使其在要求预定义资源时返回一个 HTTP 200 响应,消息体为 JSON 格式的文本:

@Rule public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort());

private String REQUESTED_RESOURCE = "/test.json";

@Before
public void setup() {
    stubFor(get(urlEqualTo(REQUESTED_RESOURCE))
      .willReturn(aResponse()
      .withStatus(200)
      .withBody("{ \"response\" : \"It worked!\" }")));
}

现在已经建立了模拟服务器,我们准备运行一些测试。

4. Blocking IO – java.io

我们可通过从网站上读取一些数据来了解原始的阻塞IO模型是如何工作的,例如:使用一个 java.net.Socket 来访问操作系统的一个端口。

4.1. 发送请求(Request)

在这个例子中,我们将创建一个GET请求来检索资源。首先,创建一个 Socket 来访问我们的WireMock服务器正在监听的端口

Socket socket = new Socket("localhost", wireMockRule.port())

对于普通的 HTTP 或 HTTPS 通信,端口应该是 80 或 443 。但是,在本例中,我们使用wireMockRule.port() 来访问前面设置的动态端口。
现在,我们在套接字上打开一个 OutputStream ,包装在 OutputStreamWriter 中,并将其传递给 PrintWiter 来编写我们的消息。确保刷新缓冲区以便发送我们的请求:

OutputStream clientOutput = socket.getOutputStream();
PrintWriter writer = new PrintWriter(new OutputStreamWriter(clientOutput));
writer.print("GET " + TEST_JSON + " HTTP/1.0\r\n\r\n");
writer.flush();

4.2. 等待响应(Response)

打开套接字上的 InputStream 来获取响应,使用 BufferedReader 读取流,并将其存储在 StringBuilder 中:

InputStream serverInput = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(serverInput));
StringBuilder ourStore = new StringBuilder();

我们使用 reader.readLine() 来阻塞,等待一个完整的行,然后将该行追加到我们的存储中。我们将一直读取,直到得到一个空值,它指示流的结尾:

for (String line; (line = reader.readLine()) != null;) {
   ourStore.append(line);
   ourStore.append(System.lineSeparator());
}

5. Non-Blocking IO – java.nio

现在,让我们看看 NIO包 的非阻塞IO模型是如何与同一个例子一起工作的。

这次,我们将创建一个 java.nio.channel.SocketChannel 来访问服务器上的端口,而不是java.net.Socket,并向它传递一个InetSocketAddress。

5.1. 发送 Request

首先, 打开 SocketChannel:

InetSocketAddress address = new InetSocketAddress("localhost", wireMockRule.port());
SocketChannel socketChannel = SocketChannel.open(address);

现在,让我们使用一个标准的UTF-8字符集 来编码和编写我们的消息:

Charset charset = StandardCharsets.UTF_8;
socket.write(charset.encode(CharBuffer.wrap("GET " + REQUESTED_RESOURCE + " HTTP/1.0\r\n\r\n")));

5.2. 读取 Response

发送请求后,我们可以使用原始缓冲区以非阻塞模式读取响应。

既然要处理文本,那么我们需要一个 ByteBuffer 来处理原始字节,一个CharBuffer 用来转换字符(借助 CharsetDecoder):

ByteBuffer byteBuffer = ByteBuffer.allocate(8192);
CharsetDecoder charsetDecoder = charset.newDecoder();
CharBuffer charBuffer = CharBuffer.allocate(8192);

如果数据是以多字节字符集发送的,CharBuffer 将有剩余空间。

注意,如果需要特别快的性能,我们可以使用 ByteBuffer.allocateDirect() 在本机内存中创建一个MappedByteBuffer。然而,在我们的例子中,从标准堆中使用 allocate() 已经足够快了。

在处理缓冲区时,我们需要知道缓冲区有多大(capacity),我们在缓冲区中的位置(current position),以及我们能走多远(limit)。

所以,我们从SocketChannel中读取,将它传递给 ByteBuffer 来存储我们的数据。从 SocketChannel读取将以 ByteBuffer当前位置为下一个要写入的字节(就在写入最后一个字节之后)结束,但其限制(limit)不变

socketChannel.read(byteBuffer)

Our SocketChannel.read() 返回可以写入缓冲区的读取字节数 ,如果断开连接,则会变成 -1.

当缓冲区由于尚未处理其所有数据而没有剩余空间时,SocketChannel.read() 将返回读取的零字节,但buffer.position() 仍将大于零。

确保从缓冲区的正确位置开始读取, 我们将使用 Buffer.flip() 来设置 ByteBuffer 的当前位置为0 以及它对 SocketChannel 写入的最后一个字节的限制。 然后,我们将使用 storeBufferContents 方法保存缓冲区内容,稍后我们将查看该方法。最后,使用 buffer.compact() 压缩缓冲区并设置当前位置,以便下次从 SocketChannel 读取。

由于数据可能部分到达,需要用终止条件将缓冲区读取代码包装成一个循环,以检查套接字是否仍然连接,或者是否已断开连接,但缓冲区中仍有数据:

while (socketChannel.read(byteBuffer) != -1 || byteBuffer.position() > 0) {
    byteBuffer.flip();
    storeBufferContents(byteBuffer, charBuffer, charsetDecoder, ourStore);
    byteBuffer.compact();
}

别忘了关闭套接字(除非我们在try with resources块中打开它):

socketChannel.close();

5.3. Buffer存储数据

来自服务器的响应将包含头,这可能会使数据量超过缓冲区的大小。因此,我们将使用StringBuilder在消息到达时构建完整的消息。
为了存储我们的消息,我们首先将原始字节解码为我们的 CharBuffer 中的字符。然后翻转指针,以便读取字符数据,并将其附加到可扩展的 StringBuilder. 最后,清除CharBuffer以准备下一个写/读循环。
现在,让我们实现传入缓冲区的完整 storeBufferContents() 方法,CharsetDecoderStringBuilder

void storeBufferContents(ByteBuffer byteBuffer, CharBuffer charBuffer, 
  CharsetDecoder charsetDecoder, StringBuilder ourStore) {
    charsetDecoder.decode(byteBuffer, charBuffer, true);
    charBuffer.flip();
    ourStore.append(charBuffer);
    charBuffer.clear();
}

6. 总结

本文中, 我们已经看到原始java.io模型如何阻塞,等待请求,并使用 Streams 来操作它接收到的数据。相反,java.nio库允许使用Buffers和Channels进行非阻塞通信,并且可以提供直接内存访问以获得更快的性能。然而,这种速度带来了处理缓冲区的额外复杂性。

在本文中,我们看到了原始 java.io 模型如何阻塞,如何等待请求并使用Streams来处理它接收到的数据。相反,java.nio库允许使用BuffersChannels进行非阻塞通信,并且可以提供直接内存访问以获得更快的性能。然而,这种速度带来了处理缓冲区的额外复杂性。

一如既往, 代码 over on GitHub.

正文到此结束