解密:曾經常問你 Java IO的人,爲何現在只說我會了?

一、簡介

說到 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 只是人與機器或者機器與機器交互的手段,除了在它們能夠完成這個交互功能外,我們關注的就是如何提高它的運行效率了,而數據格式傳輸方式是影響效率最關鍵的因素。

本文後面,也是基於這兩個點進行深入展開分析。

BATJ、字節跳動面試專題,算法專題,高端技術專題,混合開發專題,java面試專題,Android,Java小知識,到性能優化.線程.View.OpenCV.NDK,flutter,kotlin等等已經上傳到了的我的GitHub

我的GitHub學習地址:https://github.com/Meng997998/AndroidJX

二、基於字節操作的接口

基於字節的輸入和輸出操作接口分別是:InputStream 和 OutputStream 。

2.1、字節輸入流

InputStream 輸入流的類繼承層次如下圖所示:

輸入流根據數據節點類型和處理方式,分別可以劃分出了若干個子類,如下圖:

OutputStream 輸出流的類層次結構也是類似。

2.2、字節輸出流

OutputStream 輸出流的類繼承層次如下圖所示:

輸出流根據數據節點類型和處理方式,也分別可以劃分出了若干個子類,如下圖:

在這裏就不詳細的介紹各個子類的使用方法,有興趣的朋友可以查看 JDK 的 API 說明文檔,筆者也會在後期的文章會進行詳細的介紹,這裏只是重點想說一下,無論是輸入還是輸出,操作數據的方式可以組合使用,各個處理流的類並不是只操作固定的節點流,比如如下輸出方式:

//將文件輸出流包裝到序列化輸出流中,再將序列化輸出流包裝到緩衝中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 的基本工作方式。

本篇文章主要對 Java 的 IO 體系以及計算機部分網絡基礎知識做了些簡單的介紹,其實每一個模塊涉及到的知識都非常非常多,在後期的文章中,會對各個模塊進行詳細的介紹,如果有理解不到的位置,歡迎指出!

發佈了120 篇原創文章 · 獲贊 59 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章