Java 的 I/O 類庫的基本架構
Java 的 I/O 操作類在包 java.io 下,有將近 80 個類。
按數據格式分類:
- 面向字節(Byte)操作的 I/O 接口:InputStream 和 OutputStream
- 面向字符(Character)操作的 I/O 接口:Writer 和 Reader
按作用位置分類:
- 基於磁盤操作的 I/O 接口:File
- 基於網絡操作的 I/O 接口:Socket(不在java.io中)
1. IO數據格式
(1)面向字節:操作以8位為單位對二進制數據進行操作,不對數據進行轉換。這些類都是InputStream 和 OutputStream的子類。以InputStream/OutputStream為后綴的類都是字節流,可以處理所有類型的數據。
(2)面向字符:操作以字符為單位,讀時將二進制數據轉換為字符,寫時將字符轉換為二進制數據Writer 和 Reader的子類,以Writer/Reader為后綴的都是字符流。
硬盤上所有的文件都是以字節形式保存,字符只在內存中才會形成。即只在處理純文本文件時,優先考慮使用字符流,除此之外都用字節流。
InputStream 相關類層次結構:(OutputStream類似)
Writer 相關類層次結構:(Reader類似)
其中:
字符流:
- InputStreamReader/OutputStreamWriter 是字節流轉化為字符流的橋轉換器。
- BufferReader/BufferWriter 逐行讀寫流,可用於較大的文本文件。是過濾流,需要用其他的節點流做參數構造對象。
字節流:
- FileInputStream/FileOutputStream 文件輸入輸出流。
- PipedInputStream/PipedOutputStream 管道里,線程交互時使用。
- ObjectInputStream/ObjectOutputStream 對象流,實現對象序列化
讀寫操作實例:
/** * 使用FileReader進行讀取文件,然后FileWriter寫入另一個文件 */ @Test public void testFileReaderAndFileWriter() throws IOException { FileReader fileReader = new FileReader("h:\\haha.txt"); char[] buff = new char[512]; StringBuffer stringBuffer = new StringBuffer(); while (fileReader.read(buff) > 0) { stringBuffer.append(buff); } System.out.println(stringBuffer.toString()); FileWriter fileWriter = new FileWriter("h:\\haha2.txt"); fileWriter.write(stringBuffer.toString().trim()); fileWriter.close(); System.out.println("寫入文件成功"); } /** * 使用InputStreamReader進行讀取文件,然后用OutputStreamWriter寫入文件 */ @Test public void testInputStreamReader() throws IOException { //操作數據的方式是可以組合的,此處FileInputStream讀出的字節流用InputStreamReader轉化為了字符流對象 InputStreamReader inputStreamReader = new InputStreamReader(new FileInputStream("h:\\haha.txt"), "utf-8"); char[] buff = new char[512]; StringBuffer stringBuffer = new StringBuffer(); while (inputStreamReader.read(buff) > 0) { stringBuffer.append(buff); } System.out.println(stringBuffer.toString());
//寫文件時,要指定寫入的地方(網絡或本地)、路徑 OutputStreamWriter outputStreamWriter = new OutputStreamWriter(new FileOutputStream("h:\\haha2.txt"), "utf-8"); outputStreamWriter.write(stringBuffer.toString().trim()); outputStreamWriter.close(); } @Test public void testIntputStream2() throws IOException { InputStreamReader inputStreamReader = new InputStreamReader(new StringBufferInputStream("hello world")); char[] buff = new char[512]; int n = inputStreamReader.read(buff); System.out.println(n); System.out.println(buff); }
FileReader類繼承了InputStreamReader,FileReader讀取文件流,通過StreamDecoder解碼成char,其解碼字符集使用的是默認字符集。在Java中,我們應該使用File對象來判斷某個文件是否存在,如果我們用FileOutputStream或者FileWriter打開,那么它肯定會被覆蓋。
2. IO發生位置
(1) 磁盤IO工作機制
前面介紹了基本的 Java I/O 的操作接口,這些接口主要定義了如何操作數據,以及介紹了操作兩種數據結構:字節和字符的方式。還有一個關鍵問題就是數據寫到何處,其中一個主要方式就是將數據持久化到物理磁盤,下面將介紹如何將數據持久化到物理磁盤的過程。
數據在磁盤的唯一最小描述是文件,也就是說上層應用程序只能通過文件來操作磁盤上的數據,文件也是操作系統和磁盤驅動器交互的一個最小單元。值得注意的是 Java 中通常的 File 並不代表一個真實存在的文件對象,當你通過指定一個路徑描述符時,它就會返回一個代表這個路徑相關聯的一個虛擬對象,這個可能是一個真實存在的文件或者是一個包含多個文件的目錄。
何時真正會要檢查一個文件存不存?就是在真正要讀取這個文件時,例如 FileInputStream 類都是操作一個文件的接口,注意到在創建一個 FileInputStream 對象時,會創建一個 FileDescriptor 對象,其實這個對象就是真正代表一個存在的文件對象的描述,當我們在操作一個文件對象時可以通過 getFD() 方法獲取真正操作的與底層操作系統關聯的文件描述。例如可以調用 FileDescriptor.sync() 方法將操作系統緩存中的數據強制刷新到物理磁盤中。
從磁盤讀取文件過程:
當傳入一個文件路徑,將會根據這個路徑創建一個 File 對象來標識這個文件,然后將會根據這個 File 對象創建真正讀取文件的操作對象,這時將會真正創建一個關聯真實存在的磁盤文件的文件描述符 FileDescriptor,通過這個對象可以直接控制這個磁盤文件。由於我們需要讀取的是字符格式,所以需要 StreamDecoder 類將 byte 解碼為 char 格式,至於如何從磁盤驅動器上讀取一段數據,由操作系統幫我們完成。
(2)網絡IO工作機制(Socket)
Socket 描述計算機之間完成相互通信一種抽象功能。Socket 也一樣有多種,大部分情況下我們使用的都是基於 TCP/IP 的流套接字,它是一種穩定的通信協議。
下典型的基於 Socket 的通信的場景:
主機 A 的應用程序要能和主機 B 的應用程序通信,必須通過 Socket 建立連接,而建立 Socket 連接必須需要底層 TCP/IP 協議來建立 TCP 連接。建立 TCP 連接需要底層 IP 協議來尋址網絡中的主機。我們知道網絡層使用的 IP 協議可以幫助我們根據 IP 地址來找到目標主機,但是一台主機上可能運行着多個應用程序,如何才能與指定的應用程序通信就要通過 TCP 或 UPD 的地址也就是端口號來指定。這樣就可以通過一個 Socket 實例唯一代表一個主機上的一個應用程序的通信鏈路了。
(TCP/UDP:找端口號,從而與應用程序通信。IP:找主機)
建立通信鏈路
當客戶端要與服務端通信,客戶端首先要創建一個 Socket 實例,操作系統將為這個 Socket 實例分配一個沒有被使用的本地端口號,並創建一個包含本地和遠程地址和端口號的套接字數據結構,這個數據結構將一直保存在系統中直到這個連接關閉。在創建 Socket 實例的構造函數正確返回之前,將要進行 TCP 的三次握手協議,TCP 握手協議完成后,Socket 實例對象將創建完成,否則將拋出 IOException 錯誤。
與之對應的服務端將創建一個 ServerSocket 實例,ServerSocket 創建比較簡單只要指定的端口號沒有被占用,一般實例創建都會成功,同時操作系統也會為 ServerSocket 實例創建一個底層數據結構,這個數據結構中包含指定監聽的端口號和包含監聽地址的通配符,通常情況下都是“*”即監聽所有地址。之后當調用 accept() 方法時,將進入阻塞狀態,等待客戶端的請求。當一個新的請求到來時,將為這個連接創建一個新的套接字數據結構,該套接字數據的信息包含的地址和端口信息正是請求源地址和端口。這個新創建的數據結構將會關聯到 ServerSocket 實例的一個未完成的連接數據結構列表中,注意這時服務端與之對應的 Socket 實例並沒有完成創建,而要等到與客戶端的三次握手完成后,這個服務端的 Socket 實例才會返回,並將這個 Socket 實例對應的數據結構從未完成列表中移到已完成列表中。所以 ServerSocket 所關聯的列表中每個數據結構,都代表與一個客戶端的建立的 TCP 連接。
數據傳輸
傳輸數據是我們建立連接的主要目的,如何通過 Socket 傳輸數據:
當連接已經建立成功,服務端和客戶端都會擁有一個 Socket 實例,每個 Socket 實例都有一個 InputStream 和 OutputStream,正是通過這兩個對象來交換數據。同時我們也知道網絡 I/O 都是以字節流傳輸的。當 Socket 對象創建時,操作系統將會為 InputStream 和 OutputStream 分別分配一定大小的緩沖區,數據的寫入和讀取都是通過這個緩存區完成的。寫入端將數據寫到 OutputStream 對應的 SendQ 隊列中,當隊列填滿時,數據將被發送到另一端 InputStream 的 RecvQ 隊列中,如果這時 RecvQ 已經滿了,那么 OutputStream 的 write 方法將會阻塞直到 RecvQ 隊列有足夠的空間容納 SendQ 發送的數據。值得特別注意的是,這個緩存區的大小以及寫入端的速度和讀取端的速度非常影響這個連接的數據傳輸效率,由於可能會發生阻塞,所以網絡 I/O 與磁盤 I/O 在數據的寫入和讀取還要有一個協調的過程,如果兩邊同時傳送數據時可能會產生死鎖,在后面 NIO 部分將介紹避免這種情況。
3. IO調優
提升磁盤 I/O 性能通常的方法:
- 增加緩存,減少磁盤訪問次數
- 優化磁盤的管理系統,設計最優的磁盤訪問策略,以及磁盤的尋址策略(在底層操作系統層面考慮)
- 設計合理的磁盤存儲數據塊,以及訪問這些數據塊的策略(在應用層面考慮)。如我們可以給存放的數據設計索引,通過尋址索引來加快和減少磁盤的訪問,還有可以采用異步和非阻塞的方式加快磁盤的訪問效率。
- 應用合理的 RAID 策略提升磁盤 IO
網絡 I/O 優化通常有一些基本處理原則:
- 減少網絡交互次數:1)在需要網絡交互的兩端會設置緩存,比如 Oracle 的 JDBC 驅動程序提供了對查詢的 SQL 結果的緩存,在客戶端和數據庫端都有,可以有效的減少對數據庫的訪問。2)合並訪問請求:如在查詢數據庫時,我們要查 10 個 id,我可以每次查一個 id,也可以一次查 10 個 id。再比如在訪問一個頁面時通過會有多個 js 或 css 的文件,我們可以將多個 js 文件合並在一個 HTTP 鏈接中,每個文件用逗號隔開,然后發送到后端 Web 服務器根據這個 URL 鏈接,再拆分出各個文件,然后打包再一並發回給前端瀏覽器。這些都是常用的減少網絡 I/O 的辦法。
- 減少網絡傳輸數據量的大小:減少網絡數據量的辦法通常是將數據壓縮后再傳輸,如 HTTP 請求中,通常 Web 服務器將請求的 Web 頁面 gzip 壓縮后在傳輸給瀏覽器。還有就是通過設計簡單的協議,盡量通過讀取協議頭來獲取有用的價值信息。
- 盡量減少編碼:通常在網絡 I/O 中數據傳輸都是以字節形式的,也就是通常要序列化。但是我們發送要傳輸的數據都是字符形式的,從字符到字節必須編碼。但是這個編碼過程是比較耗時的,所以在要經過網絡 I/O 傳輸時,盡量直接以字節形式發送。也就是盡量提前將字符轉化為字節,或者減少字符到字節的轉化過程。
- 根據應用場景設計合適的交互方式:所謂的交互場景主要包括同步與異步、阻塞與非阻塞方式。
參考鏈接:https://www.ibm.com/developerworks/cn/java/j-lo-javaio/