1. BIO vs NIO
说起 NIO,不可避免的需要拿来与 BIO 进行对比。在对比之前,我们先理解一下 BIO 的 API。
BIO 是 Blocking IO 的缩写。什么是 Blocking IO 呢?这里其实就是指传统的基于流的 IO 编程方式,下面就是一个传统的 BIO 编程方式,代码展示的是如何从 BioTest_in.txt 读取数据,并将内容复制到 BioTest_out.txt:
public static void main(String[] args) throws Exception {
FileInputStream fis = new FileInputStream("BioTest_in.txt");
FileOutputStream fos = new FileOutputStream("BioTest_out.txt");
byte[] bytes = new byte[512];
int count;
while ((count = fis.read(bytes)) > 0) {
fos.write(bytes, 0, count);
}
fos.close();
fis.close();
}
我们再来看看,用 NIO 如何完成一个类似的工作:
public static void main(String[] args) throws Exception {
FileInputStream fis = new FileInputStream("input2.txt");
FileOutputStream fos = new FileOutputStream("output2.txt");
FileChannel inputChannel = fis.getChannel();
FileChannel outputChannel = fos.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(20);
while (true) {
buffer.clear();
int read = inputChannel.read(buffer);
if(read == -1) {
break;
}
buffer.flip();
outputChannel.write(buffer);
}
inputChannel.close();
outputChannel.close();
}
看起来 NIO 变得更复杂了,这还是没有引入 Selector 的情况下的代码,引入 Selector 复杂度会更高。
总体来说,BIO 与 NIO 有以下区别:
- BIO 是基于 Stream 的方式,而 NIO 是基于 Buffer 的方式;
- BIO 是阻塞的,NIO 是非阻塞的。
- NIO 增加了 Selectors 的机制。
1.1 基于 Stream vs 基于 Buffer
对于基于 Stream 的方式,可以同时从一个流中读取一个或多个字节。读取多少字节,完全取决于程序,默认情况下也不会进行缓存。在这种情况下,你没有办法回溯流中之前读到的位置,除非把流中读到的数据进行缓存。
而基于 Buffer 的方式,则不一样。数据在处理之前,会先读取到 Buffer 中。你可以在 Buffer 中读取任意位置的数据,而不仅限于从前往后。不过,你还是需要确认缓存中是否已经包含了你所需要的所有数据,也需要考虑当你还没处理完缓存中的数据之前,千万不要覆盖掉缓存中的数据。
看起来 Stream 也能通过使用 BufferedInputStream 等包装类,达到在缓存中进行多次多个位置的读取的目的。但是在基于流的方式下,一个流要么是输入流,要么是输出流,不可能同时既是输入流又是输出流。而基于 Buffer,一个 Buffer 既可以读,也可以写,如下所示:
public static void main(String[] args) throws Exception {
RandomAccessFile randomAccessFile = new RandomAccessFile("NioTest14.txt", "rw");
FileChannel channel = randomAccessFile.getChannel();
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);
// 读取
System.out.println(mappedByteBuffer.get(4));
mappedByteBuffer.clear();
// 在同一个Buffer中写入
mappedByteBuffer.put(0, (byte)'a');
mappedByteBuffer.put(3, (byte)'b');
channel.write(mappedByteBuffer);
randomAccessFile.close();
}
1.2 阻塞 vs 非阻塞
在传统的基于流的 IO 都是阻塞的。这就意味着,当一个线程调用 read()或 write()方法时,线程会阻塞住,直到数据读/写完成。在这个读/写过程中,该线程不能做其它事情。
NIO 的非阻塞模型,使一个线程在请求从 channel 中读取数据时,可以在 channel 中的数据准备好才进行读取操作,而不是阻塞住直到数据可读。这样在 channel 中的数据准备好之前,线程可以做其它事情。写操作也是类似的,在 channel 可写之前,线程可以去做其它事情,直到 channel 准备好。
非阻塞的方式带来了一个显著的优势:将线程与单个 IO 读写的绑定关系解放出来。这意味着,如果单个 channel 中的 IO 读写操作还没准备好,线程完全可以去处理其它 channel 的 IO 读写。换句说,单个线程具备了同时管理多个 IO 操作的能力。
1.3 Selectors
NIO 中的 Selectors 允许单个线程去监控多个 channel。你可以将多个 channel 注册到一个 selector 上,然后使用一个线程去"select"准备好读取的 channel,或者去"select"准备好写入的 channel。这个 selector 机制使单个线程管理多个 channel 变得容易了许多。示例代码如下:
// 注意:为了排版,省略了大量try catch与具体处理逻辑
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ...;
Selector selector = Selector.open();
// 注册socket连接至selector上,注册的channel为serverSocketChannel
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 选择感兴越的事件(目前是OP_ACCEPT),如果存在,就往下执行,不存在,这里就阻塞住(也有不阻塞的API:selectNow())。
selector.select();
// 获得目前可以获取到的selectionKey列表
Set<SelectionKey> selectionKeys = selector.selectedKeys();
selectionKeys.forEach(selectionKey -> {
// 当前selectionKey是否可连接
if(selectionKey.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel)selectionKey.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
// ...
// 注册OP_READ到slector上,注册的channel为client
client.register(selector, SelectionKey.OP_READ);
// 当前selectionKey是否可读
} else if(selectionKey.isReadable()) {
SocketChannel client = (SocketChannel)selectionKey.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int count = client.read(byteBuffer);
// ...
}
});
// 清空selectionKeys,表示已经处理。不清理会重复执行事件
selectionKeys.clear();
}
}
1.4 如何选择
NIO 允许使用单个或者少量的线程去管理多个 channel(网络请求/文件),代价是相对基于流的方式,解析数据变得更加复杂,正如上面的代码所示。
对于单个 socket 连接来说,不考虑 zero-copy 的因素,引入 NIO 实际会增加请求延时,降低吞吐量。所以如果是写一个客户端程序,完全可以用 BIO 来降低编程的复杂度。
但如果你需要管理的是成千上万的连接,每个连接只有少量的数据。最典型的就是类似于聊天室这种应用,NIO 就具有非常明显的优势。
Java NIO:单个线程管理大量的连接
如果你只有少量的连接,而且每个连接包含大量的网络 IO,也许一个典型的 BIO 就是最佳的选择。
Java IO: 一个连接由一个线程管理
2 Channel
一个 channel 代表与一个实体的连接,实体可以是硬件设备、文件、网络套接字或编程组件,这些实体都支持一个或多个的 IO 操作,比如读和写。
一个 channel 要么是开启的要么是关闭的。一个 channel 一旦创建就是开启状态,一旦关闭就会保持关闭。如果一个 channel 是关闭状态,试图对该 channel 进行 IO 操作都会抛出 ClosedChannelException。判断一个 channel 是否开启可以通过 isOpen 方法。
channel 是否为多线程安全取决于具体的实现。
3 Selector
Selector 是什么?它是一个 SelectableChannel 对象的多路调节器(multiplexor)。
一个 selector 可以通过 Selector 类本身的 open 创建得到,这种创建是使用了系统默认的 select provider 来创建的。一个 selector 也可以通过调用一个自定义 select provider 的 openSelector 方法得到。一个 selector 创建后会一直开启,直到调用 close 方法。
selector 与 channel 的注册关系表现为 SelectionKey 对象。一个 selector 维持三个 selection key 的集合:
- key set, 包含当前 channel 注册在 selector 上的 keys。这个集合可以通过 keys 方法得到。
- selected-key set, 这个集合里包含了 channel 感兴趣的的 key。这个集合通过 selectedKeys 方法得到。selected-key set 是 key set 的一个子集。
- cancelled-key set 是保存那些已经被取消,但还没有从 key set 中移除的 key.这个集合不会被直接访问到,cancelled-key set 是 key set 的一个子集。
刚创建 selector 时,这三个集合都是空的。
channel 调用 register 方法时,一个对应的 key 就会加入到 selector 的 key set 中。cancelled-key set 会在下一次 selection 操作时被移除。key set 本身是不能被直接修改的。
当 key 的 channel 本身关闭,或者调用 key 的 cancel 方法,都会导致这个 key 被加到 cancelled-key 中。cancelled-key set 会在下一次 selection 操作时被移除,此时,这些 key 会从 selector 的所有 key set 中被移除。
当执行 selection 操作时,key 将会被加到 selected-key 中。通过调用 set 的 remove 方法,或者通过 set 的 iterator 的 remove 方法,一个 key 可以直接从 selected-key 中移除。除此之外,selected-key 没有其它的移除方式。它并不会随着 selection 操作移除。
3.1 Selection
在每一个 selection 操作中,key 可以被添加到 selector 的 selected-key set 中,也可以从中被移除。selection 操作具体是指 select(), select(long)和 selectNow()方法,涉及以下三个步骤:
-
先把 canceled-key 中的 key 从所有集合中移除,并且移除对应 channel 的注册信息。这个步骤执行完,canceled-key 就会变空。
-
在 selection 操作开始时,JVM 会去查询底层操作系统,以确认每个 channel 是否准备好由其 key 对应的兴趣集合标记的任何 IO 操作。对于一个 channel 而言,如果准备好对应的操作,接下来两个步骤会被执行:
- 如果 channel 的 key 并没有在 selected-key 中,则会把 key 加入 selected-key 中,同时也会加入 ready-operation set,这个集合用来标记这个 channel 可以进行的具体操作。所有之前记录的 ready set 会被清除。
- 如果 channel 的 key 已经在 selected-key 中,则会把 key 加入 ready-operation set,所有之前记录的 ready set 都会被保留。换句话说,从底层操作系统返回的 readdy set 是按位或(bitwise-disjoined)的。
所有在 key set 中的 key,如果开始时没有 interest set。那么要么 selected-key 被更新,要么 key 对应的 ready-operation set 会被更新。
-
如果在执行第 2 步时,有 key 加入了 cancelled-key set,那么它们会继续在第 1 步中处理。
selection 操作在等待一个或多个 channel 时是否会保持阻塞,取决于执行的具体方法,是 select(),select(long)还是 selectNow()。
4 Buffer
Buffer 本身就是一块内存,底层实现上,它实际上是个数组。数据的读、写都是通过 Buffer 来实现的。除了数组之外,Buffer 还提供了对于数据的结构化访问方式,并且可以追踪到系统的读写过程。Java 中的 7 种原生数据类型都有各自对应的 Buffer 类型,如 IntBuffer,LongBuffer,CharBuffer 等,没有 BooleanBuffer。
所有数据的读写都是通过 Buffer 来进行的,永远不会出现直接向 Channel 写入数据的情况,或者直接从 Channel 读取数据的情况。
4.1 IO 底层实现
假如应用中低延时是非常重要的指标,那么我们就有必要从操作系统层面了解下 IO 的底层实现,我们先看一下本文中的第一个 BIO 的例子:
public static void main(String[] args) throws Exception {
FileInputStream fis = new FileInputStream("BioTest_in.txt");
FileOutputStream fos = new FileOutputStream("BioTest_out.txt");
byte[] bytes = new byte[512];
int count;
while ((count = fis.read(bytes)) > 0) {
fos.write(bytes, 0, count);
}
fos.close();
fis.close();
}
4.1.1 read() & write()
在操作系统层面,发生了如下事情:
- JVM 发送 read()的系统调用。
- 操作系统将上下文切换到内核态,读取硬盘上的数据到 input socket buffer。
- 操作系统将数据复制至用户态,并将上下文切换回用户态,此时 read()方法返回。
- JVM 执行处理逻辑,并发送 write()的系统调用。
- 操作系统将上下文切换至内核态,并将数据从用户态复制到 output socket buffer。
- 操作系统返回至用户态,JVM 继续执行后续的逻辑。
在延时与吞吐量还没有成为我们服务的瓶颈时,上面的代码可以很好的工作。但是如果仔细思考的话,对于单个用户来说,性能仍然不够好。因为上面的例子中有 4 次上下文的切换,和 2 次没有意义的拷贝。
注意到上面这个例子中,从内核态复制内容到用户态,以及从用户态复制回内核态是完全没有必要的,因为我们除了复制文件,没有对内容做任何修改。在这种情况下,zero copy 完全可以被使用。zero copy 的具体实现没有一个标准,取决于操作系统是如何实现的。在 Linux 系统中提供了 sendfile()。
4.1.2 sendfile()
使用 sendfile(),流程图就变成这样:
这里操作系统仍然存在一个内核态的拷贝。从操作系统的角度,这里已经 zero-copy 了,因为没有数据从内核态拷贝至用户态。为什么还在内核态中再拷贝一次呢?这是因为,通常硬件 DMA 访问需要连续的内存空间(和缓冲区)。如果硬件支持 scatter-n-gather,内核态的拷贝就是可以避免的,此时流程就变成这样:
很多 web 服务器支持 zero-copy,比如 Tomcat 和 Apache,需要通过参数启用。
在 Java 的 NIO 中,提供了 transferTo 方法对 zero-copy 提供了支持。
4.1.3 mmap()
上面 zero-copy 的解决方案存在一个限制,就是程序中无法操作内容,只能将其直接转发。这里有一个开销更大,但是更有用的方式,就是 mmap,short for memory-map。
mmap 允许程序将文件映射至内核态,而且可以通过用户态中直接访问,避免了不必要的复制。有得必有失,这里仍然包含了 4 次上下文切换。但是一旦操作系统将文件映射至内存,就可以获得操作系统虚拟内存管理的所有好处:热点内容可以更有效率的缓存,所有数据都是页对齐,因此对于写操作,不需要从缓冲区中进行复制。
然而,天下没有免费的午餐。mmap 虽然避免了额外的复制,但是它并不保证代码会更有效率,这取决于操作系统的实现,也许会增加启动和销毁时的开销(因为需要找到容纳文件的内存空间并且在 TLB 上维护,也需要在 unmapping 后刷新到磁盘)。page fault 会开销更大,因为内核需要从硬盘读取来更新内存空间和 TLB。因此在引入 mmap()时,必须进行性能测试,以避免糟糕的性能。
在 Java 中,相对应的类是 nio 中的 MappedByteBuffer。
4.2 HeapByteBuffer
通过调用 ByteBuffer.allocate()
方法得到。这里的 Heap 表示该 ByteBuffer 是存放在 JVM 的堆空间中,因此,它也支持 GC 和缓存优化。然而,它不是页对齐的,这就意味着,如果你需要通过 JNI 来调用时,JVM 需要复制一份至对齐缓部区。
4.3 DirectByteBuffer
通过调用 ByteBuffer.allocateDirect()
方法得到。JVM 会使用 malloc()
直接在堆外分配内存。因为它不再受 JVM 的管理,分配的内存是页对齐的,也不受 GC 的管理。这就意味着,如果需要在 native code 中操作(比如在写 OpenGL 时),DirectByteBuffer 就是一个完美的方案。然而,你需要去自己管理内存的分配与释放以避免内存泄漏。
4.4 MappedByteBuffer
通过调用 FileChannel.map()
方法得到。类似于 DirectByteBuffer
,它也是分配在 JVM 堆外的。MappedByteBuffer 本质上就是 OS mmap()的一个包装,以便代码直接操作映射的物理内存。
4.5 小结
sendfile() 与 mmap()在操作系统底层,提供了高效、低延时的 socket 数据操作解决方案。这里强调下,在实际开发中,应用场景各不相同,所以不存在绝对的银弹。如果性能、延伸没有达到瓶颈,不需要花精力去把代码切换到这两个解决方案上。在软件实施过程中,在大多数情况下,获得最佳的 ROI(投资回报率),是使软件能够工作正常,而不是使软件工作更快。由于脱离 JVM 的保护,对于复杂逻辑,很容易使软件变得不可靠,更容易崩溃。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于