一、簡介
說到 I/O,想必大家都不會陌生, I/O 英語全稱:Input/Output,即輸入/輸出,通常指資料在內部儲存器和外部儲存器或其他周邊設備之間的輸入和輸出。
比如我們常用的 SD卡、U盤、行動硬碟等等儲存檔案的硬體裝置,當我們將其插入電腦的 usb 硬體介面時,我們就可以從電腦中讀取裝置中的資訊或者寫入資訊,這個過程就涉及到 I/O 的操作。
當然,涉及 I/O 的操作,不僅僅侷限於硬體裝置的讀寫,還要網路資料的傳輸,比如,我們在電腦上用瀏覽器搜尋網際網路上的資訊,這個過程也涉及到 I/O 的操作。
無論是從磁碟中讀寫檔案,還是在網路中傳輸資料,可以說 I/O 主要為處理人機互動、機與機互動中獲取和交換資訊提供的一套解決方案。
在 Java 的 IO 體系中,類將近有 80 個,位於java.io
包下,感覺很複雜,但是這些類大致可以分成四組:
基於位元組操作的 I/O 介面:InputStream 和 OutputStream
基於字元操作的 I/O 介面:Writer 和 Reader
基於磁碟操作的 I/O 介面:File
基於網路操作的 I/O 介面:Socket
前兩組主要從傳輸資料的資料格式不同,進行分組;後兩組主要從傳輸資料的方式不同,進行分組。
雖然 Socket 類並不在 java.io
包下,但是我們仍然把它們劃分在一起,因為 I/O 的核心問題,要麼是資料格式影響 I/O 操作,要麼是傳輸方式影響 I/O 操作,也就是將什麼樣的資料寫到什麼地方的問題,I/O 只是人與機器或者機器與機器互動的手段,除了在它們能夠完成這個互動功能外,我們關注的就是如何提高它的執行效率了,而資料格式和傳輸方式是影響效率最關鍵的因素。
本文後面,也是基於這兩個點進行深入展開分析。
二、基於位元組操作的介面
基於位元組的輸入和輸出操作介面分別是:InputStream 和 OutputStream 。
2.1、位元組輸入流
InputStream 輸入流的類繼承層次如下圖所示:
輸入流根據資料節點型別和處理方式,分別可以劃分出了若干個子類,如下圖:
OutputStream 輸出流的類層次結構也是類似。
2.2、位元組輸出流
OutputStream 輸出流的類繼承層次如下圖所示:
輸出流根據資料節點型別和處理方式,也分別可以劃分出了若干個子類,如下圖:
在這裏就不詳細的介紹各個子類的使用方法,有興趣的朋友可以檢視 JDK 的 API 說明文件,筆者也會在後期的文章會進行詳細的介紹,這裏只是重點想說一下,無論是輸入還是輸出,運算元據的方式可以組合使用,各個處理流的類並不是只操作固定的節點流,比如如下輸出方式:
java複製程式碼//將檔案輸出流包裝到序列化輸出流中,再將序列化輸出流包裝到緩衝中 OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream(new File("fileName")));
另外,輸出流最終寫到什麼地方必須要指定,要麼是寫到硬碟中,要麼是寫到網路中,從圖中可以發現,寫網路實際上也是寫檔案,只不過寫到網路中,需要經過底層作業系統將資料傳送到其他的計算機中,而不是寫入到本地硬碟中。
三、基於字元操作的介面
不管是磁碟還是網路傳輸,最小的儲存單元都是位元組,而不是字元,所以 I/O 操作的都是位元組而不是字元,但是為什麼要有操作字元的 I/O 介面呢?
這是因為我們的程式中通常操作的資料都是以字元形式,爲了程式操作更方便而提供一個直接寫字元的 I/O 介面,僅此而已。
基於字元的輸入和輸出操作介面分別是:Reader 和 Writer ,下圖是字元的 I/O 操作介面涉及到的類結構圖。
3.1、字元輸入流
Reader 輸入流的類繼承層次如下圖所示:
同樣的,輸入流根據資料節點型別和處理方式,分別可以劃分出了若干個子類,如下圖:
3.2、字元輸出流
Writer 輸出流的類繼承層次如下圖所示:
同樣的,輸出流根據資料節點型別和處理方式分類,分別可以劃分出了若干個子類,如下圖:
不管是 Reader 還是 Writer 類,它們都只定義了讀取或寫入資料字元的方式,也就是說要麼是讀要麼是寫,但是並沒有規定資料要寫到哪去,寫到哪去就是我們後面要討論的基於磁碟或網路的工作機制。
四、位元組與字元的轉化
剛剛我們說到,不管是磁碟還是網路傳輸,最小的儲存單元都是位元組,而不是字元,設計字元的原因是爲了程式操作更方便,那麼怎麼將字元轉化成位元組或者將位元組轉化成字元呢?
InputStreamReader 和 OutputStreamWriter 就是轉化橋樑。
4.1、輸入流轉化過程
輸入流字元解碼相關類結構的轉化過程如下圖所示:
從圖上可以看到,InputStreamReader 類是位元組到字元的轉化橋樑, 其中StreamDecoder
指的是一個解碼操作類,Charset
指的是字符集。
InputStream 到 Reader 的過程需要指定編碼字符集,否則將採用作業系統預設字符集,很可能會出現亂碼問題,StreamDecoder 則是完成位元組到字元的解碼的實現類。
開啟原始碼部分,InputStream 到 Reader 轉化過程,如下圖:
4.1、輸出流轉化過程
輸出流轉化過程也是類似,如下圖所示:
透過 OutputStreamWriter 類完成字元到位元組的編碼過程,由 StreamEncoder
完成編碼過程。
原始碼部分,Writer 到 OutputStream 轉化過程,如下圖:
五、基於磁碟操作的介面
前面介紹了Java I/O 的操作介面,這些介面主要定義瞭如何運算元據,以及介紹了運算元據格式的方式:位元組流和字元流。
還有一個關鍵問題就是資料寫到何處,其中一個主要的處理方式就是將資料持久化到物理磁碟。
我們知道資料在磁碟的唯一最小描述就是檔案,也就是說上層應用程式只能透過檔案來操作磁碟上的資料,檔案也是作業系統和磁碟驅動器互動的一個最小單元。
在 Java I/O 體系中,File 類是唯一代表磁碟檔案本身的物件。
File 類定義了一些與平臺無關的方法來操作檔案,包括檢查一個檔案是否存在、建立、刪除檔案、重新命名檔案、判斷檔案的讀寫許可權是否存在、設定和查詢檔案的最近修改時間等等操作。
值得注意的是 Java 中通常的 File 並不代表一個真實存在的檔案物件,當你透過指定一個路徑描述符時,它就會返回一個代表這個路徑相關聯的一個虛擬物件,這個可能是一個真實存在的檔案或者是一個包含多個檔案的目錄。
例如,讀取一個檔案內容,程式如下:
以上面的程式為例,從硬碟中讀取一段文字字元,操作流程如下圖:
我們再來看看原始碼執行流程。
當我們傳入一個指定的檔名來建立 File 物件,透過 FileReader 來讀取檔案內容時,會自動建立一個FileInputStream
物件來讀取檔案內容,也就是我們上文中所說的位元組流來讀取檔案。
緊接著,會建立一個FileDescriptor
的物件,其實這個物件就是真正代表一個存在的檔案物件的描述。可以透過FileInputStream
物件呼叫getFD()
方法獲取真正與底層作業系統關聯的檔案描述。
由於我們需要讀取的是字元格式,所以需要 StreamDecoder
類將byte
解碼為char
格式,至於如何從磁碟驅動器上讀取一段資料,由作業系統幫我們完成。
六、基於網路操作的介面
繼續來說說資料寫到何處的另一種處理方式:將資料寫入網際網路中以供其他電腦能訪問。
6.1、Socket簡介
在現實中,Socket 這個概念沒有一個具體的實體,它是描述計算機之間完成相互通訊一種抽象定義。
打個比方,可以把 Socket 比作為兩個城市之間的交通工具,有了它,就可以在城市之間來回穿梭了。並且,交通工具有多種,每種交通工具也有相應的交通規則。Socket 也一樣,也有多種。大部分情況下我們使用的都是基於 TCP/IP 的流套接字,它是一種穩定的通訊協議。
典型的基於 Socket 通訊的應用程式場景,如下圖:
主機 A 的應用程式要想和主機 B 的應用程式通訊,必須透過 Socket 建立連線,而建立 Socket 連線必須需要底層 TCP/IP 協議來建立 TCP 連線。
6.2、建立通訊鏈路
我們知道網路層使用的 IP 協議可以幫助我們根據 IP 地址來找到目標主機,但是一臺主機上可能執行著多個應用程式,如何才能與指定的應用程式通訊就要透過 TCP 或 UPD 的地址也就是埠號來指定。這樣就可以透過一個 Socket 例項代表唯一一個主機上的一個應用程式的通訊鏈路了。
爲了準確無誤地把資料送達目標處,TCP 協議採用了三次握手策略,如下圖:
其中,SYN 全稱為 Synchronize Sequence Numbers,表示同步序列編號,是 TCP/IP 建立連線時使用的握手訊號。
ACK 全稱為 Acknowledge character,即確認字元,表示發來的資料已確認接收無誤。
在客戶機和伺服器之間建立正常的 TCP 網路連線時,客戶機首先發出一個 SYN 訊息,伺服器使用 SYN + ACK 應答表示接收到了這個訊息,最後客戶機再以 ACK 訊息響應。
這樣在客戶機和伺服器之間才能建立起可靠的 TCP 連線,資料纔可以在客戶機和伺服器之間傳遞。
簡單流程如下:
傳送端 –(傳送帶有 SYN 標誌的資料包 )–> 接受端(第一次握手);
接受端 –(傳送帶有 SYN + ACK 標誌的資料包)–> 傳送端(第二次握手);
傳送端 –(傳送帶有 ACK 標誌的資料包) –> 接受端(第三次握手);
完成三次握手之後,客戶端應用程式與伺服器應用程式就可以開始傳送資料了。
傳輸資料是我們建立連線的主要目的,如何透過 Socket 傳輸資料呢?
6.3、傳輸資料
當客戶端要與服務端通訊時,客戶端首先要建立一個 Socket 例項,預設作業系統將為這個 Socket 例項分配一個沒有被使用的本地埠號,並建立一個包含本地、遠端地址和埠號的套接字數據結構,這個數據結構將一直儲存在系統中直到這個連線關閉。
與之對應的服務端,也將建立一個 ServerSocket 例項,ServerSocket 建立比較簡單,只要指定的埠號沒有被佔用,一般例項建立都會成功,同時作業系統也會為 ServerSocket 例項建立一個底層數據結構,這個數據結構中包含指定監聽的埠號和包含監聽地址的萬用字元,通常情況下都是*
即監聽所有地址。
之後當呼叫 accept() 方法時,將進入阻塞狀態,等待客戶端的請求。
我們先啟動服務端程式,再執行客戶端,服務端收到客戶端傳送的資訊,服務端列印結果如下:
注意,客戶端只有與服務端建立三次握手成功之後,纔會傳送資料,而 TCP/IP 握手過程,底層作業系統已經幫我們實現了!
當連線已經建立成功,服務端和客戶端都會擁有一個 Socket 例項,每個 Socket 例項都有一個 InputStream 和 OutputStream,正如我們前面所說的,網路 I/O 都是以位元組流傳輸的,Socket 正是透過這兩個物件來交換資料。
當 Socket 物件建立時,作業系統將會為 InputStream 和 OutputStream 分別分配一定大小的緩衝區,資料的寫入和讀取都是透過這個快取區完成的。
寫入端將資料寫到 OutputStream 對應的 SendQ 佇列中,當佇列填滿時,資料將被髮送到另一端 InputStream 的 RecvQ 佇列中,如果這時 RecvQ 已經滿了,那麼 OutputStream 的 write 方法將會阻塞直到 RecvQ 佇列有足夠的空間容納 SendQ 傳送的資料。
值得特別注意的是,快取區的大小以及寫入端的速度和讀取端的速度非常影響這個連線的數據傳輸效率,由於可能會發生阻塞,所以網路 I/O 與磁碟 I/O 在資料的寫入和讀取還要有一個協調的過程,如果兩邊同時傳送資料時可能會產生死鎖的問題。
如何提高網路 IO 傳輸效率、保證數據傳輸的可靠,已經成了工程師們急需解決的問題。
6.4、IO工作方式
在計算機中,IO 傳輸資料有三種工作方式,分別是 BIO、NIO、AIO。
在講解 BIO、NIO、AIO 之前,我們先來回顧一下這幾個概念:同步與非同步,阻塞與非阻塞。
同步與非同步的區別
同步就是發起一個請求後,接受者未處理完請求之前,不返回結果。
非同步就是發起一個請求後,立刻得到接受者的迴應表示已接收到請求,但是接受者並沒有處理完,接受者通常依靠事件回撥等機制來通知請求者其處理結果。
阻塞和非阻塞的區別
阻塞就是請求者發起一個請求,一直等待其請求結果返回,也就是當前執行緒會被掛起,無法從事其他任務,只有當條件就緒才能繼續。
非阻塞就是請求者發起一個請求,不用一直等著結果返回,可以先去幹其他事情,當條件就緒的時候,就自動回來。
而我們要講的 BIO、NIO、AIO 就是同步與非同步、阻塞與非阻塞的組合。
BIO:同步阻塞 IO;
NIO:同步非阻塞 IO;
AIO:非同步非阻塞 IO;
6.4.1、BIO
BIO 俗稱同步阻塞 IO,一種非常傳統的 IO 模型,比如我們上面所舉的那個程式例子,就是一個典型的**同步阻塞 IO **的工作方式。
採用 BIO 通訊模型的服務端,通常由一個獨立的 Acceptor 執行緒負責監聽客戶端的連線。
我們一般在服務端透過while(true)
迴圈中會呼叫accept()
方法等待監聽客戶端的連線,一旦接收到一個連線請求,就可以建立通訊套接字進行讀寫操作,此時不能再接收其他客戶端連線請求,只能等待同當前連線的客戶端的操作執行完成, 不過可以透過多執行緒來支援多個客戶端的連線。
客戶端多執行緒操作,程式如下:
服務端多執行緒操作,程式如下:
服務端執行結果,如下:
如果要讓 BIO 通訊模型能夠同時處理多個客戶端請求,就必須使用多執行緒,也就是說它在接收到客戶端連線請求之後為每個客戶端建立一個新的執行緒進行鏈路處理,處理完成之後,透過輸出流返回應答給客戶端,執行緒銷燬。
這就是典型的一請求一應答通訊模型 。
如果出現100、1000、甚至10000個使用者同時訪問伺服器,這個時候,如果使用這種模型,那麼服務端也會建立與之相同的執行緒數量,執行緒數急劇膨脹可能會導致執行緒堆疊溢位、建立新執行緒失敗等問題,最終導致程序宕機或者僵死,不能對外提供服務。
當然,我們可以透過使用 Java 中 ThreadPoolExecutor 執行緒池機制來改善,讓執行緒的建立和回收成本相對較低,保證了系統有限的資源的控制,實現了 N (客戶端請求數量)大於 M (處理客戶端請求的執行緒數量)的偽非同步 I/O 模型。
6.4.2、偽非同步 BIO
爲了解決同步阻塞 I/O 面臨的一個鏈路需要一個執行緒處理的問題,後來有人對它的執行緒模型進行了最佳化,後端透過一個執行緒池來處理多個客戶端的請求接入,形成客戶端個數 M:執行緒池最大執行緒數 N 的比例關係,其中 M 可以遠遠大於 N,透過執行緒池可以靈活地調配執行緒資源,設定執行緒的最大值,防止由於海量併發接入導致資源耗盡。
偽非同步IO模型圖,如下圖:
採用執行緒池和任務佇列可以實現一種叫做偽非同步的 I/O 通訊框架,當有新的客戶端接入時,將客戶端的 Socket 封裝成一個 Task 投遞到後端的執行緒池中進行處理。
Java 的執行緒池維護一個訊息佇列和 N 個活躍執行緒,對訊息佇列中的任務進行處理。
客戶端,程式如下:
服務端,程式如下:
先啟動服務端程式,再啟動客戶端程式,看看執行結果!
服務端,執行結果如下:
客戶端,執行結果如下:
本例中測試的客戶端數量是 30,服務端使用 java 執行緒池來處理任務,執行緒數量為 5 個,服務端不用為每個客戶端都建立一個執行緒,由於執行緒池可以設定訊息佇列的大小和最大執行緒數,因此,它的資源佔用是可控的,無論多少個客戶端併發訪問,都不會導致資源的耗盡和宕機。
在活動連線數不是特別高的情況下,這種模型是還不錯,可以讓每一個連線專注於自己的 I/O 並且程式設計模型簡單,也不用過多考慮系統的過載、限流等問題。
但是,它的底層仍然是同步阻塞的 BIO 模型,當面對十萬甚至百萬級連線的時候,傳統的 BIO 模型真的是無能為力的,我們需要一種更高效的 I/O 處理模型來應對更高的併發量。
6.4.3、NIO
NIO 中的 N 可以理解為 Non-blocking,一種同步非阻塞的 I/O 模型,在 Java 1.4 中引入,對應的在java.nio
包下。
NIO 新增了 Channel、Selector、Buffer 等抽象概念,支援面向緩衝、基於通道的 I/O 操作方法。
NIO 提供了與傳統 BIO 模型中的 Socket
和 ServerSocket
相對應的 SocketChannel
和 ServerSocketChannel
兩種不同的套接字通道實現。
NIO 這兩種通道都支援阻塞和非阻塞兩種模式。阻塞模式使用就像傳統中的支援一樣,比較簡單,但是效能和可靠性都不好;非阻塞模式正好與之相反。
對於低負載、低併發的應用程式,可以使用同步阻塞 I/O 來提升開發效率和更好的維護性;對於高負載、高併發的(網路)應用,應使用 NIO 的非阻塞模式來開發。
我們先看一下 NIO 涉及到的核心關聯類圖,如下:
上圖中有三個關鍵類:Channel 、Selector 和 Buffer,它們是 NIO 中的核心概念。
Channel:可以理解為通道;
Selector:可以理解為選擇器;
Buffer:可以理解為資料緩衝流;
我們還是用前面的城市交通工具來繼續形容 NIO 的工作方式,這裏的 Channel 要比 Socket 更加具體,它可以比作為某種具體的交通工具,如汽車或是高鐵、飛機等,而 Selector 可以比作為一個車站的車輛執行排程系統,它將負責監控每輛車的當前執行狀態:是已經出站還是在路上等等,也就是說它可以輪詢每個 Channel 的狀態。
還有一個 Buffer 類,你可以將它看作為 IO 中 Stream,但是它比 IO 中的 Stream 更加具體化,我們可以將它比作為車上的座位,Channel 如果是汽車的話,那麼 Buffer 就是汽車上的座位,Channel 如果是高鐵上,那麼 Buffer 就是高鐵上的座位,它始終是一個具體的概念,這一點與 Stream 不同。
Socket 中的 Stream 只能代表是一個座位,至於是什麼座位由你自己去想象,也就是說你在上車之前並不知道這個車上是否還有沒有座位,也不知道上的是什麼車,因為你並不能選擇,這些資訊都已經被封裝在了運輸工具(Socket)裡面了。
NIO 引入了 Channel、Buffer 和 Selector 就是想把 IO 傳輸過程中涉及到的資訊具體化,讓程式設計師有機會去控制它們。
當我們進行傳統的網路 IO 操作時,比如呼叫 write() 往 Socket 中的 SendQ 佇列寫資料時,當一次寫的資料超過 SendQ 長度時,作業系統會按照 SendQ 的長度進行分割的,這個過程中需要將使用者空間資料和核心地址空間進行切換,而這個切換不是程式設計師可以控制的,由底層作業系統來幫我們處理。
而在 Buffer 中,我們可以控制 Buffer 的 capacity(容量),並且是否擴容以及如何擴容都可以控制。
理解了這些概念後我們看一下,實際上它們是如何工作的呢?
還是以上面的操作為例子,爲了方便觀看結果,本次的客戶端執行緒請求數改成15個。
客戶端,程式如下:
服務端,程式如下:
先啟動服務端程式,再啟動客戶端程式,看看執行結果!
服務端,執行結果如下:
客戶端,執行結果如下:
當然,客戶端也不僅僅只限制於 IO 的寫法,還可以使用SocketChannel
來操作客戶端,程式如下:
一樣的,先啟動服務端,再啟動客戶端,客戶端執行結果如下:
從操作上可以看到,NIO 的操作比傳統的 IO 操作要複雜的多!
Selector 被稱為選擇器 ,當然你也可以翻譯為多路複用器 。它是Java NIO 核心元件中的一個,用於檢查一個或多個 Channel(通道)的狀態是否處於連線就緒、接受就緒、可讀就緒、可寫就緒。
如此可以實現單執行緒管理多個 channels,也就是可以管理多個網路連線。
使用 Selector 的好處在於: 相比傳統方式使用多個執行緒來管理 IO,Selector 使用了更少的執行緒就可以處理通道了,並且實現網路高效傳輸!
雖然 java 中的 nio 傳輸比較快,為什麼大家都不願意用 JDK 原生 NIO 進行開發呢?
從上面的程式碼中大家都可以看出來,除了程式設計複雜、程式設計模型難之外,還有幾個讓人詬病的問題:
JDK 的 NIO 底層由 epoll 實現,該實現飽受詬病的空輪詢 bug 會導致 cpu 飆升 100%!
專案龐大之後,自行實現的 NIO 很容易出現各類 bug,維護成本較高!
但是,Google 的 Netty 框架的出現,很大程度上改善了 JDK 原生 NIO 所存在的一些讓人難以忍受的問題,關於 Netty 框架,會在後期的文章裡進行介紹。
6.4.4、AIO
最後就是 AIO 了,全稱 Asynchronous I/O,可以理解為非同步 IO,也被稱為 NIO 2,在 Java 7 中引入了 NIO 的改進版 NIO 2,它是非同步非阻塞的 IO 模型,也就是我們現在所說的 AIO。
非同步 IO 是基於事件和回撥機制實現的,也就是應用操作之後會直接返回,不會堵塞在那裏,當後臺處理完成,作業系統會通知相應的執行緒進行後續的操作。
客戶端,程式示例:
服務端,程式示例:
同樣的,先啟動服務端程式,再啟動客戶端程式,看看執行結果!
服務端,執行結果如下:
客戶端端,執行結果如下:
這種組合方式用起來比較複雜,只有在一些非常複雜的分散式情況下使用,像叢集之間的訊息同步機制一般用這種 I/O 組合方式。如 Cassandra 的 Gossip 通訊機制就是採用非同步非阻塞的方式。
Netty 之前也嘗試使用過 AIO,不過又放棄了!
七、總結
本文闡述的內容較多,從 Java 基本 I/O 類庫結構開始說起,主要介紹了 IO 的傳輸格式和傳輸方式,以及磁碟 I/O 和網路 I/O 的基本工作方式。