文章

Java NIO 与 OkIO 初探

Java 原生有两套 IO API,分别是传统基于流的 IO,以及新的基于 Channel 的 NIO。除此之外,有一个第三方库 OkIO,跟着 OkHttp 与 Moshi 一起火起来了。

传统 IO 相比各位已经比较熟悉,今天就只了解一下后面两套 API。

NIO

NIO 并不是一个好用的 API,实际工作中用的比较少,对于 Android 开发者来说用的就更少了。只做了解。

NIO 以非阻塞式出名,但事实是 NIO 的非阻塞式仅支持网络 IO,并且默认仍是阻塞的。对于文件 IO 仅支持阻塞模式。

缓冲模型

NIO 以 Channel 为模型,与 Stream 不同,Channel 是双工的,同时支持读写数据。Channel 强制使用 Buffer,并且需要手动管理 Buffer。什么意思?看下面的工作模型:

我们创建的一个 ByteBuffer,其内部有三个指针:

  • position: 执行当前读写的位置。
  • capacity: 表示 buffer 容量,是固定的。
  • limit: 表示当前可读写的范围,初始化时与 capacity 一致。

以读文件为例,完成真正的 IO 操作后,buffer 内部状态如下:

现在需要把 buffer 内部的数据取出来,但 position 只能向后读写,显然不符合需求。所以在取数据之前需要把 position 重置为 0。除此之外,与 Stream IO 一样,读入多少文件才能取多少数据,所以 limit 也需要更新,更新为当前 position 的位置。

代码如下:

byteBuffer.limit(byteBuffer.position());
byteBuffer.position(0);
// 上面两行等价于下面的便捷写法
 byteBuffer.flip();

更新后状态如下(黄色区域是 position 合法的操作范围):

这时候就可以把数据取出来了。可以想象,取完数据后 positionlimit 重合,那么就没有空间用于下一次读入数据了。因此取完数据记得再把 position 归零,而 limit 需要重置为 capacity 大小。

代码如下:

byteBuffer.position(0);
byteBuffer.limit(byteBuffer.capacity());
// 下面是便捷写法
byteBuffer.clear();

文件操作

private static void fileNio() {
  try (RandomAccessFile raf = new RandomAccessFile("/tmp/abc.txt", "r");
       FileChannel channel = raf.getChannel()) {
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    channel.read(byteBuffer);
    byteBuffer.flip(); // 调整指针位置来取数据
    System.out.println(Charset.defaultCharset().decode(byteBuffer));
    byteBuffer.clear(); // 为下一次读取调整指针位置
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

阻塞式网络操作

private static void blockingServer() {
  try (ServerSocketChannel channel = ServerSocketChannel.open();) {
    channel.bind(new InetSocketAddress(80));
    SocketChannel socketChannel = channel.accept(); // 阻塞,直到有连接进来
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    while (true) {
      socketChannel.read(byteBuffer);
      byteBuffer.flip();
      socketChannel.write(byteBuffer);
      byteBuffer.clear();
    }
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

可以看到除了取数据有点不一样,其他和 Stream 的 Socket API 没啥不同。代码跑起来之后就可以用 telnet 测试一下:

> telnet localhost 80
Trying ::1...
Connected to localhost.
Escape character is '^]'.
hi
hi
Hello
Hello

非阻塞网络操作

因为是非阻塞的,所以 channel.accept() 会立即返回,但它不一定有值呀。为了不用一个死循环一直尝试然后又变成阻塞式,这里需要一个事件监听器性质的东西 Selector。调用 Selector.select() 会阻塞,直到有事件发生。

等等,又阻塞了?!是的,又阻塞了。IO 一定是阻塞的,除非数据流的速度和 CPU 一样快 🐶。那有什么意义呢?有没有注意到,Selector 是注册到 ServerSocketChannel 上的,这意味着多个 Channel 可以共享一个 Selector。如此一来就不需要开多个线程分别去等待事件了。

好像...有一点鸡肋?恩,事实如此。

private static void nonblockingServer() {
  try (ServerSocketChannel channel = ServerSocketChannel.open();) {
    channel.configureBlocking(false);
    channel.bind(new InetSocketAddress(80));

    Selector selector = Selector.open();
    channel.register(selector, SelectionKey.OP_ACCEPT);

    selector.select(); // 阻塞!!
    for (SelectionKey key : selector.selectedKeys()) {
      if (key.isAcceptable()) {
        SocketChannel socketChannel = channel.accept();
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        while (true) {
          socketChannel.read(byteBuffer);
          byteBuffer.flip();
          socketChannel.write(byteBuffer);
          byteBuffer.clear();
        }
      }
    }
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

实验效果和阻塞式的一样。

OkIO

OkIO 本质来说是对 java 传统 IO API 的包装,提供的新 API 非常易用。并且对于一些场景 OkIO 做了许多优化,避免额外的 CPU 运算或数据拷贝。同时 OkIO 是 OkHttp 与 Moshi 的内部依赖,所以对于常见的项目,使用 OkIO 不会引入额外依赖,非常轻量。

OkIO 中用于读取的东西叫 Source,写到的东西叫 Sink

下面演示一个简单的读取例子:

private static void okio() {
  try (Source source = Okio.source(new File("/tmp/abc.txt"));
       Buffer buffer = new Buffer()) {
    source.read(buffer, 1024);
    System.out.println(buffer.readUtf8());
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

注意打印的时候,函数名是 buffer.readUtf8()。因为 OkIO 把 Buffer 视为一个外部工具,把 Source 的内容读入 Buffer 对 Buffer 来说是写操作,而从 Buffer 取数据自然就是读操作了。

上面的 Buffer 是我们自己实现的。类似于 BufferedReader,OkIO 也支持包装的 Buffer:

private static void okio() {
  try (BufferedSource source = Okio.buffer(Okio.source(new File("/tmp/abc.txt")))) {
    System.out.println(source.readUtf8());
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

不难看出 OkIO 的 API 的确比较易用,何况内部还有额外优化,何乐而不为呢?