传统的 IO 流都是阻塞式的。也就是说,当一个线程调用 read() 或 write()时,该线程被阻塞,直到有一些数据被读取或写入,该线程在此期间不能执行其他任务。因此,在完成网络通信进行 IO 操作时,由于线程会阻塞,所以服务器端必须为每个客户端都提供一个独立的线程进行处理,当服务器端需要处理大量客户端时,性能急剧下降。
Java NIO 是非阻塞模式的。当线程从某通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。因此, NIO 可以让服务器端使用一个或有限几个线程来同时处理连接到服务器端的所有客户端。
本文着重介绍NIO在阻塞和非阻塞两种方式下如何工作,以及什么是选择器?它与通道之间是什么关系?
阻塞模式与非阻塞模式
传统的阻塞IO方式:客户端向服务器端发送读写请求,服务器端便开始监听客户端的数据是否到来。再次期间客户端准备自己的数据,而服务器端就一直这样不得抽身地干等着。即使服务器端是多线程,线程的数量也是有限的,而且有时一味单纯增加线程数,只会让阻塞的线程原来越多。
NIO的非阻塞方式:
将用于传输的通道全都注册到选择器上
选择器的作用是:监控这些通道的IO状况
(什么状况?读、写、连接、接收数据)
(本文出自oschina博主happyBKs的博文:https://my.oschina.net/happyBKs/blog/1603604)
选择器和通道的关系
选择器和通道的关系:通道注册到选择器上,选择器监控通道。
当某一个通道上,某一个事件准备就绪时,那么选择器才会将这个通道分配到服务器端一个或多个线程上,再继续运行。比如说当客户端发送一些数据给服务器端,只有当客户端的所有数据都准备就绪时,选择器才会将这个注册的通道分配到服务器端的一个或者多个线程上。
那就意味这,如果客户端的线程没有将数据准备就绪时,服务器端的线程可以执行其他任务,而不必阻塞在那里。
打个比方来说,原先的传统的阻塞IO模式,相当于你没有手机等快递,算准了EMS每天中午1:00会到你们公司门口,所以你12:50在那里等着他们来,在这10分钟里你被这件事情阻塞着,什么事情都做不了,就是浪费时间;而NIO的这种通道注册选择器,选择器监控通道,等到数据准备就绪才会占用服务器线程的非阻塞IO方式,更像是带着手机等外卖,我在饿了么注册了一个用户(通道在选择器上注册了),然后定完外卖就忙自己的去了,等到外卖送来之后我接到电话下去取就可以了。
选择器( Selector)与SelectableChannle
选择器( Selector) 是 SelectableChannle 对象的多路复用器, Selector 可以同时监控多个 SelectableChannel 的 IO 状况,也就是说,利用 Selector可使一个单独的线程管理多个 Channel。 Selector 是非阻塞 IO 的核心。
SelectableChannle 的结构如下图(注意:FileChannel不是可作为选择器复用的通道!FileChannel不能注册到选择器Selector!FileChannel不能切换到非阻塞模式!FileChannel不是SelectableChannel的子类!)
选择器的使用方法
创建 Selector :通过调用 Selector.open() 方法创建一个 Selector
向选择器注册通道: SelectableChannel.register(Selector sel, int ops)
当调用 register(Selector sel, int ops) 将通道注册选择器时,选择器对通道的监听事件,需要通过第二个参数 ops 指定。
可以监听的事件类型( 可使用 SelectionKey 的四个常量表示):
读 : SelectionKey.OP_READ ( 1)
写 : SelectionKey.OP_WRITE ( 4) 连接 : SelectionKey.OP_CONNECT ( 8) 接收 : SelectionKey.OP_ACCEPT ( 16)若注册时不止监听一个事件,则可以使用“位或”操作符连接。
这些常量我么诚挚为选择键。
选择键(SelectionKey)
SelectionKey: 表示 SelectableChannel 和 Selector 之间的注册关系。每次向选择器注册通道时就会选择一个事件(选择键)。 选择键包含两个表示为整数值的操作集。操作集的每一位都表示该键的通道所支持的一类可选择操作。
方 法 | 描 述 |
int interestOps() | 获取感兴趣事件集合 |
int readyOps() | 获取通道已经准备就绪的操作的集合 |
SelectableChannel channel() | 获取注册通道 |
Selector selector() | 返回选择器 |
boolean isReadable() | 检测 Channal 中读事件是否就绪 |
boolean isWritable() | 检测 Channal 中写事件是否就绪 |
boolean isConnectable() | 检测 Channel 中连接是否就绪 |
boolean isAcceptable() | 检测 Channel 中接收是否就绪 |
Set<SelectionKey> keys() | 所有的 SelectionKey 集合。代表注册在该Selector上的Channel |
selectedKeys() | 被选择的 SelectionKey 集合。返回此Selector的已选择键集 |
int select() | 监控所有注册的Channel,当它们中间有需要处理的 IO 操作时, 该方法返回,并将对应得的 SelectionKey 加入被选择的 SelectionKey 集合中,该方法返回这些 Channel 的数量。 |
int select(long timeout) | 可以设置超时时长的 select() 操作 |
int selectNow() | 执行一个立即返回的 select() 操作,该方法不会阻塞线程 |
Selector wakeup() | 使一个还未返回的 select() 方法立即返回 |
void close() | 关闭该选择器 |
SocketChannel
Java NIO中的SocketChannel是一个连接到TCP网络套接字的通道。
操作步骤:
打开 SocketChannel
读写数据 关闭 SocketChannelJava NIO中的 ServerSocketChannel 是一个可以监听新进来的TCP连接的通道,就像标准IO中的ServerSocket一样。
这里有几个要点需要注意:
FileChannel不能使用非阻塞模式!!!
所以FileChannel不能切换到非阻塞模式。关于tFileChannel的其他要点,我忘记之前的文章中有没有提到,这里做个补充吧!前面通道间直接传输(即调用通道的transferFrom和transferTo方法),必须有一个是FileChannel。
想想也应该明白,读还好说,同一个本地文件如何同时写?阻塞是必然的。
网络NIO示例
阻塞IO模式:客户端向服务端发送文件
package com.happybks.nio.nio;import java.io.IOException;import java.net.InetSocketAddress;import java.nio.ByteBuffer;import java.nio.channels.FileChannel;import java.nio.channels.ServerSocketChannel;import java.nio.channels.SocketChannel;import java.nio.file.Paths;import java.nio.file.StandardOpenOption;import org.junit.Test;/** * * 一、使用NIO完成网络通信的三个核心: * 1. 通道(Channel):负责连接 * java.nio.channels.Channel * |-- SelectableChannel * |-- SocketChannel * |-- ServerSocketChannel * |-- DatagramChannel * * |-- Pipe.SinkChannel * |-- Pipe.SourceChannel * (注意:FileChannel不能使用非阻塞模式!!!选择其主要监控网络Channel) * * 2. 缓冲区(Buffer):负责数据的存取 * 3. 选择器(Selector):是SelectableChannel的多路复用器。用于监控SelectableChannel的IO状况 * * @author happyBKs * */public class TestBlockingNIO { //客户端 @Test public void client() throws IOException{ //1、获取通道(open这种方法是jdk1.7之后才引入的) SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 8888)); //2、分配指定大小的缓冲区 ByteBuffer buf=ByteBuffer.allocate(1024); //3、从本地读取文件,并发送到服务端 FileChannel inFileChannel=FileChannel.open(Paths.get("D:/Test/NIO/1.jpg"), StandardOpenOption.READ); while(inFileChannel.read(buf)!=-1){ buf.flip(); socketChannel.write(buf); buf.clear(); } //4、关闭通道 inFileChannel.close(); socketChannel.close(); } //服务端 @Test public void server() throws IOException{ //1、获取端口 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //2、绑定连接 serverSocketChannel.bind(new InetSocketAddress(8888)); //3、获取客户端连接的通道 SocketChannel socketChannel = serverSocketChannel.accept(); //4、接收客户端的数据,保存到本地。(提到本地,就要弄个FileChannel) FileChannel outFileChannel = FileChannel.open(Paths.get("D:/Test/NIO/2.jpg"), StandardOpenOption.WRITE,StandardOpenOption.CREATE); ByteBuffer buf=ByteBuffer.allocate(1024); while(socketChannel.read(buf)!=-1){ buf.flip(); outFileChannel.write(buf); buf.clear(); } socketChannel.close(); outFileChannel.close(); serverSocketChannel.close(); }}
运行结果:
阻塞IO模式:服务端向客户端发送反馈信息
package com.happybks.nio.nio;import java.io.IOException;import java.net.InetSocketAddress;import java.nio.ByteBuffer;import java.nio.channels.FileChannel;import java.nio.channels.ServerSocketChannel;import java.nio.channels.SocketChannel;import java.nio.file.Paths;import java.nio.file.StandardOpenOption;import org.junit.Test;/** * * 一、使用NIO完成网络通信的三个核心: * 1. 通道(Channel):负责连接 * java.nio.channels.Channel * |-- SelectableChannel * |-- SocketChannel * |-- ServerSocketChannel * |-- DatagramChannel * * |-- Pipe.SinkChannel * |-- Pipe.SourceChannel * (注意:FileChannel不能使用非阻塞模式!!!选择其主要监控网络Channel) * * 2. 缓冲区(Buffer):负责数据的存取 * 3. 选择器(Selector):是SelectableChannel的多路复用器。用于监控SelectableChannel的IO状况 * * @author happyBKs * */public class TestBlockingNIO { //客户端 @Test public void client() throws IOException{ //1、获取通道(open这种方法是jdk1.7之后才引入的) SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 8888)); //2、分配指定大小的缓冲区 ByteBuffer buf=ByteBuffer.allocate(1024); //3、从本地读取文件,并发送到服务端 FileChannel inFileChannel=FileChannel.open(Paths.get("D:/Test/NIO/1.jpg"), StandardOpenOption.READ); while(inFileChannel.read(buf)!=-1){ buf.flip(); socketChannel.write(buf); buf.clear(); }/* //在阻塞IO下,如果关闭socketChannel,那么服务端不知道客户端是否已经把所有数据发完,所以会一直阻塞。 socketChannel.shutdownOutput(); //另一种方法就是把这个线程切换成非阻塞模式 */ //接收服务端反馈 int len = 0; while((len = socketChannel.read(buf)) !=-1){ buf.flip(); System.out.println(new String(buf.array(),0,len)); } //4、关闭通道 inFileChannel.close(); socketChannel.close(); } //服务端 @Test public void server() throws IOException{ //1、获取端口 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //2、绑定连接 serverSocketChannel.bind(new InetSocketAddress(8888)); //3、获取客户端连接的通道 SocketChannel socketChannel = serverSocketChannel.accept(); //4、接收客户端的数据,保存到本地。(提到本地,就要弄个FileChannel) FileChannel outFileChannel = FileChannel.open(Paths.get("D:/Test/NIO/2.jpg"), StandardOpenOption.WRITE,StandardOpenOption.CREATE); ByteBuffer buf=ByteBuffer.allocate(1024); while(socketChannel.read(buf)!=-1){ buf.flip(); outFileChannel.write(buf); buf.clear(); } //发送反馈给客户端 buf.put("服务端接收数据成功!".getBytes()); buf.flip(); socketChannel.write(buf); socketChannel.close(); outFileChannel.close(); serverSocketChannel.close(); }}
但是当我们启动了服务端和客户端之后,虽然图片是正常复制了,但客户端线程一直阻塞在那里,没有显示接收到任何从服务端的反馈信息,因此不能确定服务端到底有没有发送数据。
原因是,我们在客户端图片传输完后,阻塞模式下没有告诉服务端是否发完了,所以才会一直阻塞。因此我们要告诉服务端发完了,一种方法就是socketChannel.shutdownOutput()。
package com.happybks.nio.nio;import java.io.IOException;import java.net.InetSocketAddress;import java.nio.ByteBuffer;import java.nio.channels.FileChannel;import java.nio.channels.ServerSocketChannel;import java.nio.channels.SocketChannel;import java.nio.file.Paths;import java.nio.file.StandardOpenOption;import org.junit.Test;/** * * 一、使用NIO完成网络通信的三个核心: * 1. 通道(Channel):负责连接 * java.nio.channels.Channel * |-- SelectableChannel * |-- SocketChannel * |-- ServerSocketChannel * |-- DatagramChannel * * |-- Pipe.SinkChannel * |-- Pipe.SourceChannel * (注意:FileChannel不能使用非阻塞模式!!!选择其主要监控网络Channel) * * 2. 缓冲区(Buffer):负责数据的存取 * 3. 选择器(Selector):是SelectableChannel的多路复用器。用于监控SelectableChannel的IO状况 * * @author happyBKs * */public class TestBlockingNIO { //客户端 @Test public void client() throws IOException{ //1、获取通道(open这种方法是jdk1.7之后才引入的) SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 8888)); //2、分配指定大小的缓冲区 ByteBuffer buf=ByteBuffer.allocate(1024); //3、从本地读取文件,并发送到服务端 FileChannel inFileChannel=FileChannel.open(Paths.get("D:/Test/NIO/1.jpg"), StandardOpenOption.READ); while(inFileChannel.read(buf)!=-1){ buf.flip(); socketChannel.write(buf); buf.clear(); } //在阻塞IO下,如果关闭socketChannel,那么服务端不知道客户端是否已经把所有数据发完,所以会一直阻塞。 socketChannel.shutdownOutput(); //另一种方法就是把这个线程切换成非阻塞模式 //接收服务端反馈 int len = 0; while((len = socketChannel.read(buf)) !=-1){ buf.flip(); System.out.println(new String(buf.array(),0,len)); } //4、关闭通道 inFileChannel.close(); socketChannel.close(); } //服务端 @Test public void server() throws IOException{ //1、获取端口 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //2、绑定连接 serverSocketChannel.bind(new InetSocketAddress(8888)); //3、获取客户端连接的通道 SocketChannel socketChannel = serverSocketChannel.accept(); //4、接收客户端的数据,保存到本地。(提到本地,就要弄个FileChannel) FileChannel outFileChannel = FileChannel.open(Paths.get("D:/Test/NIO/2.jpg"), StandardOpenOption.WRITE,StandardOpenOption.CREATE); ByteBuffer buf=ByteBuffer.allocate(1024); while(socketChannel.read(buf)!=-1){ buf.flip(); outFileChannel.write(buf); buf.clear(); } //发送反馈给客户端 buf.put("服务端接收数据成功!".getBytes()); buf.flip(); socketChannel.write(buf); socketChannel.close(); outFileChannel.close(); serverSocketChannel.close(); }}
客户端成功接收到了服务端发送的信息:
非阻塞模式
我们先来个简单的,客户端向服务端发送一个时间信息。
package com.happybks.nio.nio;import java.io.IOException;import java.net.InetSocketAddress;import java.nio.ByteBuffer;import java.nio.channels.SelectionKey;import java.nio.channels.Selector;import java.nio.channels.ServerSocketChannel;import java.nio.channels.SocketChannel;import java.util.Date;import java.util.Iterator;import org.junit.Test;public class TestNonBlockingNIO { //客户端 @Test public void client() throws IOException{ //1、获取通道 SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1",8888)); //2、切换非阻塞模式 socketChannel.configureBlocking(false); //3、分配指定大小的缓冲区 ByteBuffer buf=ByteBuffer.allocate(1024); //4、发送数据服务器 buf.put(new Date().toString().getBytes()); buf.flip(); socketChannel.write(buf); buf.clear(); //5、关闭通道 socketChannel.close(); } //服务端 @Test public void server() throws IOException{ //1、获取通道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //2、切换非阻塞模式 serverSocketChannel.configureBlocking(false); //3、绑定连接 serverSocketChannel.bind(new InetSocketAddress(8888)); //4、获取选择器 Selector selector = Selector.open(); //5、将通道注册到选择器上(第二个选项参数叫做选择键,用于告诉选择器需要监控这个通道的什么状态或者说什么事件(读、写、连接、接受)) //选择键是整型值,如果需要监控该通道的多个状态或事件,可以将多个选择键用位运算符“或”“|”来连接 //这里服务端首先要监听客户端的接受状态 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); //6、轮询式地获取选择器上已经“准备就绪”的事件 while(selector.select() > 0){ //7、获取当前选择中所有注册的“选择键(已就绪的监听事件)” Iteratoriterator = selector.selectedKeys().iterator(); while(iterator.hasNext()){ //8、获取准备“就绪”的是事件 SelectionKey sk=iterator.next(); //9、判断是什么事件准备就绪 if(sk.isAcceptable()){ //10、若接受就绪,获取客户端连接 SocketChannel socketChannel = serverSocketChannel.accept(); //11、客户端连接socketChannel也需要切换非阻塞模式 socketChannel.configureBlocking(false); //12、将该通道注册到选择器上,监控客户端socketChannel的读就绪事件 socketChannel.register(selector, SelectionKey.OP_READ); } else if(sk.isReadable()){ //13、获取当前选择器上“读就绪”状态的通道 SocketChannel socketChannel = (SocketChannel) sk.channel(); //14、读取数据 ByteBuffer buf=ByteBuffer.allocate(1024); int len=0; while((len=socketChannel.read(buf))>0){ buf.flip(); System.out.println(new String(buf.array(),0,len)); buf.clear(); } } //15、取消选择键SelectionKey iterator.remove(); } } }}
注意:SelectionKey使用完之后,一定要取消掉!!否则一直有效,如一个通道已经连接完成accept,如果不取消,下次还有这个连接完成。
怎么取消?通过它的迭代器的remove方法。
我们先启动server(),然后启动client()。发现显示:
点击一下,或者在其旁边的下拉菜单中切换一下控制台。
下面,我们在此基础上加入个客户端不断写入的功能,有点像聊天室,只不过只有服务端能看到。
package com.happybks.nio.nio;import java.io.IOException;import java.net.InetSocketAddress;import java.nio.ByteBuffer;import java.nio.channels.SelectionKey;import java.nio.channels.Selector;import java.nio.channels.ServerSocketChannel;import java.nio.channels.SocketChannel;import java.util.Date;import java.util.Iterator;import java.util.Scanner;import org.junit.Test;public class TestNonBlockingNIO { //客户端 @Test public void client() throws IOException{ //1、获取通道 SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1",8888)); //2、切换非阻塞模式 socketChannel.configureBlocking(false); //3、分配指定大小的缓冲区 ByteBuffer buf=ByteBuffer.allocate(1024); //4、发送数据服务器 Scanner scanner =new Scanner(System.in); while(scanner.hasNext()){ String inputStr=scanner.next(); buf.put((new Date().toString()+"\n"+inputStr).getBytes()); buf.flip(); socketChannel.write(buf); buf.clear(); } scanner.close(); /* buf.put(new Date().toString().getBytes()); buf.flip(); socketChannel.write(buf); buf.clear();*/ //5、关闭通道 socketChannel.close(); } //服务端 @Test public void server() throws IOException{ //1、获取通道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //2、切换非阻塞模式 serverSocketChannel.configureBlocking(false); //3、绑定连接 serverSocketChannel.bind(new InetSocketAddress(8888)); //4、获取选择器 Selector selector = Selector.open(); //5、将通道注册到选择器上(第二个选项参数叫做选择键,用于告诉选择器需要监控这个通道的什么状态或者说什么事件(读、写、连接、接受)) //选择键是整型值,如果需要监控该通道的多个状态或事件,可以将多个选择键用位运算符“或”“|”来连接 //这里服务端首先要监听客户端的接受状态 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); //6、轮询式地获取选择器上已经“准备就绪”的事件 while(selector.select() > 0){ //7、获取当前选择中所有注册的“选择键(已就绪的监听事件)” Iteratoriterator = selector.selectedKeys().iterator(); while(iterator.hasNext()){ //8、获取准备“就绪”的是事件 SelectionKey sk=iterator.next(); //9、判断是什么事件准备就绪 if(sk.isAcceptable()){ //10、若接受就绪,获取客户端连接 SocketChannel socketChannel = serverSocketChannel.accept(); //11、客户端连接socketChannel也需要切换非阻塞模式 socketChannel.configureBlocking(false); //12、将该通道注册到选择器上,监控客户端socketChannel的读就绪事件 socketChannel.register(selector, SelectionKey.OP_READ); } else if(sk.isReadable()){ //13、获取当前选择器上“读就绪”状态的通道 SocketChannel socketChannel = (SocketChannel) sk.channel(); //14、读取数据 ByteBuffer buf=ByteBuffer.allocate(1024); int len=0; while((len=socketChannel.read(buf))>0){ buf.flip(); System.out.println(new String(buf.array(),0,len)); buf.clear(); } } //15、取消选择键SelectionKey iterator.remove(); } } }}
我们启动服务端,然后开启一个客户端,输入两次信息
可以看到,每次客户端输入之后,服务端都会接收到信息:
如果我们开启第二个客户端,发送消息:
服务端也能正常接收到:
如果要用NIO开发一个多人聊天室,原理类似,只不过,要记得将发送的服务端的信息发送给其他客户端。