tips:客户端的通道注册和事件监听是在服务器端实现的!NIO的本质是避免原始的TCP连接建立时使用的三次握手操作,减少连接的开销!因为Channel非直连,而是注册到了多路复用器上。一个TCP连接对应多个channel。
我们使用nio来实现客户端发送数据,服务端接收数据这样一个例子。
先来看下NIO的几个概念:
Channel
通道,它就像自来水管道一样,网络数据通过Channel读取和写入,通道与流的不同之处在于通道是双向的,流是单向的,且是OutPutStream和InputStream的子类。通道可以用于读、写或者二者同时进行。
而最关键的是通道可以与多路复用器selector结合起来,有多种的状态位,方便多路复用器去识别。
事实上通道分为两大类:一类是网络读写的SelectableChannel;一类是用于文件操作的FileChannel。
我们使用的ServerSocketChannel和SocketChannel都是SelectableChannel的子类。它们是对Socket和ServerScoket的向上的抽象,Channel是一个TCP连接之间的抽象,一个Tcp连接可以对应多个Channel,而不是以前的方式只有一个通信信道,这个时候就减少TCP了连接的次数。,然后将SocketChannel的相应事件注册到selector多路复用器上,监听对应的事件。
private void accept(SelectionKey key) { try { //1 获取服务通道 ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); //2 执行阻塞方法 SocketChannel sc = ssc.accept(); //3 设置阻塞模式 sc.configureBlocking(false); //4 注册到多路复用器上,并设置读取标识 sc.register(this.seletor, SelectionKey.OP_READ); } catch (IOException e) { e.printStackTrace(); } }
Selector
多路复用器,它是NIO变成的基础,非常重要,多路复用器选择已经就绪的任务的能力。
简单说,就是Selector会不断轮询注册在其上的通道Channel,如果某个通道发生了读写操作,这个通道就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以取得就绪的channel集合,从而进行后续的IO操作。
一个多路复用器Selector可以负责成千上万的Channel通道,没有上限,这也是JDK采用了epoll替代了传统的select实现,获得连接句柄没有限制,这也就意味着我们只需要一个线程thread负责selector的轮询,就可以接入成千上万的客户端,这是JDK NIO库的巨大进步。
Selector线程类似于一个管理者master,管理成千上万的通道,然后轮询哪个通道数据已经准备好,通知CPU执行IO的读写操作。
selector模式
当网络IO事件(即Channel)注册到选择器以后,selector会分配给每个管道一个key值,相当于标签。selector选择器是以轮询的方式进行查找在其上注册的所有网络IO事件(channel)。当我们的网络IO事件(Channel
)准备就绪,selector就会识别,会通过key来找到相应的管道channel,进行数据的相关操作。
事件状态
每个管道都会对selector选择器注册不同的事件状态,以便选择器查找。
SelectionKey.OP_CONNECT
SelectionKey.OP_ACCEPT
SelectionKey.OP_READ
SelectionKey.OP_WRITE
我们来看一幅图:
图示分析:
public Server(int port){ try { //1 打开路复用器 this.seletor = Selector.open(); //2 打开服务器通道 ServerSocketChannel ssc = ServerSocketChannel.open(); //3 设置服务器通道为非阻塞模式 ssc.configureBlocking(false); //4 绑定地址 ssc.bind(new InetSocketAddress(port)); //5 把服务器通道注册到多路复用器上,并且监听阻塞事件 ssc.register(this.seletor, SelectionKey.OP_ACCEPT); System.out.println("Server start, port :" + port); } catch (IOException e) { e.printStackTrace(); } }
构造方法中的步骤描述了服务器端的实例化流程:
1 打开多路复用器selector。
2 打开ServerSocket服务器通道
3设置非阻塞模式,才能使得channel和selector结合使用。
4 绑定了监听端口
5将serverSocketChannel注册到selector上,监听阻塞事件。
看下sever.java的run方法:
@Override public void run() { while(true){ try { //1 必须要让多路复用器开始监听 select方法阻塞 直到有新的key进来 this.seletor.select(); //2 返回多路复用器已经选择的结果集 Iterator<SelectionKey> keys = this.seletor.selectedKeys().iterator(); //3 进行遍历 while(keys.hasNext()){ //4 获取一个选择的元素 SelectionKey key = keys.next(); //5 直接从容器中移除就可以了 keys.remove(); //6 如果是有效的 if(key.isValid()){ //7 如果为阻塞状态 if(key.isAcceptable()){ this.accept(key); } //8 如果为可读状态 if(key.isReadable()){ this.read(key); } //9 写数据 if(key.isWritable()){ //this.write(key); //ssc } } } } catch (IOException e) { e.printStackTrace(); } } }
server.java启动时,进入run方法执行到this.selector.select()阻塞,直到有新的SocketChannel注册key到selector。
注意:实例化server'时注册的通道不会向下执行,this.selector.select()直接阻塞,监听客户端的通道注册。
run方法分析:
1 在run方法中服务器端迭代selectionKey,每次迭代拿出来后执行keys.remove();删除这个key。
2 判断key的状态,当客户端启动以后(只要执行sc.connect(address);),客户端的SocketChannel就被连接到了selector上,这个时候:
Iterator<SelectionKey> keys = this.seletor.selectedKeys().iterator();
开始执行,得到了多路复用器上的所有key,其中有一个是服务器端的,监听的阻塞事件,所以当客户端connect方法执行后(只是启动,不发送数据),相应服务器端拿到了所有key进行迭代和判断:
if(key.isAcceptable()){ this.accept(key); }
服务器端的那个key必然是服务器端的阻塞状态的key,代码一定会执行到上边代码进入accept方法:
private void accept(SelectionKey key) { try { //1 获取服务通道 ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); //2 执行阻塞方法 SocketChannel sc = ssc.accept(); //3 设置阻塞模式 sc.configureBlocking(false); //4 注册到多路复用器上,并设置读取标识 sc.register(this.seletor, SelectionKey.OP_READ); } catch (IOException e) { e.printStackTrace(); } }
通过这个key就得到了ServerSocketChannel,并从ssc.accept方法获得了SocketChannel,设置其为阻塞模式,并注册到多路复用器上监听“读就绪事件”,由于这个客户端的SocketChannel已经被注册到了selctor多路复用器上,那么server的run方法一定会继续将this.selector.select()阻塞方法放开,毕竟有新的通道注册上去了。由于刚才的SocketChannel注册了读就绪事件,那么:
if(key.isReadable()){ this.read(key); }
就会执行。
private void read(SelectionKey key) { try { //1 清空缓冲区旧的数据 this.readBuf.clear(); //2 获取之前注册的socket通道对象 SocketChannel sc = (SocketChannel) key.channel(); //3 读取数据 int count = sc.read(this.readBuf); //4 如果没有数据 if(count == -1){ key.channel().close(); key.cancel(); return; } //5 有数据则进行读取 读取之前需要进行复位方法(把position 和limit进行复位) this.readBuf.flip(); //6 根据缓冲区的数据长度创建相应大小的byte数组,接收缓冲区的数据 byte[] bytes = new byte[this.readBuf.remaining()]; //7 接收缓冲区数据 this.readBuf.get(bytes); //8 打印结果 String body = new String(bytes).trim(); System.out.println("Server : " + body); // 9..可以写回给客户端数据 } catch (IOException e) { e.printStackTrace(); } }
读取通道serverSocket的数据打印。在读取前清空缓冲区readBuffer。
接下来就可以进行客户端和服务器端的通信了!!!
需要注意的是,服务器端的accept方法只会在客户端启动的时候执行一次用来获得ServerSocket通道,并将其注册到多路复用器上,监听对应的事件。通道注册后,当前客户端就不需要再次注册通道了,除非通道断开!后续的客户端发送buffer和服务端读取buffer不需要注册通道,只是事件的监听而已。
看下客户端的代码,比较简单:
import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SocketChannel; public class Client { //需要一个Selector public static void main(String[] args) { //创建连接的地址 InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8765); //声明连接通道 SocketChannel sc = null; //建立缓冲区 ByteBuffer buf = ByteBuffer.allocate(1024); try { //打开通道 sc = SocketChannel.open(); //进行连接 sc.connect(address); while(true){ //定义一个字节数组,然后使用系统录入功能: byte[] bytes = new byte[1024]; System.in.read(bytes); //把数据放到缓冲区中 buf.put(bytes); //对缓冲区进行复位 buf.flip(); //写出数据 sc.write(buf); //清空缓冲区数据 buf.clear(); } } catch (IOException e) { e.printStackTrace(); } finally { if(sc != null){ try { sc.close(); } catch (IOException e) { e.printStackTrace(); } } } } }
这个例子中我们实现的是客户端发送数据--->服务器端读取数据,对于双向通信可以自行研究实现!