天天看點

I/O的整體介紹

 java的i/o操作類在包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 操作的都是位元組而不是字元,但是為啥有操作字元的 I/O 接口呢?這是因為我們的程式中通常操作的資料都是以字元形式,為了操作友善當然要提供一個直接寫字元的 I/O 接口,如此而已。我們知道字元到位元組必須要經過編碼轉換,而這個編碼又非常耗時,而且還會經常出現亂碼問題,是以 I/O 的編碼問題經常是讓人頭疼的問題。

位元組與字元的轉化接口:

InputStreamReader: 位元組到字元的轉化

OutputStreamWriter:字元到位元組的轉化

InputStream 到 Reader和Writer到OutputStream的過程要指定編碼字元集,否則将采用作業系統預設字元集,很可能會出現亂碼問題。StreamDecoder&StreamEncoder正是完成解碼和編碼的實作類。

資料在磁盤的唯一最小描述就是檔案,也就是說上層應用程式隻能通過檔案來操作磁盤上的資料,檔案也是作業系統和磁盤驅動器互動的一個最小單元。

Java 中通常的 File 并不代表一個真實存在的檔案對象,當你通過指定一個路徑描述符時,它就會傳回一個代表這個路徑相關聯的一個虛拟對象,這個可能是一個真實存在的檔案或者是一個包含多個檔案的目錄。何時真正會要檢查一個檔案存不存?就是在真正要讀取這個檔案時,例如 FileInputStream 類就是操作一個檔案的接口,注意到在建立一個 FileInputStream 對象時,會建立一個 FileDescriptor 對象,其實這個對象就是真正代表一個存在的檔案對象的描述,當我們在操作一個檔案對象時可以通過 getFD() 方法擷取真正操作的與底層作業系統關聯的檔案描述。例如可以調用 FileDescriptor.sync() 方法将作業系統緩存中的資料強制重新整理到實體磁盤中。

當傳入一個檔案路徑,将會根據這個路徑建立一個 File 對象來辨別這個檔案,然後将會根據這個 File 對象建立真正讀取檔案的操作對象,這時将會真正建立一個關聯真實存在的磁盤檔案的檔案描述符 FileDescriptor,通過這個對象可以直接控制這個磁盤檔案。由于我們需要讀取的是字元格式,是以需要 StreamDecoder 類将 byte 解碼為 char 格式。至于如何從磁盤驅動器上讀取一段資料,由作業系統幫我們完成。至于作業系統是如何将資料持久化到磁盤以及如何建立資料結構需要根據目前作業系統使用何種檔案系統來回答。

Socket 這個概念沒有對應到一個具體的實體,它是描述計算機之間完成互相通信一種抽象功能。打個比方,可以把 Socket 比作為兩個城市之間的交通工具,有了它,就可以在城市之間來回穿梭了。交通工具有多種,每種交通工具也有相應的交通規則。Socket 也一樣,也有多種。大部分情況下我們使用的都是基于 TCP/IP 的流套接字,它是一種穩定的通信協定。

下圖是典型的基于 Socket 的通信的場景:

I/O的整體介紹

主機 A 的應用程式要能和主機 B 的應用程式通信,必須通過 Socket 建立連接配接,而建立 Socket 連接配接必須需要底層 TCP/IP 協定來建立 TCP 連接配接。建立 TCP 連接配接需要底層 IP 協定來尋址網絡中的主機。我們知道網絡層使用的 IP 協定可以幫助我們根據 IP 位址來找到目标主機,但是一台主機上可能運作着多個應用程式,如何才能與指定的應用程式通信就要通過 TCP 或 UPD 的位址也就是端口号來指定。這樣就可以通過一個 Socket 執行個體唯一代表一個主機上的一個應用程式的通信鍊路了。

當用戶端要與服務端通信,用戶端首先要建立一個 Socket 執行個體,作業系統将為這個 Socket 執行個體配置設定一個沒有被使用的本地端口号,并建立一個包含本地和遠端位址和端口号的套接字資料結構,這個資料結構将一直儲存在系統中直到這個連接配接關閉。在建立 Socket 執行個體的構造函數正确傳回之前,将要進行 TCP 的三次握手協定,TCP 握手協定完成後,Socket 執行個體對象将建立完成,否則将抛出 IOException 錯誤。

服務端将建立一個 ServerSocket 執行個體,ServerSocket 建立比較簡單隻要指定的端口号沒有被占用,一般執行個體建立都會成功,同時作業系統也會為 ServerSocket 執行個體建立一個底層資料結構,這個資料結構中包含指定監聽的端口号和包含監聽位址的通配符,通常情況下都是“*”即監聽所有位址,之後當調用 accept() 方法時,将進入阻塞狀态,等待用戶端的請求。當一個新的請求到來時,将為這個連接配接建立一個新的套接字資料結構,該套接字資料的資訊包含的位址和端口資訊正是請求源位址和端口。這個新建立的資料結構将會關聯到 ServerSocket 執行個體的一個未完成的連接配接資料結構清單中,注意這時服務端與之對應的 Socket 執行個體并沒有完成建立,而要等到與用戶端的三次握手完成後,這個服務端的 Socket 執行個體才會傳回,并将這個 Socket 執行個體對應的資料結構從未完成清單中移到已完成清單中。是以 ServerSocket 所關聯的清單中每個資料結構,都代表與一個用戶端的建立的 TCP 連接配接。

當連接配接已經建立成功,服務端和用戶端都會擁有一個 Socket 執行個體,每個 Socket 執行個體都有一個 InputStream 和 OutputStream,正是通過這兩個對象來交換資料。同時我們也知道網絡 I/O 都是以位元組流傳輸的。當 Socket 對象建立時,作業系統将會為 InputStream 和 OutputStream 分别配置設定一定大小的緩沖區,資料的寫入和讀取都是通過這個緩存區完成的。寫入端将資料寫到 OutputStream 對應的 SendQ 隊列中,當隊列填滿時,資料将被發送到另一端 InputStream 的 RecvQ 隊列中,如果這時 RecvQ 已經滿了,那麼 OutputStream 的 write 方法将會阻塞直到 RecvQ 隊列有足夠的空間容納 SendQ 發送的資料。值得特别注意的是,這個緩存區的大小以及寫入端的速度和讀取端的速度非常影響這個連接配接的資料傳輸效率。

應用程式通常都需要通路磁盤讀取資料,而磁盤 I/O 通常都很耗時,我們要判斷 I/O 是否是一個瓶頸,我們有一些參數名額可以參考:

如我們可以壓力測試應用程式看系統的 I/O wait 名額是否正常,例如測試機器有 4 個 CPU,那麼理想的 I/O wait 參數不應該超過 25%,如果超過 25% 的話,I/O 很可能成為應用程式的性能瓶頸。Linux 作業系統下可以通過 iostat 指令檢視。

通常我們在判斷 I/O 性能時還會看另外一個參數就是 IOPS,我們應用程式需要最低的 IOPS 是多少,而我們的磁盤的 IOPS 能不能達到我們的要求。每個磁盤的 IOPS 通常是在一個範圍内,這和存儲在磁盤的資料塊的大小和通路方式也有關。但是主要是由磁盤的轉速決定的,磁盤的轉速越高磁盤的 IOPS 也越高。

現在為了提高磁盤 I/O 的性能,通常采用一種叫 RAID 的技術,就是将不同的磁盤組合起來來提高 I/O 性能,目前有多種 RAID 技術,每種 RAID 技術對 I/O 性能提升會有不同,可以用一個 RAID 因子來代表,磁盤的讀寫吞吐量可以通過 iostat 指令來擷取,于是我們可以計算出一個理論的 IOPS 值,計算公式如下是以:

( 磁盤數 * 每塊磁盤的 IOPS)/( 磁盤讀的吞吐量 +RAID 因子 * 磁盤寫的吞吐量 )=IOPS

提升磁盤 I/O 性能通常的方法有:

增加緩存,減少磁盤通路次數。

優化磁盤的管理系統,設計最優的磁盤通路政策,以及磁盤的尋址政策,這裡是在底層作業系統層面考慮的。

設計合理的磁盤存儲資料塊,以及通路這些資料塊的政策,這裡是在應用層面考慮的。如我們可以給存放的資料設計索引,通過尋址索引來加快和減少磁盤的通路,還有可以采用異步和非阻塞的方式加快磁盤的通路效率。

應用合理的 RAID 政策提升磁盤 IO,每種 RAID 的差別我們可以用下表所示:

I/O的整體介紹

網絡 I/O 優化通常有一些基本處理原則:

一個是減少網絡互動的次數:要減少網絡互動的次數通常我們在需要網絡互動的兩端會設定緩存,比如 Oracle 的 JDBC 驅動程式,就提供了對查詢的 SQL 結果的緩存,在用戶端和資料庫端都有,可以有效的減少對資料庫的通路。關于 Oracle JDBC 的記憶體管理可以參考《 Oracle JDBC 記憶體管理》。除了設定緩存還有一個辦法是,合并通路請求:如在查詢資料庫時,我們要查 10 個 id,我可以每次查一個 id,也可以一次查 10 個 id。再比如在通路一個頁面時通過會有多個 js 或 css 的檔案,我們可以将多個 js 檔案合并在一個 HTTP 連結中,每個檔案用逗号隔開,然後發送到後端 Web 伺服器根據這個 URL 連結,再拆分出各個檔案,然後打包再一并發回給前端浏覽器。這些都是常用的減少網絡 I/O 的辦法。

減少網絡傳輸資料量的大小:減少網絡資料量的辦法通常是将資料壓縮後再傳輸,如 HTTP 請求中,通常 Web 伺服器将請求的 Web 頁面 gzip 壓縮後在傳輸給浏覽器。還有就是通過設計簡單的協定,盡量通過讀取協定頭來擷取有用的價值資訊。比如在代理程式設計時,有 4 層代理和 7 層代理都是來盡量避免要讀取整個通信資料來取得需要的資訊。

盡量減少編碼:通常在網絡 I/O 中資料傳輸都是以位元組形式的,也就是通常要序列化。但是我們發送要傳輸的資料都是字元形式的,從字元到位元組必須編碼。但是這個編碼過程是比較耗時的,是以在要經過網絡 I/O 傳輸時,盡量直接以位元組形式發送。也就是盡量提前将字元轉化為位元組,或者減少字元到位元組的轉化過程。

根據應用場景設計合适的互動方式:所謂的互動場景主要包括同步與異步阻塞與非阻塞方式。

所謂同步就是一個任務的完成需要依賴另外一個任務時,隻有等待被依賴的任務完成後,依賴的任務才能算完成,這是一種可靠的任務序列。要麼成功都成功,失敗都失敗,兩個任務的狀态可以保持一緻。而異步是不需要等待被依賴的任務完成,隻是通知被依賴的任務要完成什麼工作,依賴的任務也立即執行,隻要自己完成了整個任務就算完成了。至于被依賴的任務最終是否真正完成,依賴它的任務無法确定,是以它是不可靠的任務序列。我們可以用打電話和發短信來很好的比喻同步與異步操作。

在設計到 IO 處理時通常都會遇到一個是同步還是異步的處理方式的選擇問題。因為同步與異步的 I/O 處理方式對調用者的影響很大,在資料庫産品中都會遇到這個問題。因為 I/O 操作通常是一個非常耗時的操作,在一個任務序列中 I/O 通常都是性能瓶頸。但是同步與異步的處理方式對程式的可靠性影響非常大,同步能夠保證程式的可靠性,而異步可以提升程式的性能,必須在可靠性和性能之間做個平衡,沒有完美的解決辦法。

阻塞與非阻塞主要是從 CPU 的消耗上來說的,阻塞就是 CPU 停下來等待一個慢的操作完成 CPU 才接着完成其它的事。非阻塞就是在這個慢的操作在執行時 CPU 去幹其它别的事,等這個慢的操作完成時,CPU 再接着完成後續的操作。雖然表面上看非阻塞的方式可以明顯的提高 CPU 的使用率,但是也帶了另外一種後果就是系統的線程切換增加。增加的 CPU 使用時間能不能補償系統的切換成本需要好好評估。

組合的方式可以由四種,分别是:同步阻塞、同步非阻塞、異步阻塞、異步非阻塞,這四種方式都對 I/O 性能有影響。

I/O的整體介紹