Java面試10-網絡IO模型詳解

前言

Java面試專欄的第10篇,這篇博客 南國帶你主要回顧一下在Java網絡IO常見的幾種模型 以及大名鼎鼎的Netty框架。

注意這裏所講的網絡IO和我在Java面試09——IO知識大盤點 講述的IO不一樣,上一篇我們主要講述的是文件的讀寫。傳統IO的Java編程主要是以流的形式,NIO是以塊的方式。當然 這一篇博客裏面 我們還會講述NIO。

該篇博客部分內容 參考以下博客,感謝前人的成果:
1.Java面試常考的 BIO,NIO,AIO 總結
2.面試|JAVA的網絡IO模型徹底講解

閒話不多說,乾貨送上~

1. 基本概念

在敘述Java 網絡IO之前,讓我們先來弄清楚幾個基本概念:同步與異步 阻塞與非阻塞。熟悉Java的朋友是不是看到之後 很熟悉了。如果 你之前 已經搞清楚這其中的區別,那麼 就再次和南國一起 把知識回顧一下吧。

1.1 同步與異步

  • 同步: 同步就是發起一個調用後,被調用者未處理完請求之前,調用不返回。

  • 異步: 異步就是發起一個調用後,立刻得到被調用者的迴應表示已接收到請求,但是被調用者並沒有返回結果,此時我們可以處理其他的請求,被調用者通常依靠事件,回調等機制來通知調用者其返回結果。

同步和異步的區別最大在於異步的話調用者不需要等待處理結果,被調用者會通過回調等機制來通知調用者其返回結果。

1.2 阻塞與非阻塞

  • 阻塞: 阻塞就是發起一個請求,調用者一直等待請求結果返回,也就是當前線程會被掛起,無法從事其他任務,只有當條件就緒才能繼續。
  • 非阻塞: 非阻塞就是發起一個請求,調用者不用一直等着結果返回,可以先去幹其他事情。

舉個生活中簡單的例子,你媽媽讓你燒水,小時候你比較笨啊,在哪裏傻等着水開(同步阻塞)。等你稍微再長大一點,你知道每次燒水的空隙可以去幹點其他事,然後只需要時不時來看看水開了沒有(同步非阻塞)。後來,你們家用上了水開了會發出聲音的壺,這樣你就只需要聽到響聲後就知道水開了,在這期間你可以隨便幹自己的事情,你需要去倒水了(異步非阻塞)。

2. BIO(Blocking I/O)

同步阻塞I/O模式,數據的讀取寫入必須阻塞在一個線程內等待其完成。

2.1 傳統BIO(同步阻塞IO)

BIO通信(一請求一應答1:1)模型圖如下:
在這裏插入圖片描述
採用 BIO 通信模型 的服務端,通常由一個獨立的 Acceptor 線程負責監聽客戶端的連接。我們一般通過在 while(true) 循環中服務端會調用 accept() 方法等待接收客戶端的連接的方式監聽請求,請求一旦接收到一個連接請求,就可以建立通信套接字在這個通信套接字上進行讀寫操作,此時不能再接收其他客戶端連接請求,只能等待同當前連接的客戶端的操作執行完成, 不過可以通過多線程來支持多個客戶端的連接,如上圖所示。

如果要讓 BIO 通信模型 能夠同時處理多個客戶端請求,就必須使用多線程(主要原因是 socket.accept()、 socket.read()、 socket.write() 涉及的三個主要函數都是同步阻塞的),也就是說它在接收到客戶端連接請求之後爲每個客戶端創建一個新的線程進行鏈路處理,處理完成之後,通過輸出流返回應答給客戶端,線程銷燬。這就是典型的 一請求一應答通信模型 。

該模型的最大問題就是缺乏彈性伸縮能力,當客戶端併發訪問量增加後,服務端的線程數和客戶端併發訪問數呈現1:1的正比關係,由於線程是Java虛擬機非常寶貴的系統資源,當線程數膨脹之後,系統性能將極具下降,隨着併發訪問量的繼續增大,系統會發生線程對棧溢出、創建新線程失敗等問題,並最終導致進程宕機或者僵死,不能對外提供服務。

我們可以設想一下如果這個連接不做任何事情的話就會造成不必要的線程開銷,不過可以通過 線程池機制 改善,線程池還可以讓線程的創建和回收成本相對較低。使用 FixedThreadPool 可以有效的控制了線程的最大數量,保證了系統有限的資源的控制,實現了N(客戶端請求數量):M(處理客戶端請求的線程數量)的僞異步I/O模型(N 可以遠遠大於 M),下面一節"僞異步 BIO"中會詳細介紹到。

2.2 僞異步 IO

後端通過一個線程池來處理多個客戶端的請求接入,形成客戶端個數M:線程池最大線程數N的比例關係,其中M可以遠遠大於N.通過線程池可以靈活地調配線程資源,設置線程的最大值,防止由於海量併發接入導致線程耗盡。

僞異步IO模型圖(M:N)
在這裏插入圖片描述
採用線程池和任務隊列可以實現一種叫做僞異步的 I/O 通信框架,它的模型圖如上圖所示。當有新的客戶端接入時,將客戶端的 Socket 封裝成一個Task(該任務實現java.lang.Runnable接口)投遞到後端的線程池中進行處理,JDK 的線程池維護一個消息隊列和 N 個活躍線程,對消息隊列中的任務進行處理。由於線程池可以設置消息隊列的大小和最大線程數,因此,它的資源佔用是可控的,無論多少個客戶端併發訪問,都不會導致資源的耗盡和宕機。

僞異步I/O通信框架採用了線程池實現,因此避免了爲每個請求都創建一個獨立線程造成的線程資源耗盡問題。不過因爲它的底層還是同步阻塞的BIO模型,因此無法從根本上解決問題。

3. NIO

3.1 同步非阻塞IO

關於 NIO,南國在上一篇博客Java面試09——IO知識大盤點 中有描述過,NIO中的N可以理解爲Non-blocking,不單純是New。它提供了 Channel , Selector,Buffer3個核心組件,支持面向緩衝的,基於通道的I/O操作方法。 NIO提供了與傳統BIO模型中的 Socket 和 ServerSocket 相對應的 SocketChannel 和 ServerSocketChannel 兩種不同的套接字通道實現,兩種通道都支持阻塞和非阻塞兩種模式。阻塞模式使用就像傳統中的支持一樣,比較簡單,但是性能和可靠性都不好;非阻塞模式正好與之相反。對於低負載、低併發的應用程序,可以使用同步阻塞I/O來提升開發速率和更好的維護性;對於高負載、高併發的(網絡)應用,應使用 NIO 的非阻塞模式來開發。

但是在實際應用中,爲什麼大家都不願意用 JDK 原生 NIO 進行開發呢?除了編程複雜、編程模型難之外,它還有以下讓人詬病的問題:

  • JDK 的 NIO 底層由 epoll 實現,該實現飽受詬病的空輪詢 bug 會導致 cpu 飆升 100%
  • 項目龐大之後,自行實現的 NIO 很容易出現各類 bug,維護成本較高,上面這一坨代碼我都不能保證沒有 bug

Netty 的出現很大程度上改善了 JDK 原生 NIO 所存在的一些讓人難以忍受的問題。後續我們 會詳細剖析Netty框架

3.2 異步非阻塞IO(AIO)

AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改進版 NIO 2,它是異步非阻塞的IO模型。異步 IO 是基於事件和回調機制實現的,也就是應用操作之後會直接返回,不會堵塞在那裏,當後臺處理完成,操作系統會通知相應的線程進行後續的操作。

AIO 是異步IO的縮寫,雖然 NIO 在網絡操作中,提供了非阻塞的方法,但是 NIO 的 IO 行爲還是同步的。對於 NIO 來說,我們的業務線程是在 IO 操作準備好時,得到通知,接着就由這個線程自行進行 IO 操作,IO操作本身是同步的。

說到這裏 讓我們來總結一下:
在這裏插入圖片描述

4. Netty

4.1 原理

Netty是一個高性能、異步事件驅動的NIO框架,基於JAVA NIO提供的API實現。它提供了對TCP、UDP和文件傳輸的支持,作爲一個異步NIO框架,Netty的所有IO操作都是異步非阻塞的,通過Future-Listener機制,用戶可以方便的主動獲取或者通過通知機制獲得IO操作結果。

4.2 高性能

在IO編程過程中,當需要同時處理多個客戶端接入請求時,可以利用多線程或者IO多路複用技術進行處理。

IO多路複用技術通過把多個IO的阻塞複用到同一個select的阻塞上,從而使得系統在單線程的情況下可以同時處理多個客戶端請求。與傳統的多線程/多進程模型比,I/O多路複用的最大優勢是系統開銷小,系統不需要創建新的額外進程或者線程,也不需要維護這些進程和線程的運行,降低了系統的維護工作量,節省了系統資源。

與Socket類和ServerSocket類相對應,NIO也提供了SocketChannel和ServerSocketChannel兩種不同的套接字通道實現。

4.2.1 多路複用通訊方式

Netty架構按照Reactor模式設計和實現(Reactor模式 後面南國會講到)。

服務端通信序列圖如下:
在這裏插入圖片描述
備註:上圖中部分文字沒有顯示出來。圖中 7.設置新建客戶端連接的Socket參數; 10.decode請求消息

客戶端通信序列圖如下:
在這裏插入圖片描述
備註:上圖中部分文字沒有顯示出來。圖中 9.判斷連接是否完成,完成連接執行步驟10 12.decode請求消息

Netty的IO線程NioEventLoop由於聚合了多路複用器Selector,可以同時併發處理成百上千個客戶端Channel,由於讀寫操作都是非阻塞的,這就可以充分提升IO線程的運行效率,避免由於頻繁IO阻塞導致的線程掛起

4.2.2 Reactor線程模型

常用的Reactor線程模型有三種:

  • Reactor單線程模型;
  • Reactor多線程模型;
  • 主從Reactor多線程模型。

1.Reactor單線程模型
Reactor單線程模型,指的是所有的IO操作都在同一個NIO線程上面完成,NIO線程的職責如下: 1) 作爲NIO服務端,接收客戶端的TCP連接; 2) 作爲NIO客戶端,向服務端發起TCP連接; 3) 讀取通信對端的請求或者應答消息; 4) 向通信對端發送消息請求或者應答消息。
在這裏插入圖片描述
由於Reactor模式使用的是異步非阻塞IO,所有操作都不會導致阻塞,理論上一個線程可以獨立處理所有IO相關的操作。從架構層面來看,一個NIO線程確實完全可以承擔起職責。例如,通過Acceptor類接收客戶端的TCP鏈接請求消息,當鏈路建立成功之後,通過Dispatcher將對應的ByteBuffer派發給指定的Handler上進行編解碼。用戶線程消息編碼後通過NIO線程將消息發送給客戶端。

這種方式不適合高負載,大併發的應用場景,主要原因如下:
A),一個NIO線程同時處理成百上千的鏈路,性能上無法支撐,即便NIO線程的CPU負荷達到100%,也無法滿足海量消息的編碼、解碼、讀取和發送。
B),當NIO線程負載過重之後,處理速度將變慢,這會導致大量客戶端鏈接超時,超時之後往往會進行重發,這更加重了NIO線程的負載,最終會導致大量消息積壓和處理超時,成爲系統的性能瓶頸。
C),可靠性問題:一旦NIO線程意外跑飛,或者進入死循環,會導致整個系統通訊模塊不可用,不能接受和處理外部消息,造成節點故障。

2.Reactor多線程模型
Rector多線程模型與單線程模型最大的區別就是有一組NIO線程處理IO操作有專門一個NIO線程-Acceptor線程用於監聽服務端,接收客戶端的TCP連接請求; 網絡IO操作-讀、寫等由一個NIO線程池負責,線程池可以採用標準的JDK線程池實現,它包含一個任務隊列和N個可用的線程,由這些NIO線程負責消息的讀取、解碼、編碼和發送

一個NIO線程可以同時處理N條鏈路,但是一個鏈路只對應一個NIO線程,防止併發操作問題。
在這裏插入圖片描述
在絕大多數場景下,Reactor多線程模型可以滿足性能需求。但是,在個別特殊場景中,一個NIO線程負責監聽和處理所有客戶端鏈接可能會存在性能問題。例如併發百萬客戶端鏈接,或者服務端需要多客戶端握手進行安全認證,但是認證本身非常損耗性能。在這種場景下,單獨一個Acceptor線程可能會存在性能不足的問題,爲了解決性能問題,產生了第三種Reactor線程模型—主從Reactor多線程模型。

3.主從Reactor多線程模型
服務端用於接收客戶端連接的不再是個1個單獨的NIO線程,而是一個獨立的NIO線程池。Acceptor接收到客戶端TCP連接請求處理完成後(可能包含接入認證等),將新創建的SocketChannel註冊到IO線程池(sub reactor線程池)的某個IO線程上,由它負責SocketChannel的讀寫和編解碼工作。Acceptor線程池僅僅只用於客戶端的登陸、握手和安全認證,一旦鏈路建立成功,就將鏈路註冊到後端subReactor線程池的IO線程上,由IO線程負責後續的IO操作
在這裏插入圖片描述

4.2.3 Netty線程模型

Netty框架的主要線程模型就是IO線程,線程模型設計的好壞,決定了系統的吞吐量、併發性和安全性等架構質量屬性。

Netty的線程模型被精心的設計,即提升了框架的併發性能,又能在很大程度避免鎖,局部實現了無鎖化設計。

Netty的線程模型並不是一成不變的,它實際取決於用戶的啓動參數配置。通過設置不同的啓動參數,Netty可以同時支持Reactor單線程模型,多線程模型和主從Reactor多線程模型。

服務啓動的時候,會創建兩個NioEventLoopGroup,它們實際是兩個獨立的Reactor線程池。一個用於接收客戶端的TCP鏈接,另一個用於處理IO相關的讀寫操作,或者執行系統Task、定時任務Task等。
Netty用於接收客戶端請求的線程池職責如下:
1),接收客戶端TCP鏈接,初始化Channel參數;
2),將鏈路狀態變更時間通知給ChannelPipeLine。

Netty處理IO操作的Reactor線程池職責如下:
1),異步讀取通訊端的數據報,發送讀事件到ChannelPipeLine
2),異步發送消息到通信對端,調用ChannelPipeLine的消息發送接口
3),執行系統調用Task
4),執行定時任務Task,例如鏈路空閒狀態監測定時任務。

爲了儘可能的提示性能,Netty在很多地方進行了無鎖化的設計,例如在IO線程內部進行,線程操作,避免多線程競爭導致的性能下降問題。表面上看,串行化設計似乎CPU利用率不高,併發度不夠。但是通過調整NIO線程池的線程參數,可以同時啓動多個串行化的線程並行運行,這種局部無鎖化的串行設計相比一個隊列—多個工作線程的模型更優。設計原理如下圖:
在這裏插入圖片描述
Netty的NioEventLoop讀取到消息之後,直接調用ChannelPipeLine的fireChannelRead(Object msg).只要用戶不主動切換線程,一直都是由NioEventLoop調用用戶的Handler,期間不進行線程切換。這種串行化處理方式避免了多線程操作導致的鎖的競爭,從性能角度看是最優的。

Netty的多線程編程最佳實踐如下:
1),創建兩個NioEventLoopGroup,用於隔離NIO Acceptor和NIO IO線程。
2),儘量不要在ChannelHandler中啓動用戶線程(解碼後用於將POJO消息派發到後端業務線程的除外)。
3),解碼要放在NIO線程調用的解碼Handler中進行,不要切換到用戶線程中完成消息的解碼。
4),如果業務邏輯操作非常簡單,沒有複雜的業務邏輯計算,沒有可能會導致線程被阻塞的磁盤操作,數據庫操作,網絡操作等,可以直接在NIO線程上完成業務邏輯編排,不需要切換到用戶線程。
5),如果業務邏輯處理複雜,不要在NIO線程上,完成,建議將解碼後的POJO消息封裝成Task,派發到業務線程池中由業務線程執行,以保證NIO線程儘快被釋放,處理其他的IO操作。

推薦的線程數量計算公式有以下兩種。
1),公式1:線程數量=(線程總時間/瓶頸資源時間)瓶頸資源線程的並行數;
2),公式2:QPS=1000/線程總時間
線程數。

由於用戶場景的不同,對於一些複雜的系統,實際上很難計算出最優線程配置,只能是根據測試數據和用戶場景,結合公式給出一個相對合理的範圍,然後對範圍內的數據進行性能測試,選擇相對最優值。

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