目录
一、Buffer缓冲区
二、Channel通道
1.Channel和Stream的区别
2.Socketchannel
3.ServerSocketChannel
三、Selector选择器
四、NIO三大件的工作流程
提到NIO网络编程,就不得不提一下NIO中的三大组件:Buffer、Channel、Selector,JDK源码开发者在这里用了很通用形象的三个词语,去分别赋予三个类的抽象含义,Buffer即缓冲,Channel即管道,Selector为选择器,因为底层实现机制的复杂, 便于开发
者的理解就用了这三个词语去定义NIO的核心类,但是除了表面上的这层含义,Java开发者还是需要去花点时间,真正深入了解其背后的原理实现。
读NIO源码会发现Buffer最终是实现了一个在内存中的字节数组,所以一个 Buffer 本质上是内存中的一块,我们可以将数据写入这块内存,之后从这块内存获取数据。这一点改变了原有的IO方式,回忆一下原来的IO方式,我们的编程范式是从Stream流里去
读取数据,一个一个字节的读取,读完以后就放在自己提前定义的数组对象里面,读取的时候只能顺序移动下标、且不能回溯,但是NIO的buffer不一样了,内部使用字节数组存储数据,并维护几个特殊变量,实现数据的反复利用。
首先用mark来备份当前的position,然后用position表示当前可以写入或读取数据的位置,再用capacity来表示缓存数组大小,最后还有一个表示剩余容量的limit参数,就像下面这张图的样子。
Buffer提供了八种类型的Buffer,覆盖了能从 IO 中传输的所有的 Java 基本数据类型,但是在网络编程的场景下用的最多的还是ByteBuffer。
ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer
JDK NIO原生的ByteBuffer功能虽然强大,但是在直接用于开发是有些费劲的,这时候经过Netty封装的ByteBuf则显得简单多了,ByteBuffer的实现类包括"HeapByteBuffer"和"DirectByteBuffer"两种,前者的Heap方式是基于堆内存使用的缓冲字节数组,而后
者的Direct则是使用的堆外内存(系统内存)。
HeapByteBuf最终往下追溯allocateArray()方法,可以发现这个是直接实例化New出来的字节数组,毫无疑问就是交给JVM托管的对象了。
protected byte[] allocateArray(int initialCapacity){
return new byte[initialCapacity];
}
而DirectByteBuffer追溯到最终的allocatDirect方法里,可以看到此处调用JDK的native方法去往堆外空间来分配对象数组。
通道是对原来 IO 包中的Stream流的模拟,通过Channel这个对象,我们可以读取和写入数据,并且通过Channel的所有发送数据都要读到缓冲区Buffer中,所有要接收的数据都要写到缓冲区Buffer里。NIO中的Channel和IO中的Stream最显著的区别如下:
流是单向的,通道是双向的,可读可写。
流读写是阻塞的,通道可以异步读写。
流中的数据可以选择性的先读到缓存中,通道的数据总是要先读到一个缓存中,或从缓存中写入
解释一下为啥Stream流是单向的?之前用IO的方式去读写数据的时候,读写是要分离的,即必须要明确是InputStream还是OutputStream,而在Channel这里,一条连接客户端和服务端的Channel是共用的,NIO开发中可以利用
channel.read()
方法去读取socket缓冲区的数据,也可以通过
channel.write()
去刷出数据到客户端。Channel的实现类很多,这里需要重点了解的就是Socketchannel
和ServerSocketChannel
。
再解释一下为何流是阻塞的而通道不是?流的read和write都是同步操作,在Stream中调用读写方法时,必须要等IO操作完成以后才能执行下一步,需要顺序执行而没有异步的方式可以用。而NIO中Channel的读写是可以设置为非阻塞的,非阻塞模式下,
write()方法在尚未写出任何内容时可能就返回了,这种模式下须得在while循环中判断来调用write()。
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
channel.write(buf);
}
SocketChannel 暂时可以理解成一个 TCP 客户端(其实SocketChannel还可以作为服务端中Worker线程组要处理的TCP长连接),打开一个 TCP 连接的姿势如下:
// 打开一个通道
SocketChannel socketChannel = SocketChannel.open();
// 发起连接
socketChannel.connect(new InetSocketAddress("localhost", 80));
而读写数据的方式也很方便,读时read到缓冲buffer,写时刷出缓冲buffer即可:
// 读取数据
socketChannel.read(buffer);
// 写入数据到网络连接中
while(buffer.hasRemaining()) {
socketChannel.write(buffer);
}
ServerSocketChannel 可以理解为服务端,ServerSocketChannel 用于监听机器端口,管理从这个端口进来的 TCP 连接。
// 实例化
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 监听端口
serverSocketChannel.socket().bind(new InetSocketAddress(80));
while (true) {
// 一旦有一个 TCP 连接进来,就对应创建一个 SocketChannel 进行处理
SocketChannel socketChannel = serverSocketChannel.accept();
}
ServerSocketChannel 不和 Buffer 打交道了,因为它并不实际处理数据,它一旦接收到请求后,实例化 SocketChannel,之后在这个连接通道上的数据传递它就不管了,因为它需要继续监听端口,等待下一个连接;每一个TCP连接都分配给一个
SocketChannel来处理了,读写都基于后面的SocketChannel,这部分其实也是网络编程中经典的Reactor设计模式。
Selector是三大组件中的最C位的对象,Selector建立在非阻塞的基础之上,IO多路复用在Java中实现就是它,它做到了一个线程管理多个Channel,可以向Selector注册感兴趣的事件,当事件就绪,通过
Selector.select()
方法获取注册的事件,进行相应的操作。
具体的工作流程:Selecor通过一种类似于事件的机制来解决这个问题。首先你需要把你的连接,也就是 Channel 绑定到 Selector 上,然后你可以在接收数据的线程来调用 Selector.select() 方法来等待数据到来。这个 select 方法是一个阻塞方法,这个线程会
一直卡在这儿,直到这些 Channel 中的任意一个有数据到来,就会结束等待返回数据。它的返回值是一个迭代器,你可以从这个迭代器里面获取所有 Channel 收到的数据,然后来执行你的数据接收的业务逻辑。既可以选择直接在这个线程里面来执行接收数
据的业务逻辑,也可以将任务分发给其他的线程来执行,如何选择完全可以由你的代码来控制。
大致流程如下代码,也可以说是NIO编程的模板代码:
Selector selector = Selector.open();
// 实例化一个服务端的ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//开启非阻塞
serverSocketChannel.configureBlocking(false);
//绑定端口
serverSocketChannel.socket().bind(new InetSocketAddress(1234));
//ServerSocketChannel注册selector,并表示关心连接事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//没有socket就绪,select方法会被阻塞一段时间,并返回0
while (selector.select() > 0) {
//socket就绪则会返回具体的事件类型和数目
Set keys = selector.selectedKeys();
//遍历事件
Iterator iterator = keys.iterator();
//根据事件类型,进行不同的处理逻辑;
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
...
} else if (key.isReadable() && key.isValid()) {
...
}
keys.remove(key);
}
}
一个服务端程序启动一个Selector,在Netty中一个NioEventLoop对应一个Selector,Netty在解决JDK NIO的epoll空轮询bug时,采用的策略是废弃原来的有问题的Selector,然后重建一个Selector。因此在Reactor的主从反应堆这里,不同的反应堆可以取不同
的Selector事件来选择关心,可以用注册的事件有如下四种:
SelectionKey.OP_CONNECT
SelectionKey.OP_ACCEPT
SelectionKey.OP_READ
SelectionKey.OP_WRITE
例如主Reactor通常就是设计为关心的OP_ACCEPT,而从Reactor就更关心其余的读写以及连接状态事件。Selector中的select是实现多路复用的关键,这个方法会一直阻塞直到至少一个channel被选择(即该channel注册的事件发生了为止,除非当前线程发生
中断或者selector的wakeup方法被调用。
Selector.select方法最终调用的是EPollSelectorImpl的doSelect方法,在深入远吗就会发现其中的subSelector.poll() ,这里是select的核心,由native函数poll0实现了(不忍心贴代码,看懂要花挺多的时间了)。
所以将上面NIO的三大组件串起来,并结合Reactor设计模式用于网络编程开发的,基本模板代码思路就是如下:
首先创建
ServerSocketChannel
对象,和真正处理业务的线程池然后对上述
ServerSocketChannel
对象进行绑定一个对应的端口,并设置为非阻塞紧接着创建
Selector
对象并打开,然后把这Selector
对象注册到ServerSocketChannel
中,并设置好监听的事件,监听SelectionKey.OP_ACCEPT
。接着就是
Selector
对象进行死循环监听每一个Channel
通道的事件,循环执行Selector.select()
方法,轮询就绪的Channel
。从
Selector中
获取所有的SelectorKey
(这个就可以看成是不同的事件),如果SelectorKey
是处于OP_ACCEPT
状态,说明是新的客户端接入,调用ServerSocketChannel.accept
接收新的客户端。然后对这个把这个接受的新客户端的
Channel
通道注册到ServerSocketChannel
上,并且把之前的OP_ACCEPT
状态改为SelectionKey.OP_READ
读取事件状态,并且设置为非阻塞的,然后把当前的这个SelectorKey
给移除掉,说明这个事件完成了如果第5步的时候过来的事件不是
OP_ACCEPT
状态,那就是OP_READ
读取数据的事件状态,然后调用本文章的上面的那个读取数据的机制就可以了。
当然Netty的网络编程风格上要优化了许多,它的工作流程步骤:
创建 NIO 线程组
EventLoopGroup
和ServerBootstrap
。设置
ServerBootstrap
的属性:线程组、SO_BACKLOG 选项,设置NioServerSocketChannel
为Channel
,设置业务处理Handler
绑定端口,启动服务器程序。
在业务处理
Handler处理器
中,读取客户端发送的数据,并给出响应。