在上文中《Java IO(1)基礎知識——位元組與字元》了解到了什麼是位元組和字元,主要是為了對Java IO中有關位元組流和字元流有一個更好的了解。
本文所述的輸出輸出指的是Java中傳統的IO,也就是阻塞式輸入輸出(Blocking I/O, BIO),在JDK1.4之後出現了新的輸入輸出API——NIO(New I/O或Non-blocking I/O),也就是同步非阻塞式輸入輸出,再到後面随着NIO的發展出現了新的異步非阻塞式的輸入輸出——AIO。
本文将對BIO,即阻塞式輸入輸出的位元組流以及字元流做簡要概述。 需要明确對于輸出:InputStream、Reader表示輸入,前者表示位元組流,後者表示字元流;OutStream、Writer表示輸出,前者表示位元組流,後者表示字元流。
位元組流(InputStream、OutputStream)
對于位元組流的輸入頂層類是InputStram、其輸出對應的頂層類是OutputStream。
輸入流(InputStream)
站在程式的角度,讀取檔案的動作稱為輸入,InputStream是一個抽象類,Java中IO的設計并不僅僅是隻有InputStream類,因為存在許多輸入流,例如網絡、檔案等,這些都能為程式提供資料源,而不同的資料源則通過不同的InputStream子類來接收。
- ByteArrayInputStream——位元組數組。
- StringBufferInputStream——String對象,這個類年代久遠已經被廢除了,想要将String對象轉換為流,推薦使用StringReader。
- FileInputStream——從檔案中讀取資訊,這個流是比較常用的類,因為通常情況下我們都是對檔案進行讀寫操作,是以也會着重讨論這個類。
- PipedInputStream——和PipedOutputStream配合使用實作“管道化”的概念。
- FileterInputStream——這個類比較特殊,從名字上看叫做“過濾器輸入流”,它是在輸入流中為“裝飾器”提供基類。
着重來看FileInputStream類,如何從檔案中讀取資訊。
FileInputStream 一共有3個構造方法:
- InputStream in = new FileInputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接傳遞檔案路徑字元串,在這個構造函數中會為路徑中的檔案建立File對象。
- InputStream in = new FileInputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)); //傳遞File類型的對象,也就是我們自己為路徑中的檔案構造為File檔案類型。
- InputStream in = new FileInputStream(new FileDescriptor()); //第三個構造方法傳遞的是“檔案描述符”對象,通過檔案描述符來定位檔案,如果比較了解Linux和C的話應該是對“檔案描述符”這個概念有所耳聞,在許多C源碼中就時常出現“fd”這個變量,其表示的就是檔案描述符,就是用于定位檔案。這一個在Java日常的應用開發中不常用,用到它的地方其實就是System.out.println的封裝。暫時可以忽略。
其實深入到FileInputStream這個對象的源碼可以發現,大部分核心的源碼都是native方法,之是以隻用nativa方法是因為本地方法速度快。
1 File file = new File("/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json");
2 InputStream in = new FileInputStream(file);
3 byte[] b = new byte[64];
4 in.read(b);
5 System.out.println(new String(b));
複制
這段代碼是讀取本地檔案擷取檔案中的資訊,其中read方法關鍵,FileInputStream中一共有3個read重載方法:
- public int read() //傳回讀取的位元組,FileInputStream是按照位元組流的方式讀取,使用該方法将一次讀取一個位元組并傳回該位元組。該方法中會調用private native int read0()本地方法。
- public int read(byte b[]) //将讀取的位元組全部放到位元組數組b中,這個位元組數組b是我們提前定義好的,用于存放讀取檔案的位元組表示,傳回一共讀取的字(1個字母表示1個字,1中文通常則是3個字)。該方法會調用private native int readBytes(byte b[], int off, int len)本地方法。
- read(byte b[], int off, int len) //讀取資料的開始處以及待存放位元組數組的長度,基本同上,傳回一共讀取的字元(1個字母表示1個字元,1中文通常占用3個位元組也就是3個字元)。該方法會調用private native int readBytes(byte b[], int off, int len)本地方法。
這基本上就構成了通過FileInputStream位元組流讀取檔案的API,到了這裡應該會有一個疑問,那就是讀取出來的位元組放到我們定義的位元組數組中,而這個數組有需要在初始化時給定大小,那此時是如何知道待讀取的檔案大小呢?上面定義的64個位元組大小的數組,如果待讀取的檔案有128位元組甚至更大呢?就好像上面的例子,如果之定義1個位元組大小,那麼最後隻會輸出檔案中的第1個位元組。但如果定義64個位元組大小的位元組數組,那又顯得比較浪費。
輸出流(OutputStream)
同樣是站在程式的角度,寫入檔案的操作稱為輸出。和InputStream類比,它也有許多實作類,在這裡不再一一舉出,着重來看FileOutputStream輸出到本地檔案的類。如果檔案不存在則建立。
1 OutputStream out = new FileOutputStream("/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json");
2 String str = "this is data";
3 out.write(str.getBytes()); // 由于是以位元組流的方式輸出,自然也是需要将輸出的内容轉換為位元組。
複制
FileOutputStream類的構造方法一共有5個:主要是分為“檔案位址”、“是否以追加方式寫入”、“檔案描述符”。
- OutputStream out = new FileOutputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接傳遞檔案路徑字元串,在構造方法中會将其構造為一個File對象,如果檔案不存在則會建立檔案,預設将覆寫檔案的内容進行寫入。因為它實際上是調用FileInputStream(File, boolean)構造方法。
- OutputStream out = new FileOutputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)) //傳遞File對象,預設将覆寫檔案的内容進行寫入。實際還是調用FileInputStream(File, boolean)。
- OutputStream out = new FileOutputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”, true); //第一個參數如第一點所述,第二個參數則表示以追加的方式寫入。
- OutputStream out = new FileOutputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””), true) //向上參考
- OutputStream out = new FileOutputStream (new FileDescriptor()); //第三個構造方法傳遞的是“檔案描述符”對象,不需要過多的關注這個構造方法,因為實在能用的地方不多。
對于檔案輸出的核心API是write方法,對應檔案輸入的read方法。既然read能單個讀取,那麼write也有單個寫入,其重載方法一共有3個。
- public void write(int b); //寫入單個位元組,該方法會調用private native write(b, append)這個方法是私有且本地的,至于第二個append的參數則是表示是否追加寫入檔案,這裡的參數是在構造方法中定義的,預設不追加寫入而是以覆寫的方式寫入。
- public void write(byte b[]); //寫入位元組,這裡傳遞轉換後的位元組數組,通常我們是需要寫入一個字元串,而這裡調用String.valueOf将其轉換為字元數組。此方法會調用private native void writeBytes(byte b[], int off, int len, boolean append),和寫入的類似,第二個參數表示位元組數組從哪個地方開始寫入,len表示寫入多少,最後一個還是表示是否是追加寫入。
- public void write(byte b[], int off, int len); //分析見上 這是對OutputStream的其中一個實作類做的簡要講述,API也較為簡單,類比很好掌握。
字元流(Reader、Writer)
輸入流(Reader)
對于字元流的檔案讀取方式可以不用像位元組流那樣,讀取出來是一個位元組,想要輸出顯示這個位元組則需要将這個位元組轉換為字元。字元流讀取出來的檔案則直接就是字元,不需要再重新轉化。Reader和InputStream類似,也是一個抽象類,它也有不少的實作,其主要實作如下。
- CharArrayReader
- StringReader
- InputStreamReader——這個類略有不同,這個類是位元組流和字元流之間的橋梁,它能将位元組流轉換為字元流,相對比于“FileInputStream”,位元組流的本地檔案讀取實際上是InputStreamReader的子類——FileReader
- PipedReader
- FilterReader
對比字元流的FileInputStream類,此處使用FileReader。和FileInputStream類似它同樣有3個構造方法:
- Reader reader = new FileReader(/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接傳遞檔案路徑字元串,在這個構造函數中會為路徑中的檔案建立File對象。
- Reader reader = new FileReader(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)); //傳遞File類型的對象,也就是我們自己為路徑中的檔案構造為File檔案類型。
- Reader reader = new FileReader(new FileDescriptor()); //第三個構造方法傳遞的是“檔案描述符”對象,通過檔案描述符來定位檔案,如果比較了解Linux和C的話應該是對“檔案描述符”這個概念有所耳聞,在許多C源碼中就時常出現“fd”這個變量,其表示的就是檔案描述符,就是用于定位檔案,暫時對它可以忽略。
可以看到它的API操作幾乎和FileInputStream如出一轍,唯一不同的是,它定義的是字元數組而不是位元組數組。
1 Reader reader = new FileReader("/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json");
2 char[] c = new char[64];
3 reader.read(c);
4 System.out.println(String.valueOf(c));
複制
同位元組輸入流FileInputStream類似,它的讀取API也是read,并且它也有3個重載方法。如果還能記得FileInputStream的3個read重載方法,那麼這裡也不難猜出FileReader的3個read重載方法分别是:讀取一個字元;讀取所有字元;讀取範圍内的字元。實際上進入FileReader類後可以發現在FileReader類中并沒有read方法,因為它繼承自InputStreamReader,最後發現實際上FileReader#read調用的是父類InputputStreamReader#read方法,而且和位元組流的read使用native本地方法略有不同,InputputStreamReader并沒有采用native方法,而是使用了一個叫做StreamDecoder類,這個類源于sun包,并沒有源代碼,不過還是可以帶着好奇心來一看反編譯後的結果。
//InputputStreamReader#read
public int read(char cbuf[], int offset, int length) throws IOException {
return sd.read(cbuf, offset, length); //調用的StreamDecoder#read方法
}
複制
對于使用FileReader#read方法調用的則是它的父類InputStreamReader#read,其實我認為可以這麼了解:基于字元流的輸入輸出實際上是我們人為對它進行了轉換,資料在網絡中的傳輸實際還是以二進制流的方式,或者說是位元組的方式,為了我們友善閱讀,在傳輸到達時人為地将其轉換為了字元的形式。是以即使這裡是使用的FileReader以字元流的方式輸入,但實際上它使用了位元組-字元之間的橋梁——InputStreamReader類。也就是說StreamDecoder類很就是位元組-字元轉換的核心類。關于StreamDecoder類确實涉及比較複雜,Reader字元流本身也比位元組流要複雜不少。這個地方的源碼暫時還未深入了解。
輸出流(Writer)
和位元組輸出流以及字元輸入流之間的對比Writer也有很多實作類,我們找到有關本地檔案寫入的類——FileWriter,同樣發現它繼承自OutputStreamWriter,這個類是Writer的位元組子類和InputStreamReader類似是位元組流和字元流轉換的橋梁。
有了上面的例子,這裡不再逐個叙述它的構造方法以及write重載方法,有一個需要關注的地方就是它的flush方法。
1 Writer writer = new FileWriter("/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json");
2 String str = "hello";
3 writer.write(str);
4 writer.flush();
複制
上面的代碼中如果不調用flush方法,字元串将不會寫入到檔案中。這是因為在寫檔案時,Java會将資料先存入緩存區,緩存區滿後再一次寫入到檔案中,在這裡“hello”并沒有占滿緩存,故需要在調用write方法後再調用flush方法防止在緩存區中的資料沒有及時寫入檔案。
不過這裡有一個令我比較疑惑的是,在使用位元組流輸出隻含1個字元到檔案時,并沒有使用flush也會将資料寫到檔案;而在字元流中則像上面的那種情況如果不使用flush則資料不會寫入檔案。答案确實是使用位元組流輸出資料到檔案時,不需要使用flush,因為調用FileInputStream并沒有重寫flush方法,而是直接調用了父類OutputStream的falush方法,而OutputStream#flush方法裡什麼都沒有,就是一個空方法;而使用FileWriter中雖然也并未實作flush方法,但在其父類OutputStreamWriter卻實作了Writer的flush方法,因為在Writer類中flush方法是一個抽象方法必須實作。這裡實際又會有一個疑問,為什麼字元流不需要緩存,而位元組流需要呢?其實就是因為對于位元組流來說,是直接操作檔案流,可以了解為“端到端”,而對于字元流來說中間多了一次轉換為字元在“端到端”的中間利用了緩存(記憶體)将字元存放在了緩存中。是以在實際開發中利用位元組流的方式輸入輸出相對更多。
小結
上面說了這麼多,看似并沒有多少幹貨,大多是關于這幾個流的使用方法,如果仔細看下來會發現最大的幹貨在于最後的flush疑問。這實際上能揭開關于“位元組流”和“字元流”之間的差別。 在重複一次,盡管位元組流中有flush方法,但是flush在位元組流FileOutputStream并沒用,JDK源碼能說明一切,因為FileOutputStream調用的flush方法根本就是一個空實作。然而在字元流中那就可得注意了,在FileReader調用了write方法後記住調用flush方法,清空緩存寫入檔案。 這個問題基本就能解釋位元組流和字元流之間的差別了,位元組流直接操作檔案,字元流雖然最後的呈現以及寫入是字元,但其最終還是以位元組在傳輸,位元組到字元的轉換是在記憶體中完成的,這也就是字元流用到了緩存的原因。其實想想就可以知道,對于兩者哪個更好,位元組流更常用,因為它直接操作檔案讀取寫入位元組并且不限于文本,可以是音樂、圖檔、視訊,而字元流主要是針對純文字檔案,況且它還要轉換一次,效率恐怕就沒有位元組來得那麼快了,故一般就是直接使用位元組流——InputStream和OutputStream操作檔案。
什麼是(同步)阻塞式輸入輸出(Blocking I/O)
這一部分的内容将解釋本文的另一主題——阻塞式輸出輸出。
首先需要了解何為“阻塞”。如果對顯示鎖Lock有所了解的話,應該是會知道它的兩個方法一個是阻塞式擷取鎖——lock,直到成功地擷取所後才傳回;另一個是非阻塞式擷取鎖——tryLock,它首先嘗試擷取鎖,成功擷取所則成功傳回,未能擷取鎖也會立即傳回,并不會一直等在這裡擷取鎖。相對于阻塞式的IO也是類似,阻塞式IO也會一直等待資料的讀取和寫入直到完成;而對應的非阻塞式IO則不會這樣做,它會立即傳回,不管是完成或未完成。
再舉個例子,在現實生活中你去買煙,老闆說等下我去倉庫裡拿,你就一直在那裡等老闆從倉庫裡拿煙,這個時候你啥也不做就幹等着,這就是阻塞;對于非阻塞,你還是在買煙,你還是在等老闆給你拿煙,不過此時你可以玩玩手機,時不時問下老闆好了沒有。
上面的例子都是在“同步”條件下的阻塞與非阻塞。當然還有異步阻塞與非阻塞,這裡暫不涉及異步相關,是以本文所述阻塞與非阻塞均是在同步狀态下。
在此有必要了解什麼是同步,通俗地說就是你進行下一步動作需要依賴上一步的執行結果。有時在我們的應用程式中,讀取檔案并不是下一步所必需的,也就是說這是兩個不相幹的邏輯,此時如果采用同步的手段去讀取檔案,讀完過後再做另外的邏輯顯然這個時間就被浪費了,通常情況下采取的措施是——僞異步,單獨建立一個線程執行讀取檔案的操作,代碼形如以下所示:
1 new Thread(new Runnable() {
2 @Override
3 public void run() {
4 readFile();
5 }
6 }).start();
7 doSomething();
8 //lamda表達式則更加簡單:
9 //new Thread(() -> readFile()).start();
10 //doSomething();
複制
脫離場景談同步阻塞式的傳統IO顯得很無力也不好了解,下面将結合Socket網絡程式設計再次試着進一步了解“同步阻塞式IO”。
以Java中使用UDP進行資料通信為例,伺服器端在建立一個socket後會調用其receive等待用戶端資料的到來,而DatagramSocket#receive就是阻塞地等待用戶端資料,如果資料一直不來,它将會一直“卡”在這個方法的調用處,也就是程式此時被阻塞挂起,程式無法繼續執行。
1 //同步阻塞式,伺服器端接收資料
2 DatagramPacket request = new DatagramPacket(new byte[1024], 1024);
3 socket.receive(request);
4 processData(new String(request.getData()));
複制
試想以上代碼,用戶端發來的第1條、第2條……這些資料并無直接聯系,它們隻需要交給伺服器端處理即可,但此時伺服器端是同步阻塞式的擷取資料并進行處理,在第1條資料未處理完時,第2條資料就必須等待,通常地做法就是上面提到的采用僞異步的方式對接收到的資料進行處理。
1 //(僞)異步阻塞式,伺服器端接收資料
2 DatagramPacket request = new DatagramPacket(new byte[1024], 1024);
3 socket.receive(request);
4 new Thread(() -> { //lamda表達式
5 try {
6 processData(new String(request.getData()));
7 } catch (InterruptedException e) {
8 e.printStackTrace();
9 }
10 }).start();
複制
上面代碼服務端接收到資料後将新開啟一個線程對資料進行處理(更好地方式是利用線程池來管理線程),盡管采用了“僞異步”的方式處理資料,但實際上這是針對的是用戶端發送資料多,發送資料快時所做的改進措施,但如果用戶端發送的資料少,發送資料慢,實際上上面的修改并無多大意義,因為此時的症結不在于對伺服器端對資料接收與處理的快慢,而在于伺服器端将會一直阻塞擷取資料使得伺服器端程式被挂起。是以問題還是回到了“阻塞”式IO上來,想要解決這個問題就需要使用到“非阻塞”式IO,這也是下節所講内容。