深入分析 Java I/O 的工作机制
深入分析 Java I/O 的工作機(jī)制
I/O 問題是任何編程語(yǔ)言都無(wú)法回避的問題,可以說 I/O 問題是整個(gè)人機(jī)交互的核心問題,因?yàn)?I/O 是機(jī)器獲取和交換信息的主要渠道。在當(dāng)今這個(gè)數(shù)據(jù)大爆炸時(shí)代,I/O 問題尤其突出,很容易成為一個(gè)性能瓶頸。正因如此,所以 Java 在 I/O 上也一直在做持續(xù)的優(yōu)化,如從 1.4 開始引入了 NIO,提升了 I/O 的性能。關(guān)于 NIO 我們將在后面詳細(xì)介紹。
Java 的 I/O 操作類在包 java.io 下,大概有將近 80 個(gè)類,但是這些類大概可以分成四組,分別是:
前兩組主要是根據(jù)傳輸數(shù)據(jù)的數(shù)據(jù)格式,后兩組主要是根據(jù)傳輸數(shù)據(jù)的方式,雖然 Socket 類并不在 java.io 包下,但是我仍然把它們劃分在一起,因?yàn)槲覀€(gè)人認(rèn)為 I/O 的核心問題要么是數(shù)據(jù)格式影響 I/O 操作,要么是傳輸方式影響 I/O 操作,也就是將什么樣的數(shù)據(jù)寫到什么地方的問題,I/O 只是人與機(jī)器或者機(jī)器與機(jī)器交互的手段,除了在它們能夠完成這個(gè)交互功能外,我們關(guān)注的就是如何提高它的運(yùn)行效率了,而數(shù)據(jù)格式和傳輸方式是影響效率最關(guān) 鍵的因素了。我們后面的分析也是基于這兩個(gè)因素來(lái)展開的。
基于字節(jié)的 I/O 操作接口
基于字節(jié)的 I/O 操作接口輸入和輸出分別是:InputStream 和 OutputStream,InputStream 輸入流的類繼承層次如下圖所示:
圖 1. InputStream 相關(guān)類層次結(jié)構(gòu)
InputStreamReader 類是字節(jié)到字符的轉(zhuǎn)化橋梁,InputStream 到 Reader 的過程要指定編碼字符集,否則將采用操作系統(tǒng)默認(rèn)字符集,很可能會(huì)出現(xiàn)亂碼問題。StreamDecoder 正是完成字節(jié)到字符的解碼的實(shí)現(xiàn)類。也就是當(dāng)你用如下方式讀取一個(gè)文件時(shí):
圖 2. OutputStream 相關(guān)類層次結(jié)構(gòu)
這里就不詳細(xì)解釋每個(gè)子類如何使用了,如果不清楚的話可以參考一下 JDK 的 API 說明文檔,這里只想說明兩點(diǎn),一個(gè)是操作數(shù)據(jù)的方式是可以組合使用的,如這樣組合使用
OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream("fileName"));
還有一點(diǎn)是流最終寫到什么地方必須要指定,要么是寫到磁盤要么是寫到網(wǎng)絡(luò)中,其實(shí)從上面的類圖中我們發(fā)現(xiàn),寫網(wǎng)絡(luò)實(shí)際上也是寫文件,只不過 寫網(wǎng)絡(luò)還有一步需要處理就是底層操作系統(tǒng)再將數(shù)據(jù)傳送到其它地方而不是本地磁盤。關(guān)于網(wǎng)絡(luò) I/O 和磁盤 I/O 我們將在后面詳細(xì)介紹。
基于字符的 I/O 操作接口
不管是磁盤還是網(wǎng)絡(luò)傳輸,最小的存儲(chǔ)單元都是字節(jié),而不是字符,所以 I/O 操作的都是字節(jié)而不是字符,但是為啥有操作字符的 I/O 接口呢?這是因?yàn)槲覀兊某绦蛑型ǔ2僮鞯臄?shù)據(jù)都是以字符形式,為了操作方便當(dāng)然要提供一個(gè)直接寫字符的 I/O 接口,如此而已。我們知道字符到字節(jié)必須要經(jīng)過編碼轉(zhuǎn)換,而這個(gè)編碼又非常耗時(shí),而且還會(huì)經(jīng)常出現(xiàn)亂碼問題,所以 I/O 的編碼問題經(jīng)常是讓人頭疼的問題。關(guān)于 I/O 編碼問題請(qǐng)參考另一篇文章 《深入分析Java中的中文編碼問題》。
下圖是寫字符的 I/O 操作接口涉及到的類,Writer 類提供了一個(gè)抽象方法 write(char cbuf[], int off, int len) 由子類去實(shí)現(xiàn)。
圖 3. Writer 相關(guān)類層次結(jié)構(gòu)
讀字符的操作接口也有類似的類結(jié)構(gòu),如下圖所示:
圖 4.Reader 類層次結(jié)構(gòu)
讀字符的操作接口中也是 int read(char cbuf[], int off, int len),返回讀到的 n 個(gè)字節(jié)數(shù),不管是 Writer 還是 Reader 類它們都只定義了讀取或?qū)懭氲臄?shù)據(jù)字符的方式,也就是怎么寫或讀,但是并沒有規(guī)定數(shù)據(jù)要寫到哪去,寫到哪去就是我們后面要討論的基于磁盤和網(wǎng)絡(luò)的工作機(jī) 制。
字節(jié)與字符的轉(zhuǎn)化接口
另外數(shù)據(jù)持久化或網(wǎng)絡(luò)傳輸都是以字節(jié)進(jìn)行的,所以必須要有字符到字節(jié)或字節(jié)到字符的轉(zhuǎn)化。字符到字節(jié)需要轉(zhuǎn)化,其中讀的轉(zhuǎn)化過程如下圖所示:
圖 5. 字符解碼相關(guān)類結(jié)構(gòu)
InputStreamReader 類是字節(jié)到字符的轉(zhuǎn)化橋梁,InputStream 到 Reader 的過程要指定編碼字符集,否則將采用操作系統(tǒng)默認(rèn)字符集,很可能會(huì)出現(xiàn)亂碼問題。StreamDecoder 正是完成字節(jié)到字符的解碼的實(shí)現(xiàn)類。也就是當(dāng)你用如下方式讀取一個(gè)文件時(shí):
清單 1.讀取文件
try { StringBuffer str = new StringBuffer(); char[] buf = new char[1024]; FileReader f = new FileReader("file"); while(f.read(buf)>0){ str.append(buf); } str.toString(); } catch (IOException e) {}
圖 6. 字符編碼相關(guān)類結(jié)構(gòu)
通過 OutputStreamWriter 類完成,字符到字節(jié)的編碼過程,由 StreamEncoder 完成編碼過程。
磁盤 I/O 工作機(jī)制
前面介紹了基本的 Java I/O 的操作接口,這些接口主要定義了如何操作數(shù)據(jù),以及介紹了操作兩種數(shù)據(jù)結(jié)構(gòu):字節(jié)和字符的方式。還有一個(gè)關(guān)鍵問題就是數(shù)據(jù)寫到何處,其中一個(gè)主要方式就是將數(shù)據(jù)持久化到物理磁盤,下面將介紹如何將數(shù)據(jù)持久化到物理磁盤的過程。
我 們知道數(shù)據(jù)在磁盤的唯一最小描述就是文件,也就是說上層應(yīng)用程序只能通過文件來(lái)操作磁盤上的數(shù)據(jù),文件也是操作系統(tǒng)和磁盤驅(qū)動(dòng)器交互的一 個(gè)最小單元。值得注意的是 Java 中通常的 File 并不代表一個(gè)真實(shí)存在的文件對(duì)象,當(dāng)你通過指定一個(gè)路徑描述符時(shí),它就會(huì)返回一個(gè)代表這個(gè)路徑相關(guān)聯(lián)的一個(gè)虛擬對(duì)象,這個(gè)可能是一個(gè)真實(shí)存在的文件或者是 一個(gè)包含多個(gè)文件的目錄。為何要這樣設(shè)計(jì)?因?yàn)榇蟛糠智闆r下,我們并不關(guān)心這個(gè)文件是否真的存在,而是關(guān)心這個(gè)文件到底如何操作。例如我們手機(jī)里通常存了 幾百個(gè)朋友的電話號(hào)碼,但是我們通常關(guān)心的是我有沒有這個(gè)朋友的電話號(hào)碼,或者這個(gè)電話號(hào)碼是什么,但是這個(gè)電話號(hào)碼到底能不能打通,我們并不是時(shí)時(shí)刻刻 都去檢查,而只有在真正要給他打電話時(shí)才會(huì)看這個(gè)電話能不能用。也就是使用這個(gè)電話記錄要比打這個(gè)電話的次數(shù)多很多。
何時(shí)真 正會(huì)要檢 查一個(gè)文件存不存?就是在真正要讀取這個(gè)文件時(shí),例如 FileInputStream 類都是操作一個(gè)文件的接口,注意到在創(chuàng)建一個(gè) FileInputStream 對(duì)象時(shí),會(huì)創(chuàng)建一個(gè) FileDescriptor 對(duì)象,其實(shí)這個(gè)對(duì)象就是真正代表一個(gè)存在的文件對(duì)象的描述,當(dāng)我們?cè)诓僮饕粋€(gè)文件對(duì)象時(shí)可以通過 getFD() 方法獲取真正操作的與底層操作系統(tǒng)關(guān)聯(lián)的文件描述。例如可以調(diào)用 FileDescriptor.sync() 方法將操作系統(tǒng)緩存中的數(shù)據(jù)強(qiáng)制刷新到物理磁盤中。
下面以清單 1 的程序?yàn)槔?#xff0c;介紹下如何從磁盤讀取一段文本字符。如下圖所示:
圖 7. 從磁盤讀取文件
當(dāng)傳入一個(gè)文件路徑,將會(huì)根據(jù)這個(gè)路徑創(chuàng)建一個(gè) File 對(duì)象來(lái)標(biāo)識(shí)這個(gè)文件,然后將會(huì)根據(jù)這個(gè) File 對(duì)象創(chuàng)建真正讀取文件的操作對(duì)象,這時(shí)將會(huì)真正創(chuàng)建一個(gè)關(guān)聯(lián)真實(shí)存在的磁盤文件的文件描述符 FileDescriptor,通過這個(gè)對(duì)象可以直接控制這個(gè)磁盤文件。由于我們需要讀取的是字符格式,所以需要 StreamDecoder 類將 byte 解碼為 char 格式,至于如何從磁盤驅(qū)動(dòng)器上讀取一段數(shù)據(jù),由操作系統(tǒng)幫我們完成。至于操作系統(tǒng)是如何將數(shù)據(jù)持久化到磁盤以及如何建立數(shù)據(jù)結(jié)構(gòu)需要根據(jù)當(dāng)前操作系統(tǒng)使用 何種文件系統(tǒng)來(lái)回答,至于文件系統(tǒng)的相關(guān)細(xì)節(jié)可以參考另外的文章。Java Socket 的工作機(jī)制
Socket 這個(gè)概念沒有對(duì)應(yīng)到一個(gè)具體的實(shí)體,它是描述計(jì)算機(jī)之間完成相互通信一種抽象功能。打個(gè)比方,可以把 Socket 比作為兩個(gè)城市之間的交通工具,有了它,就可以在城市之間來(lái)回穿梭了。交通工具有多種,每種交通工具也有相應(yīng)的交通規(guī)則。Socket 也一樣,也有多種。大部分情況下我們使用的都是基于 TCP/IP 的流套接字,它是一種穩(wěn)定的通信協(xié)議。
下圖是典型的基于 Socket 的通信的場(chǎng)景:
圖 8.Socket 通信示例主機(jī) A 的應(yīng)用程序要能和主機(jī) B 的應(yīng)用程序通信,必須通過 Socket 建立連接,而建立 Socket 連接必須需要底層 TCP/IP 協(xié)議來(lái)建立 TCP 連接。建立 TCP 連接需要底層 IP 協(xié)議來(lái)尋址網(wǎng)絡(luò)中的主機(jī)。我們知道網(wǎng)絡(luò)層使用的 IP 協(xié)議可以幫助我們根據(jù) IP 地址來(lái)找到目標(biāo)主機(jī),但是一臺(tái)主機(jī)上可能運(yùn)行著多個(gè)應(yīng)用程序,如何才能與指定的應(yīng)用程序通信就要通過 TCP 或 UPD 的地址也就是端口號(hào)來(lái)指定。這樣就可以通過一個(gè) Socket 實(shí)例唯一代表一個(gè)主機(jī)上的一個(gè)應(yīng)用程序的通信鏈路了。
建立通信鏈路
當(dāng)客戶端要與服務(wù)端通信,客戶端首先要?jiǎng)?chuàng)建一個(gè) Socket 實(shí)例,操作系統(tǒng)將為這個(gè) Socket 實(shí)例分配一個(gè)沒有被使用的本地端口號(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)閉。在創(chuàng)建 Socket 實(shí)例的構(gòu)造函數(shù)正確返回之前,將要進(jìn)行 TCP 的三次握手協(xié)議,TCP 握手協(xié)議完成后,Socket 實(shí)例對(duì)象將創(chuàng)建完成,否則將拋出 IOException 錯(cuò)誤。
與之對(duì)應(yīng)的服務(wù)端將創(chuàng)建一個(gè) ServerSocket 實(shí)例,ServerSocket 創(chuàng)建比較簡(jiǎn)單只要指定的端口號(hào)沒有被占用,一般實(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)聽的端口號(hào)和包含監(jiān)聽地址的通配符,通常情況下都是“*”即監(jiān)聽所有地址。之后當(dāng)調(diào)用 accept() 方法時(shí),將進(jìn)入阻塞狀態(tài),等待客戶端的請(qǐng)求。當(dāng)一個(gè)新的請(qǐng)求到來(lái)時(shí),將為這個(gè)連接創(chuàng)建一個(gè)新的套接字?jǐn)?shù)據(jù)結(jié)構(gòu),該套接字?jǐn)?shù)據(jù)的信息包含的地址和端口信息正 是請(qǐng)求源地址和端口。這個(gè)新創(chuàng)建的數(shù)據(jù)結(jié)構(gòu)將會(huì)關(guān)聯(lián)到 ServerSocket 實(shí)例的一個(gè)未完成的連接數(shù)據(jù)結(jié)構(gòu)列表中,注意這時(shí)服務(wù)端與之對(duì)應(yīng)的 Socket 實(shí)例并沒有完成創(chuàng)建,而要等到與客戶端的三次握手完成后,這個(gè)服務(wù)端的 Socket 實(shí)例才會(huì)返回,并將這個(gè) Socket 實(shí)例對(duì)應(yīng)的數(shù)據(jù)結(jié)構(gòu)從未完成列表中移到已完成列表中。所以 ServerSocket 所關(guān)聯(lián)的列表中每個(gè)數(shù)據(jù)結(jié)構(gòu),都代表與一個(gè)客戶端的建立的 TCP 連接。
數(shù)據(jù)傳輸
傳輸數(shù)據(jù)是我們建立連接的主要目的,如何通過 Socket 傳輸數(shù)據(jù),下面將詳細(xì)介紹。
當(dāng)連接已經(jīng)建立成功,服務(wù)端和客戶端都會(huì)擁有一個(gè) Socket 實(shí)例,每個(gè) Socket 實(shí)例都有一個(gè) InputStream 和 OutputStream,正是通過這兩個(gè)對(duì)象來(lái)交換數(shù)據(jù)。同時(shí)我們也知道網(wǎng)絡(luò) I/O 都是以字節(jié)流傳輸?shù)?。?dāng) Socket 對(duì)象創(chuàng)建時(shí),操作系統(tǒng)將會(huì)為 InputStream 和 OutputStream 分別分配一定大小的緩沖區(qū),數(shù)據(jù)的寫入和讀取都是通過這個(gè)緩存區(qū)完成的。寫入端將數(shù)據(jù)寫到 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ù)。值得特別注意的是,這個(gè)緩存區(qū)的大小以及寫入端的速度和讀取端的速度非常影響這個(gè)連接的數(shù)據(jù)傳輸效率,由于可能會(huì)發(fā)生阻塞,所以網(wǎng)絡(luò) I/O 與磁盤 I/O 在數(shù)據(jù)的寫入和讀取還要有一個(gè)協(xié)調(diào)的過程,如果兩邊同時(shí)傳送數(shù)據(jù)時(shí)可能會(huì)產(chǎn)生死鎖,在后面 NIO 部分將介紹避免這種情況。
NIO 的工作方式
BIO 帶來(lái)的挑戰(zhàn)
BIO 即阻塞 I/O,不管是磁盤 I/O 還是網(wǎng)絡(luò) I/O,數(shù)據(jù)在寫入 OutputStream 或者從 InputStream 讀取時(shí)都有可能會(huì)阻塞。一旦有線程阻塞將會(huì)失去 CPU 的使用權(quán),這在當(dāng)前的大規(guī)模訪問量和有性能要求情況下是不能接受的。雖然當(dāng)前的網(wǎng)絡(luò) I/O 有一些解決辦法,如一個(gè)客戶端一個(gè)處理線程,出現(xiàn)阻塞時(shí)只是一個(gè)線程阻塞而不會(huì)影響其它線程工作,還有為了減少系統(tǒng)線程的開銷,采用線程池的辦法來(lái)減少線 程創(chuàng)建和回收的成本,但是有一些使用場(chǎng)景仍然是無(wú)法解決的。如當(dāng)前一些需要大量 HTTP 長(zhǎng)連接的情況,像淘寶現(xiàn)在使用的 Web 旺旺項(xiàng)目,服務(wù)端需要同時(shí)保持幾百萬(wàn)的 HTTP 連接,但是并不是每時(shí)每刻這些連接都在傳輸數(shù)據(jù),這種情況下不可能同時(shí)創(chuàng)建這么多線程來(lái)保持連接。即使線程的數(shù)量不是問題,仍然有一些問題還是無(wú)法避免 的。如這種情況,我們想給某些客戶端更高的服務(wù)優(yōu)先級(jí),很難通過設(shè)計(jì)線程的優(yōu)先級(jí)來(lái)完成,另外一種情況是,我們需要讓每個(gè)客戶端的請(qǐng)求在服務(wù)端可能需要訪 問一些競(jìng)爭(zhēng)資源,由于這些客戶端是在不同線程中,因此需要同步,而往往要實(shí)現(xiàn)這些同步操作要遠(yuǎn)遠(yuǎn)比用單線程復(fù)雜很多。以上這些情況都說明,我們需要另外一 種新的 I/O 操作方式。
NIO 的工作機(jī)制
我們先看一下 NIO 涉及到的關(guān)聯(lián)類圖,如下:
圖 9.NIO 相關(guān)類圖上圖中有兩個(gè)關(guān)鍵類:Channel 和 Selector,它們是 NIO 中兩個(gè)核心概念。我們還用前面的城市交通工具來(lái)繼續(xù)比喻 NIO 的工作方式,這里的 Channel 要比 Socket 更加具體,它可以比作為某種具體的交通工具,如汽車或是高鐵等,而 Selector 可以比作為一個(gè)車站的車輛運(yùn)行調(diào)度系統(tǒng),它將負(fù)責(zé)監(jiān)控每輛車的當(dāng)前運(yùn)行狀態(tài):是已經(jīng)出戰(zhàn)還是在路上等等,也就是它可以輪詢每個(gè) Channel 的狀態(tài)。這里還有一個(gè) Buffer 類,它也比 Stream 更加具體化,我們可以將它比作為車上的座位,Channel 是汽車的話就是汽車上的座位,高鐵上就是高鐵上的座位,它始終是一個(gè)具體的概念,與 Stream 不同。Stream 只能代表是一個(gè)座位,至于是什么座位由你自己去想象,也就是你在去上車之前并不知道,這個(gè)車上是否還有沒有座位了,也不知道上的是什么車,因?yàn)槟悴⒉荒苓x 擇,這些信息都已經(jīng)被封裝在了運(yùn)輸工具(Socket)里面了,對(duì)你是透明的。NIO 引入了 Channel、Buffer 和 Selector 就是想把這些信息具體化,讓程序員有機(jī)會(huì)控制它們,如:當(dāng)我們調(diào)用 write() 往 SendQ 寫數(shù)據(jù)時(shí),當(dāng)一次寫的數(shù)據(jù)超過 SendQ 長(zhǎng)度是需要按照 SendQ 的長(zhǎng)度進(jìn)行分割,這個(gè)過程中需要有將用戶空間數(shù)據(jù)和內(nèi)核地址空間進(jìn)行切換,而這個(gè)切換不是你可以控制的。而在 Buffer 中我們可以控制 Buffer 的 capacity,并且是否擴(kuò)容以及如何擴(kuò)容都可以控制。
理解了這些概念后我們看一下,實(shí)際上它們是如何工作的,下面是典型的一段 NIO 代碼:
清單 2. NIO 工作代碼示例
public void selector() throws IOException { ByteBuffer buffer = ByteBuffer.allocate(1024); Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false);//設(shè)置為非阻塞方式 ssc.socket().bind(new InetSocketAddress(8080)); ssc.register(selector, SelectionKey.OP_ACCEPT);//注冊(cè)監(jiān)聽的事件 while (true) { Set selectedKeys = selector.selectedKeys();//取得所有key集合 Iterator it = selectedKeys.iterator(); while (it.hasNext()) { SelectionKey key = (SelectionKey) it.next(); if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) { ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel(); SocketChannel sc = ssChannel.accept();//接受到服務(wù)端的請(qǐng)求 sc.configureBlocking(false); sc.register(selector, SelectionKey.OP_READ); it.remove(); } else if ((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) { SocketChannel sc = (SocketChannel) key.channel(); while (true) { buffer.clear(); int n = sc.read(buffer);//讀取數(shù)據(jù) if (n <= 0) { break; } buffer.flip(); } it.remove(); } } } }
調(diào)用 Selector 的靜態(tài)工廠創(chuàng)建一個(gè)選擇器,創(chuàng)建一個(gè)服務(wù)端的 Channel 綁定到一個(gè) Socket 對(duì)象,并把這個(gè)通信信道注冊(cè)到選擇器上,把這個(gè)通信信道設(shè)置為非阻塞模式。然后就可以調(diào)用 Selector 的 selectedKeys 方法來(lái)檢查已經(jīng)注冊(cè)在這個(gè)選擇器上的所有通信信道是否有需要的事件發(fā)生,如果有某個(gè)事件發(fā)生時(shí),將會(huì)返回所有的 SelectionKey,通過這個(gè)對(duì)象 Channel 方法就可以取得這個(gè)通信信道對(duì)象從而可以讀取通信的數(shù)據(jù),而這里讀取的數(shù)據(jù)是 Buffer,這個(gè) Buffer 是我們可以控制的緩沖器。
在上面的這段程序中,是將 Server 端的監(jiān)聽連接請(qǐng)求的事件和處理請(qǐng)求的事件放在一個(gè)線程中,但是在實(shí)際應(yīng)用中,我們通常會(huì)把它們放在兩個(gè)線程中,一個(gè)線程專門負(fù)責(zé)監(jiān)聽客戶端的連接請(qǐng)求,而 且是阻塞方式執(zhí)行的;另外一個(gè)線程專門來(lái)處理請(qǐng)求,這個(gè)專門處理請(qǐng)求的線程才會(huì)真正采用 NIO 的方式,像 Web 服務(wù)器 Tomcat 和 Jetty 都是這個(gè)處理方式,關(guān)于 Tomcat 和 Jetty 的 NIO 處理方式可以參考文章《 Jetty 的工作原理和與 Tomcat 的比較》。
下圖是描述了基于 NIO 工作方式的 Socket 請(qǐng)求的處理過程:
圖 10. 基于 NIO 的 Socket 請(qǐng)求的處理過程
上圖中的 Selector 可以同時(shí)監(jiān)聽一組通信信道(Channel)上的 I/O 狀態(tài),前提是這個(gè) Selector 要已經(jīng)注冊(cè)到這些通信信道中。選擇器 Selector 可以調(diào)用 select() 方法檢查已經(jīng)注冊(cè)的通信信道上的是否有 I/O 已經(jīng)準(zhǔn)備好,如果沒有至少一個(gè)信道 I/O 狀態(tài)有變化,那么 select 方法會(huì)阻塞等待或在超時(shí)時(shí)間后會(huì)返回 0。上圖中如果有多個(gè)信道有數(shù)據(jù),那么將會(huì)將這些數(shù)據(jù)分配到對(duì)應(yīng)的數(shù)據(jù) Buffer 中。所以關(guān)鍵的地方是有一個(gè)線程來(lái)處理所有連接的數(shù)據(jù)交互,每個(gè)連接的數(shù)據(jù)交互都不是阻塞方式,所以可以同時(shí)處理大量的連接請(qǐng)求。
Buffer 的工作方式
上面介紹了 Selector 將檢測(cè)到有通信信道 I/O 有數(shù)據(jù)傳輸時(shí),通過 selelct() 取得 SocketChannel,將數(shù)據(jù)讀取或?qū)懭?Buffer 緩沖區(qū)。下面討論一下 Buffer 如何接受和寫出數(shù)據(jù)?
Buffer 可以簡(jiǎn)單的理解為一組基本數(shù)據(jù)類型的元素列表,它通過幾個(gè)變量來(lái)保存這個(gè)數(shù)據(jù)的當(dāng)前位置狀態(tài),也就是有四個(gè)索引。如下表所示:
表 1.Buffer 中的參數(shù)項(xiàng)
| capacity | 緩沖區(qū)數(shù)組的總長(zhǎng)度 |
| position | 下一個(gè)要操作的數(shù)據(jù)元素的位置 |
| limit | 緩沖區(qū)數(shù)組中不可操作的下一個(gè)元素的位置,limit<=capacity |
| mark | 用于記錄當(dāng)前 position 的前一個(gè)位置或者默認(rèn)是 0 |
在實(shí)際操作數(shù)據(jù)時(shí)它們有如下關(guān)系圖:
我們通過 ByteBuffer.allocate(11) 方法創(chuàng)建一個(gè) 11 個(gè) byte 的數(shù)組緩沖區(qū),初始狀態(tài)如上圖所示,position 的位置為 0,capacity 和 limit 默認(rèn)都是數(shù)組長(zhǎng)度。當(dāng)我們寫入 5 個(gè)字節(jié)時(shí)位置變化如下圖所示:這時(shí)我們需要將緩沖區(qū)的 5 個(gè)字節(jié)數(shù)據(jù)寫入 Channel 通信信道,所以我們需要調(diào)用 byteBuffer.flip() 方法,數(shù)組的狀態(tài)又發(fā)生如下變化:
這時(shí)底層操作系統(tǒng)就可以從緩沖區(qū)中正確讀取這 5 個(gè)字節(jié)數(shù)據(jù)發(fā)送出去了。在下一次寫數(shù)據(jù)之前我們?cè)谡{(diào)一下 clear() 方法。緩沖區(qū)的索引狀態(tài)又回到初始位置。
這里還要說明一下 mark,當(dāng)我們調(diào)用 mark() 時(shí),它將記錄當(dāng)前 position 的前一個(gè)位置,當(dāng)我們調(diào)用 reset 時(shí),position 將恢復(fù) mark 記錄下來(lái)的值。
還有一點(diǎn)需要說明,通過 Channel 獲取的 I/O 數(shù)據(jù)首先要經(jīng)過操作系統(tǒng)的 Socket 緩沖區(qū)再將數(shù)據(jù)復(fù)制到 Buffer 中,這個(gè)的操作系統(tǒng)緩沖區(qū)就是底層的 TCP 協(xié)議關(guān)聯(lián)的 RecvQ 或者 SendQ 隊(duì)列,從操作系統(tǒng)緩沖區(qū)到用戶緩沖區(qū)復(fù)制數(shù)據(jù)比較耗性能,Buffer 提供了另外一種直接操作操作系統(tǒng)緩沖區(qū)的的方式即 ByteBuffer.allocateDirector(size),這個(gè)方法返回的 byteBuffer 就是與底層存儲(chǔ)空間關(guān)聯(lián)的緩沖區(qū),它的操作方式與 linux2.4 內(nèi)核的 sendfile 操作方式類似。
I/O 調(diào)優(yōu)
下面就磁盤 I/O 和網(wǎng)絡(luò) I/O 的一些常用的優(yōu)化技巧進(jìn)行總結(jié)如下:
磁盤 I/O 優(yōu)化
性能檢測(cè)
我們的應(yīng)用程序通常都需要訪問磁盤讀取數(shù)據(jù),而磁盤 I/O 通常都很耗時(shí),我們要判斷 I/O 是否是一個(gè)瓶頸,我們有一些參數(shù)指標(biāo)可以參考:
如我們可以壓力測(cè)試應(yīng)用程序看系統(tǒng)的 I/O wait 指標(biāo)是否正常,例如測(cè)試機(jī)器有 4 個(gè) CPU,那么理想的 I/O wait 參數(shù)不應(yīng)該超過 25%,如果超過 25% 的話,I/O 很可能成為應(yīng)用程序的性能瓶頸。Linux 操作系統(tǒng)下可以通過 iostat 命令查看。
通常我們?cè)谂袛?I/O 性能時(shí)還會(huì)看另外一個(gè)參數(shù)就是 IOPS,我們應(yīng)用程序需要最低的 IOPS 是多少,而我們的磁盤的 IOPS 能不能達(dá)到我們的要求。每個(gè)磁盤的 IOPS 通常是在一個(gè)范圍內(nèi),這和存儲(chǔ)在磁盤的數(shù)據(jù)塊的大小和訪問方式也有關(guān)。但是主要是由磁盤的轉(zhuǎn)速?zèng)Q定的,磁盤的轉(zhuǎn)速越高磁盤的 IOPS 也越高。
現(xiàn)在為了提高磁盤 I/O 的性能,通常采用一種叫 RAID 的技術(shù),就是將不同的磁盤組合起來(lái)來(lái)提高 I/O 性能,目前有多種 RAID 技術(shù),每種 RAID 技術(shù)對(duì) I/O 性能提升會(huì)有不同,可以用一個(gè) RAID 因子來(lái)代表,磁盤的讀寫吞吐量可以通過 iostat 命令來(lái)獲取,于是我們可以計(jì)算出一個(gè)理論的 IOPS 值,計(jì)算公式如下所以:
( 磁盤數(shù) * 每塊磁盤的 IOPS)/( 磁盤讀的吞吐量 +RAID 因子 * 磁盤寫的吞吐量 )=IOPS
這個(gè)公式的詳細(xì)信息請(qǐng)查閱參考資料 Understanding Disk I/O。
提升 I/O 性能
提升磁盤 I/O 性能通常的方法有:
表 2.RAID 策略
| RAID 0 | 數(shù)據(jù)被平均寫到多個(gè)磁盤陣列中,寫數(shù)據(jù)和讀數(shù)據(jù)都是并行的,所以磁盤的 IOPS 可以提高一倍。 |
| RAID 1 | RAID 1 的主要作用是能夠提高數(shù)據(jù)的安全性,它將一份數(shù)據(jù)分別復(fù)制到多個(gè)磁盤陣列中。并不能提升 IOPS 但是相同的數(shù)據(jù)有多個(gè)備份。通常用于對(duì)數(shù)據(jù)安全性較高的場(chǎng)合中。 |
| RAID 5 | 這中設(shè)計(jì)方式是前兩種的折中方式,它將數(shù)據(jù)平均寫到所有磁盤陣列總數(shù)減一的磁盤中,往另外一個(gè)磁盤中寫入這份數(shù)據(jù)的奇偶校驗(yàn)信息。如果其中一個(gè)磁盤損壞,可以通過其它磁盤的數(shù)據(jù)和這個(gè)數(shù)據(jù)的奇偶校驗(yàn)信息來(lái)恢復(fù)這份數(shù)據(jù)。 |
| RAID 0+1 | 如名字一樣,就是根據(jù)數(shù)據(jù)的備份情況進(jìn)行分組,一份數(shù)據(jù)同時(shí)寫到多個(gè)備份磁盤分組中,同時(shí)多個(gè)分組也會(huì)并行讀寫。 |
網(wǎng)絡(luò) I/O 優(yōu)化
網(wǎng)絡(luò) I/O 優(yōu)化通常有一些基本處理原則:
同步與異步
所謂同步就是一個(gè)任務(wù)的完成需要依賴另外一個(gè)任務(wù)時(shí),只有等待被依賴的任務(wù)完成后,依賴的任務(wù)才能算完成,這是一種可靠的任務(wù)序列。要么成 功都成功,失敗都失敗,兩個(gè)任務(wù)的狀態(tài)可以保持一致。而異步是不需要等待被依賴的任務(wù)完成,只是通知被依賴的任務(wù)要完成什么工作,依賴的任務(wù)也立即執(zhí)行, 只要自己完成了整個(gè)任務(wù)就算完成了。至于被依賴的任務(wù)最終是否真正完成,依賴它的任務(wù)無(wú)法確定,所以它是不可靠的任務(wù)序列。我們可以用打電話和發(fā)短信來(lái)很 好的比喻同步與異步操作。
在設(shè)計(jì)到 IO 處理時(shí)通常都會(huì)遇到一個(gè)是同步還是異步的處理方式的選擇問題。因?yàn)橥脚c異步的 I/O 處理方式對(duì)調(diào)用者的影響很大,在數(shù)據(jù)庫(kù)產(chǎn)品中都會(huì)遇到這個(gè)問題。因?yàn)?I/O 操作通常是一個(gè)非常耗時(shí)的操作,在一個(gè)任務(wù)序列中 I/O 通常都是性能瓶頸。但是同步與異步的處理方式對(duì)程序的可靠性影響非常大,同步能夠保證程序的可靠性,而異步可以提升程序的性能,必須在可靠性和性能之間做 個(gè)平衡,沒有完美的解決辦法。
阻塞與非阻塞
阻塞與非阻塞主要是從 CPU 的消耗上來(lái)說的,阻塞就是 CPU 停下來(lái)等待一個(gè)慢的操作完成 CPU 才接著完成其它的事。非阻塞就是在這個(gè)慢的操作在執(zhí)行時(shí) CPU 去干其它別的事,等這個(gè)慢的操作完成時(shí),CPU 再接著完成后續(xù)的操作。雖然表面上看非阻塞的方式可以明顯的提高 CPU 的利用率,但是也帶了另外一種后果就是系統(tǒng)的線程切換增加。增加的 CPU 使用時(shí)間能不能補(bǔ)償系統(tǒng)的切換成本需要好好評(píng)估。
兩種的方式的組合
組合的方式可以由四種,分別是:同步阻塞、同步非阻塞、異步阻塞、異步非阻塞,這四種方式都對(duì) I/O 性能有影響。下面給出分析,并有一些常用的設(shè)計(jì)用例參考。
表 3. 四種組合方式
| 同步阻塞 | 最常用的一種用法,使用也是最簡(jiǎn)單的,但是 I/O 性能一般很差,CPU 大部分在空閑狀態(tài)。 |
| 同步非阻塞 | 提升 I/O 性能的常用手段,就是將 I/O 的阻塞改成非阻塞方式,尤其在網(wǎng)絡(luò) I/O 是長(zhǎng)連接,同時(shí)傳輸數(shù)據(jù)也不是很多的情況下,提升性能非常有效。 這種方式通常能提升 I/O 性能,但是會(huì)增加 CPU 消耗,要考慮增加的 I/O 性能能不能補(bǔ)償 CPU 的消耗,也就是系統(tǒng)的瓶頸是在 I/O 還是在 CPU 上。 |
| 異步阻塞 | 這種方式在分布式數(shù)據(jù)庫(kù)中經(jīng)常用到,例如在網(wǎng)一個(gè)分布式數(shù)據(jù)庫(kù)中寫一條記錄,通常會(huì)有一份是同步阻塞的記錄,而還有兩至三份是備份記錄會(huì)寫到其它機(jī)器上,這些備份記錄通常都是采用異步阻塞的方式寫 I/O。 異步阻塞對(duì)網(wǎng)絡(luò) I/O 能夠提升效率,尤其像上面這種同時(shí)寫多份相同數(shù)據(jù)的情況。 |
| 異步非阻塞 | 這種組合方式用起來(lái)比較復(fù)雜,只有在一些非常復(fù)雜的分布式情況下使用,像集群之間的消息同步機(jī)制一般用這種 I/O 組合方式。如 Cassandra 的 Gossip 通信機(jī)制就是采用異步非阻塞的方式。 它適合同時(shí)要傳多份相同的數(shù)據(jù)到集群中不同的機(jī)器,同時(shí)數(shù)據(jù)的傳輸量雖然不大,但是卻非常頻繁。這種網(wǎng)絡(luò) I/O 用這個(gè)方式性能能達(dá)到最高。 |
雖然異步和非阻塞能夠提升 I/O 的性能,但是也會(huì)帶來(lái)一些額外的性能成本,例如會(huì)增加線程數(shù)量從而增加 CPU 的消耗,同時(shí)也會(huì)導(dǎo)致程序設(shè)計(jì)的復(fù)雜度上升。如果設(shè)計(jì)的不合理的話反而會(huì)導(dǎo)致性能下降。在實(shí)際設(shè)計(jì)時(shí)要根據(jù)應(yīng)用場(chǎng)景綜合評(píng)估一下。
下面舉一些異步和阻塞的操作實(shí)例:
在 Cassandra 中要查詢數(shù)據(jù)通常會(huì)往多個(gè)數(shù)據(jù)節(jié)點(diǎn)發(fā)送查詢命令,但是要檢查每個(gè)節(jié)點(diǎn)返回?cái)?shù)據(jù)的完整性,所以需要一個(gè)異步查詢同步結(jié)果的應(yīng)用場(chǎng)景,部分代碼如下:
清單 3.異步查詢同步結(jié)果
class AsyncResult implements IAsyncResult{ private byte[] result_; private AtomicBoolean done_ = new AtomicBoolean(false); private Lock lock_ = new ReentrantLock(); private Condition condition_; private long startTime_; public AsyncResult(){ condition_ = lock_.newCondition();// 創(chuàng)建一個(gè)鎖 startTime_ = System.currentTimeMillis(); } /*** 檢查需要的數(shù)據(jù)是否已經(jīng)返回,如果沒有返回阻塞 */ public byte[] get(){ lock_.lock(); try{ if (!done_.get()){condition_.await();} }catch (InterruptedException ex){ throw new AssertionError(ex); }finally{lock_.unlock();} return result_; } /*** 檢查需要的數(shù)據(jù)是否已經(jīng)返回 */ public boolean isDone(){return done_.get();} /*** 檢查在指定的時(shí)間內(nèi)需要的數(shù)據(jù)是否已經(jīng)返回,如果沒有返回拋出超時(shí)異常 */ public byte[] get(long timeout, TimeUnit tu) throws TimeoutException{ lock_.lock(); try{ boolean bVal = true; try{ if ( !done_.get() ){ long overall_timeout = timeout - (System.currentTimeMillis() - startTime_); if(overall_timeout > 0)// 設(shè)置等待超時(shí)的時(shí)間 bVal = condition_.await(overall_timeout, TimeUnit.MILLISECONDS); else bVal = false; } }catch (InterruptedException ex){ throw new AssertionError(ex); } if ( !bVal && !done_.get() ){// 拋出超時(shí)異常 throw new TimeoutException("Operation timed out."); } }finally{lock_.unlock(); } return result_; } /*** 該函數(shù)拱另外一個(gè)線程設(shè)置要返回的數(shù)據(jù),并喚醒在阻塞的線程 */ public void result(Message response){ try{ lock_.lock(); if ( !done_.get() ){ result_ = response.getMessageBody();// 設(shè)置返回的數(shù)據(jù) done_.set(true); condition_.signal();// 喚醒阻塞的線程 } }finally{lock_.unlock();} } }
總結(jié)
本文闡述的內(nèi)容較多,從 Java 基本 I/O 類庫(kù)結(jié)構(gòu)開始說起,主要介紹了磁盤 I/O 和網(wǎng)絡(luò) I/O 的基本工作方式,最后介紹了關(guān)于 I/O 調(diào)優(yōu)的一些方法。
《新程序員》:云原生和全面數(shù)字化實(shí)踐50位技術(shù)專家共同創(chuàng)作,文字、視頻、音頻交互閱讀總結(jié)
以上是生活随笔為你收集整理的深入分析 Java I/O 的工作机制的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 是 String , StringBuf
- 下一篇: 从Java代码到Java堆理解和优化您的