IO輸入輸出模型是每個Java開發人員必須理解的重點

2020-02-16     道以致遠



前言

最近在跟公司新來的幾個做Java開發的年輕同事聊天時,發現他們很多人的基礎知識並不是很紮實,特別是關於Java對輸入/輸出處理的支持上,雖然在工作中經常用到,但是都是在憑記憶寫代碼,基本上沒有理解到位,為此我專門抽了半個小時時間給他們系統的串了一下要點,希望他們能夠將自己所學的瑣碎知識點和技能串連起來,對Java I/O這部分有一個更加深入的理解。

到底什麼是I/O?

說起計算機系統中的輸入輸出,我們在應用開發中用到的一般都是外部數據源與計算機中央處理單元之間的數據輸入和輸出。

我們編寫的大部分應用程式基本都會涉及到數據的輸入和輸出操作過程。 包括將數據從外部存儲讀取到處理器管理調度的內部存儲空間,以及將內部存儲空間的數據寫出到外部存儲系統。

當然除了外部存儲系統外,還有通過網絡連接的其它應用程式之間的通信,也存在著輸入和輸出這樣的過程,這樣的輸入一般都是其它應用通過網絡數據流將數據讀入內部存儲空間,或者將數據從內部緩衝空間寫出到網絡數據流中。

從硬體的角度來說,輸入輸出操作一般由CPU來完成,但是隨著硬體結構的發展,項各類存儲設備和網絡設備的出現,有了眾多的輸入輸出控制設備。

它們可以在沒有CPU參與的情況下直接通過數據總線跟系統的主內存進行數據的交互,我們稱之為直接內存訪問(DMA)技術。

在最初的Java編程中對於輸入輸出功能的支持是由CPU來執行的,該過程是CPU在執行數據輸入時將外部數據先讀入作業系統管理的內存緩衝區,然後在拷貝到我們應用程式運行進程管理的內存緩衝區,這個過程CPU是全程參與的,被稱為I/O導向型處理器。

也就是說處理該操作的線程不結束,CPU做不了其它事情。

當我們的作業系統支持DMA來進行輸入輸出的讀寫時,CPU對輸入輸出的處理就編程了響應就緒選擇事件並交由相對應的線程處理,真正的數據流讀寫則是由DMA控制器來完成。

這樣我們的CPU就不用再在數據輸入/輸出操作期間被獨占,而是被解放出來執行其它任務。



關於數據流

在數據輸入輸出描述中,我們抽象出了一個概念叫做流Stream, 簡單數來就是從一個點到另外一個點的數據有序流動,或者說是一個任意長度的有序位元組序列。

在Java編程中,我們為了更好的管理數據流動,將流分為輸入流和輸出流,並抽象了兩個接口定義InputStream和OutputStream來分別描述它們。

因為我們計算機底層對於數據處理的基本單位是位元組byte,所以我們數據流的基本單元也是比特,我們也稱這樣的數據流為位元組流。

每個數據流都有兩個端點,分別為數據源和數據目的地。通常它們可以是文件,網絡數據流等。

很多小夥伴在學習Java編程時,很容易被I/O這部分的一些列概念定義搞混了。因為所有的數據流都是以位元組流為基礎根據各類數據類型的定義進行的編碼處理後的結果。

在理解Java的I/O類型時,需要先了解一個設計模式,那就是裝飾器模式。

簡單來說,裝飾器模式是通過一個基礎的接口來描述所有接口約定,然後用基礎實現類和附加實現類結合共同實現該接口,如此一來我們就可以保留原有接口實現的功能,並且能夠通過實現類來提供附加處理功能,並提供統一的對外接口。

Java的輸入輸出流類型定義就遵循的這一模式,數據流的基礎實現ByteInputStream和ByteOutputStream作為基礎類,增加了繼承繼承接口的過濾功能接口和緩衝功能接口,通過它們的實現類我們可以對本來只能處理單個位元組的數據流類變為可以通過特定緩衝區來緩存一定數量的位元組後進行處理的BufferStream以及緩存後對整體數據添加條件過濾的BufferFilterStream。

進而我們根據各種數據類型的編碼規則,對基礎的位元組流進行編解碼處理,定義出了字符,整型,長整型,浮點型等能夠處理各基礎類型的數據流類型定義。



標準 輸入輸出

許多作業系統都支持這種標準輸入/輸出,它是在開始執行時,電腦程式和它運行環境之間預先連接的輸入輸出流。

這種預連接的流通常有標準輸入,標準輸出和標準錯誤流。

最常見的實現了編解碼功能的就是我們常說的標準流,Java編程中我們從java.system中能夠看到in,out,err等標準I/O流的定義。

標準輸入默認從鍵盤讀取它的輸入。

標準輸出和標準錯誤默認將它們的輸出到螢幕上。

數據流的分類

說到數據流就不得不說我們常見的數據流類型,通常我們處理的數據主要分為兩種類型,一種是基於文件存儲塊的塊類型數據流,另一種是基於網絡位元組流的流類型數據流。

而我們編寫的幾乎所有的應用程式都會或多或少的涉及到這兩種類似數據流的處理,當然也有極少數特殊的應用程式不需要我們操作數據流。

我們知道計算機作業系統中是以文件系統作為數據存儲和操作的基礎組件的,文件系統設計是基於數據存儲的物理磁碟結構來設計的,因為磁碟是以固定的分區塊來管理數據存取的。

所以基於文件的數據流都是面向塊操作的數據流,而像基於網絡訪問的數據流動則是面向位元組流的數據流。

很明顯面向塊的數據流處理要比面向流的數據處理要快速。



關於NIO模型

前面講到Java開發最初對於I/O的支持模式是單線路的,也就是說需要CPU全程參與數據讀取過程。每個數據輸入或者輸出的處理線程都需要CPU來阻塞執行。即一旦開始讀寫,CPU將等待其結束,然後才能進行其它任務執行。

隨著技術的發展,NIO模型的出現,該模型巧妙的設計了一個內存緩衝區和通道概念,將原來針對具體數據流的操作,封裝成了對於緩衝區的操作。並將所有操作都抽象到一個通道模型里。

其中引入的緩衝區概念Buffer是新一代輸入輸出操作模型的基礎,它本質上是一塊內存空間的抽象,並將其具體的操作指令化封裝給連接到它的通道Channel上。

由通道來封裝跟這內存區域關聯的數據源或者目的地。通道的read()和write()方法來觸發CPU對讀寫事件的響應處理。

具體就是在read()指令發出後,作業系統會將數據從數據源讀入到指定的緩衝區里,而當write()指令發出後,則會將連接到該通道的緩衝區數據排空出緩衝區到指定的數據目的地。

如此我們就不難理解,我們可以將基於任意數據源建立的流封裝成對應的數據通道,然後為通道指定連接的緩衝區,接下來就是發送相關指令來進行目標數據的操作了。

我們使用Java編寫的所有應用程式都是受到JVM進程管理的,所以跟作業系統的通信也是有JVM負責完成的。

JVM在執行I/O時,通過請求作業系統執行一個write操作排空緩衝區內容到存儲器,使用一個讀操作來從存儲設備讀取數據填充緩衝區空間。

假設我們的讀操作包含一個硬碟驅動步驟,作業系統會發布一個命令給硬碟控制器來從硬碟上讀取一個位元組塊到作業系統的緩衝區。

一旦這一操作完成,作業系統會拷貝其緩衝區的內容到由我們發起的read()操作的進程指定的緩衝區里。

如果我們的應用程式進程發布一個read()方法調用作業系統,那麼作業系統會請求硬碟控制器來從硬碟上讀取數據位元組塊。



DMA技術問題

這裡硬碟控制器就是通過前面提到的直接內存訪問技術(DMA)來將數據從硬碟讀取到作業系統的緩衝區里。

因為DMA能夠允許特定的硬體子系統獨立於CPU而直接訪問主系統內存。

其實,這種從作業系統緩衝區拷貝位元組到應用程式進程緩衝區並不是一個高效的方式。

如果讓DMA控制器直接將數據拷貝到進程緩衝區會更高效,但是我們在用它編程過程中會遇到兩個問題:

DMA控制器通常不能直接跟運行JVM進程的用戶空間進行交互,它只能跟作業系統的內核空間交互。

一般面向塊存儲的設備,其操作的數據塊是固定大小的,而我們應用程式的JVM進程可能需要的數據類型大小不一定就是存儲塊的倍數,這就會出現數據流類型的錯配。

所以,必須在直接訪問控制器的數據與具體應用程式的數據之間有一個適配過程,而這個過程就是由我們的作業系統來執行的。

通常我們的作業系統會在JVM進程和DMA控制器之間數據轉換時對數據進行分解和重組,使得它們之間能夠順暢的通信。



NIO三要素:緩衝器,通道,就緒選擇器

NIO使得CPU從過去的I/O導向型CPU工作中解放來,交給了各種可以直接進行內存訪問設備的控制器來負責輸入輸出工作。

而通道Channel概念的引入,使其跟設備控制器有更好的通信交流,來更加高效的向應用程式的緩衝區里填充數據或將應用程式的位元組緩衝區排空。

同樣的Java為我們提供了對這一概念的抽象接口定義Channel,作為封裝了基礎數據流操作的基礎接口。

我們常見的具體通道定義比如FileChannel,SocketChannel,DaragramChannel等,都是對具體類型流封裝後連接特定緩衝區的通道。


就緒選擇器

前面我們提到過,I/O可分為面向塊的或面向流的兩類。

從文件中讀取數據或者將數據寫入到文件中是一個面向塊的I/O實例。

相反的,從鍵盤讀取或者將數據寫入到網絡連接中的實例是一個面向流的I/O實例。

面向流的I/O通常比面向塊的I/O速度慢,此外,輸入往往是斷斷續續的,原因可能是用戶可能會在輸入字符流時暫停了,或者在網絡連接中出現短暫的慢速,導致播放視頻斷斷續續地進行。

現代作業系統在處理面向流數據處理時,我們不再像傳統的I/O模型那樣阻塞CPU等待輸入和輸出工作完成,而是採用非阻塞模式,CPU在啟動了設備控制器後就會轉而處理其它任務,而讓一個線程不斷的去輪詢其處理狀態,但是這種輪詢也是對CPU資源的浪費,特別是要同時監控多個輸入輸出工作運行狀態時。



此時就引入了一個新的概念叫做就緒選擇,而Java對此抽象出了Selector。

作業系統以非阻塞模式來監視所有流處理的集合,並返回一個指示,告訴線程哪些流已經準備就緒執行I/O。

這樣一來,我們可以使用單個線程通過共同的代碼進行多路管理活動的流。

這種多路復用模式肯能因為作業系統對其支持的不同而不同。

java.io為我們提供了Selector類實例,它可以用來檢測一個或者多個通道並決定哪個通道準備好讀或者寫操了。

這種單線程管理多個通道方式非常高效,能夠使用更少的線程,因為線程的創建和線程上下文切換都是在性能和內存使用方面非常昂貴的操作。

總結

本文是我關於Java輸入輸出模型理解的要點總結,從I/O基礎原理到我們java對其支持的編程模型設計,特別是NIO模型的重要概念的理解,做了一次系統的串連,由於數據的輸入輸出可以是貫穿著我們應用開發的始終,同時也是決定我們設計的應用程式性能好壞的關鍵環節,我們必須對其基礎的概念有一個深刻的理解和把握,我們才能設計出性能高效的應用程式。

由於是討論整理,所以有一些思維跳躍,成文不是很規範,請各位小夥伴諒解,希望本文能夠對初學java應用開發的同學有所幫助。



在這個抗疫關鍵的時刻,再次向奮戰在一線的所有英雄們致以我崇高的敬意!

武漢加油,中國一定勝!

文章來源: https://twgreatdaily.com/nov5TXEBrZ4kL1ViEu98.html