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 合法的操作范围):
这时候就可以把数据取出来了。可以想象,取完数据后 position
与 limit
重合,那么就没有空间用于下一次读入数据了。因此取完数据记得再把 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 的确比较易用,何况内部还有额外优化,何乐而不为呢?