從實踐角度來理解BIO和NIO

前言

這段時間自己在看一些Java中BIO和NIO之類的東西,看了很多博客,發現各種關於NIO的概念說的天花亂墜頭頭是道,可以說是非常的完整,但是整個看下來之後,自己對NIO還是一知半解的狀態,所以這篇文章不會提到很多的概念,而是站在一個實踐的角度,寫一些我自己關於NIO的見解,站在實踐過後的高度下再回去看概念,應該對概念會有一個更好的理解。

實現一個簡易單線程服務器

要講明白BIO和NIO,首先我們應該自己實現一個簡易的服務器,不用太複雜,單線程即可。

  • 爲什麼使用單線程作爲演示

    因爲在單線程環境下可以很好地對比出BIO和NIO的一個區別,當然我也會演示在實際環境中BIO的所謂一個請求對應一個線程的狀況。

  • 服務端

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
    public class Server {
    	public static void main(String[] args) {
    		byte[] buffer = new byte[1024];
    		try {
    			ServerSocket serverSocket = new ServerSocket(8080);
    			System.out.println("服務器已啓動並監聽8080端口");
    			while (true) {
    				System.out.println();
    				System.out.println("服務器正在等待連接...");
    				Socket socket = serverSocket.accept();
    				System.out.println("服務器已接收到連接請求...");
    				System.out.println();
    				System.out.println("服務器正在等待數據...");
    				socket.getInputStream().read(buffer);
    				System.out.println("服務器已經接收到數據");
    				System.out.println();
    				String content = new String(buffer);
    				System.out.println("接收到的數據:" + content);
    			}
    		} catch (IOException e) {
    			// TODO Auto-generated catch block
    			e.printStackTrace();
    		}
    	}
    }
    
  • 客戶端

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    public class Consumer {
    	public static void main(String[] args) {
    		try {
    			Socket socket = new Socket("127.0.0.1",8080);
    			socket.getOutputStream().write("向服務器發數據".getBytes());
    			socket.close();
    		} catch (IOException e) {
    			// TODO Auto-generated catch block
    			e.printStackTrace();
    		}
    	}
    }
    
  • 代碼解析

    我們首先創建了一個服務端類,在類中實現實例化了一個SocketServer並綁定了8080端口。之後調用accept方法來接收連接請求,並且調用read方法來接收客戶端發送的數據。最後將接收到的數據打印。

    完成了服務端的設計後,我們來實現一個客戶端,首先實例化Socket對象,並且綁定ip爲127.0.0.1(本機),端口號爲8080,調用write方法向服務器發送數據。

    簡易服務器

  • 運行結果

    當我們啓動服務器,但客戶端還沒有向服務器發起連接時,控制檯結果如下:

    簡易服務器結果1

    當客戶端啓動並向服務器發送數據後,控制檯結果如下:

    簡易服務器結果2

  • 結論

    從上面的運行結果,首先我們至少可以看到,在服務器啓動後,客戶端還沒有連接服務器時,服務器由於調用了accept方法,將一直阻塞,直到有客戶端請求連接服務器。

對客戶端功能進行擴展

在上文中,我們實現的客戶端的邏輯主要是,建立Socket –> 連接服務器 –> 發送數據,我們的數據是在連接服務器之後就立即發送的,現在我們來對客戶端進行一次擴展,當我們連接服務器後,不立即發送數據,而是等待控制檯手動輸入數據後,再發送給服務端。(服務端代碼保持不變)

  • 代碼

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    public class Consumer {
    	public static void main(String[] args) {
    		try {
    			Socket socket = new Socket("127.0.0.1",8080);
    			String message = null;
    			Scanner sc = new Scanner(System.in);
    			message = sc.next();
    			socket.getOutputStream().write(message.getBytes());
    			socket.close();
    			sc.close();
    		} catch (IOException e) {
    			// TODO Auto-generated catch block
    			e.printStackTrace();
    		}
    	}
    }
    
  • 測試

    當服務端啓動,客戶端還沒有請求連接服務器時,控制檯結果如下:

    擴展客戶端1

    當服務端啓動,客戶端連接服務端,但沒有發送數據時,控制檯結果如下:

    擴展客戶端2

    當服務端啓動,客戶端連接服務端,並且發送數據時,控制檯結果如下:

    擴展客戶端3

  • 結論

    從上文的運行結果中我們可以看到,服務器端在啓動後,首先需要等待客戶端的連接請求(第一次阻塞),如果沒有客戶端連接,服務端將一直阻塞等待,然後當客戶端連接後,服務器會等待客戶端發送數據(第二次阻塞),如果客戶端沒有發送數據,那麼服務端將會一直阻塞等待客戶端發送數據。服務端從啓動到收到客戶端數據的這個過程,將會有兩次阻塞的過程。這就是BIO的非常重要的一個特點,BIO會產生兩次阻塞,第一次在等待連接時阻塞,第二次在等待數據時阻塞。

BIO

  • 在單線程條件下BIO的弱點

    在上文中,我們實現了一個簡易的服務器,這個簡易的服務器是以單線程運行的,其實我們不難看出,當我們的服務器接收到一個連接後,並且沒有接收到客戶端發送的數據時,是會阻塞在read()方法中的,那麼此時如果再來一個客戶端的請求,服務端是無法進行響應的。換言之,在不考慮多線程的情況下,BIO是無法處理多個客戶端請求的

  • BIO如何處理併發

    在剛纔的服務器實現中,我們實現的是單線程版的BIO服務器,不難看出,單線程版的BIO並不能處理多個客戶端的請求,那麼如何能使BIO處理多個客戶端請求呢。

    其實不難想到,我們只需要在每一個連接請求到來時,創建一個線程去執行這個連接請求,就可以在BIO中處理多個客戶端請求了,這也就是爲什麼BIO的其中一條概念是服務器實現模式爲一個連接一個線程,即客戶端有連接請求時服務器端就需要啓動一個線程進行處理

  • 多線程BIO服務器簡易實現

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    
    public class Server {
    	public static void main(String[] args) {
    		byte[] buffer = new byte[1024];
    		try {
    			ServerSocket serverSocket = new ServerSocket(8080);
    			System.out.println("服務器已啓動並監聽8080端口");
    			while (true) {
    				System.out.println();
    				System.out.println("服務器正在等待連接...");
    				Socket socket = serverSocket.accept();
    				new Thread(new Runnable() {
    					@Override
    					public void run() {
    						System.out.println("服務器已接收到連接請求...");
    						System.out.println();
    						System.out.println("服務器正在等待數據...");
    						try {
    							socket.getInputStream().read(buffer);
    						} catch (IOException e) {
    							// TODO Auto-generated catch block
    							e.printStackTrace();
    						}
    						System.out.println("服務器已經接收到數據");
    						System.out.println();
    						String content = new String(buffer);
    						System.out.println("接收到的數據:" + content);
    					}
    				}).start();
    				
    			}
    		} catch (IOException e) {
    			// TODO Auto-generated catch block
    			e.printStackTrace();
    		}
    	}
    }
    
  • 運行結果

    多線程BIO

    多線程BIO2

    很明顯,現在我們的服務器的狀態就是一個線程對應一個請求,換言之,服務器爲每一個連接請求都創建了一個線程來處理。

  • 多線程BIO服務器的弊端

    多線程BIO服務器雖然解決了單線程BIO無法處理併發的弱點,但是也帶來一個問題:如果有大量的請求連接到我們的服務器上,但是卻不發送消息,那麼我們的服務器也會爲這些不發送消息的請求創建一個單獨的線程,那麼如果連接數少還好,連接數一多就會對服務端造成極大的壓力。所以如果這種不活躍的線程比較多,我們應該採取單線程的一個解決方案,但是單線程又無法處理併發,這就陷入了一種很矛盾的狀態,於是就有了NIO。

NIO

  • NIO的引入

    我們先來看看單線程模式下BIO服務器的代碼,其實NIO需要解決的最根本的問題就是存在於BIO中的兩個阻塞,分別是等待連接時的阻塞等待數據時的阻塞

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    
    public class Server {
    	public static void main(String[] args) {
    		byte[] buffer = new byte[1024];
    		try {
    			ServerSocket serverSocket = new ServerSocket(8080);
    			System.out.println("服務器已啓動並監聽8080端口");
    			while (true) {
    				System.out.println();
    				System.out.println("服務器正在等待連接...");
    				//阻塞1:等待連接時阻塞
    				Socket socket = serverSocket.accept();
    				System.out.println("服務器已接收到連接請求...");
    				System.out.println();
    				System.out.println("服務器正在等待數據...");
    				//阻塞2:等待數據時阻塞
    				socket.getInputStream().read(buffer);
    				System.out.println("服務器已經接收到數據");
    				System.out.println();
    				String content = new String(buffer);
    				System.out.println("接收到的數據:" + content);
    			}
    		} catch (IOException e) {
    			// TODO Auto-generated catch block
    			e.printStackTrace();
    		}
    	}
    }
    

    我們需要再老調重談的一點是,如果單線程服務器在等待數據時阻塞,那麼第二個連接請求到來時,服務器是無法響應的。如果是多線程服務器,那麼又會有爲大量空閒請求產生新線程從而造成線程佔用系統資源,線程浪費的情況。

    那麼我們的問題就轉移到,如何讓單線程服務器在等待客戶端數據到來時,依舊可以接收新的客戶端連接請求

  • 模擬NIO解決方案

    如果要解決上文中提到的單線程服務器接收數據時阻塞,而無法接收新請求的問題,那麼其實可以讓服務器在等待數據時不進入阻塞狀態,問題不就迎刃而解了嗎?

    • 第一種解決方案(等待連接時和等待數據時不阻塞)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      
      public class Server {
      	public static void main(String[] args) throws InterruptedException {
      		ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
      		try {
      			//Java爲非阻塞設置的類
      			ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
      			serverSocketChannel.bind(new InetSocketAddress(8080));
      			//設置爲非阻塞
      			serverSocketChannel.configureBlocking(false);
      			while(true) {
      				SocketChannel socketChannel = serverSocketChannel.accept();
      				if(socketChannel==null) {
      					//表示沒人連接
      					System.out.println("正在等待客戶端請求連接...");
      					Thread.sleep(5000);
      				}else {
      					System.out.println("當前接收到客戶端請求連接...");
      				}
      				if(socketChannel!=null) {
                          //設置爲非阻塞
      					socketChannel.configureBlocking(false);
      					byteBuffer.flip();//切換模式  寫-->讀
      					int effective = socketChannel.read(byteBuffer);
      					if(effective!=0) {
      						String content = Charset.forName("utf-8").decode(byteBuffer).toString();
      						System.out.println(content);
      					}else {
      						System.out.println("當前未收到客戶端消息");
      					}
      				}
      			}
      		} catch (IOException e) {
      			// TODO Auto-generated catch block
      			e.printStackTrace();
      		}
      	}
      }
      
    • 運行結果

      NIO解決方案1

      不難看出,在這種解決方案下,雖然在接收客戶端消息時不會阻塞,但是又開始重新接收服務器請求,用戶根本來不及輸入消息,服務器就轉向接收別的客戶端請求了,換言之,服務器弄丟了當前客戶端的請求

    • 解決方案二(緩存Socket,輪詢數據是否準備好)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      
      public class Server {
      	public static void main(String[] args) throws InterruptedException {
      		ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
      		
      		List<SocketChannel> socketList = new ArrayList<SocketChannel>();
      		try {
      			//Java爲非阻塞設置的類
      			ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
      			serverSocketChannel.bind(new InetSocketAddress(8080));
      			//設置爲非阻塞
      			serverSocketChannel.configureBlocking(false);
      			while(true) {
      				SocketChannel socketChannel = serverSocketChannel.accept();
      				if(socketChannel==null) {
      					//表示沒人連接
      					System.out.println("正在等待客戶端請求連接...");
      					Thread.sleep(5000);
      				}else {
      					System.out.println("當前接收到客戶端請求連接...");
      					socketList.add(socketChannel);
      				}
      				for(SocketChannel socket:socketList) {
      					socket.configureBlocking(false);
      					int effective = socket.read(byteBuffer);
      					if(effective!=0) {
      						byteBuffer.flip();//切換模式  寫-->讀
      						String content = Charset.forName("UTF-8").decode(byteBuffer).toString();
      						System.out.println("接收到消息:"+content);
      						byteBuffer.clear();
      					}else {
      						System.out.println("當前未收到客戶端消息");
      					}
      				}
      			}
      		} catch (IOException e) {
      			// TODO Auto-generated catch block
      			e.printStackTrace();
      		}
      	}
      }
      
    • 運行結果

      NIO解決方案2

      NIO解決方案3

    • 代碼解析

      解決方案一中,我們採用了非阻塞方式,但是發現一旦非阻塞,等待客戶端發送消息時就不會再阻塞了,而是直接重新去獲取新客戶端的連接請求,這就會造成客戶端連接丟失,而在解決方案二中,我們將連接存儲在一個list集合中,每次等待客戶端消息時都去輪詢,看看消息是否準備好,如果準備好則直接打印消息。可以看到,從頭到尾我們一直沒有開啓第二個線程,而是一直採用單線程來處理多個客戶端的連接,這樣的一個模式可以很完美地解決BIO在單線程模式下無法處理多客戶端請求的問題,並且解決了非阻塞狀態下連接丟失的問題。

  • 存在的問題(解決方案二)

    從剛纔的運行結果中其實可以看出,消息沒有丟失,程序也沒有阻塞。但是,在接收消息的方式上可能有些許不妥,我們採用了一個輪詢的方式來接收消息,每次都輪詢所有的連接,看消息是否準備好,測試用例中只是三個連接,所以看不出什麼問題來,但是我們假設有1000萬連接,甚至更多,採用這種輪詢的方式效率是極低的。另外,1000萬連接中,我們可能只會有100萬會有消息,剩下的900萬並不會發送任何消息,那麼這些連接程序依舊要每次都去輪詢,這顯然是不合適的。

  • 真實NIO中如何解決

    在真實NIO中,並不會在Java層上來進行一個輪詢,而是將輪詢的這個步驟交給我們的操作系統來進行,他將輪詢的那部分代碼改爲操作系統級別的系統調用(select函數,在linux環境中爲epoll),在操作系統級別上調用select函數,主動地去感知有數據的socket。

關於使用select/epoll和直接在應用層做輪詢的區別

我們在之前實現了一個使用Java做多個客戶端連接輪詢的邏輯,但是在真正的NIO源碼中其實並不是這麼實現的,NIO使用了操作系統底層的輪詢系統調用 select/epoll(windows:select,linux:epoll),那麼爲什麼不直接實現而要去調用系統來做輪詢呢?

  • select底層邏輯

    select

    假設有A、B、C、D、E五個連接同時連接服務器,那麼根據我們上文中的設計,程序將會遍歷這五個連接,輪詢每個連接,獲取各自數據準備情況,那麼和我們自己寫的程序有什麼區別呢

    首先,我們寫的Java程序其本質在輪詢每個Socket的時候也需要去調用系統函數,那麼輪詢一次調用一次,會造成不必要的上下文切換開銷。

    而Select會將五個請求從用戶態空間全量複製一份到內核態空間,在內核態空間來判斷每個請求是否準備好數據,完全避免頻繁的上下文切換。所以效率是比我們直接在應用層寫輪詢要高的。

    如果select沒有查詢到到有數據的請求,那麼將會一直阻塞(是的,select是一個阻塞函數)。如果有一個或者多個請求已經準備好數據了,那麼select將會先將有數據的文件描述符置位,然後select返回。返回後通過遍歷查看哪個請求有數據。

    select的缺點

    1. 底層存儲依賴bitmap,處理的請求是有上限的,爲1024。

    2. 文件描述符是會置位的,所以如果當被置位的文件描述符需要重新使用時,是需要重新賦空值的。

    3. fd(文件描述符)從用戶態拷貝到內核態仍然有一筆開銷。

    4. select返回後還要再次遍歷,來獲知是哪一個請求有數據。

  • poll函數底層邏輯

    poll的工作原理和select很像,先來看一段poll內部使用的一個結構體。

    1
    2
    3
    4
    5
    
    struct pollfd{
        int fd;
        short events;
        short revents;
    }
    

    poll同樣會將所有的請求拷貝到內核態,和select一樣,poll同樣是一個阻塞函數,當一個或多個請求有數據的時候,也同樣會進行置位,但是它置位的是結構體pollfd中的events或者revents置位,而不是對fd本身進行置位,所以在下一次使用的時候不需要再進行重新賦空值的操作。poll內部存儲不依賴bitmap,而是使用pollfd數組的這樣一個數據結構,數組的大小肯定是大於1024的。解決了select 1、2兩點的缺點。

  • epoll函數底層邏輯

    epoll是最新的一種多路IO複用的函數。這裏只說說它的特點。

    epoll和上述兩個函數最大的不同是,它的fd是共享在用戶態和內核態之間的,所以可以不必進行從用戶態到內核態的一個拷貝,這樣可以節約系統資源;另外,在select和poll中,如果某個請求的數據已經準備好,它們會將所有的請求都返回,供程序去遍歷查看哪個請求存在數據,但是epoll只會返回存在數據的請求,這是因爲epoll在發現某個請求存在數據時,首先會進行一個重排操作,將所有有數據的fd放到最前面的位置,然後返回(返回值爲存在數據請求的個數N),那麼我們的上層程序就可以不必將所有請求都輪詢,而是直接遍歷epoll返回的前N個請求,這些請求都是有數據的請求。

Java中BIO和NIO的概念

通常一些文章都是在開頭放上概念,但是我這次選擇將概念放在結尾,因爲通過上面的實操,相信大家對Java中BIO和NIO都有了自己的一些理解,這時候再來看概念應該會更好理解一些了。

概念整理於:https://blog.csdn.net/guanghuichenshao/article/details/79375967?tdsourcetag=s_pcqq_aiomsg

先來個例子理解一下概念,以銀行取款爲例:

  • 同步 : 自己親自出馬持銀行卡到銀行取錢(使用同步IO時,Java自己處理IO讀寫)。
  • 異步 : 委託一小弟拿銀行卡到銀行取錢,然後給你(使用異步IO時,Java將IO讀寫委託給OS處理,需要將數據緩衝區地址和大小傳給OS(銀行卡和密碼),OS需要支持異步IO操作API)。
  • 阻塞 : ATM排隊取款,你只能等待(使用阻塞IO時,Java調用會一直阻塞到讀寫完成才返回)。
  • 非阻塞 : 櫃檯取款,取個號,然後坐在椅子上做其它事,等號廣播會通知你辦理,沒到號你就不能去,你可以不斷問大堂經理排到了沒有,大堂經理如果說還沒到你就不能去(使用非阻塞IO時,如果不能讀寫Java調用會馬上返回,當IO事件分發器會通知可讀寫時再繼續進行讀寫,不斷循環直到讀寫完成)。

Java對BIO、NIO的支持:

  • Java BIO (blocking I/O): 同步並阻塞,服務器實現模式爲一個連接一個線程,即客戶端有連接請求時服務器端就需要啓動一個線程進行處理,如果這個連接不做任何事情會造成不必要的線程開銷,當然可以通過線程池機制改善。
  • Java NIO (non-blocking I/O): 同步非阻塞,服務器實現模式爲一個請求一個線程,即客戶端發送的連接請求都會註冊到多路複用器上,多路複用器輪詢到連接有I/O請求時才啓動一個線程進行處理。

BIO、NIO適用場景分析:

  • BIO方式適用於連接數目比較小且固定的架構,這種方式對服務器資源要求比較高,併發侷限於應用中,JDK1.4以前的唯一選擇,但程序直觀簡單易理解。
  • NIO方式適用於連接數目多且連接比較短(輕操作)的架構,比如聊天服務器,併發侷限於應用中,編程比較複雜,JDK1.4開始支持。

結語

本文介紹了一些關於JavaBIO和NIO從自己實操的角度上的一些理解,我個人認爲這樣去理解BIO和NIO會比光看概念會有更深的理解,也希望各位同學可以自己去敲一遍,通過程序的運行結果得出自己對JavaBIO和NIO的理解。