一、簡(jiǎn)介
說(shuō)到 I/O,想必大家都不會(huì)陌生, I/O 英語(yǔ)全稱(chēng):Input/Output,即輸入/輸出,通常指數(shù)據(jù)在內(nèi)部存儲(chǔ)器和外部存儲(chǔ)器或其他周邊設(shè)備之間的輸入和輸出。
比如我們常用的 SD 卡、U 盤(pán)、移動(dòng)硬盤(pán)等等存儲(chǔ)文件的硬件設(shè)備,當(dāng)我們將其插入電腦的 usb 硬件接口時(shí),我們就可以從電腦中讀取設(shè)備中的信息或者寫(xiě)入信息,這個(gè)過(guò)程就涉及到 I/O 的操作。
當(dāng)然,涉及%20I/O%20的操作,不僅僅局限于硬件設(shè)備的讀寫(xiě),還要網(wǎng)絡(luò)數(shù)據(jù)的傳輸,比如,我們?cè)陔娔X上用瀏覽器搜索互聯(lián)網(wǎng)上的信息,這個(gè)過(guò)程也涉及到%20I/O%20的操作。
無(wú)論是從磁盤(pán)中讀寫(xiě)文件,還是在網(wǎng)絡(luò)中傳輸數(shù)據(jù),可以說(shuō) I/O 主要為處理人機(jī)交互、機(jī)與機(jī)交互中獲取和交換信息提供的一套解決方案。
在 JAVA 的 IO 體系中,類(lèi)將近有 80 個(gè),位于java.io包下,感覺(jué)很復(fù)雜,但是這些類(lèi)大致可以分成四組:
- 基于字節(jié)操作的 I/O 接口:InputStream 和 OutputStream
- 基于字符操作的 I/O 接口:Writer 和 Reader
- 基于磁盤(pán)操作的 I/O 接口:File
- 基于網(wǎng)絡(luò)操作的 I/O 接口:Socket
前兩組主要從傳輸數(shù)據(jù)的數(shù)據(jù)格式不同,進(jìn)行分組;后兩組主要從傳輸數(shù)據(jù)的方式不同,進(jìn)行分組。
雖然 Socket 類(lèi)并不在java.io包下,但是我們?nèi)匀话阉鼈儎澐衷谝黄穑驗(yàn)?I/O 的核心問(wèn)題,要么是數(shù)據(jù)格式影響 I/O 操作,要么是傳輸方式影響 I/O 操作,也就是將什么樣的數(shù)據(jù)寫(xiě)到什么地方的問(wèn)題,I/O 只是人與機(jī)器或者機(jī)器與機(jī)器交互的手段,除了在它們能夠完成這個(gè)交互功能外,我們關(guān)注的就是如何提高它的運(yùn)行效率了,而數(shù)據(jù)格式和傳輸方式是影響效率最關(guān)鍵的因素。
本文后面,也是基于這兩個(gè)點(diǎn)進(jìn)行深入展開(kāi)分析。
二、基于字節(jié)操作的接口
基于字節(jié)的輸入和輸出操作接口分別是:InputStream 和 OutputStream 。
2.1、字節(jié)輸入流
InputStream 輸入流的類(lèi)繼承層次如下圖所示:

輸入流根據(jù)數(shù)據(jù)節(jié)點(diǎn)類(lèi)型和處理方式,分別可以劃分出了若干個(gè)子類(lèi),如下圖:

OutputStream 輸出流的類(lèi)層次結(jié)構(gòu)也是類(lèi)似。
2.2、字節(jié)輸出流
OutputStream 輸出流的類(lèi)繼承層次如下圖所示:

輸出流根據(jù)數(shù)據(jù)節(jié)點(diǎn)類(lèi)型和處理方式,也分別可以劃分出了若干個(gè)子類(lèi),如下圖:

在這里就不詳細(xì)的介紹各個(gè)子類(lèi)的使用方法,有興趣的朋友可以查看 JDK 的 API 說(shuō)明文檔,筆者也會(huì)在后期的文章會(huì)進(jìn)行詳細(xì)的介紹,這里只是重點(diǎn)想說(shuō)一下,無(wú)論是輸入還是輸出,操作數(shù)據(jù)的方式可以組合使用,各個(gè)處理流的類(lèi)并不是只操作固定的節(jié)點(diǎn)流,比如如下輸出方式:
//將文件輸出流包裝到序列化輸出流中,再將序列化輸出流包裝到緩沖中OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream(new File("fileName")));
另外,輸出流最終寫(xiě)到什么地方必須要指定,要么是寫(xiě)到硬盤(pán)中,要么是寫(xiě)到網(wǎng)絡(luò)中,從圖中可以發(fā)現(xiàn),寫(xiě)網(wǎng)絡(luò)實(shí)際上也是寫(xiě)文件,只不過(guò)寫(xiě)到網(wǎng)絡(luò)中,需要經(jīng)過(guò)底層操作系統(tǒng)將數(shù)據(jù)發(fā)送到其他的計(jì)算機(jī)中,而不是寫(xiě)入到本地硬盤(pán)中。
三、基于字符操作的接口
不管是磁盤(pán)還是網(wǎng)絡(luò)傳輸,最小的存儲(chǔ)單元都是字節(jié),而不是字符,所以 I/O 操作的都是字節(jié)而不是字符,但是為什么要有操作字符的 I/O 接口呢?
這是因?yàn)槲覀兊某绦蛑型ǔ2僮鞯臄?shù)據(jù)都是以字符形式,為了程序操作更方便而提供一個(gè)直接寫(xiě)字符的 I/O 接口,僅此而已。
基于字符的輸入和輸出操作接口分別是:Reader 和 Writer ,下圖是字符的 I/O 操作接口涉及到的類(lèi)結(jié)構(gòu)圖。
3.1、字符輸入流
Reader 輸入流的類(lèi)繼承層次如下圖所示:

同樣的,輸入流根據(jù)數(shù)據(jù)節(jié)點(diǎn)類(lèi)型和處理方式,分別可以劃分出了若干個(gè)子類(lèi),如下圖:

3.2、字符輸出流
Writer 輸出流的類(lèi)繼承層次如下圖所示:

同樣的,輸出流根據(jù)數(shù)據(jù)節(jié)點(diǎn)類(lèi)型和處理方式分類(lèi),分別可以劃分出了若干個(gè)子類(lèi),如下圖:

不管是 Reader 還是 Writer 類(lèi),它們都只定義了讀取或?qū)懭霐?shù)據(jù)字符的方式,也就是說(shuō)要么是讀要么是寫(xiě),但是并沒(méi)有規(guī)定數(shù)據(jù)要寫(xiě)到哪去,寫(xiě)到哪去就是我們后面要討論的基于磁盤(pán)或網(wǎng)絡(luò)的工作機(jī)制。
四、字節(jié)與字符的轉(zhuǎn)化
剛剛我們說(shuō)到,不管是磁盤(pán)還是網(wǎng)絡(luò)傳輸,最小的存儲(chǔ)單元都是字節(jié),而不是字符,設(shè)計(jì)字符的原因是為了程序操作更方便,那么怎么將字符轉(zhuǎn)化成字節(jié)或者將字節(jié)轉(zhuǎn)化成字符呢?
InputStreamReader 和 OutputStreamWriter 就是轉(zhuǎn)化橋梁。
4.1、輸入流轉(zhuǎn)化過(guò)程
輸入流字符解碼相關(guān)類(lèi)結(jié)構(gòu)的轉(zhuǎn)化過(guò)程如下圖所示:

從圖上可以看到,InputStreamReader 類(lèi)是字節(jié)到字符的轉(zhuǎn)化橋梁, 其中StreamDecoder指的是一個(gè)解碼操作類(lèi),Charset指的是字符集。
InputStream 到 Reader 的過(guò)程需要指定編碼字符集,否則將采用操作系統(tǒng)默認(rèn)字符集,很可能會(huì)出現(xiàn)亂碼問(wèn)題,StreamDecoder 則是完成字節(jié)到字符的解碼的實(shí)現(xiàn)類(lèi)。
打開(kāi)源碼部分,InputStream 到 Reader 轉(zhuǎn)化過(guò)程,如下圖:

4.1、輸出流轉(zhuǎn)化過(guò)程
輸出流轉(zhuǎn)化過(guò)程也是類(lèi)似,如下圖所示:

通過(guò) OutputStreamWriter 類(lèi)完成字符到字節(jié)的編碼過(guò)程,由 StreamEncoder 完成編碼過(guò)程。
源碼部分,Writer 到 OutputStream 轉(zhuǎn)化過(guò)程,如下圖:

五、基于磁盤(pán)操作的接口
前面介紹了 Java I/O 的操作接口,這些接口主要定義了如何操作數(shù)據(jù),以及介紹了操作數(shù)據(jù)格式的方式:字節(jié)流和字符流。
還有一個(gè)關(guān)鍵問(wèn)題就是數(shù)據(jù)寫(xiě)到何處,其中一個(gè)主要的處理方式就是將數(shù)據(jù)持久化到物理磁盤(pán)。
我們知道數(shù)據(jù)在磁盤(pán)的唯一最小描述就是文件,也就是說(shuō)上層應(yīng)用程序只能通過(guò)文件來(lái)操作磁盤(pán)上的數(shù)據(jù),文件也是操作系統(tǒng)和磁盤(pán)驅(qū)動(dòng)器交互的一個(gè)最小單元。

在 Java I/O 體系中,F(xiàn)ile 類(lèi)是唯一代表磁盤(pán)文件本身的對(duì)象。
File 類(lèi)定義了一些與平臺(tái)無(wú)關(guān)的方法來(lái)操作文件,包括檢查一個(gè)文件是否存在、創(chuàng)建、刪除文件、重命名文件、判斷文件的讀寫(xiě)權(quán)限是否存在、設(shè)置和查詢文件的最近修改時(shí)間等等操作。
值得注意的是 Java 中通常的 File 并不代表一個(gè)真實(shí)存在的文件對(duì)象,當(dāng)你通過(guò)指定一個(gè)路徑描述符時(shí),它就會(huì)返回一個(gè)代表這個(gè)路徑相關(guān)聯(lián)的一個(gè)虛擬對(duì)象,這個(gè)可能是一個(gè)真實(shí)存在的文件或者是一個(gè)包含多個(gè)文件的目錄。
例如,讀取一個(gè)文件內(nèi)容,程序如下:

以上面的程序?yàn)槔瑥挠脖P(pán)中讀取一段文本字符,操作流程如下圖:

我們?cè)賮?lái)看看源碼執(zhí)行流程。
當(dāng)我們傳入一個(gè)指定的文件名來(lái)創(chuàng)建 File 對(duì)象,通過(guò) FileReader 來(lái)讀取文件內(nèi)容時(shí),會(huì)自動(dòng)創(chuàng)建一個(gè)FileInputStream對(duì)象來(lái)讀取文件內(nèi)容,也就是我們上文中所說(shuō)的字節(jié)流來(lái)讀取文件。

緊接著,會(huì)創(chuàng)建一個(gè)FileDescriptor的對(duì)象,其實(shí)這個(gè)對(duì)象就是真正代表一個(gè)存在的文件對(duì)象的描述。可以通過(guò)FileInputStream對(duì)象調(diào)用getFD()方法獲取真正與底層操作系統(tǒng)關(guān)聯(lián)的文件描述。

由于我們需要讀取的是字符格式,所以需要 StreamDecoder 類(lèi)將byte解碼為char格式,至于如何從磁盤(pán)驅(qū)動(dòng)器上讀取一段數(shù)據(jù),由操作系統(tǒng)幫我們完成。
六、基于網(wǎng)絡(luò)操作的接口
繼續(xù)來(lái)說(shuō)說(shuō)數(shù)據(jù)寫(xiě)到何處的另一種處理方式:將數(shù)據(jù)寫(xiě)入互聯(lián)網(wǎng)中以供其他電腦能訪問(wèn)。
6.1、Socket 簡(jiǎn)介
在現(xiàn)實(shí)中,Socket 這個(gè)概念沒(méi)有一個(gè)具體的實(shí)體,它是描述計(jì)算機(jī)之間完成相互通信一種抽象定義。
打個(gè)比方,可以把 Socket 比作為兩個(gè)城市之間的交通工具,有了它,就可以在城市之間來(lái)回穿梭了。并且,交通工具有多種,每種交通工具也有相應(yīng)的交通規(guī)則。Socket 也一樣,也有多種。大部分情況下我們使用的都是基于 TCP/IP 的流套接字,它是一種穩(wěn)定的通信協(xié)議。
典型的基于 Socket 通信的應(yīng)用程序場(chǎng)景,如下圖:

主機(jī) A 的應(yīng)用程序要想和主機(jī) B 的應(yīng)用程序通信,必須通過(guò) Socket 建立連接,而建立 Socket 連接必須需要底層 TCP/IP 協(xié)議來(lái)建立 TCP 連接。
6.2、建立通信鏈路
我們知道網(wǎng)絡(luò)層使用的 IP 協(xié)議可以幫助我們根據(jù) IP 地址來(lái)找到目標(biāo)主機(jī),但是一臺(tái)主機(jī)上可能運(yùn)行著多個(gè)應(yīng)用程序,如何才能與指定的應(yīng)用程序通信就要通過(guò) TCP 或 UPD 的地址也就是端口號(hào)來(lái)指定。這樣就可以通過(guò)一個(gè) Socket 實(shí)例代表唯一一個(gè)主機(jī)上的一個(gè)應(yīng)用程序的通信鏈路了。
為了準(zhǔn)確無(wú)誤地把數(shù)據(jù)送達(dá)目標(biāo)處,TCP 協(xié)議采用了三次握手策略,如下圖:

其中,SYN 全稱(chēng)為 Synchronize Sequence Numbers,表示同步序列編號(hào),是 TCP/IP 建立連接時(shí)使用的握手信號(hào)。
ACK 全稱(chēng)為 Acknowledge character,即確認(rèn)字符,表示發(fā)來(lái)的數(shù)據(jù)已確認(rèn)接收無(wú)誤。
在客戶機(jī)和服務(wù)器之間建立正常的 TCP 網(wǎng)絡(luò)連接時(shí),客戶機(jī)首先發(fā)出一個(gè) SYN 消息,服務(wù)器使用 SYN + ACK 應(yīng)答表示接收到了這個(gè)消息,最后客戶機(jī)再以 ACK 消息響應(yīng)。
這樣在客戶機(jī)和服務(wù)器之間才能建立起可靠的 TCP 連接,數(shù)據(jù)才可以在客戶機(jī)和服務(wù)器之間傳遞。
簡(jiǎn)單流程如下:
- 發(fā)送端 –(發(fā)送帶有 SYN 標(biāo)志的數(shù)據(jù)包 )–> 接受端(第一次握手);
- 接受端 –(發(fā)送帶有 SYN + ACK 標(biāo)志的數(shù)據(jù)包)–> 發(fā)送端(第二次握手);
- 發(fā)送端 –(發(fā)送帶有 ACK 標(biāo)志的數(shù)據(jù)包) –> 接受端(第三次握手);
完成三次握手之后,客戶端應(yīng)用程序與服務(wù)器應(yīng)用程序就可以開(kāi)始傳送數(shù)據(jù)了。
傳輸數(shù)據(jù)是我們建立連接的主要目的,如何通過(guò) Socket 傳輸數(shù)據(jù)呢?
6.3、傳輸數(shù)據(jù)
當(dāng)客戶端要與服務(wù)端通信時(shí),客戶端首先要?jiǎng)?chuàng)建一個(gè) Socket 實(shí)例,默認(rèn)操作系統(tǒng)將為這個(gè) Socket 實(shí)例分配一個(gè)沒(méi)有被使用的本地端口號(hào),并創(chuàng)建一個(gè)包含本地、遠(yuǎn)程地址和端口號(hào)的套接字?jǐn)?shù)據(jù)結(jié)構(gòu),這個(gè)數(shù)據(jù)結(jié)構(gòu)將一直保存在系統(tǒng)中直到這個(gè)連接關(guān)閉。

與之對(duì)應(yīng)的服務(wù)端,也將創(chuàng)建一個(gè) ServerSocket 實(shí)例,ServerSocket 創(chuàng)建比較簡(jiǎn)單,只要指定的端口號(hào)沒(méi)有被占用,一般實(shí)例創(chuàng)建都會(huì)成功,同時(shí)操作系統(tǒng)也會(huì)為 ServerSocket 實(shí)例創(chuàng)建一個(gè)底層數(shù)據(jù)結(jié)構(gòu),這個(gè)數(shù)據(jù)結(jié)構(gòu)中包含指定監(jiān)聽(tīng)的端口號(hào)和包含監(jiān)聽(tīng)地址的通配符,通常情況下都是*即監(jiān)聽(tīng)所有地址。
之后當(dāng)調(diào)用 accept() 方法時(shí),將進(jìn)入阻塞狀態(tài),等待客戶端的請(qǐng)求。

我們先啟動(dòng)服務(wù)端程序,再運(yùn)行客戶端,服務(wù)端收到客戶端發(fā)送的信息,服務(wù)端打印結(jié)果如下:

注意,客戶端只有與服務(wù)端建立三次握手成功之后,才會(huì)發(fā)送數(shù)據(jù),而 TCP/IP 握手過(guò)程,底層操作系統(tǒng)已經(jīng)幫我們實(shí)現(xiàn)了!
當(dāng)連接已經(jīng)建立成功,服務(wù)端和客戶端都會(huì)擁有一個(gè) Socket 實(shí)例,每個(gè) Socket 實(shí)例都有一個(gè) InputStream 和 OutputStream,正如我們前面所說(shuō)的,網(wǎng)絡(luò) I/O 都是以字節(jié)流傳輸?shù)模琒ocket 正是通過(guò)這兩個(gè)對(duì)象來(lái)交換數(shù)據(jù)。
當(dāng) Socket 對(duì)象創(chuàng)建時(shí),操作系統(tǒng)將會(huì)為 InputStream 和 OutputStream 分別分配一定大小的緩沖區(qū),數(shù)據(jù)的寫(xiě)入和讀取都是通過(guò)這個(gè)緩存區(qū)完成的。
寫(xiě)入端將數(shù)據(jù)寫(xiě)到 OutputStream 對(duì)應(yīng)的 SendQ 隊(duì)列中,當(dāng)隊(duì)列填滿時(shí),數(shù)據(jù)將被發(fā)送到另一端 InputStream 的 RecvQ 隊(duì)列中,如果這時(shí) RecvQ 已經(jīng)滿了,那么 OutputStream 的 write 方法將會(huì)阻塞直到 RecvQ 隊(duì)列有足夠的空間容納 SendQ 發(fā)送的數(shù)據(jù)。
值得特別注意的是,緩存區(qū)的大小以及寫(xiě)入端的速度和讀取端的速度非常影響這個(gè)連接的數(shù)據(jù)傳輸效率,由于可能會(huì)發(fā)生阻塞,所以網(wǎng)絡(luò) I/O 與磁盤(pán) I/O 在數(shù)據(jù)的寫(xiě)入和讀取還要有一個(gè)協(xié)調(diào)的過(guò)程,如果兩邊同時(shí)傳送數(shù)據(jù)時(shí)可能會(huì)產(chǎn)生死鎖的問(wèn)題。
如何提高網(wǎng)絡(luò) IO 傳輸效率、保證數(shù)據(jù)傳輸?shù)目煽浚呀?jīng)成了工程師們急需解決的問(wèn)題。
6.4、IO 工作方式
在計(jì)算機(jī)中,IO 傳輸數(shù)據(jù)有三種工作方式,分別是 BIO、NIO、AIO。
在講解 BIO、NIO、AIO 之前,我們先來(lái)回顧一下這幾個(gè)概念:同步與異步,阻塞與非阻塞。
同步與異步的區(qū)別
- 同步就是發(fā)起一個(gè)請(qǐng)求后,接受者未處理完請(qǐng)求之前,不返回結(jié)果。
- 異步就是發(fā)起一個(gè)請(qǐng)求后,立刻得到接受者的回應(yīng)表示已接收到請(qǐng)求,但是接受者并沒(méi)有處理完,接受者通常依靠事件回調(diào)等機(jī)制來(lái)通知請(qǐng)求者其處理結(jié)果。
阻塞和非阻塞的區(qū)別
- 阻塞就是請(qǐng)求者發(fā)起一個(gè)請(qǐng)求,一直等待其請(qǐng)求結(jié)果返回,也就是當(dāng)前線程會(huì)被掛起,無(wú)法從事其他任務(wù),只有當(dāng)條件就緒才能繼續(xù)。
- 非阻塞就是請(qǐng)求者發(fā)起一個(gè)請(qǐng)求,不用一直等著結(jié)果返回,可以先去干其他事情,當(dāng)條件就緒的時(shí)候,就自動(dòng)回來(lái)。
而我們要講的 BIO、NIO、AIO 就是同步與異步、阻塞與非阻塞的組合。
- BIO:同步阻塞 IO;
- NIO:同步非阻塞 IO;
- AIO:異步非阻塞 IO;
6.4.1、BIO
BIO 俗稱(chēng)同步阻塞 IO,一種非常傳統(tǒng)的 IO 模型,比如我們上面所舉的那個(gè)程序例子,就是一個(gè)典型的**同步阻塞 IO **的工作方式。

采用 BIO 通信模型的服務(wù)端,通常由一個(gè)獨(dú)立的 Acceptor 線程負(fù)責(zé)監(jiān)聽(tīng)客戶端的連接。
我們一般在服務(wù)端通過(guò)while(true)循環(huán)中會(huì)調(diào)用accept()方法等待監(jiān)聽(tīng)客戶端的連接,一旦接收到一個(gè)連接請(qǐng)求,就可以建立通信套接字進(jìn)行讀寫(xiě)操作,此時(shí)不能再接收其他客戶端連接請(qǐng)求,只能等待同當(dāng)前連接的客戶端的操作執(zhí)行完成, 不過(guò)可以通過(guò)多線程來(lái)支持多個(gè)客戶端的連接。
客戶端多線程操作,程序如下:

服務(wù)端多線程操作,程序如下:

服務(wù)端運(yùn)行結(jié)果,如下:

如果要讓 BIO 通信模型能夠同時(shí)處理多個(gè)客戶端請(qǐng)求,就必須使用多線程,也就是說(shuō)它在接收到客戶端連接請(qǐng)求之后為每個(gè)客戶端創(chuàng)建一個(gè)新的線程進(jìn)行鏈路處理,處理完成之后,通過(guò)輸出流返回應(yīng)答給客戶端,線程銷(xiāo)毀。
這就是典型的一請(qǐng)求一應(yīng)答通信模型 。
如果出現(xiàn) 100、1000、甚至 10000 個(gè)用戶同時(shí)訪問(wèn)服務(wù)器,這個(gè)時(shí)候,如果使用這種模型,那么服務(wù)端也會(huì)創(chuàng)建與之相同的線程數(shù)量,線程數(shù)急劇膨脹可能會(huì)導(dǎo)致線程堆棧溢出、創(chuàng)建新線程失敗等問(wèn)題,最終導(dǎo)致進(jìn)程宕機(jī)或者僵死,不能對(duì)外提供服務(wù)。
當(dāng)然,我們可以通過(guò)使用 Java 中 ThreadPoolExecutor 線程池機(jī)制來(lái)改善,讓線程的創(chuàng)建和回收成本相對(duì)較低,保證了系統(tǒng)有限的資源的控制,實(shí)現(xiàn)了 N (客戶端請(qǐng)求數(shù)量)大于 M (處理客戶端請(qǐng)求的線程數(shù)量)的偽異步 I/O 模型。
6.4.2、偽異步 BIO
為了解決同步阻塞 I/O 面臨的一個(gè)鏈路需要一個(gè)線程處理的問(wèn)題,后來(lái)有人對(duì)它的線程模型進(jìn)行了優(yōu)化,后端通過(guò)一個(gè)線程池來(lái)處理多個(gè)客戶端的請(qǐng)求接入,形成客戶端個(gè)數(shù) M:線程池最大線程數(shù) N 的比例關(guān)系,其中 M 可以遠(yuǎn)遠(yuǎn)大于 N,通過(guò)線程池可以靈活地調(diào)配線程資源,設(shè)置線程的最大值,防止由于海量并發(fā)接入導(dǎo)致資源耗盡。
偽異步 IO 模型圖,如下圖:

采用線程池和任務(wù)隊(duì)列可以實(shí)現(xiàn)一種叫做偽異步的 I/O 通信框架,當(dāng)有新的客戶端接入時(shí),將客戶端的 Socket 封裝成一個(gè) Task 投遞到后端的線程池中進(jìn)行處理。
Java 的線程池維護(hù)一個(gè)消息隊(duì)列和 N 個(gè)活躍線程,對(duì)消息隊(duì)列中的任務(wù)進(jìn)行處理。
客戶端,程序如下:

服務(wù)端,程序如下:

先啟動(dòng)服務(wù)端程序,再啟動(dòng)客戶端程序,看看運(yùn)行結(jié)果!
服務(wù)端,運(yùn)行結(jié)果如下:

客戶端,運(yùn)行結(jié)果如下:

本例中測(cè)試的客戶端數(shù)量是 30,服務(wù)端使用 java 線程池來(lái)處理任務(wù),線程數(shù)量為 5 個(gè),服務(wù)端不用為每個(gè)客戶端都創(chuàng)建一個(gè)線程,由于線程池可以設(shè)置消息隊(duì)列的大小和最大線程數(shù),因此,它的資源占用是可控的,無(wú)論多少個(gè)客戶端并發(fā)訪問(wèn),都不會(huì)導(dǎo)致資源的耗盡和宕機(jī)。
在活動(dòng)連接數(shù)不是特別高的情況下,這種模型是還不錯(cuò),可以讓每一個(gè)連接專(zhuān)注于自己的 I/O 并且編程模型簡(jiǎn)單,也不用過(guò)多考慮系統(tǒng)的過(guò)載、限流等問(wèn)題。
但是,它的底層仍然是同步阻塞的 BIO 模型,當(dāng)面對(duì)十萬(wàn)甚至百萬(wàn)級(jí)連接的時(shí)候,傳統(tǒng)的 BIO 模型真的是無(wú)能為力的,我們需要一種更高效的 I/O 處理模型來(lái)應(yīng)對(duì)更高的并發(fā)量。
6.4.3、NIO
NIO 中的 N 可以理解為 Non-blocking,一種同步非阻塞的 I/O 模型,在 Java 1.4 中引入,對(duì)應(yīng)的在java.nio包下。
NIO 新增了 Channel、Selector、Buffer 等抽象概念,支持面向緩沖、基于通道的 I/O 操作方法。
NIO 提供了與傳統(tǒng) BIO 模型中的 Socket 和 ServerSocket 相對(duì)應(yīng)的 SocketChannel 和 ServerSocketChannel 兩種不同的套接字通道實(shí)現(xiàn)。
NIO 這兩種通道都支持阻塞和非阻塞兩種模式。阻塞模式使用就像傳統(tǒng)中的支持一樣,比較簡(jiǎn)單,但是性能和可靠性都不好;非阻塞模式正好與之相反。
對(duì)于低負(fù)載、低并發(fā)的應(yīng)用程序,可以使用同步阻塞 I/O 來(lái)提升開(kāi)發(fā)效率和更好的維護(hù)性;對(duì)于高負(fù)載、高并發(fā)的(網(wǎng)絡(luò))應(yīng)用,應(yīng)使用 NIO 的非阻塞模式來(lái)開(kāi)發(fā)。
我們先看一下 NIO 涉及到的核心關(guān)聯(lián)類(lèi)圖,如下:

上圖中有三個(gè)關(guān)鍵類(lèi):Channel 、Selector 和 Buffer,它們是 NIO 中的核心概念。
- Channel:可以理解為通道;
- Selector:可以理解為選擇器;
- Buffer:可以理解為數(shù)據(jù)緩沖流;
我們還是用前面的城市交通工具來(lái)繼續(xù)形容 NIO 的工作方式,這里的 Channel 要比 Socket更加具體,它可以比作為某種具體的交通工具,如汽車(chē)或是高鐵、飛機(jī)等,而 Selector 可以比作為一個(gè)車(chē)站的車(chē)輛運(yùn)行調(diào)度系統(tǒng),它將負(fù)責(zé)監(jiān)控每輛車(chē)的當(dāng)前運(yùn)行狀態(tài):是已經(jīng)出站還是在路上等等,也就是說(shuō)它可以輪詢每個(gè) Channel 的狀態(tài)。
還有一個(gè) Buffer 類(lèi),你可以將它看作為 IO 中 Stream,但是它比 IO 中的 Stream 更加具體化,我們可以將它比作為車(chē)上的座位,Channel 如果是汽車(chē)的話,那么 Buffer 就是汽車(chē)上的座位,Channel 如果是高鐵上,那么 Buffer 就是高鐵上的座位,它始終是一個(gè)具體的概念,這一點(diǎn)與 Stream 不同。
Socket 中的 Stream 只能代表是一個(gè)座位,至于是什么座位由你自己去想象,也就是說(shuō)你在上車(chē)之前并不知道這個(gè)車(chē)上是否還有沒(méi)有座位,也不知道上的是什么車(chē),因?yàn)槟悴⒉荒苓x擇,這些信息都已經(jīng)被封裝在了運(yùn)輸工具(Socket)里面了。
NIO 引入了 Channel、Buffer 和 Selector 就是想把 IO 傳輸過(guò)程中涉及到的信息具體化,讓程序員有機(jī)會(huì)去控制它們。
當(dāng)我們進(jìn)行傳統(tǒng)的網(wǎng)絡(luò) IO 操作時(shí),比如調(diào)用 write() 往 Socket 中的 SendQ 隊(duì)列寫(xiě)數(shù)據(jù)時(shí),當(dāng)一次寫(xiě)的數(shù)據(jù)超過(guò) SendQ 長(zhǎng)度時(shí),操作系統(tǒng)會(huì)按照 SendQ 的長(zhǎng)度進(jìn)行分割的,這個(gè)過(guò)程中需要將用戶空間數(shù)據(jù)和內(nèi)核地址空間進(jìn)行切換,而這個(gè)切換不是程序員可以控制的,由底層操作系統(tǒng)來(lái)幫我們處理。
而在 Buffer 中,我們可以控制 Buffer 的 capacity(容量),并且是否擴(kuò)容以及如何擴(kuò)容都可以控制。
理解了這些概念后我們看一下,實(shí)際上它們是如何工作的呢?
還是以上面的操作為例子,為了方便觀看結(jié)果,本次的客戶端線程請(qǐng)求數(shù)改成 15 個(gè)。
客戶端,程序如下:

服務(wù)端,程序如下:

先啟動(dòng)服務(wù)端程序,再啟動(dòng)客戶端程序,看看運(yùn)行結(jié)果!
服務(wù)端,運(yùn)行結(jié)果如下:

客戶端,運(yùn)行結(jié)果如下:

當(dāng)然,客戶端也不僅僅只限制于 IO 的寫(xiě)法,還可以使用SocketChannel來(lái)操作客戶端,程序如下:

一樣的,先啟動(dòng)服務(wù)端,再啟動(dòng)客戶端,客戶端運(yùn)行結(jié)果如下:

從操作上可以看到,NIO 的操作比傳統(tǒng)的 IO 操作要復(fù)雜的多!
Selector 被稱(chēng)為選擇器 ,當(dāng)然你也可以翻譯為多路復(fù)用器 。它是 Java NIO 核心組件中的一個(gè),用于檢查一個(gè)或多個(gè) Channel(通道)的狀態(tài)是否處于連接就緒、接受就緒、可讀就緒、可寫(xiě)就緒。
如此可以實(shí)現(xiàn)單線程管理多個(gè) channels,也就是可以管理多個(gè)網(wǎng)絡(luò)連接。

使用 Selector 的好處在于: 相比傳統(tǒng)方式使用多個(gè)線程來(lái)管理 IO,Selector 使用了更少的線程就可以處理通道了,并且實(shí)現(xiàn)網(wǎng)絡(luò)高效傳輸!
雖然 java 中的 nio 傳輸比較快,為什么大家都不愿意用 JDK 原生 NIO 進(jìn)行開(kāi)發(fā)呢?
從上面的代碼中大家都可以看出來(lái),除了編程復(fù)雜、編程模型難之外,還有幾個(gè)讓人詬病的問(wèn)題:
- JDK 的 NIO 底層由 epoll 實(shí)現(xiàn),該實(shí)現(xiàn)飽受詬病的空輪詢 bug 會(huì)導(dǎo)致 cpu 飆升 100%!
- 項(xiàng)目龐大之后,自行實(shí)現(xiàn)的 NIO 很容易出現(xiàn)各類(lèi) bug,維護(hù)成本較高!
但是,google 的 Netty 框架的出現(xiàn),很大程度上改善了 JDK 原生 NIO 所存在的一些讓人難以忍受的問(wèn)題,關(guān)于 Netty 框架,會(huì)在后期的文章里進(jìn)行介紹。
6.4.4、AIO
最后就是 AIO 了,全稱(chēng) Asynchronous I/O,可以理解為異步 IO,也被稱(chēng)為 NIO 2,在 Java 7 中引入了 NIO 的改進(jìn)版 NIO 2,它是異步非阻塞的 IO 模型,也就是我們現(xiàn)在所說(shuō)的 AIO。
異步 IO 是基于事件和回調(diào)機(jī)制實(shí)現(xiàn)的,也就是應(yīng)用操作之后會(huì)直接返回,不會(huì)堵塞在那里,當(dāng)后臺(tái)處理完成,操作系統(tǒng)會(huì)通知相應(yīng)的線程進(jìn)行后續(xù)的操作。
客戶端,程序示例:

服務(wù)端,程序示例:

同樣的,先啟動(dòng)服務(wù)端程序,再啟動(dòng)客戶端程序,看看運(yùn)行結(jié)果!
服務(wù)端,運(yùn)行結(jié)果如下:

客戶端端,運(yùn)行結(jié)果如下:

這種組合方式用起來(lái)比較復(fù)雜,只有在一些非常復(fù)雜的分布式情況下使用,像集群之間的消息同步機(jī)制一般用這種 I/O 組合方式。如 Cassandra 的 Gossip 通信機(jī)制就是采用異步非阻塞的方式。
Netty 之前也嘗試使用過(guò) AIO,不過(guò)又放棄了!
七、總結(jié)
本文闡述的內(nèi)容較多,從 Java 基本 I/O 類(lèi)庫(kù)結(jié)構(gòu)開(kāi)始說(shuō)起,主要介紹了 IO 的傳輸格式和傳輸方式,以及磁盤(pán) I/O 和網(wǎng)絡(luò) I/O 的基本工作方式。
本篇文章主要對(duì) Java 的 IO 體系以及計(jì)算機(jī)部分網(wǎng)絡(luò)基礎(chǔ)知識(shí)做了些簡(jiǎn)單的介紹,其實(shí)每一個(gè)模塊涉及到的知識(shí)都非常非常多,在后期的文章中,會(huì)對(duì)各個(gè)模塊進(jìn)行詳細(xì)的介紹,如果有理解不到的位置,歡迎指出!
好了各位讀者朋友們,以上就是本文的全部?jī)?nèi)容了。能看到這里的都是最優(yōu)秀的程序員,我們必須要伸出驕傲的大拇指為你點(diǎn)個(gè)贊
我是一名從事多年開(kāi)發(fā)的java老程序猿員,目前辭職在做自己的java私人訂制課程,今年年初我花了一個(gè)月整理了一份最適合2019年學(xué)習(xí)的java學(xué)習(xí)干貨資料,從最基礎(chǔ)的javase到spring各種框架都有整理,送給每一位java小伙伴,想要獲取的可以關(guān)注我的頭條號(hào)并在后臺(tái)私信我:02,即可免費(fèi)獲取。
作者: 炸雞可樂(lè)
來(lái)源:公眾號(hào)“Java極客技術(shù)”
鏈接:https://url.cn/5kGu6QF