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)》

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