公衆号JavaGuide 原文位址:BIO,NIO,AIO 簡要總結
熟練掌握 BIO,NIO,AIO 的基本概念以及一些常見問題是你準備面試的過程中不可或缺的一部分,另外這些知識點也是我們學習 Netty 的基礎。
文章目錄
- BIO,NIO,AIO 總結
- 1. BIO (Blocking I/O)
- 1.1 傳統 BIO
- 1.2 僞異步 IO
- 1.3 代碼示例
- 1.4 總結
- 2. NIO (New I/O)
- 2.1 NIO 簡介
- 2.2 NIO的特性/NIO與IO差別
- 1)Non-blocking IO(非阻塞IO)
- 2)Buffer(緩沖區)
- 3)Channel (通道)
- 4)Selectors(選擇器)
- 2.3 NIO 讀資料和寫資料方式
- 2.4 NIO核心元件簡單介紹
- 2.5 代碼示例
- 3. AIO (Asynchronous I/O)
- 4.Netty程式設計
BIO,NIO,AIO 總結
Java 中的 BIO、NIO和 AIO 了解為是 Java 語言對作業系統的各種 IO 模型的封裝。程式員在使用這些 API 的時候,不需要關心作業系統層面的知識,也不需要根據不同作業系統編寫不同的代碼。隻需要使用Java的API就可以了。
在講 BIO,NIO,AIO 之前先來回顧一下這樣幾個概念:同步與異步,阻塞與非阻塞。
同步與異步
- 同步: 同步就是發起一個調用後,被調用者未處理完請求之前,調用不傳回。
- 異步: 異步就是發起一個調用後,立刻得到被調用者的回應表示已接收到請求,但是被調用者并沒有傳回結果,此時我們可以處理其他的請求,被調用者通常依靠事件,回調等機制來通知調用者其傳回結果。
同步和異步的差別最大在于異步的話調用者不需要等待處理結果,被調用者會通過回調等機制來通知調用者其傳回結果。
阻塞和非阻塞
- 阻塞: 阻塞就是發起一個請求,調用者一直等待請求結果傳回,也就是目前線程會被挂起,無法從事其他任務,隻有當條件就緒才能繼續。
- 非阻塞: 非阻塞就是發起一個請求,調用者不用一直等着結果傳回,可以先去幹其他事情。
同步阻塞、同步非阻塞和異步非阻塞
老張愛喝茶,廢話不說,煮開水。
出場人物:老張,水壺兩把(普通水壺,簡稱水壺;會響的水壺,簡稱響水壺)。
1 老張把水壺放到火上,立等水開。(同步阻塞)
老張覺得自己有點傻
2 老張把水壺放到火上,去客廳看電視,時不時去廚房看看水開沒有。(同步非阻塞)
老張還是覺得自己有點傻,于是變高端了,買了把會響笛的那種水壺。水開之後,能大聲發出嘀~~~~的噪音。
3 老張把響水壺放到火上,立等水開。(異步阻塞)
老張覺得這樣傻等意義不大
4 老張把響水壺放到火上,去客廳看電視,水壺響之前不再去看它了,響了再去拿壺。(異步非阻塞)
1. BIO (Blocking I/O)
同步阻塞I/O模式,資料的讀取寫入必須阻塞在一個線程内等待其完成。
1.1 傳統 BIO
BIO通信(一請求一應答)模型圖如下
采用 BIO 通信模型 的服務端,通常由一個獨立的 Acceptor 線程負責監聽用戶端的連接配接。我們一般通過在
while(true)
循環中服務端會調用
accept()
方法等待接收用戶端的連接配接的方式監聽請求,請求一旦接收到一個連接配接請求,就可以建立通信套接字在這個通信套接字上進行讀寫操作,此時不能再接收其他用戶端連接配接請求,隻能等待同目前連接配接的用戶端的操作執行完成, 不過可以通過多線程來支援多個用戶端的連接配接,如上圖所示。
如果要讓 BIO 通信模型 能夠同時處理多個用戶端請求,就必須使用多線程(主要原因是
socket.accept()
、
socket.read()
、
socket.write()
涉及的三個主要函數都是同步阻塞的),也就是說它在接收到用戶端連接配接請求之後為每個用戶端建立一個新的線程進行鍊路處理,處理完成之後,通過輸出流傳回應答給用戶端,線程銷毀。這就是典型的 一請求一應答通信模型 。我們可以設想一下如果這個連接配接不做任何事情的話就會造成不必要的線程開銷,不過可以通過 線程池機制 改善,線程池還可以讓線程的建立和回收成本相對較低。使用
FixedThreadPool
可以有效的控制了線程的最大數量,保證了系統有限的資源的控制,實作了N(用戶端請求數量):M(處理用戶端請求的線程數量)的僞異步I/O模型(N 可以遠遠大于 M),下面一節"僞異步 BIO"中會詳細介紹到。
我們再設想一下當用戶端并發通路量增加後這種模型會出現什麼問題?
在 Java 虛拟機中,線程是寶貴的資源,線程的建立和銷毀成本很高,除此之外,線程的切換成本也是很高的。尤其在 Linux 這樣的作業系統中,線程本質上就是一個程序,建立和銷毀線程都是重量級的系統函數。如果并發通路量增加會導緻線程數急劇膨脹可能會導緻線程堆棧溢出、建立新線程失敗等問題,最終導緻程序當機或者僵死,不能對外提供服務。
1.2 僞異步 IO
為了解決同步阻塞I/O面臨的一個鍊路需要一個線程處理的問題,後來有人對它的線程模型進行了優化一一一後端通過一個線程池來處理多個用戶端的請求接入,形成用戶端個數M:線程池最大線程數N的比例關系,其中M可以遠遠大于N.通過線程池可以靈活地調配線程資源,設定線程的最大值,防止由于海量并發接入導緻線程耗盡。
僞異步IO模型圖
采用線程池和任務隊列可以實作一種叫做僞異步的 I/O 通信架構,它的模型圖如上圖所示。當有新的用戶端接入時,将用戶端的 Socket 封裝成一個Task(該任務實作java.lang.Runnable接口)投遞到後端的線程池中進行處理,JDK 的線程池維護一個消息隊列和 N 個活躍線程,對消息隊列中的任務進行處理。由于線程池可以設定消息隊列的大小和最大線程數,是以,它的資源占用是可控的,無論多少個用戶端并發通路,都不會導緻資源的耗盡和當機。
僞異步I/O通信架構采用了線程池實作,是以避免了為每個請求都建立一個獨立線程造成的線程資源耗盡問題。不過因為它的底層任然是同步阻塞的BIO模型,是以無法從根本上解決問題。
1.3 代碼示例
下面代碼中示範了BIO通信(一請求一應答)模型。我們會在用戶端建立多個線程依次連接配接服務端并向其發送"目前時間+:hello world",服務端會為每個用戶端線程建立一個線程來處理。
用戶端
public class IOClient {
public static void main(String[] args) {
new Thread(() -> {
try {
Socket socket = new Socket("127.0.0.1", 8000);
while (true) {
try {
socket.getOutputStream().write((new Date() + ": hello world").getBytes());
socket.getOutputStream().flush();
Thread.sleep(2000);
} catch (Exception e) {
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
服務端
public class IOServer {
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8000);
// (1) 接收新連接配接線程
new Thread(() -> {
while (true) {
try {
// (1) 阻塞方法擷取新的連接配接
Socket socket = serverSocket.accept();
// (2) 每一個新的連接配接都建立一個線程,負責讀取資料
new Thread(() -> {
try {
byte[] data = new byte[1024];
InputStream inputStream = socket.getInputStream();
while (true) {
int len;
// (3) 按位元組流方式讀取資料
while ((len = inputStream.read(data)) != -1) {
System.out.println(new String(data, 0, len));
}
}
} catch (IOException e) {
}
}).start();
} catch (IOException e) {
}
}
}).start();
}
}
1.4 總結
在活動連接配接數不是特别高(小于單機1000)的情況下,這種模型是比較不錯的,可以讓每一個連接配接專注于自己的 I/O 并且程式設計模型簡單,也不用過多考慮系統的過載、限流等問題。線程池本身就是一個天然的漏鬥,可以緩沖一些系統處理不了的連接配接或請求。但是,當面對十萬甚至百萬級連接配接的時候,傳統的 BIO 模型是無能為力的。是以,我們需要一種更高效的 I/O 處理模型來應對更高的并發量。
2. NIO (New I/O)
2.1 NIO 簡介
NIO是一種同步非阻塞的I/O模型,在Java 1.4 中引入了NIO架構,對應 java.nio 包,提供了 Channel , Selector,Buffer等抽象。
NIO中的N可以了解為Non-blocking,不單純是New。它支援面向緩沖的,基于通道的I/O操作方法。 NIO提供了與傳統BIO模型中的
Socket
和
ServerSocket
相對應的
SocketChannel
和
ServerSocketChannel
兩種不同的套接字通道實作,兩種通道都支援阻塞和非阻塞兩種模式。阻塞模式使用就像傳統中的支援一樣,比較簡單,但是性能和可靠性都不好;非阻塞模式正好與之相反。對于低負載、低并發的應用程式,可以使用同步阻塞I/O來提升開發速率和更好的維護性;對于高負載、高并發的(網絡)應用,應使用 NIO 的非阻塞模式來開發。
2.2 NIO的特性/NIO與IO差別
如果是在面試中回答這個問題,我覺得首先肯定要從 NIO 流是非阻塞 IO 而 IO 流是阻塞 IO 說起。然後,可以從 NIO 的3個核心元件/特性為 NIO 帶來的一些改進來分析。如果,你把這些都回答上了我覺得你對于 NIO 就有了更為深入一點的認識,面試官問到你這個問題,你也能很輕松的回答上來了。
1)Non-blocking IO(非阻塞IO)
IO流是阻塞的,NIO流是不阻塞的。
Java NIO使我們可以進行非阻塞IO操作。比如說,單線程中從通道讀取資料到buffer,同時可以繼續做别的事情,當資料讀取到buffer中後,線程再繼續處理資料。寫資料也是一樣的。另外,非阻塞寫也是如此。一個線程請求寫入一些資料到某通道,但不需要等待它完全寫入,這個線程同時可以去做别的事情。
Java IO的各種流是阻塞的。這意味着,當一個線程調用
read()
或
write()
時,該線程被阻塞,直到有一些資料被讀取,或資料完全寫入。該線程在此期間不能再幹任何事情了
2)Buffer(緩沖區)
IO 面向流(Stream oriented),而 NIO 面向緩沖區(Buffer oriented)。
Buffer是一個對象,它包含一些要寫入或者要讀出的資料。在NIO類庫中加入Buffer對象,展現了新庫與原I/O的一個重要差別。在面向流的I/O中·可以将資料直接寫入或者将資料直接讀到 Stream 對象中。雖然 Stream 中也有 Buffer 開頭的擴充類,但隻是流的包裝類,還是從流讀到緩沖區,而 NIO 卻是直接讀到 Buffer 中進行操作。
在NIO厍中,所有資料都是用緩沖區處理的。在讀取資料時,它是直接讀到緩沖區中的; 在寫入資料時,寫入到緩沖區中。任何時候通路NIO中的資料,都是通過緩沖區進行操作。
最常用的緩沖區是 ByteBuffer,一個 ByteBuffer 提供了一組功能用于操作 byte 數組。除了ByteBuffer,還有其他的一些緩沖區,事實上,每一種Java基本類型(除了Boolean類型)都對應有一種緩沖區。
3)Channel (通道)
NIO 通過Channel(通道) 進行讀寫。
通道是雙向的,可讀也可寫,而流的讀寫是單向的。無論讀寫,通道隻能和Buffer互動。因為 Buffer,通道可以異步地讀寫。
4)Selectors(選擇器)
NIO有選擇器,而IO沒有。
選擇器用于使用單個線程處理多個通道。是以,它需要較少的線程來處理這些通道。線程之間的切換對于作業系統來說是昂貴的。 是以,為了提高系統效率選擇器是有用的。
2.3 NIO 讀資料和寫資料方式
通常來說NIO中的所有IO都是從 Channel(通道) 開始的。
- 從通道進行資料讀取 :建立一個緩沖區,然後請求通道讀取資料。
- 從通道進行資料寫入 :建立一個緩沖區,填充資料,并要求通道寫入資料。
資料讀取和寫入操作圖示:
2.4 NIO核心元件簡單介紹
NIO 包含下面幾個核心的元件:
- Channel(通道)
- Buffer(緩沖區)
- Selector(選擇器)
整個NIO體系包含的類遠遠不止這三個,隻能說這三個是NIO體系的“核心API”。我們上面已經對這三個概念進行了基本的闡述,這裡就不多做解釋了。
2.5 代碼示例
public class NIOServer {
public static void main(String[] args) throws IOException {
Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();
new Thread(() -> {
try {
// 對應IO程式設計中服務端啟動
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
listenerChannel.socket().bind(new InetSocketAddress(8000));
listenerChannel.configureBlocking(false);
listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
while (true) {
// 監測是否有新的連接配接,這裡的1指的是阻塞的時間為1ms
if (serverSelector.select(1) > 0) {
Set<SelectionKey> set = serverSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
try {
// (1) 每來一個新連接配接,不需要建立一個線程,而是直接注冊到clientSelector
SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
clientChannel.configureBlocking(false);
clientChannel.register(clientSelector, SelectionKey.OP_READ);
} finally {
keyIterator.remove();
}
}
}
}
}
} catch (IOException ignored) {
}
}).start();
new Thread(() -> {
try {
while (true) {
// (2) 批量輪詢是否有哪些連接配接有資料可讀,這裡的1指的是阻塞的時間為1ms
if (clientSelector.select(1) > 0) {
Set<SelectionKey> set = clientSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isReadable()) {
try {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// (3) 讀取資料以塊為機關批量讀取
clientChannel.read(byteBuffer);
byteBuffer.flip();
System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer)
.toString());
} finally {
keyIterator.remove();
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
}
} catch (IOException ignored) {
}
}).start();
}
}
相信大部分沒有接觸過NIO的同學應該會直接跳過代碼來到這一行:原來使用JDK原生NIO的API實作一個簡單的服務端通信程式是如此複雜!
是以為什麼大家都不願意用 JDK 原生 NIO 進行開發呢?從上面的代碼中大家都可以看出來,是真的難用!除了程式設計複雜、程式設計模型難之外,它還有以下讓人诟病的問題:
- JDK的NIO程式設計需要了解很多的概念,程式設計複雜,對NIO入門非常不友好,程式設計模型不友好,ByteBuffer的api簡直反人類
- 對NIO程式設計來說,一個比較合适的線程模型能充分發揮它的優勢,而JDK沒有給你實作,你需要自己實作,就連簡單的自定義協定拆包都要你自己實作
- JDK 的 NIO 底層由 epoll 實作,該實作飽受诟病的空輪詢 bug 會導緻 cpu 飙升 100%
- 項目龐大之後,自行實作的 NIO 很容易出現各類 bug,維護成本較高,上面這一坨代碼我都不能保證沒有 bug
我們還是先對照NIO來解釋一下幾個核心思路
NIO模型中通常會有兩個線程,每個線程綁定一個輪詢器selector,在我們這個例子中
serverSelector
負責輪詢是否有新的連接配接,
clientSelector
負責輪詢連接配接是否有資料可讀
服務端監測到新的連接配接之後,不再建立一個新的線程,而是直接将新連接配接綁定到
clientSelector
上,這樣就不用IO模型中1w個while循環在死等,參見(1)
clientSelector
被一個while死循環包裹着,如果在某一時刻有多條連接配接有資料可讀,那麼通過
clientSelector.select(1)
方法可以輪詢出來,進而批量處理
JDK的NIO猶如帶刺的玫瑰,雖然美好,讓人向往,但是使用不當會讓你抓耳撓腮,痛不欲生,正因為如此,Netty橫空出世!Netty 的出現很大程度上改善了 JDK 原生 NIO 所存在的一些讓人難以忍受的問題。
關于Netty請看後文簡要解釋
3. AIO (Asynchronous I/O)
AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改進版 NIO 2,它是異步非阻塞的IO模型。異步 IO 是基于事件和回調機制實作的,也就是應用操作之後會直接傳回,不會堵塞在那裡,當背景處理完成,作業系統會通知相應的線程進行後續的操作。
AIO 是異步IO的縮寫,雖然 NIO 在網絡操作中,提供了非阻塞的方法,但是 NIO 的 IO 行為還是同步的。對于 NIO 來說,我們的業務線程是在 IO 操作準備好時,得到通知,接着就由這個線程自行進行 IO 操作,IO操作本身是同步的。(除了 AIO 其他的 IO 類型都是同步的,這一點可以從底層IO線程模型解釋,推薦一篇文章:《漫話:如何給女朋友解釋什麼是Linux的五種IO模型?》 )
查閱網上相關資料,我發現就目前來說 AIO 的應用還不是很廣泛,Netty 之前也嘗試使用過 AIO,不過又放棄了。
4.Netty程式設計
那麼Netty到底是何方神聖?
用一句簡單的話來說就是:Netty封裝了JDK的NIO,讓你用得更爽,你不用再寫一大堆複雜的代碼了。
用官方正式的話來說就是:Netty是一個異步事件驅動的網絡應用架構,用于快速開發可維護的高性能伺服器和用戶端。
下面是我總結的使用Netty不使用JDK原生NIO的原因
- 使用JDK自帶的NIO需要了解太多的概念,程式設計複雜,一不小心bug橫飛
- Netty底層IO模型随意切換,而這一切隻需要做微小的改動,改改參數,Netty可以直接從NIO模型變身為IO模型
- Netty自帶的拆包解包,異常檢測等機制讓你從NIO的繁重細節中脫離出來,讓你隻需要關心業務邏輯
- Netty解決了JDK的很多包括空輪詢在内的bug
- Netty底層對線程,selector做了很多細小的優化,精心設計的reactor線程模型做到非常高效的并發處理
- 自帶各種協定棧讓你處理任何一種通用協定都幾乎不用親自動手
- Netty社群活躍,遇到問題随時郵件清單或者issue
- Netty已經曆各大rpc架構,消息中間件,分布式通信中間件線上的廣泛驗證,健壯性無比強大
首先,引入Maven依賴
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.6.Final</version>
</dependency>
然後,下面是服務端實作部分
public class NettyServer {
public static void main(String[] args) {
ServerBootstrap serverBootstrap = new ServerBootstrap();
NioEventLoopGroup boos = new NioEventLoopGroup();
NioEventLoopGroup worker = new NioEventLoopGroup();
serverBootstrap
.group(boos, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) {
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
System.out.println(msg);
}
});
}
})
.bind(8000);
}
}
這麼一小段代碼就實作了我們前面NIO程式設計中的所有的功能,包括服務端啟動,接受新連接配接,列印用戶端傳來的資料,怎麼樣,是不是比JDK原生的NIO程式設計優雅許多?
初學Netty的時候,由于大部分人對NIO程式設計缺乏經驗,是以,将Netty裡面的概念與IO模型結合起來可能更好了解
1.
boos
對應,
IOServer.java
中的接受新連接配接線程,主要負責建立新連接配接
2.
worker
對應
IOClient.java
中的負責讀取資料的線程,主要用于讀取資料以及業務邏輯處理
然後剩下的邏輯我在後面的系列文章中會詳細分析,你可以先把這段代碼拷貝到你的IDE裡面,然後運作main函數
然後下面是用戶端NIO的實作部分
public class NettyClient {
public static void main(String[] args) throws InterruptedException {
Bootstrap bootstrap = new Bootstrap();
NioEventLoopGroup group = new NioEventLoopGroup();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel ch) {
ch.pipeline().addLast(new StringEncoder());
}
});
Channel channel = bootstrap.connect("127.0.0.1", 8000).channel();
while (true) {
channel.writeAndFlush(new Date() + ": hello world!");
Thread.sleep(2000);
}
}
}
在用戶端程式中,
group
對應了我們
IOClient.java
中main函數起的線程