NIO编程总结

NIO(Non-blocking I/O,在Java领域,也称为New I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。

NIO的三个核心组件介绍

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

什么是Channel

这个概念绝对是一级概念,Channel是一个管道,用于连接字节缓冲区和另一端的实体, 这个字节缓冲区就是ByteBuffer, 另一端的实体可以是一个File 或者是 Socket ;

基于IO的网络编程, 数据的交互借助于InputStream或者是OutputStream, 而Channel可以理解成对Stream的又一层封装;在这种编程模型中 服务端想和客户端进行交互,就需要从服务端自己的ServerSocketChannel中获取前来连接的客户端的SocketChannel,并把他注册关联上感性趣的事件到自己的Selector选择器上, 这样一旦客户端把Buffer中的数据推送进channel, 服务端就可以感知,进而处理。

常用的Chanenl

NIO编程总结_第1张图片

  • 文件通道: FileChannel
  • 套接字通道
    • 服务端: ServerSocketChannel
    • 客户端: SocketChannel
  • 数据包通道: DataGramSocket

Channel 与 Stream(流)

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编程总结_第2张图片

Selector选择器NIO的第三个组件,三者的关系图如上所示

解释:一个线程专门管理Selector,Selector再去管理各个Channel通道。

什么是selector? 作用是什么?

selector是选择器的意思, 和它直接关联的组件是Channel, 没错,它的作用就是不断的轮询绑定在他身上的所有channel. 一旦有通道发生了它感兴趣的事件,接着处理此事件

selector维护了什么?

无论是服务端的Selector 还是客户端的Selector 它都维护了三个Set集合 , 里面封装的是 SelectionKey, 他是channel注册进Selector的产物,一般是使用它反向获取channel

  1. key set
  • 他是一个全集,每当channel通过register方法注册进选择器时,于此同时也会把包含自己信息的key添加到这个全集中来 注册的信息就会以SelectionKey的封装形式保存在这个集合中, 选择器每次轮询的channel,就是这里面的channel
  1. selected key
  • 感兴趣的key的集合, 举个例子, 通道1注册进选择器时,告诉选择器,我可能会给你发信息,你得盯着我,读我给你的信息, 于是选择器对通道1感性趣的事件是 read, 那么在选择器轮询channel时, 一旦通道1出现了write操作,就会被选择器感知,开始read
  • 每次遍历selected key时我们会执行这行代码:Set selectionKeys = selector.selectedKeys(); 它的意思是,我们取出了 选择器的感性事件的set集合,只要程序还在运行,只要选择器一旦被open(),除非我们手动的close() 否则选择器对象就不会被释放,所以它的感兴趣的set集合是不会被自动会收到,于是我们就得收到的把处理过的感兴趣的事件对应的SelectionKey移除出这个set集合,不然下一次轮询时,这个事件还会再一次被处理,并且无限制的处理下去
  • key有且仅有两种方式从 selected-key-set 中剔除 1. 通过Set的remove()方法, 2.通过迭代器的remove()方法
  1. cannelled key
  • 取消的key的集合,代表原来感兴趣的事件,现在不感兴趣了. 下一次轮询,进行select() 本集合中的SelectionKey会从key set中移除, 意味着它所关联的channel将会被选择器丢弃掉,不再进行监听
  • 关闭channel 或者是调用了cancel()方法都会将key添加到cannelled key 集合中
  • 使用场景: 一般会在客户端主动断开连接的时候使用它.

selector的select()方法

select(long); // 设置超时时间

selectNow(); // 立即返回,不阻塞

select(); 阻塞轮询

select()过程的细节:

  • 第一步, cannelled-key中的每一个元素会从全集key set中剔除,表示这些可以关联的通道不会被注册

  • 第二步

    操作系统帮我们轮询每一个通道是否有选择器感性趣的事情发生

    • 对于一条准备就绪的channel(发生事件通道),他至少会发生下面两件事之一:
      • 它的key会被添加进selected-key-set中,来标识它将被选中,进而处理
      • 如果它的key,已经存在于这个集合中了,下一步就是它的 read-operation将被更新
  • 第三步: 如果在轮询时发现了有任何key被放置在了cannelled-key-set中,重复第一步,不再注册它关联的通道

romove key 和 cannel key 的区别

前者是把key从selected key set集合,也就是被选中的集合中剔除出去,表示当前的事件已经处理完了

后者是表示,把key从全集中剔除出去, 表示想要废弃这个key关联的channel

selector的创建

他是根据不同操作系统提供的不同的Provider使用provide()创建出来的

NIO编程模型

NIO编程总结_第3张图片

如上图, 在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编程模型总结

你可能感兴趣的:(网络编程,nio,java,开发语言)