一篇帶你徹底讀懂 IO 流技術(shù)!
一、摘要
說(shuō)到 IO,相信大家都不陌生,英文全稱:Input/Output,即輸入/輸出,通常指數(shù)據(jù)在內(nèi)部存儲(chǔ)器和外部存儲(chǔ)器或其他周邊設(shè)備之間的輸入和輸出。
比如我們常用的SD卡、U盤、移動(dòng)硬盤等等存儲(chǔ)文件的硬件設(shè)備,當(dāng)我們將其插入電腦的 usb 硬件接口時(shí),我們就可以從電腦中讀取設(shè)備中的信息或者寫入信息,這個(gè)過(guò)程就涉及到 I/O 的操作。
當(dāng)然,涉及 I/O 的操作,也不僅僅局限于硬件設(shè)備的讀寫,還有網(wǎng)絡(luò)數(shù)據(jù)的傳輸。比如,我們?cè)陔娔X上用瀏覽器搜索互聯(lián)網(wǎng)上的信息,這個(gè)信息的過(guò)程也涉及到 I/O 的操作。
無(wú)論是從磁盤中讀寫文件,還是在網(wǎng)絡(luò)中傳輸數(shù)據(jù),可以說(shuō) I/O 主要為處理人機(jī)交互、機(jī)與機(jī)交互中獲取和交換信息提供的一套解決方案。
在 Java 的 IO 體系中,類將近有 80 個(gè),位于java.io包下,初步看起來(lái)感覺(jué)非常復(fù)雜,但是經(jīng)過(guò)一番梳理之后,你會(huì)發(fā)現(xiàn)還是有規(guī)律可循的。
從傳輸數(shù)據(jù)的格式角度看,可以大致分為兩組:
- 基于字節(jié)操作的 I/O 接口:InputStream 和 OutputStream
- 基于字符操作的 I/O 接口:Reader 和 Writer
從傳輸數(shù)據(jù)的方式角度看,也可以大致分為兩組:
- 基于磁盤操作的 I/O 接口:File
- 基于網(wǎng)絡(luò)操作的 I/O 接口:Socket
雖然 Socket 類并不在java.io包下,但是我們?nèi)匀话阉鼈儎澐衷谝黄穑驗(yàn)?I/O 的核心問(wèn)題,要么是數(shù)據(jù)格式影響 I/O 操作,要么是傳輸方式影響 I/O 操作,也就是將什么樣的數(shù)據(jù)寫到什么地方的問(wèn)題。
I/O 只是人與機(jī)器或者機(jī)器與機(jī)器交互的手段,除了在它們能夠完成這個(gè)交互功能外,我們關(guān)注的就是如何提高它的運(yùn)行效率,而數(shù)據(jù)格式和傳輸方式是影響效率最關(guān)鍵的因素。
下面我們基于這兩點(diǎn),來(lái)展開(kāi)分析!
二、傳輸格式的分類
從傳輸格式角度看,可以分兩類:字節(jié)流和字符流。
- 基于字節(jié)的輸入和輸出操作接口分別是:InputStream 和 OutputStream
- 基于字符的輸入和輸出操作接口分別是:Reader 和 Writer 。
2.1、字節(jié)流接口
字節(jié)流,是 I/O 流中最底層的流,能處理任何類型的數(shù)據(jù)傳輸,比如文字、圖片、視頻、文件等。
2.1.1、基于字節(jié)輸入流的接口
打開(kāi) JDK 源碼,整理之后,InputStream 輸入流接口的類繼承層次如下圖所示:
這些輸入流類,根據(jù)角色不同,還可以進(jìn)行分類,分為:節(jié)點(diǎn)流和處理流。
- 節(jié)點(diǎn)流:指的是向指定的設(shè)備,比如磁盤、網(wǎng)絡(luò),進(jìn)行讀/寫數(shù)據(jù),也被稱為底層流,直接和數(shù)據(jù)源相接
- 處理流:指的是在已存在的節(jié)點(diǎn)流或者處理流基礎(chǔ)上,包裝一些更加方便操作 io 流的功能,比如壓縮、序列化、緩沖操作等,也被稱為包裝流
輸入流類,根據(jù)角色的劃分類別如下:
OutputStream 輸出流的類層次結(jié)構(gòu)也是類似。
2.1.2、基于字節(jié)輸出流的接口
OutputStream 輸入流接口的類繼承層次如下圖所示:
字節(jié)輸出流類,根據(jù)角色的劃分類別如下:
這里就不詳細(xì)的介紹各個(gè)子類的使用方法,有興趣的朋友可以查看 JDK 的 API 說(shuō)明文檔,筆者也會(huì)在后期的系列文章會(huì)進(jìn)行詳細(xì)的介紹。
這里只是重點(diǎn)想說(shuō)一下,無(wú)論是輸入還是輸出,操作數(shù)據(jù)的方式可以組合使用,各個(gè)處理流的類并不是只操作固定的節(jié)點(diǎn)流,比如如下輸出方式:
//將文件輸出流包裝到序列化輸出流中,再將序列化輸出流包裝到緩沖中
OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream(new File("fileName")));
另外,輸出流最終寫到什么地方必須要指定,要么是寫到硬盤中,要么是寫到網(wǎng)絡(luò)中,從圖中可以發(fā)現(xiàn),寫網(wǎng)絡(luò)實(shí)際上也是寫文件,只不過(guò)寫到網(wǎng)絡(luò)中,需要經(jīng)過(guò)底層操作系統(tǒng)將數(shù)據(jù)發(fā)送到其他指定的計(jì)算機(jī)中,而不是寫入到本地硬盤中。
2.2、字符流接口
不管是磁盤還是網(wǎng)絡(luò)傳輸,最小的存儲(chǔ)單元都是字節(jié),而不是字符,所以 I/O 操作的都是字節(jié)而不是字符。
那為什么要有操作字符的 I/O 接口呢?
這是因?yàn)槲覀兊某绦蛑型ǔ2僮鞯臄?shù)據(jù)都是以字符形式,為了程序操作更方便而提供一個(gè)直接寫字符的 I/O 接口,僅此而已!
除此之外,使用字節(jié)流操控文字時(shí)不是很方便,容易亂碼,由此誕生了不同的字符集以及對(duì)應(yīng)的字符編碼規(guī)則!
由于全世界的文字博大精深,不同的字符集,占用的字節(jié)位數(shù)不同,以中文為例,在GBK編碼規(guī)則中,一個(gè)中文使用二個(gè)字節(jié)存儲(chǔ);而在UTF-8編碼規(guī)則中,一個(gè)中文使用三個(gè)字節(jié)存儲(chǔ),如果寫入和讀取的編碼規(guī)則不一樣,讀取的字節(jié)數(shù)很容易裂開(kāi),導(dǎo)致出現(xiàn)亂碼。
比如以下案例:
public static void main(String[] args) throws Exception {
byte[] bytes = "學(xué)習(xí)Java語(yǔ)言".getBytes("ISO8859-1");
File file = new File("encoding.txt");
OutputStream out = new FileOutputStream(file);
out.write(bytes);
out.close();
}
文件的內(nèi)容如下:
??Java??
為了更方便地處理中文這些字符,計(jì)算機(jī)就推出了字符編碼規(guī)則。
實(shí)現(xiàn)原理:字節(jié)流 + 編碼表。
- 當(dāng)寫入一段文字時(shí),會(huì)使用指定的字符集,將該 String 編碼為一系列字節(jié),將結(jié)果存儲(chǔ)到新的字節(jié)數(shù)組中,進(jìn)行傳輸
- 當(dāng)讀取一段文字時(shí),通過(guò)指定的字符集,解碼指定的字節(jié)數(shù)組來(lái)構(gòu)造新的 String,從而解決文字亂碼的問(wèn)題。
2.2.1、基于字符輸入流的接口
Reader 輸入流接口的類繼承層次如下圖所示:
同樣的,字符輸入流類,根據(jù)角色的劃分類別如下:
2.2.2、基于字符輸出流的接口
Writer 輸出流的類繼承層次如下圖所示:
字符輸出流類,根據(jù)角色的劃分類別如下:
2.3、字節(jié)與字符的轉(zhuǎn)化
剛剛我們說(shuō)到,不管是磁盤還是網(wǎng)絡(luò)傳輸,最小的存儲(chǔ)單元都是字節(jié),而不是字符,設(shè)計(jì)字符的原因是為了程序更方便的操作文本。
那么怎么將字符轉(zhuǎn)化成字節(jié)或者將字節(jié)轉(zhuǎn)化成字符呢?
其中,InputStreamReader和OutputStreamWriter就是轉(zhuǎn)化橋梁。
2.3.1、輸入流轉(zhuǎn)換方案
輸入流字符解碼相關(guān)類結(jié)構(gòu)的轉(zhuǎn)化過(guò)程如下圖所示:
從圖上可以看到,InputStreamReader類是字節(jié)到字符的轉(zhuǎn)化橋梁, 其中StreamDecoder指的是一個(gè)解碼操作類,Charset指的是字符集。
InputStream到Reader的過(guò)程需要指定編碼字符集,否則將采用操作系統(tǒng)默認(rèn)字符集,很可能會(huì)出現(xiàn)亂碼問(wèn)題,StreamDecoder則是完成字節(jié)到字符的解碼的實(shí)現(xiàn)類。
案例如下:
File file = new File("encoding.txt");
FileInputStream inputStream =new FileInputStream(file);
//字節(jié)輸入流轉(zhuǎn)為字符輸入流
InputStreamReader streamReader =new InputStreamReader(inputStream, Charset.forName("UTF-8"));
2.3.2、輸出流轉(zhuǎn)換方案
輸出流轉(zhuǎn)化過(guò)程也是類似,如下圖所示:
通過(guò)OutputStreamWriter類完成字符到字節(jié)的編碼過(guò)程,由StreamEncoder 完成編碼過(guò)程。
案例如下:
File file = new File("output.txt");
FileOutputStream outputStream =new FileOutputStream(file);
//字符輸出流轉(zhuǎn)字節(jié)輸出流
OutputStreamWriter streamWriter =new OutputStreamWriter(outputStream, Charset.forName("UTF-8"));
三、傳輸方式的分類
上文我們介紹了數(shù)據(jù)的傳輸格式,可以通過(guò)字節(jié)流和字符流接口來(lái)完成數(shù)據(jù)的傳輸,至于數(shù)據(jù)寫到何處,主要取決于數(shù)據(jù)的傳輸方式。
從傳輸方式角度看,可以分兩類:磁盤和網(wǎng)絡(luò)。
- 基于磁盤操作的操作接口是:File
- 基于網(wǎng)絡(luò)操作的操作接口是:Socket
3.1、文件接口
我們知道數(shù)據(jù)在磁盤的唯一最小描述就是文件,也就是說(shuō)上層應(yīng)用程序只能通過(guò)文件來(lái)操作磁盤上的數(shù)據(jù),文件也是操作系統(tǒng)和磁盤驅(qū)動(dòng)器交互的一個(gè)最小單元。
在 Java I/O 體系中,**File類是唯一代表磁盤文件本身的對(duì)象**。
File 類定義了一些與平臺(tái)無(wú)關(guān)的方法來(lái)操作文件,包括檢查一個(gè)文件是否存在、創(chuàng)建、刪除文件、重命名文件、判斷文件的讀寫權(quán)限是否存在、設(shè)置和查詢文件的最近修改時(shí)間等等操作。
值得注意的是 Java 中通常的 File 并不代表一個(gè)真實(shí)存在的文件對(duì)象,當(dāng)你通過(guò)指定一個(gè)路徑描述符時(shí),它就會(huì)返回一個(gè)代表這個(gè)路徑相關(guān)聯(lián)的一個(gè)虛擬對(duì)象,這個(gè)可能是一個(gè)真實(shí)存在的文件或者是一個(gè)包含多個(gè)文件的目錄。
例如,讀取一個(gè)文件內(nèi)容,程序如下:
public static void main(String[] args) throws Exception {
StringBuilder str = new StringBuilder();
char[] buf = new char[1024];
// 讀取文件的內(nèi)容
FileReader f = new FileReader("input.txt");
while(f.read(buf)>0){
str.append(buf);
}
str.toString();
}
以上面的程序?yàn)槔瑥挠脖P中讀取一段文本字符,操作流程如下圖:
當(dāng)我們傳入一個(gè)指定的文件名來(lái)創(chuàng)建File對(duì)象,通過(guò)FileReader來(lái)讀取文件內(nèi)容時(shí),會(huì)自動(dòng)創(chuàng)建一個(gè)FileInputStream對(duì)象來(lái)讀取文件內(nèi)容,也就是我們上文中所說(shuō)的字節(jié)流來(lái)讀取文件。
緊接著,會(huì)創(chuàng)建一個(gè)FileDescriptor的對(duì)象,其實(shí)這個(gè)對(duì)象就是真正代表一個(gè)存在的文件對(duì)象的描述。
由于我們需要讀取的是字符格式,所以需要StreamDecoder類通過(guò)解碼方法decode,將字節(jié)轉(zhuǎn)字符,至于如何從磁盤驅(qū)動(dòng)器上讀取一段數(shù)據(jù),由操作系統(tǒng)幫我們完成。
3.2、網(wǎng)絡(luò)接口
繼續(xù)來(lái)說(shuō)說(shuō)數(shù)據(jù)傳輸?shù)牧硪环N處理方式:網(wǎng)絡(luò)通信。
3.2.1、Socket 簡(jiǎn)介
在 Java 網(wǎng)絡(luò)體系中,Socket是描述計(jì)算機(jī)之間完成相互通信一種抽象定義。
光從描述看可能很難理解,打個(gè)比方,可以把Socket比作為兩個(gè)城市之間的交通工具,有了它,就可以在城市之間來(lái)回穿梭了;并且,交通工具有多種,每種交通工具也有相應(yīng)的交通規(guī)則。
Socket 也一樣,也有多種,大部分情況下我們使用的都是基于 TCP/IP 的流套接字,它是一種穩(wěn)定的通信協(xié)議。
比較典型的基于 Socket 通信的應(yīng)用程序場(chǎng)景,如下圖:
主機(jī) A 的應(yīng)用程序要想和主機(jī) B 的應(yīng)用程序通信,必須通過(guò) Socket 建立連接,而建立 Socket 連接必須需要底層 TCP/IP 協(xié)議來(lái)建立 TCP 連接。
3.2.2、建立通信鏈路
我們知道網(wǎng)絡(luò)層使用的 IP 協(xié)議可以幫助我們根據(jù) IP 地址來(lái)找到目標(biāo)主機(jī),但是一臺(tái)主機(jī)上可能運(yùn)行著多個(gè)應(yīng)用程序,如何才能與指定的應(yīng)用程序通信呢?
這個(gè)時(shí)候需要通過(guò) TCP 或 UPD 協(xié)議,也就是指定對(duì)應(yīng)的端口號(hào)。
通過(guò) IP + 端口號(hào),就可以創(chuàng)建一個(gè)代表唯一一個(gè)主機(jī)上的一個(gè)應(yīng)用程序的通信鏈路了,創(chuàng)建后的通信鏈路我們稱它為 Socket 實(shí)例。
以 TCP 協(xié)議為例,為了準(zhǔn)確無(wú)誤地把數(shù)據(jù)送達(dá)目標(biāo)處,TCP 協(xié)議采用了三次握手策略,如下圖:
其中,SYN 全稱為 Synchronize Sequence Numbers,表示同步序列編號(hào),是 TCP/IP 建立連接時(shí)使用的握手信號(hào)。
ACK 全稱為 Acknowledge character,即確認(rèn)字符,表示發(fā)來(lái)的數(shù)據(jù)已確認(rèn)接收無(wú)誤。
在客戶機(jī)和客戶機(jī)之間建立正常的 TCP 網(wǎng)絡(luò)連接時(shí),發(fā)送端首先發(fā)出一個(gè) SYN 消息,接收端使用 SYN + ACK 應(yīng)答表示接收到了這個(gè)消息,最后發(fā)送端再以 ACK 消息響應(yīng)。
整體流程如下:
- 發(fā)送端 –(發(fā)送帶有 SYN 標(biāo)志的數(shù)據(jù)包 )–> 接受端(第一次握手);
- 接受端 –(發(fā)送帶有 SYN + ACK 標(biāo)志的數(shù)據(jù)包)–> 發(fā)送端(第二次握手);
- 發(fā)送端 –(發(fā)送帶有 ACK 標(biāo)志的數(shù)據(jù)包) –> 接受端(第三次握手);
完成三次握手之后,發(fā)送端和接收端之間建立起可靠的 TCP 連接,客戶端應(yīng)用程序與服務(wù)器應(yīng)用程序就可以開(kāi)始傳送數(shù)據(jù)了。
3.2.3、傳輸數(shù)據(jù)
當(dāng)客戶端要與服務(wù)端通信時(shí),客戶端首先要?jiǎng)?chuàng)建一個(gè) Socket 實(shí)例,也就是指定目標(biāo)服務(wù)器的 IP 和端口。
默認(rèn)操作系統(tǒng)將為這個(gè) Socket 實(shí)例分配一個(gè)沒(méi)有被使用的本地端口號(hào),并創(chuàng)建一個(gè)包含本地、遠(yuǎn)程地址和端口號(hào)的套接字?jǐn)?shù)據(jù)結(jié)構(gòu),這個(gè)數(shù)據(jù)結(jié)構(gòu)將一直保存在系統(tǒng)中直到這個(gè)連接關(guān)閉。
- 客戶端簡(jiǎn)單示例
public static void main(String[] args) throws IOException {
//通過(guò)IP和端口與服務(wù)端建立連接
Socket socket =new Socket("127.0.0.1",8080);
//將字符流轉(zhuǎn)化成字節(jié)流,并輸出
BufferedWriter bufferedWriter =new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String str="Hello,我是客戶端!";
bufferedWriter.write(str);
bufferedWriter.flush();
bufferedWriter.close();
}
- 服務(wù)端簡(jiǎn)單示例
public static void main(String[] args) throws Exception {
//初始化服務(wù)端socket并且綁定 8080 端口
ServerSocket serverSocket = new ServerSocket(8080);
//循環(huán)監(jiān)聽(tīng)所有連接的客戶端請(qǐng)求
while (true){
try {
//等待客戶端的連接
Socket socket = serverSocket.accept();
//將字節(jié)流轉(zhuǎn)化成字符流,讀取客戶端輸入的內(nèi)容
BufferedReader bufferedReader =new BufferedReader(new InputStreamReader(socket.getInputStream()));
//讀取一行數(shù)據(jù)
String str = bufferedReader.readLine();
//輸出打印
System.out.println("服務(wù)端收到客戶端發(fā)送的信息:" + str);
} catch (Exception e) {
}
}
}
我們先啟動(dòng)服務(wù)端程序,再運(yùn)行客戶端,服務(wù)端收到客戶端發(fā)送的信息,打印結(jié)果如下:
服務(wù)端收到客戶端發(fā)送的信息:Hello,我是客戶端!
注意,客戶端只有與服務(wù)端建立三次握手成功之后,才會(huì)發(fā)送數(shù)據(jù),而 TCP/IP 握手過(guò)程,底層操作系統(tǒng)已經(jīng)幫我們實(shí)現(xiàn)了!
當(dāng)連接已經(jīng)建立成功,服務(wù)端和客戶端都會(huì)擁有一個(gè)Socket實(shí)例,每個(gè)Socket實(shí)例都有一個(gè)InputStream和OutputStream,正如我們前面所說(shuō)的,網(wǎng)絡(luò) I/O 都是以字節(jié)流傳輸?shù)模琒ocket正是通過(guò)這兩個(gè)對(duì)象來(lái)交換數(shù)據(jù)。
當(dāng)Socket對(duì)象創(chuàng)建時(shí),操作系統(tǒng)同時(shí)將會(huì)為InputStream和OutputStream分別分配一定大小的緩沖區(qū),數(shù)據(jù)的寫入和讀取都是通過(guò)這個(gè)緩存區(qū)完成的。
發(fā)送端將數(shù)據(jù)寫到OutputStream對(duì)應(yīng)的SendQ隊(duì)列中,當(dāng)隊(duì)列填滿時(shí),數(shù)據(jù)將被發(fā)送到另一端InputStream的RecvQ隊(duì)列中,如果這時(shí)RecvQ已經(jīng)滿了,那么OutputStream的write方法將會(huì)阻塞直到RecvQ隊(duì)列有足夠的空間容納SendQ發(fā)送的數(shù)據(jù)。
值得特別注意的是,緩存區(qū)的大小以及寫入端的速度和讀取端的速度非常影響這個(gè)連接的數(shù)據(jù)傳輸效率,由于可能會(huì)發(fā)生阻塞,所以網(wǎng)絡(luò) I/O 和磁盤 I/O 在數(shù)據(jù)的寫入和讀取還要有一個(gè)協(xié)調(diào)的過(guò)程,如果兩邊同時(shí)傳送數(shù)據(jù),可能會(huì)產(chǎn)生死鎖的問(wèn)題。
如何提高網(wǎng)絡(luò) IO 傳輸效率、保證數(shù)據(jù)傳輸?shù)目煽浚@個(gè)我們后面單獨(dú)開(kāi)篇進(jìn)行講解。
四、小結(jié)
本文闡述的內(nèi)容較多,整合了很多有用的信息,從 Java 基本的 I/O 類庫(kù)結(jié)構(gòu)開(kāi)始說(shuō)起,主要介紹了 IO 的傳輸格式和傳輸方式,包括字節(jié)流和字符流接口相關(guān)的分類介紹,以及磁盤 I/O 和網(wǎng)絡(luò) I/O 的基本工作方式。
五、參考
1、https://developer.ibm.com/zh/articles/j-lo-javaio/