在上文中《Java IO(1)基礎知識——位元組與字元》瞭解到了什麼是位元組和字元,主要是為了對Java IO中有關位元組流和字元流有一個更好的瞭解。 本文所述的輸出輸出指的是Java中傳統的IO,也就是阻塞式輸入輸出(Blocking I/O, BIO),在JDK1.4之後出現了新的輸入輸出API——N ...
在上文中《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,這也是下節所講內容。
這是一個能給程式員加buff的公眾號