NIO(Non-blocking I/O,在Java领域,也称为New I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。
1、selector
selector是NIO的选择器,NIO的同步功能就是靠selector来实现的,多个channel可以注册到同一个selector上,然后用一个线程来管理一个selector选择器,就实现了通过一个线程来管理多个通道实现客户端同步请求的效果。我们可以将Server端的监听连接请求的事件和处理请求的事件放在一个线程中,但是在事件应用中,我们通常会把它们放在两个线程中:一个线程专门负责监听客户端的连接请求(OP_ACCEPT),而且是以阻塞方式执行的;另外一个线程专门负责处理请求(OP_READ、OP_WRITE等),这个专门处理请求的线程才会真正采用NIO的方式。
2、channel
NIO通道是一个对象,可以通过它(从客户端写入缓冲区)读取和写入数据(从缓冲区通过通道发送给客户端),可以理解为是对原I/O包中流的模拟,通道和流的区别在于,通道是双向的,通道可用于读、写或者同时读写,而流只能是一个方向,inputstream是读,outputstream是写。
3、buffer
NIO缓冲区就是一个数组,有byteBuffer、charBuffer、floatBuffer、LongBuffer等。数据总是从通道读取到缓冲区,或者从缓冲区写入到通道。
这个概念绝对是一级概念,Channel是一个管道,用于连接字节缓冲区和另一端的实体, 这个字节缓冲区就是ByteBuffer, 另一端的实体可以是一个File 或者是 Socket ;
基于IO的网络编程, 数据的交互借助于InputStream或者是OutputStream, 而Channel可以理解成对Stream的又一层封装;在这种编程模型中 服务端想和客户端进行交互,就需要从服务端自己的ServerSocketChannel中获取前来连接的客户端的SocketChannel,并把他注册关联上感性趣的事件到自己的Selector选择器上, 这样一旦客户端把Buffer中的数据推送进channel, 服务端就可以感知,进而处理。
Channel是NIO编程模型中一大组件,它类似IO中的Stream,但是两者也有本质的区别;
为什么说是类似呢? 看下面的两段代码, 需求是磁盘上的文件进行读写
在IO编程中,我们第一步可能要像下面这样获取输入流,按字节把磁盘上的数据读取到程序中,再进行下一步操作
FileInputStream fileInputStream = new FileInputStream("123.txt");
在NIO编程中,目标是需要先获取通道,再基于Channel进行读写
FileInputStream fileInputStream = new FileInputStream("123.txt");
FileChannel channel = fileInputStream.channel();
对用户来说,在IO / NIO 中这两种都直接关联这磁盘上的数据文件,数据的读写首先都是获取Stream和Channel,所以说他们相似;
但是: 对于Stream来说,所有的Stream都是单向的,对我们的程序来说,Stream要么只能是从里面获取数据的输入流,要么是往里面输入数据的输出流,因为InputStream和outputStream都是抽象类,在java中是不支持多继承的, 而通道不同,他是双向的,对一个通道可读可写
怎么理解 Channel可以是双向的?#
如上图,凡是同时实现了readable,writeable接口的类,都双向的通道. 下面是典型的例子
SocketChannel
在NIO网络编程中,服务端可以通过ServerSocketChannel获取客户端的SocketChannel
这个SocketChannel可以read() 客户端的消息存入Buffer, 往客户端 write()buffer里的内容
socketChannel1.read(byteBuffer);
socketChannel1.write(byteBuffer);
对于一个channel,我们既能从中获取数据,也能往外read数据
Selector选择器NIO的第三个组件,三者的关系图如上所示
解释:一个线程专门管理Selector,Selector再去管理各个Channel通道。
selector是选择器的意思, 和它直接关联的组件是Channel, 没错,它的作用就是不断的轮询绑定在他身上的所有channel. 一旦有通道发生了它感兴趣的事件,接着处理此事件
无论是服务端的Selector 还是客户端的Selector 它都维护了三个Set集合 , 里面封装的是 SelectionKey, 他是channel注册进Selector的产物,一般是使用它反向获取channel
Set selectionKeys = selector.selectedKeys();
它的意思是,我们取出了 选择器的感性事件的set集合,只要程序还在运行,只要选择器一旦被open()
,除非我们手动的close()
否则选择器对象就不会被释放,所以它的感兴趣的set集合是不会被自动会收到,于是我们就得收到的把处理过的感兴趣的事件对应的SelectionKey移除出这个set集合,不然下一次轮询时,这个事件还会再一次被处理,并且无限制的处理下去select()
本集合中的SelectionKey会从key set中移除, 意味着它所关联的channel将会被选择器丢弃掉,不再进行监听cancel()
方法都会将key添加到cannelled key 集合中select()
方法select(long); // 设置超时时间
selectNow(); // 立即返回,不阻塞
select(); 阻塞轮询
select()过程的细节:
第一步, cannelled-key中的每一个元素会从全集key set中剔除,表示这些可以关联的通道不会被注册
第二步
操作系统帮我们轮询每一个通道是否有选择器感性趣的事情发生
第三步: 如果在轮询时发现了有任何key被放置在了cannelled-key-set中,重复第一步,不再注册它关联的通道
前者是把key从selected key set集合,也就是被选中的集合中剔除出去,表示当前的事件已经处理完了
后者是表示,把key从全集中剔除出去, 表示想要废弃这个key关联的channel
他是根据不同操作系统提供的不同的Provider
使用provide()
创建出来的
如上图, 在NIO网络编程模式中,不再是传统的多线程编程模型,当有新的客户端的连接到来,不再重新开辟新的线程去跑本次连接,而是统一,一条线程处理所有的连接, 而一次连接本质上就是一个Channel, NIO网络编程模型是基于事件驱动型的; 即,有了提前约定好的事件发生,接着处理事件,没有事件发生,选择器就一直轮询 。
下面我们同通过一个简单聊天室实例来了解上面说的上图的流程
我们需要先定义一些变量
port = 9999 //服务端端口
private Charset charset = Charset.forName("UTF-8"); //编码格式
//打开服务器套接字通道
ServerSocketChannel serverChannel = ServerSocketChannel.open();
//创建Selector实例 选择器 Selector.open()
Selector selector = Selector.open();
// 设置为非阻塞的方式
server.configureBlocking(false);
//绑定想要监听的端口
server.bind(new InetSocketAddress(port));
// 注册到选择器上,设置为监听状态
server.register(selector,SelectionKey.OP_ACCEPT);
//=================现在就正在监听port端口===================
while(true){ //轮询
if (selector.select() == 0){ // 阻塞式等待 channel上有事件发生
continue;
}
// 可以通过这个方法,知道可用通道的集合
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey sk = (SelectionKey) keyIterator.next();
keyIterator.remove(); //处理一次事件就要把当前的key剔除!!!
dealWithSelectionKey(server, sk); //自定义方法 处理数据
}
}
可以公开的情报:
SelectionKey
SelectionKey,选择键,在每次通道注册到选择器上时都会创建一个SelectionKey储存在该选择器上,该SelectionKey保存了注册的通道channel、注册的选择器、通道事件类型操作符等信息。
监听的Channel通道触发了一个事件意思是该事件已经就绪。
这四种事件用SelectionKey的四个 final变量 来表示:
SelectionKey.OP_CONNECT 连接就绪
SelectionKey.OP_ACCEPT 接收就绪
SelectionKey.OP_READ 读就绪
SelectionKey.OP_WRITE 写就绪
拿到了有事件的 SelectionKey
dealWithSelectionKey(ServerSocketChannel server, SelectionKey sk){
//先判断一下哪些事件已经就绪
if (sk.isAcceptable()) {
SocketChannel sc = server.accept(); //非阻塞获取 套接字通道 连接成功
// 设置非阻塞模式
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);// 注册选择器,并设置为读取模式,以后这个就用通道
// 将此对应的channel设置为准备接受其他客户端请求
sk.interestOps(SelectionKey.OP_ACCEPT);
System.out.println("Server is listening from client :" + sc.getRemoteAddress());//远程地址
sc.write(charset.encode("Please input your name.")); //给链接的客户端发消息
}
if (sk.isReadable()) {
// 返回该SelectionKey对应的 Channel 客户端,其中有数据需要读取
SocketChannel sc = (SocketChannel) sk.channel();
// 获取当前 是哪个客户端发起的信息
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
StringBuilder content = new StringBuilder();
try {
while (true) {// todo todo todo 很重要的一点!!! read方法是非阻塞的, 很可能还有没读取到数据就返回了
int read = socketChannel.read(byteBuffer);
if (read <= 0) {
content.append(charset.decode(byteBuffer));//设置缓冲区编码格式
break;
}
}
} catch (IOException io) { //如果发生错误,比如客户端chanel了链接。
sk.cancel();
if (sk.channel() != null) {
sk.channel().close(); //关闭该套接字通道
}
}
//=============== 广播数据到所有的SocketChannel中==============
for (SelectionKey key : selector.keys()) {
Channel targetchannel = key.channel();
// targetchannel != sc 不回发给发送此内容的客户端
if (targetchannel instanceof SocketChannel && targetchannel != sc) {
SocketChannel dest = (SocketChannel) targetchannel;
dest.write(charset.encode(content));
}
}
}
}
对于客户端,如果它想往服务端发送键盘录入的内容时,获取键盘录入对象是免不了的事, 但是这对象会阻塞,于是客户端不得不开启一条新的线程运行读取服务器回传的数据,让自己能随时读取回传的数据,还能输入消息不被阻塞。这样我们的客户端就比较完善了。
先来准备一些变量
port = 9999; //服务端端口
Charset charset = Charset.forName("UTF-8"); //编码格式
String name = ""; //聊天昵称
Selector selector = Selector.open();
//连接远程主机的IP和端口
SocketChannel sc = SocketChannel.open(new InetSocketAddress("127.0.0.1", port));
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
======================== init完成 ===========================
//开辟一个新线程来读取从服务器端的数据
new Thread(new ClientThread()).start();
//在主线程中 从键盘读取数据输入到服务器端
Scanner scan = new Scanner(System.in);
while (scan.hasNextLine()) {
String line = scan.nextLine();
//不允许发空消息
if ("".equals(line)) {
continue;
}
if ("".equals(name)) {
name = line;
line = name + USER_CONTENT_SPILIT;
} else {
line = name + USER_CONTENT_SPILIT + line;
}
//sc既能写也能读,这边是写
sc.write(charset.encode(line));
}
读取从服务器端回传的数据 线程
private class ClientThread implements Runnable {
@Override
public void run() {
try {
while (true) {
int readyChannels = selector.select();
if (readyChannels == 0) {
continue;
}
//可以通过这个方法,知道可用通道的集合
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey sk = (SelectionKey) keyIterator.next();
keyIterator.remove();
dealWithSelectionKey(sk);
}
}
} catch (IOException io) {
io.getMessage();
}
}
private void dealWithSelectionKey(SelectionKey sk) throws IOException {
if (sk.isReadable()) {
//使用 NIO 读取 Channel中的数据,这个和全局变量sc是一样的,因为只注册了一个SocketChannel
//sc既能写也能读,这边是读
SocketChannel sc = (SocketChannel) sk.channel();
ByteBuffer buff = ByteBuffer.allocate(1024);
String content = "";
while (sc.read(buff) > 0) {
buff.flip();
content += charset.decode(buff);
}
//若系统发送通知名字已经存在,则需要换个昵称
if (USER_EXIST.equals(content)) {
name = "";
}
System.out.println(content);
sk.interestOps(SelectionKey.OP_READ);
}
}
}
参考:Nio编程模型总结