想要开发高性能的服务器,传统的BIO显然是不行的,Java提供了java.nio类库来帮助我们实现这件事。关于NIO的文章网上有很多博客,但是相应的解释图则比较少。于是我便自己整理了几张关系图,便于理解。在看原理图之前,我们还是需要先看下关于NIO的一些基础概念。
NIO的全称是non-block IO,也就是非阻塞IO。与传统的BIO相对应。
Java IO 的各种流都是阻塞的,这意味着,当一个线程进行流处理(如read()和write())时,无论是否有数据,该线程会一直被阻塞,直到读取到数据或者发生异常才会返回。在此期间线程不能干其他的事情,就算当前没有数据,线程依然保持等待状态。这样无疑会浪费大量的资源。而在NIO的非阻塞模式下,线程发送数据与接收数据都是通过通道进行的,线程只需要去查看是否有数据要处理,如果没有就直接返回,不会等待。
我们可以设想下这么一个场景。在一家餐厅里面,每来一位客户老板就分配一位服务员前去接待。期间服务员需要一直待在客户身边,处理客户的各种请求,比如:点餐、加菜等等。一开始这么做还行,后面随着店的生意越来越好,服务员就开始不够用了。这时解决方案有两个,一个是再找些服务员进来,当这种方法显然不现实,开销太大。于是老板就像出了另一种方法,他发现在客户用餐的过程中,大部分客户是没有提需求的,服务员处于闲置状态。于是他便安排了一位总管,这位总管负责查看所有客户的状态。一旦有某个客户提出需求,他便安排一位空闲的服务员去处理。如此一来,老板在没有增加额外开销的情况下便解决了问题。
在这个场景中,传统的一个服务员服务一个客户的方法就相当于BIO,而后来的改进就是NIO。
(1)定义:缓冲区。它本质上就是一个数组,不过它还提供了对数据的结构化访问以及维护读写位置等信息。
(2)作用:负责与管道进行交互。在面向流的IO中我们可以直接把数据写到Stream对象里,而面向管道的IO则不行,进程需要先把要写入的数据写到缓冲区,再通过缓冲区把数据写到管道对象里。
(3)类别:最常用的是ByteBuffer,也就是字节缓冲区,其他的还有CharBuffer、ShortBuffer等。ByteBuffer除了具有一般缓冲的操作之外还提供了一些特有操作,方便网络读写。
(1)定义:通道,可以通过它来读出和写入数据,与流的不同之处在于,通道是全双工的,同时支持读写操作,而流只能在一个方向上移动。
(2)作用:用于在字节缓冲区和通道的另一侧的实体(通常是一个文件或者套接字)进行进行有效的数据传输。
多路复用器,也叫选择器。它会不断地轮询注册在它上面的管道Channel,并且返回那些准备就绪的管道,以便进行后续的IO处理。Selector就相当于我们前面所举例子中的总管。而总管查看客户状态的方式有两种,一种是轮询,也就是总管每隔一段时间就挨个去问,你有没有什么需要我帮助的。另一种则是总管就站在中间不懂,由客户主动告知总管,我有什么请求。这篇博客用的是轮询的方法。
(1)serversocketchannel和socketchannel的区别:
ServerSocketChannel和SocketChannel是一对,它们是java.nio下面实现通信的类。服务器必须先建立ServerSocketChannel来等待客户端的连接。客户端则必须建立相对应的SocketChannel来与服务器建立连接,服务器接收到客户端的连接后,创建一个新的SocketChannel并通过ServerSocketChannel.accept()方法和Client端的SocketChannel建立连接,之后双方就可以进行通信了。也就是服务器端的每一个SocketChannel都唯一标识了一个客户端。
根据原理图我们来梳理一下Java中NIO通信的过程:
(1)、服务器打开了ServerSocketChannel,并绑定端口号。
(2)、打开Selector多路复用器,同时将ServerSocketChannel注册到Selector模块上,并指明我们感兴趣的事件是OP_ACCEPT。
(3)、开启Selector,Selector将会每隔一段时间轮询注册在它上面的SocketChannel是否有用户感兴趣的事件发生。目前Selector上面只有一个ServerSocketChannel。
(4)、当有用户想要和服务器建立连接时,ServerSocketChannel的标志位被置为OP_ACCPET。Selector将该SocketChannel返回给服务器进程。服务器创建一个SocketChannel和该客户端的SocketChannel建立连接。
(5)、服务器将创建的SocketChannel注册到Selector上,并指明感兴趣的事件是OP_READ。也就是当这个管道中数据可读时再来通知我。
(6)、现在Selector上面一共监听了两个SocketChannel,一个是ServerSocketChannel,用来处理客户端连接;一个是SocketChannel,用来监听某个客户端是否有数据发过来。后面每当有新的客户端尝试连接服务器时,服务器就会重复第四和第五步操作。
注意点:这里Server端不是所有SocketChannel都共享一个Buffer,而是每次处理一个事件都新建一个Buffer。
package nioserver;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class TimeServer {
/**
* @param args
* @throws IOException
*/
public static void main(String[] args) throws IOException {
int port = 8080;
if (args != null && args.length > 0) {
try{
port = Integer.valueOf(args[0]);
}catch(NumberFormatException e){
}
}
MultiplexerTimeServer timeServer = new MultiplexerTimeServer(port);
new Thread(timeServer,"NIO-MultiplexerTimeServer-001").start();
}
}
package nioserver;
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;
public class MultiplexerTimeServer implements Runnable{
private Selector selector;
private ServerSocketChannel servChannel;
private volatile boolean stop;
public MultiplexerTimeServer(int port){
try {
//1、打开ServerSocketChannel,用于监听客户端连接,是所有客户端连接的父通道
servChannel = ServerSocketChannel.open();
//2、绑定监听端口号,设置连接为非阻塞IO
servChannel.configureBlocking(false);
//这里的1024是请求传入连接队列的最大长度
servChannel.socket().bind(new InetSocketAddress(port),1024);
//3、创建选择器
selector = Selector.open();
//4、将管道注册到Selector上,监听accept事件
servChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("The time server is start in port : " + port);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
System.exit(1);
}
}
public void stop(){
this.stop = true;
}
public void run() {
while(!stop){
try {
//其中的1000为休眠时间,Selector每隔1s都被唤醒一次
selector.select(1000);
//5、Selector轮询注册在它上面的所有SocketChannel,并返回所有有服务器感兴趣事件发生的SocketChannel
Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
SelectionKey key = null;
//6、服务器迭代处理所有需要处理的SocketChannel
while(it.hasNext()){
key = it.next();
//移除出未处理的队列
it.remove();
try{
handleInput(key);
}catch(Exception e){
if(key != null){
key.cancel();
if(key.channel() != null)
key.channel().close();
}
}
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
//多路复用器关闭后,所有注册在上面的Channel和Pipe等资源都会被自动去
//注册和关闭,所以不需要重复关闭资源
if(selector != null){
try {
selector.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
private void handleInput(SelectionKey key) throws IOException {
//判断key值是否有效
if(key.isValid()){
//6、Selector监听到有新的客户端接入,处理新接入的连接请求
if(key.isAcceptable()){
/*
* 通过ServerSocketChannel的accept接收客户端的连接请求并创建SocketChannel实例
* 完成上述操作之后相当于完成了TCP的三次握手,TCP物理链路正式建立
* 我们将SocketChannel设置为异步非阻塞
* 同时也可以对其TCP参数进行设置,例如TCP发送和接收缓存区的大小等
*/
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel sc = ssc.accept();
//设置客户端链路为非阻塞模式
sc.configureBlocking(false);
//8、将新接入的SocketChannel注册到Selector上,监听读操作
sc.register(selector,SelectionKey.OP_READ);
String welcome = "Welcome,Please input your order:\n";
doWrite(sc,welcome);
}
//9、监听到注册的SocketChannel有可读事件发生,进行处理
if(key.isReadable()){
/*
* 读取客户端的请求消息
* 我们无法得知客户端发送的码流大小,作为例程,我们开辟一个1k的缓冲区
* 然后调用read方法读取请求码流
*/
//读取数据
SocketChannel sc = (SocketChannel) key.channel();
//10、分配一个新的缓存空间,大小为1024,异步读取客户端的消息
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
int readBytes = sc.read(readBuffer);
/*
* read()方法的三种返回值
* 返回值大于0:读到了直接,对字节进行编解码
* 返回值等于0:没有读到字节,属于正常场景,忽略
* 返回值为-1:链路已经关闭,需要关闭SocketChannel释放资源
*/
if(readBytes > 0){
readBuffer.flip();
//开辟一个空间,大小为缓存区中还剩余的字节数
byte[] bytes = new byte[readBuffer.remaining()];
readBuffer.get(bytes);
String body = new String(bytes,"UTF-8");
System.out.println("The time server receive order : " + body);
String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new java.util.Date(
System.currentTimeMillis()).toString() : "BAD ORDER";
doWrite(sc,currentTime);
}else if(readBytes < 0){
//对端链路关闭
key.cancel();
sc.close();
}else
;//读到0字节忽略
}
}
}
private void doWrite(SocketChannel channel, String response) throws IOException {
//如果接受到消息不为空,并且不是空白行
//strim方法可用于从字符串的开始和结束处修剪空白(如上所定义)。
if(response != null && response.trim().length() > 0){
byte[] bytes = response.getBytes();
//9、将消息异步发送给客户端
//分配写空间缓存区
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
//把bytes放入到写缓存中
writeBuffer.put(bytes);
/*
* 翻转这个缓冲区,将limit设为当前位置,
* 当使用完put()方法时,position位于数据的末尾,我们需要把它移动到0
* 这样调用get操作时我们才能把缓存区中的字节数组复制到新创建的直接数组中
*/
writeBuffer.flip();
//调用write方法将缓存区中的字节数组发送出去
//需要处理写半包的场景
channel.write(writeBuffer);
}
}
}
从压测的结果我们可以看出,并发在2.5w的时候是没有任何异常的。我尝试再网上增的话就开始出现异常了。
说明:本文代码部分主要来自《netty权威指南一书》
C10k系列文章:
《C10k破局(一)——线程池和消息队列实现高并发服务器》
《C10k破局(二)——Java NIO实现高并发服务器(一张图看懂Java NIO)》