上篇Netty框架系列博客介绍了BIO编程模型和及其优缺点,今天来分析NIO。
什么是NIO
日常工作及面试中的NIO其实有两种理解:
- JDK1.8之后新提供的java.nio包,包含了支持非阻塞通信的selector,Chanel类。
- NIO,即NoBlocking IO,非阻塞IO,是指底层操作系统对非阻塞IO通信提供的内核支持,如Linux操作系统的select,poll,epoll等系统API,Windows系统下的IOCP,Mactonish的kequeue。
NIO服务端典型代码
传统BIO关注的是我要读,而NIO为了减少CPU等待时间,关注的是我可以读,采取的方式则是轮询。好比开了一个烧烤店,每来一个客人,BIO烧烤店都要找一个服务专员(Thread线程)专门负责招待这个客人,直到该客人结账离开。而NIO烧烤店则是专门指派一个大堂经理,所有的服务生则组成一个人力资源池。经理会不停地走动,询问每桌是否有招待需要(如点餐,加水,结账),如果客人有需求,则选派一名空闲的服务生来服务,服务完成后,再次进人力入资源池。
NIO的Java服务端,可以借助java.nio包来实现。
非阻塞for循环进行轮询
nio提供的SocketChanel类可以支持配置为非阻塞通信。当处于这种配置时,accpet函数和read函数都不会进行阻塞,如果没有客户端连接和发送数据,函数返回null或0。可以在代码中进行有效性判断,并将连接着的chanel放入一个链表中,利用for循环进行遍历。
package com.qinghaihu.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;
/**
* @ClassName NIOServerFor
* @Description TODO
* @Author:Zhang Lianzhong
* @Date 2020/11/22 4:27 下午
* @Version 1.0
**/
public class NIOServerFor implements Runnable{
private ServerSocketChannel channel;
private volatile boolean stop;
private List<SocketChannel> channelList = new ArrayList<>();
/**
* @param port
* 构造函数
*/
public NIOServerFor(int port) {
try {
channel = ServerSocketChannel.open();//打开socketchannel
channel.configureBlocking(false);//配置通道为非阻塞的状态
channel.socket().bind(new InetSocketAddress(port), 1024);//通道socket绑定地址和端口 1024指的是连接等待队列的大小
System.out.println("NIO 服务启动 端口: "+ port);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public void stop(){
this.stop=true;
}
@Override
public void run() {
System.out.println("NIO 服务 run()");
while(!stop){
try {
Thread.sleep(1000); //此处设置休眠1000ms,是为了方便观察调试
SocketChannel socketChannel = channel.accept();
if(null != socketChannel){
channelList.add(socketChannel);
}else {
System.out.println("accpet result is null!");
}
for(SocketChannel sc:channelList){
ByteBuffer readbuf = ByteBuffer.allocate(1024);
int readbytes = sc.read(readbuf);//从channel中读取byte数据并存放readbuf
if(readbytes>0){
readbuf.flip();//检测时候为完整的内容,若不是则返回完整的
byte[] bytes = new byte[readbuf.remaining()];
readbuf.get(bytes);
String string = new String(bytes, "UTF-8");//把读取的数据转换成string
System.out.println("服务器接受到命令 :"+ string);
//"查询时间"就是读取的命令,此字符串要与客户端发送的一致,才能获取当前时间,否则就是bad order
String currenttime = "查询时间".equalsIgnoreCase(string) ? new java.util.Date(System.currentTimeMillis()).toString() : "bad order";
dowrite(sc,currenttime);//获取到当前时间后,就需要把当前时间的字符串发送出去
}
}
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* @param sc
* @param currenttime
* @throws IOException
* 服务器的业务操作,将当前时间写到通道内
*/
private void dowrite(SocketChannel sc, String currenttime) throws IOException {
System.out.println("服务器 dowrite currenttime"+ currenttime);
if(currenttime !=null && currenttime.trim().length()>0){
byte[] bytes = currenttime.getBytes();//将当前时间序列化
ByteBuffer writebuf = ByteBuffer.allocate(bytes.length);
writebuf.put(bytes);//将序列化的内容写入分配的内存
writebuf.flip();
sc.write(writebuf); //将此内容写入通道
}
}
}
非阻塞selector轮询
分析基于for循环轮询的代码可以很容易看出,这种模式存在一个缺点就是会发生频繁的系统调用。如果有一万个客户端,意味着一次轮询就要进行一万次系统调用,会大量的时间花在用户态和内核态的切换上。
JDK提供了Selector类来优化这一点,一次轮询系统函数调用,可以将所有的Chanel 文件描述符传递到操作系统内核,从而有效减少了用户态内核态切换。
package com.qinghaihu.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.Iterator;
import java.util.Set;
/**
* @ClassName NIOServer
* @Description TODO
* @Author:Zhang Lianzhong
* @Date 2020/11/22 4:01 下午
* @Version 1.0
**/
public class NIOServer implements Runnable {
//多路复用器 Selector会对注册在其上面的channel进行轮询,
// 当某个channel发生读写操作时,Channel就会处于相应的就绪状态,通过SelectionKey的值进行IO 操作
private Selector selector;//多路复用器
private ServerSocketChannel channel;
private volatile boolean stop;
/**
* @param port
* 构造函数
*/
public NIOServer(int port) {
try {
selector = Selector.open();//打开多路复用器
channel = ServerSocketChannel.open();//打开socketchannel
channel.configureBlocking(false);//配置通道为非阻塞的状态
channel.socket().bind(new InetSocketAddress(port), 1024);//通道socket绑定地址和端口 1024指的是连接等待队列的大小
channel.register(selector, SelectionKey.OP_ACCEPT);//将通道channel在多路复用器selector上注册为连接操作
System.out.println("NIO 服务启动 端口: "+ port);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public void stop(){
this.stop=true;
}
@Override
public void run() {//线程的Runnable程序
System.out.println("NIO 服务 run()");
while(!stop){
try {
selector.select(1000);//Chanel状态查询,阻塞式调用。这里设置最大阻塞时间1000ms。
//获取多路复用器的事件值SelectionKey,并存放在迭代器中
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();
SelectionKey key =null;
//System.out.println("NIO 服务 try");
while(iterator.hasNext()){
System.out.println("NIO 服务 iterator.hasNext()");
key = iterator.next();
iterator.remove();//获取后冲迭代器中删除此值
try {
handleinput(key);//根据SelectionKey的值进行相应的读写操作
} catch (Exception e) {
if(key!=null){
key.cancel();
if(key.channel()!=null)
key.channel().close();
}
}
}
} catch (IOException e) {
System.out.println("NIO 服务 run catch IOException");
e.printStackTrace();
System.exit(1);
}
}
}
/**
* @param key
* @throws IOException
* 根据SelectionKey的值进行相应的读写操作
*/
private void handleinput(SelectionKey key) throws IOException {
System.out.println("NIO 服务 handleinput");
if(key.isValid()){//判断所传的SelectionKey值是否可用
if(key.isAcceptable()){//在构造函数中注册的key值为OP_ACCEPT,,在判断是否为接收操作
ServerSocketChannel ssc = (ServerSocketChannel)key.channel();//获取key值所对应的channel
SocketChannel sc = ssc.accept();//设置为接收非阻塞通道
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);//并把这个通道注册为OP_READ
}
if(key.isReadable()){//判断所传的SelectionKey值是否为OP_READ,通过上面的注册后,经过轮询后就会是此操作
SocketChannel sc = (SocketChannel)key.channel();//获取key对应的channel
ByteBuffer readbuf = ByteBuffer.allocate(1024);
int readbytes = sc.read(readbuf);//从channel中读取byte数据并存放readbuf
if(readbytes > 0){
readbuf.flip();//检测时候为完整的内容,若不是则返回完整的
byte[] bytes = new byte[readbuf.remaining()];
readbuf.get(bytes);
String string = new String(bytes, "UTF-8");//把读取的数据转换成string
System.out.println("服务器接受到命令 :"+ string);
//"查询时间"就是读取的命令,此字符串要与客户端发送的一致,才能获取当前时间,否则就是bad order
String currenttime = "查询时间".equalsIgnoreCase(string) ? new java.util.Date(System.currentTimeMillis()).toString() : "bad order";
dowrite(sc,currenttime);//获取到当前时间后,就需要把当前时间的字符串发送出去
}else if (readbytes < 0){
key.cancel();
sc.close();
}else{}
}
}
}
/**
* @param sc
* @param currenttime
* @throws IOException
* 服务器的业务操作,将当前时间写到通道内
*/
private void dowrite(SocketChannel sc, String currenttime) throws IOException {
System.out.println("服务器 dowrite currenttime"+ currenttime);
if(currenttime !=null && currenttime.trim().length()>0){
byte[] bytes = currenttime.getBytes();//将当前时间序列化
ByteBuffer writebuf = ByteBuffer.allocate(bytes.length);
writebuf.put(bytes);//将序列化的内容写入分配的内存
writebuf.flip();
sc.write(writebuf); //将此内容写入通道
}
}
}
NIO系统内核API分析
我们从BIO开始分析,接着引入了NIO的两种轮询模式。但我们需要了解的是,这种通信模式的优化,不是简简单单的JDK类的升级,关键还是类背后所依赖的操作系统的内核的升级。
平时我们的Web应用程序基本都是部署在Linux操作系统上,这里就以Linux的内核API做一番分析。使用Linux操作系统查看API手册,可以借助man 2 command来实现,这里2 表示文档类型为系统调用System Call,command为具体的待查询API函数名。
NIO所依赖的常见内核API为select,poll,epoll,它们都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
-
select
select函数监视文件描述符,调用后select函数会阻塞,直到有描述符就绪,或者超时,函数返回,当select函数返回后,就可以遍历描述符,找到就绪的描述符。
select的一个缺点在于单个进程能够监视的文件描述符的数量也存在最大限制,在Linux上一般为1024(fd_set),可以通过修改宏定义甚至重新编译内核的方式提升这一限制。但是这样也会造成效率的降低。 -
poll
poll将文件描述符参数从数组传递改为链表传递,没有最大限制(但是数量过大后性能也是会下降)。和select函数一样,poll返回后,需要轮询来获取就绪的描述符。
select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在同一时刻可能只有很少的就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。 -
epoll
相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符。