爲了更好的演示BIO與NIO之間的區別,咱們先用一個服務器示例來了解一個BIO實現網絡通行的過程。java
public class BioServer { public static void main(String[] args) throws IOException { byte[] bs = new byte[1024]; // 建立一個新的ServerSocket,綁定一個InetSocketAddress,監聽8000端口上的鏈接請求 ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(8000)); // accept專門負責通訊 while(true) { System.out.println("等待鏈接---"); // =====①:accept()函數的執行 Socket accept = serverSocket.accept(); // 這裏會阻塞以釋放CPU資源 System.out.println("鏈接成功---"); System.out.println("等待數據傳輸---"); // =====②:getInputStrea()函數獲取客戶端傳送的輸入流 int read = accept.getInputStream().read(bs); // 這裏也可能阻塞 System.out.println("數據傳輸成功---" + read); String content = new String(bs); System.out.println(content); } } }
public class Client { public static void main(String[] args) throws IOException { // 創建一個socket去鏈接服務端 Socket socket = new Socket(); socket.connect(new InetSocketAddress("127.0.0.1", 8000)); Scanner scanner = new Scanner(System.in); while (true) { // =====③:getOutputStream()函數中寫入的是從控制檯輸入的字符 String next = scanner.next(); socket.getOutputStream().write(next.getBytes()); } // socket.close(); } }
首先咱們先開啓服務端,開啓後的控制檯輸出以下,程序會在運行到①的地方停下來阻塞掉,等待客戶端鏈接上來。若是沒有客戶端鏈接的話,這個線程將會一直停在這裏。
c++
那麼咱們如今先開啓客戶端,而後不在控制檯輸入數據,以下圖所示,服務端程序會一直卡在②的地方停下來,由於客戶端卡在了③的位置,你一直沒有在控制檯輸入字符,客戶端的沒有輸出流,那麼服務端沒辦法接收到客戶端發送過來的數據,從而阻塞在②的位置。
數組
假設如今客戶端傳來一條信息,那麼客戶端程序就能夠接受到這條數據,阻塞在②處的線程就會重新運行下去。
緩存
從這裏咱們很容易想到這種模式的服務器的缺陷,首先,它一次只能接收一個接收一個客戶端的請求,要是有多個,沒辦法,在處理完前面的鏈接前,它是沒辦法往下執行的,那麼若是前面鏈接一直不傳送消息過來,就像咱們剛剛將程序阻塞在③處同樣,那麼服務端就沒法往下運行了,面對這種問題,咱們想到用多線程來解決,一個請求對應一個線程,那麼就沒有線程在③阻塞的問題了。服務器
public static void main(String[] args) throws IOException { byte[] bs = new byte[1024]; // 建立一個新的ServerSocket,綁定一個InetSocketAddress,監聽8000端口上的鏈接請求 ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(8000)); // accept專門負責通訊 while(true) { System.out.println("等待鏈接---"); // =====①:accept()函數的執行 Socket socket = serverSocket.accept(); // 這裏會阻塞以釋放CPU資源 System.out.println("鏈接成功---"); // =====④:新建一個線程來處理這個客戶端鏈接 Thread thread = new Thread(new ExecuteSocket(socket)); thread.start(); } } static class ExecuteSocket implements Runnable { byte[] bs = new byte[1024]; Socket socket; // 處理每一個客戶端鏈接——讀寫 public ExecuteSocket(Socket socket) { this.socket = socket; } @Override public void run() { try { // =====⑤:這裏仍是有阻塞的,不過是在線程裏阻塞,不影響主線程 socket.getInputStream().read(bs); } catch (IOException e) { e.printStackTrace(); } String content = new String(bs); System.out.println(content); } } }
客戶端仍是用剛纔的客戶端,沒什麼影響畢竟。網絡
那麼如今咱們就能夠開啓客戶端和服務端了,咱們嘗試下開啓兩個客戶端,服務端的控制檯輸出以下:
數據結構
咱們能夠發現如今服務端的main線程並無阻塞,而是能夠繼續往下執行,由於在④處它開啓了一個子線程去處理這個鏈接的請求了,因此哪怕是客戶端不發送數據,阻塞也是在子線程中的⑤處發生的,這樣對服務端處理下一個請求並無太大的影響。多線程
問題到這裏看似好像解決了,可是讓咱們考慮一下這種方案的影響,當咱們要管理多個併發客戶端時,咱們須要爲每一個新的客戶端Socket建立一個新的Thread,以下圖所示:
併發
因此這種模型也有不少的吐槽點,首先,在任什麼時候候都有可能有大量的線程處於休眠狀態,只是等待輸入或者輸出數據就緒,這對於咱們的系統來講就是一種巨大的資源浪費;而後,咱們須要爲每一個線程都分配內存,其默認值大小區間爲64kb到1M。並且,咱們還要考慮到,哪怕虛擬機自己是能夠支持大量線程,可是遠在達到該極限以前,上下文切換所帶來的開銷就會給咱們的系統帶來巨大的資源消耗。socket
首先咱們來了解一下NIO的原理。假設如今Java開發了兩個API,一個叫Socket.setNoBlock(boolean)
,可讓socket所在線程在沒有獲得客戶端發送過來的數據時也不會阻塞,而是繼續進行。另一個叫ServerSocket.setNoBlock(boolean)
,可讓ServerSocket所在線程在沒有獲得客戶端鏈接時也不會阻塞而往下運行。下面咱們用僞代碼來分析一波:
public class BioServer { public static void main(String[] args) throws IOException { List<Socket> socketList = null; // 用以存放鏈接服務端的socket byte[] bs = new byte[1024]; ServerSocket serverSocket = new ServerSocket(); // =====①:這個地方是僞代碼,如今假設方法執行後serverSocket在沒有客戶端鏈接的狀況下也會繼續執行 serverSocket.setNoBlock(true); serverSocket.bind(new InetSocketAddress(8000)); while(true) { System.out.println("等待鏈接---"); Socket socket = serverSocket.accept(); // 如今這裏不會阻塞以釋放CPU資源 if (socket == null) { // 沒客戶端鏈接過來 // =====:②找到之前鏈接服務端的socket,看它們有沒有發給我數據 for (Socket socket1 : socketList) { int read = socket.getInputStream().read(bs); if (read != 0) { // 這個socket有數據傳過來 // 這裏處理你的業務邏輯 } } } else { // 有客戶端鏈接過來 // =====:③這個地方是僞代碼,如今假設方法設置後socket不會阻塞 socket.setNoBlock(true); // =====:④將這個socket添加到socketList中 socketList.add(socket); for (Socket socket1 : socketList) { // 遍歷socketList,看看哪一個socket給服務端發送數據 int read = socket.getInputStream().read(bs); if (read != 0) { // 這個socket有數據傳過來 // 這裏處理你的業務邏輯 } } } } } }
這裏咱們聲明瞭一個socketList,用以存放鏈接到服務端的socket。如今咱們在①處設置了讓這個serverSocket在本次循環就算沒有客戶端鏈接上來也不會阻塞,而是繼續執行下去。執行下去以後判斷分兩叉,一叉是沒有客戶端鏈接過來的狀況,那麼就在②拿出socketList,看看以前鏈接的socket裏面有沒有哪一個給我發數據,有的話就來處理一下。另一叉就是在有客戶端鏈接上來的狀況了,首先咱們在③處將socket也設置爲非阻塞的,而後將這個socket添加到SocketList當中,而後繼續拿出socket,看看有沒有哪一個socket給我發數據,有就處理一下。
如今到這裏,NIO的思路基本理清了,下面咱們用代碼來實現一個簡單的服務端。
這裏咱們仍是利用List來緩存Socket,以後再輪詢是否有傳輸的數據。
public class NioServer { public static void main(String[] args) { List<SocketChannel> list = new ArrayList<>(); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); try { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(8001)); ssc.configureBlocking(false); // 在這裏設置爲非阻塞 while (true) { SocketChannel socketChannel = ssc.accept(); if (socketChannel == null) { Thread.sleep(1000); System.out.println("沒有客戶端鏈接上來"); for (SocketChannel channel : list) { int k = channel.read(byteBuffer); System.out.println(k + "===== no connection ====="); if (k != 0) { // 有鏈接發來數據 byteBuffer.flip(); System.out.println(new String(byteBuffer.array())); } } } else { socketChannel.configureBlocking(false); list.add(socketChannel); // 獲得套接字,循環全部的套接字,經過套接字獲取數據 for (SocketChannel channel : list) { int k = channel.read(byteBuffer); System.out.println(k + "===== connection ====="); if (k != 0) { byteBuffer.flip(); System.out.println(new String(byteBuffer.array())); } } } } } catch (IOException | InterruptedException e) { e.printStackTrace(); } } }
OK,如今將上面的代碼運行起來,再運行兩個客戶端代碼,向8001端口發送數據,運行結果以下:
這種非阻塞實現可讓服務端節省下許多資源。可是這樣的實現仍是有弊端:
咱們在這裏採用了輪詢的方式來接收消息,每次都會輪詢全部的鏈接,查看哪一個套接字中有準備好的消息。在鏈接到服務端的鏈接還少的時候,這種方式是沒有問題的,可是若是如今有100w個鏈接,此時再使用輪詢的話,效率就變得十分低下。並且很大一部分的鏈接基本都不發消息的,在100w個鏈接中可能只有10w個鏈接會有消息,可是每次鏈接程序後咱們都得去輪詢,這是很不適合的。
首先咱們要知道一個class java.nio.channels.Selector
,它是實現Java的非阻塞I/O的關鍵。什麼是Selector,這裏舉例作解釋:
在一個養雞場,有這麼一我的,天天的工做就是不停檢查幾個特殊的雞籠,若是有雞進來,有雞出去,有雞生蛋,有雞生病等等,就把相應的狀況記錄下來,若是雞場的負責人想知道狀況,只須要詢問那我的便可。
在這裏,這我的就至關Selector,每一個雞籠至關於一個SocketChannel,每一個線程經過一個Selector能夠管理多個SocketChannel。
爲了實現Selector管理多個SocketChannel,必須將具體的SocketChannel對象註冊到Selector,並聲明須要監聽的事件(這樣Selector才知道須要記錄什麼數據),一共有4種事件:
SelectionKey.OP_CONNECT(8)
SelectionKey.OP_ACCEPT(16)
SelectionKey.OP_READ(1)
SelectionKey.OP_WRITE(4)
這個很好理解,每次請求到達服務器,都是從connect開始,connect成功後,服務端開始準備accept,準備就緒,開始讀數據,並處理,最後寫回數據返回。
因此,當SocketChannel有對應的事件發生時,Selector均可以觀察到,並進行相應的處理。
public class NioServer { public static void main(String[] args) throws IOException { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); ssc.socket().bind(new InetSocketAddress(8001)); Selector selector = Selector.open(); ssc.register(selector, SelectionKey.OP_ACCEPT); while (true) { int n = selector.select(); if (n == 0) continue; // 若是沒有鏈接發來數據,跳過這次循環 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); if (key.isAcceptable()) { SocketChannel socketChannel = ((ServerSocketChannel) key.channel()).accept(); socketChannel.configureBlocking(false); // 將選擇器註冊到客戶端信道 // 並指定該信道key值的屬性爲OP_READ, // 同時爲該信道指定關聯的附件 socketChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(1024)); } if (key.isReadable()) { // handle Read } if (key.isWritable() && key.isValid()) { // handle Write } if (key.isConnectable()) { System.out.println("isConnectable = true"); } iterator.remove(); } } } }
從這裏咱們看出,雖然以前咱們用NIO作了多個客戶端輪詢,可是在真正在NIO實現時,咱們並不會去這麼作,而是使用Selector
,將輪詢的邏輯交由Selector
處理,而Selector
最終會調用到系統函數select()/epoll()。
假設有多個鏈接同時鏈接服務器,那麼根據上下文的設計,程序將會遍歷這多個鏈接,輪詢每一個鏈接以獲取各自數據的準備狀況,那麼這和咱們本身寫的程序有什麼區別呢?
首先,咱們本身寫的Java程序本質也是在輪詢每一個Socket的時候去調用系統函數,那麼輪詢一個調用一次,會形成沒必要要的上下文切換開銷。
而select會將請求從用戶態空間全量複製一份到內核態空間,在內核空間來判斷每一個請求是否準備好數據,徹底避免頻繁的上下文切換。因此效率是比咱們直接在應用層輪詢要高的。
若是select沒有查詢到到有數據的請求,那麼將會一直阻塞(是的,select是一個阻塞函數)。若是有一個或者多個請求已經準備好數據了,那麼select將會先將有數據的文件描述符置位,而後select返回。返回後經過遍歷查看哪一個請求有數據。
select的缺點:
poll的工做原理和select很像,先看一段poll內部使用的一個結構體
struct pollfd{ int fd; short events; short revents; }
poll一樣會將全部的請求拷貝到內核態,和select同樣,poll一樣是一個阻塞函數,當一個或多個請求有數據的時候,也一樣會進行置位,可是它置位的是結構體pollfd中的events或者revents置位,而不是對fd自己進行置位,因此在下一次使用的時候不須要再進行從新賦空值的操做。poll內部存儲不依賴bitmap,而是使用pollfd數組的這樣一個數據結構,數組的大小確定是大於1024的。解決了select 一、2兩點的缺點。
epoll是最新的一種多路IO複用的函數。這裏只說說它的特色。
epoll和上述兩個函數最大的不一樣是,它的fd是共享在用戶態和內核態之間的,因此能夠沒必要進行從用戶態到內核態的一個拷貝,這樣能夠節約系統資源;另外,在select和poll中,若是某個請求的數據已經準備好,它們會將全部的請求都返回,供程序去遍歷查看哪一個請求存在數據,可是epoll只會返回存在數據的請求,這是由於epoll在發現某個請求存在數據時,首先會進行一個重排操做,將全部有數據的fd放到最前面的位置,而後返回(返回值爲存在數據請求的個數N),那麼咱們的上層程序就能夠沒必要將全部請求都輪詢,而是直接遍歷epoll返回的前N個請求,這些請求都是有數據的請求。