概述
在大部分的行業系統或者功能性需求中,對於程序員來說,接觸到io的機會還是比較少的,其中大多也是簡單的上傳下載、讀寫文件等簡單運用。最近工作中都是網絡通信相關的應用,接觸io、nio等比較多,所以嘗試着深入學習並且描述下來。
io往往是我們忽略但是卻又非常重要的部分,在這個講究人機交互體驗的年代,io問題漸漸成了核心問題。Java傳統的io是基於流的io,從jdk1.4開始提供基於塊的io,即nio,會在后面的文章介紹。
流
流的概念可能比較抽象,可以想象一下水流的樣子。
io在本質上是單個字節的移動,而流可以說是字節移動的載體和方式,它不停的向目標處移動數據,我們要做的就是根據流的方向從流中讀取數據或者向流中寫入數據。
想象下倒水的場景:倒一杯水,水是連成一片往地上流動,而不是等杯中的水全部倒出懸浮在空中,然后一起掉落地面。最簡單的Java流的例子就是下載電影,肯定不是等電影全部下載在內存中再保存到磁盤上,本質上是下載一個字節就保存一個字節。
一個流,必有源和目標,它們可以是計算機內存的某些區域,也可以是磁盤文件,甚至可以是Internet上的某個URL。流的方向是重要的,根據流的方向,流可分為兩類:輸入流和輸出流。我們從輸入流讀取數據,向輸出流寫入數據。
io分類
Java對io的支持主要集中在io包下,顯然可以分為下面兩類:
- 基於字節操作的io接口:InputStream 和 OutputStream
- 基於字符操作的io接口:Writer 和 Reader
不管磁盤還是網絡傳輸,最小的存儲單位都是字節。但是程序中操作的數據大多都是字符形式的,所以Java也提供了字符型的流。io包下的類主要提供了io流本身的支持:流的形態,流里裝的是什么。但是流並不等於io,還有很重要的一點:數據的傳輸方式,也就是數據寫到哪里的問題,主要是以下兩種:
- 基於磁盤操作的io接口:File
- 基於網絡操作的io接口:Socket
對此Java的其他一些類庫提供了支持。
字節流、字符流的io接口說明
字節流包括輸入流InputStream和輸出流OutputStream。字符流包括輸入流Reader,
InputStream相關類圖如下,只列舉了一級子類:
InputStream提供了一些read方法供子類繼承,用來讀取字節。
OutputStream相關類圖如下:
OutputStream提供了一些write方法供子類繼承,用來寫入字節。
Reader相關類圖如下:
Reader提供了一些read方法供子類繼承,用來讀取字符。
Writer相關類圖如下:
Writer提供了一些write方法供子類繼承,用來寫入字符。
每個字符流子類幾乎都會有一個相對應的字節流子類,兩者功能一樣,差別只是在於操作的是字節還是字符。例如CharArrayReader和 ByteArrayInputStream,兩者都是在內存中建立數組緩沖區作為輸入流,不同的只是前者數組用來存放字符,每次從數組中讀取一個字符;后 者則是針對字節。
ByteArrayInputStream、CharArrayReader | 為多線程的通信提供緩沖區操作功能。常用於讀取網絡中的定長數據包 |
ByteArrayOutputStream、CharArrayWriter | 為多線程的通信提供緩沖區操作功能。常用於接收足夠長度的數據后進行一次性寫入 |
FileInputStream、FileReader | 把文件寫入內存作為輸入流,實現對文件的讀取操作 |
FileOutputStream、FileWriter | 把內存中的數據作為輸出流寫入文件,實現對文件的寫操作 |
StringReader | 讀取String的內容作為輸入流 |
StringWriter | 將數據寫入一個String |
SequenceInputStream | 將多個輸入流中的數據合並為一個數據流 |
PipedInputStream、PipedReader、PipedOutputStream、PipedWriter | 管道流,主要用於2個線程之間傳遞數據 |
ObjectInputStream | 讀取對象數據作為輸入流,對象中的 transient 和 static 類型的成員變量不會被讀取或寫入 |
ObjectOutputStream | 將數據寫入對象 |
FilterInputStream、FilterOutputStream、FilterReader、FilterWriter | 過濾流通常源和目標是其他的輸入輸出流,大家可以看到有眾多的子類,各有用途,就不一一介紹了 |
字節流和字符流轉換
任何數據的持久化和網絡傳輸都是以字節形式進行的,所以字節流和字符流之間必然存在轉換問題。字符轉字節是編碼過程,字節轉字符是解碼過程。io包中提供了InputStreamReader和OutputStreamWriter用於字符和字節的轉換。
來看一個小例子:
char[] charArr = new char[1]; StringBuffer sb = new StringBuffer(); FileReader fr = new FileReader("test.txt"); while(fr.read(charArr) != -1) { sb.append(charArr); } System.out.println("編碼:" + fr.getEncoding()); System.out.println("文件內容:" + sb.toString());
FileReader類其實就是簡單的包裝一下FileInputStream,但是它繼承InputStreamReader類,當調用read方法時其實調用的是StreamDecoder類的read方法,這個StreamDecoder正是完成字節到字符的解碼的實現類。如下圖:
InputStream 到 Reader 的過程要指定編碼字符集,否則將采用操作系統默認字符集,很可能會出現亂碼問題。上例代碼輸出如下:
編碼:UTF8
文件內容:hello�����Dz����ļ�!
再來看一個例子,換一個字符集:
char[] charArr = new char[1]; StringBuffer sb = new StringBuffer(); //設置編碼 InputStreamReader isr = new InputStreamReader( new FileInputStream("D:/test.txt") , "GBK"); while(isr.read(charArr) != -1) { sb.append(charArr); } System.out.println("編碼:" + isr.getEncoding()); System.out.println("文件內容:" + sb.toString());
輸出正常:
編碼:GBK
文件內容:hello!我是測試文件!
編碼過程也是類似的,就不再說了。
io包與設計模式
對於io包,下面的用法是經常看到的:
InputStream in = new BufferedInputStream(new ObjectInputStream(new FileInputStream(new File("xxx"))));
很自然的想到了Decorator(裝飾器)模式,Java的io包屬於Decorator模式的經典案例。GOF對於Decorator的適用性是這么描述的:
- 在不影響其他對象的情況下,以動態、透明的方式給單個對象添加職責。
- 處理那些可以撤銷的職責。
- 當不能采用生成子類的方法進行擴充時。一種情況是,可能有大量獨立的擴展,為支持每一種組合將產生大量的子類,使得子類數據呈爆炸性增長。另一種情況可能是因為類定義被隱藏,或類定義被隱藏,或類定義不能生成子類。
以InputStream為例。假設這么一種情況:現在只有InputStream類,需要根據需求設計它的子類。
需求1:讀取某個文件到內存中的一個緩沖區的流
需求2:讀取某個文件並提供行計數器的流
需求3:讀取某個文件並反序列化為對象的流
理所應當,我們建立3個子類:FileBufferedInputStream、FileLineNumberInputStream、FileObjectInputStream。但是如果再來N個這樣的需求,那么類圖將會變為下圖這樣:
出現了“類爆炸”的情況,java顯然沒有這樣做,以InputStream為例,實際情況如下圖:
對應Decorator模式的類圖,InputStream的角色是Component,它主要定義了read抽象方法;FileInputStream、ByteArrayInputStream、ObjectInputStream、PipedInputStream、 SequenceInputStream的角色是ConcreteComponent,它們都是具有某種功能的流。其中前四者,它們的源是byte數組、或者String對象、或者文件等,可以看作是真正的數據來源,被稱作原始流。
FilterInputStream類即是Decorator模式中的Decorator角色,裝飾器,部分代碼如下:
protected volatile InputStream in; protected FilterInputStream(InputStream in) { this.in = in; }
它派生出的多個子類即是ConcreteDecorator,用來給輸入流加上不同的功能。它們的源通常都是其他的輸入流,所以也叫它們鏈接流。
那么java為什么要這么設計呢?前面說的“類爆炸”是一個原因;另外通過子類來擴展基類功能是靜態的,而裝飾器模式是動態的添加組合功能,使用中非常靈活,並且減少了大量的功能重復。
另一種在io包中普遍存在的設計模式是Adapter(適配器)模式,以InputStream子類FileInputStream為例,部分代碼如下:
/* File Descriptor - handle to the open file */ private FileDescriptor fd; public FileInputStream(File file) throws FileNotFoundException { ... fd = new FileDescriptor(); ... }
在FileInputStream繼承了InputStrem類型,同時持有一個對FileDiscriptor的引用。這是將一個FileDiscriptor對象適配成InputStrem類型的對象形式的適配器模式。如下圖:
其他例子就不多說了。
磁盤IO工作機制
io中數據寫到何處也是重要的一點,其中最主要的就是將數據持久化到磁盤。數據在磁盤上最小的描述就是文件,上層應用對磁盤的讀和寫都是針對文件而言的。在java中,以File類來表示文件,如:
File file = new File("D:/test.txt");
但是嚴格來說,File並不表示一個真實的存在於磁盤上的文件。就像上面代碼的文件其實並不存在,File做的只是根據你所提供的文件描述符,返回某一路徑的虛擬對象,它並不關心文件或路徑是否存在,可能存在,也可能是捏造的。就好象一張名片,名片的背后代表的是人。為什么要這么設計?在我看來還是要提高訪問磁盤的效率,有點延遲加載的意思。大部分情況下,我們最關心的並不是文件存不存在,而是文件要如何操作。比如你手里有很多名片,你可能更關心的是有沒有某某局長的名片,而只有在需要聯系時,才發現名片是假的。也就是關心名片本身要強過名片的真偽。
以FileInputStream讀取文件為例,過程是這樣的:當傳入一個文件路徑時,會根據這個路徑創建File對象,作為這個文件的一個“名片”。當我們試圖通過FileInputStream對象去操作文件的時候,將會真正創建一個關聯真實存在的磁盤文件的文件描述符FileDescriptor,通過FileInputStream構造方法可以看出:
fd = new FileDescriptor();
如果說File是文件的名片,那么FileDescriptor就是真正指向了一個打開的文件,可以操作磁盤文件。例如FileDescriptor.sync()方法可以將緩存中的數據強制刷新到磁盤文件中。如果我們需要讀取的是字符,還需要通過StreamDecoder類將字節解碼成字符。至於如何從物理磁盤上讀取數據,那就是操作系統做的事情了。過程如圖(圖摘自網上):
Socket工作機制
Socket要說起來並不那么形象,它的中文翻譯是“插座”,至於“套接字”這個翻譯我實在不知道從何而來。可以這樣理解插座的概念,由於本身有電網的存在,如果我們買了一台新電器,我們只要插上插座連接到電網上就能夠使用。Socket就像一個插座,計算機通過Socket就能和網絡或者其他計算機上進行通訊;當有數據通訊的需求時,只需要建立一個Socket“插座”,通過網卡與其他計算機相連獲取數據。
Socket位於傳輸層和應用層之間,向應用層統一提供編程接口,應用層不必知道傳輸層的協議細節。Java中對Socket的支持主要是以下兩種:
(1)基於TCP的Socket:提供給應用層可靠的流式數據服務,使用TCP的Socket應用程序協議:BGP,HTTP,FTP,TELNET等。優點:基於數據傳輸的可靠性。
(2)基於UDP的Socket:適用於數據傳輸可靠性要求不高的場合。基於UDP的Socket應用程序協議:RIP,SNMP,L2TP等。
大部分情況下我們使用的都是基於TCP/IP協議的流Socket,因為它是一種穩定的通信協議。以此為例:
一台計算機要和另一台計算機進行通訊,獲取其上應用程序的數據,必須通過Socket建立連接,要知道對方的IP和端口號。建立一個Socket連接需要通過底層TCP/IP協議來建立TCP連接,而建立TCP連接必須通過底層IP協議根據給定的IP在網絡中找到目標主機。目標計算機上可能跑着多個應用,所以我們必須要根據端口號來制定目標應用程序,這樣就可以通過一個 Socket 實例唯一代表一個主機上的一個應用程序的通信鏈路了。
那么Socket是如何建立通訊鏈路的呢?
假設有一台計算機作為客戶端,另一台作為服務端。當客戶端需要向服務端通信,客戶端首先要創建一個Socket實例:
Socket socket = new Socket("127.0.0.1",1234);
若沒有指定端口號,操作系統將為這個Socket實例分配一個沒有被使用的本地端口號。此外創建了一個包含本地和遠程地址和端口號的套接字數據結構,這個數據結構將一直保存在系統中直到這個連接關閉,代碼如下:
public Socket(String host, int port) throws UnknownHostException, IOException { this(host != null ? new InetSocketAddress(host, port) : new InetSocketAddress(InetAddress.getByName(null), port), (SocketAddress) null, true); }
客戶端試圖和服務端建立TCP連接,此時會進行三次握手。
第一次握手:建立連接時,客戶端發送syn包(syn=j)到服務器,並進入SYN_SEND狀態,等待服務器確認;
第二次握手:服務器收到syn包,必須確認客戶的SYN(ack=j+1),同時自己也發送一個SYN包(syn=k),即SYN+ACK包,此時服務器進入SYN_RECV狀態;
第三次握手:客戶端收到服務器的SYN+ACK包,向服務器發送確認包ACK(ack=k+1),此包發送完畢,客戶端和服務器進入ESTABLISHED狀態,完成三次握手。
完成三次握手后Socket的構造函數成功返回,Socket實例創建完畢。
互聯網是一種盡力而為(best-effort)的網絡,客戶端的起始消息或服務器端的回復消息都可能在傳輸過程中丟失。出於這個原因,TCP 協議實現將以遞增的時間間隔重復發送幾次握手消息。如果TCP客戶端在一段時間后還沒有收到服務器的返回消息,則發生超時並放棄連接。這種情況下,構造函數將拋出IOException 異常。
而服務端也需要創建與之對應的ServerSocket,ServerSocket的創建比較簡單,只需要指定端口號:
ServerSocket serverSocket = new ServerSocket(10001);
同時操作系統也會為ServerSocket實例創建一個底層數據結構:
bind(new InetSocketAddress(bindAddr, port), backlog); //見構造方法
這個數據結構中包含指定監聽的端口號和包含監聽地址的通配符,通常情況下是監聽所有地址,下面是比較典型的ServerSocket代碼:
public void testSocket() throws Exception { ServerSocket serverSocket = new ServerSocket(10002); Socket socket = null; try { while (true) { socket = serverSocket.accept(); System.out.println("socket連接:" + socket.getRemoteSocketAddress().toString()); BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); while(true) { String readLine = in.readLine(); System.out.println("收到消息" + readLine); if("end".equals(readLine)) { break; } //客戶端斷開連接 socket.sendUrgentData(0xFF); } } } catch (SocketException se) { System.out.println("客戶端斷開連接"); } catch (IOException e) { e.printStackTrace(); } finally { System.out.println("socket關閉:" + socket.getRemoteSocketAddress().toString()); socket.close(); } }
當調用accept()方法時,服務端將進入阻塞狀態,等待客戶端的請求。當有客戶端請求到來時,將為這個鏈接創建一個套接字數據結構,包括請求客戶端的地址和端口號。該數據結構將被關聯到ServerSocket實例的一個未連接列表里。此時連接並沒有成功建立,處於三次握手階段,Socket構造函數並未成功返回。當三次握手成功后,會將Socket實例對應的數據結構從未完成列表移到完成列表中。所以 ServerSocket 所關聯的列表中每個數據結構,都代表與一個客戶端的建立的 TCP 連接。
當連接成功創建后,我們要做的就是傳輸數據,這才是主要目的。如上例代碼,在客戶端和服務端都有一個Socket實例,而每個Socket實例都會擁有一個InputStream和OutputStream,我們正是通過它們傳輸數據。當Socket對象創建時,操作系統將會為InputStream和OutputStream分別分配一定大小的緩沖區,數據的寫入和讀取都是通過緩存區完成的。發送端的緩沖區稱之為SendQ,是一個FIFO的隊列,接收端的緩沖區稱之為RecvQ,同樣也是FIFO隊列。
數據傳輸時,發送端將數據寫入到OutputStream對應的SendQ隊列中,以字節為單位發送到接收端InputStream的RecvQ隊列中。當SendQ隊列填滿時,發送端的write方法將會阻塞住;而當RecvQ隊列中沒有數據時,接收端的read方法也將被阻塞。
一些情況下,客戶端和服務端之間可能會產生死鎖問題,例如:
- 如果在連接建立后,客戶端和服務器端都立即嘗試接收數據,顯然將導致死鎖。
- 客戶端和服務端都嘗試向對方write數據,並且數據長度大於兩端緩沖區的和。此時會導致不管客戶端還是服務端RecvQ和SendQ都滿了,剩下的數據無法發送,兩個write操作都不能完成,兩個程序都將永遠保持阻塞狀態,產生死鎖。
死鎖的問題是要注意的,需要對數據的寫入和讀取做一個協調,解決死鎖的方式可以使用多線程,也可以使用非阻塞的io,這里就不再深究了。
關於Java中IO的內容大概就說這么多了,后面會寫寫NIO的內容。