C10k破局(二)——Java NIO实现高并发服务器(一张图看懂Java NIO)

想要开发高性能的服务器,传统的BIO显然是不行的,Java提供了java.nio类库来帮助我们实现这件事。关于NIO的文章网上有很多博客,但是相应的解释图则比较少。于是我便自己整理了几张关系图,便于理解。在看原理图之前,我们还是需要先看下关于NIO的一些基础概念。

一、什么是NIO

NIO的全称是non-block IO,也就是非阻塞IO。与传统的BIO相对应。

Java IO 的各种流都是阻塞的,这意味着,当一个线程进行流处理(如read()和write())时,无论是否有数据,该线程会一直被阻塞,直到读取到数据或者发生异常才会返回。在此期间线程不能干其他的事情,就算当前没有数据,线程依然保持等待状态。这样无疑会浪费大量的资源。而在NIO的非阻塞模式下,线程发送数据与接收数据都是通过通道进行的,线程只需要去查看是否有数据要处理,如果没有就直接返回,不会等待。

我们可以设想下这么一个场景。在一家餐厅里面,每来一位客户老板就分配一位服务员前去接待。期间服务员需要一直待在客户身边,处理客户的各种请求,比如:点餐、加菜等等。一开始这么做还行,后面随着店的生意越来越好,服务员就开始不够用了。这时解决方案有两个,一个是再找些服务员进来,当这种方法显然不现实,开销太大。于是老板就像出了另一种方法,他发现在客户用餐的过程中,大部分客户是没有提需求的,服务员处于闲置状态。于是他便安排了一位总管,这位总管负责查看所有客户的状态。一旦有某个客户提出需求,他便安排一位空闲的服务员去处理。如此一来,老板在没有增加额外开销的情况下便解决了问题。

在这个场景中,传统的一个服务员服务一个客户的方法就相当于BIO,而后来的改进就是NIO。

二、NIO的三个关键组件

1、Buffer

(1)定义:缓冲区。它本质上就是一个数组,不过它还提供了对数据的结构化访问以及维护读写位置等信息。

(2)作用:负责与管道进行交互。在面向流的IO中我们可以直接把数据写到Stream对象里,而面向管道的IO则不行,进程需要先把要写入的数据写到缓冲区,再通过缓冲区把数据写到管道对象里。

(3)类别:最常用的是ByteBuffer,也就是字节缓冲区,其他的还有CharBuffer、ShortBuffer等。ByteBuffer除了具有一般缓冲的操作之外还提供了一些特有操作,方便网络读写。

2、Channel

(1)定义:通道,可以通过它来读出和写入数据,与流的不同之处在于,通道是全双工的,同时支持读写操作,而流只能在一个方向上移动。

(2)作用:用于在字节缓冲区和通道的另一侧的实体(通常是一个文件或者套接字)进行进行有效的数据传输。

3、Selector

多路复用器,也叫选择器。它会不断地轮询注册在它上面的管道Channel,并且返回那些准备就绪的管道,以便进行后续的IO处理。Selector就相当于我们前面所举例子中的总管。而总管查看客户状态的方式有两种,一种是轮询,也就是总管每隔一段时间就挨个去问,你有没有什么需要我帮助的。另一种则是总管就站在中间不懂,由客户主动告知总管,我有什么请求。这篇博客用的是轮询的方法。

三、NIO原理

1、概念解释

(1)serversocketchannel和socketchannel的区别:

ServerSocketChannel和SocketChannel是一对,它们是java.nio下面实现通信的类。服务器必须先建立ServerSocketChannel来等待客户端的连接。客户端则必须建立相对应的SocketChannel来与服务器建立连接,服务器接收到客户端的连接后,创建一个新的SocketChannel并通过ServerSocketChannel.accept()方法和Client端的SocketChannel建立连接,之后双方就可以进行通信了。也就是服务器端的每一个SocketChannel都唯一标识了一个客户端。

2、原理图

根据原理图我们来梳理一下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,用来监听某个客户端是否有数据发过来。后面每当有新的客户端尝试连接服务器时,服务器就会重复第四和第五步操作。

注意点:这里的Buffer不是所有的Client共享一个,而是每次处理一个事件都新建一个Buffer。

四、代码实现

TimeServer主类

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();
	}
}

NIO类

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<SelectionKey> selectedKeys = selector.selectedKeys();
				Iterator<SelectionKey> 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)》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章