1. 程式人生 > >Java IO(2)阻塞式輸入輸出(BIO)

Java IO(2)阻塞式輸入輸出(BIO)

本文所述的輸出輸出指的是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上來。