1. 程式人生 > >二十三、NIO與IO

二十三、NIO與IO

java NIO由以下幾個核心部分組成:

  • Channels(通道)
  • Buffers(緩衝區)
  • Selectors(選擇器)
  • 其他

Channel和Buffer:

所有的IO再NIO中都從一個Channel開始.Channel有點像流,資料可以從Channel讀到Buffer中,也可以從Buffer寫到Channel中:
這裡寫圖片描述

Channel的主要實現如下:

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel
    這些通道覆蓋了UDP和TCP網路IO

Buffer的實現:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • intBuffer
  • LongBuffer
  • ShortBuffer
    這些Buffer覆蓋了通過IO傳送的基本資料型別(JAVA NIO還有個MappedByteBuffer,表示記憶體對映檔案)

Selector

Selector允許單執行緒處理多個Channel,如果你的應用打開了多個連線(通道),每個連線的流量都很低,使用selector就會很方便,如:在一個聊天伺服器中.如下圖,一個selector處理3個Channel的問題

這裡寫圖片描述

使用selector,得向selector註冊Channel,然後呼叫它的select(),這個方法會一直阻塞到某個註冊的通道事件就緒.一旦這個方法返回,執行緒就可以處理這些事件,事件的例子就是新連線進來,資料接收等.

Java NIO vs. IO

我應該何時使用IO,何時使用NIO呢?在本文中,我會盡量清晰地解析Java NIO和IO的差異、它們的使用場景,以及它們如何影響您的程式碼設計。

下表總結了java NIO和IO之間的主要差別,我會更詳細的描述表中每部分的差異.

IO NIO
Stream oriented
Buffer Oriented
Blocking IO Non Blocking IO
  Selectors

面向流和麵向緩衝

Java NIO和IO之間最大的區別是:IO是面向流的,NIO是面向緩衝區的.java IO面向流意味著每次從流中讀一個或多個位元組,直至讀取所有位元組,他們沒有被快取在任何地方.此外,它不能前後移動流中的資料,如果徐亞前後移動從流中讀取的資料,需要先將它快取到一個緩衝區. Java NIO的緩衝導向方法略有不同,資料讀取到一個它稍後處理的緩衝區,需要時在緩衝區中前後移動,這就增加了處理過程中的靈活性,但是,還需要檢查是否該緩衝區中包含所有您需要處理的資料,而且,需要確保當更多的資料讀入緩衝區時,不要覆蓋緩衝區裡面尚未處理的資料.

阻塞與非阻塞IO

Java IO的各種流是阻塞的,這意味著,當一個執行緒呼叫read()或者write()時,該執行緒被阻塞,直到有一些資料被讀取,或資料完全寫入,該執行緒在此期間不能再幹任何事情.Java NIO的非阻塞模式,使一個執行緒從某通道傳送請求讀取資料,但是它僅能得到目前可用的資料,如果目前沒有資料可用,就什麼都不會獲取.而不是保持執行緒阻塞,所有直至資料變的可以讀取之前,該執行緒可以繼續做其他事情,非阻塞寫也是如此,一個執行緒請求希爾一些資料到通道,但不需要等待它完全寫入,這個執行緒同時可以去做別的事,執行緒同城將非阻塞IO的空閒時間用於在其他通道執行IO操作,所以一個單獨的執行緒現在可以管理多個輸入和輸出通道.

選擇器(Selectors)
選擇器允許一個單獨的執行緒來監聽多個輸入通道,你可以註冊多個通道使用一個選擇器,然後使用一個單獨的執行緒來”選擇”通道:這些通道里已經有可以處理的輸入,或者選擇已準備寫入的通道.這種選擇機制,使一個單獨的執行緒很容易管理多個通道.

資料處理
使用純粹的NIO設計相較IO設計,資料處理也受到影響。

在IO設計中,我們從InputStream或 Reader逐位元組讀取資料。假設你正在處理一基於行的文字資料流,例如:

Name: Anna 
Age: 25 
Email: [email protected] 
Phone: 1234567890 

該文字行的流可以這樣處理:
Java程式碼

InputStream input = … ; // get the InputStream from the client socket 
BufferedReader reader = new BufferedReader(new InputStreamReader(input)); 

String nameLine   = reader.readLine(); 
String ageLine    = reader.readLine(); 
String emailLine  = reader.readLine(); 
String phoneLine  = reader.readLine(); 

請注意處理狀態由程式執行多久決定。換句話說,一旦reader.readLine()方法返回,你就知道肯定文字行就已讀完, readline()阻塞直到整行讀完,這就是原因。你也知道此行包含名稱;同樣,第二個readline()呼叫返回的時候,你知道這行包含年齡等。 正如你可以看到,該處理程式僅在有新資料讀入時執行,並知道每步的資料是什麼。一旦正在執行的執行緒已處理過讀入的某些資料,該執行緒不會再回退資料(大多如 此)。下圖也說明了這條原則:

這裡寫圖片描述

而一個NIO的實現會有所不同,下面是一個簡單的例子:
Java程式碼

ByteBuffer buffer = ByteBuffer.allocate(48); 
int bytesRead = inChannel.read(buffer);

注意第二行,從通道讀取位元組到ByteBuffer。當這個方法呼叫返回時,你不知道你所需的所有資料是否在緩衝區內。你所知道的是,該緩衝區包含一些位元組,這使得處理有點困難。
假設第一次 read(buffer)呼叫後,讀入緩衝區的資料只有半行,例如,“Name:An”,你能處理資料嗎?顯然不能,需要等待,直到整行資料讀入快取,在此之前,對資料的任何處理毫無意義。
所以,你怎麼知道是否該緩衝區包含足夠的資料可以處理呢?好了,你不知道。發現的方法只能檢視緩衝區中的資料。其結果是,在你知道所有資料都在緩衝區裡之前,你必須檢查幾次緩衝區的資料。這不僅效率低下,而且可以使程式設計方案雜亂不堪。例如:
java程式碼

ByteBuffer buffer = ByteBuffer.allocate(48); 
    int bytesRead = inChannel.read(buffer); 
    while(! bufferFull(bytesRead) ) { 
    bytesRead = inChannel.read(buffer); 
} 

bufferFull()方法必須跟蹤有多少資料讀入緩衝區,並返回真或假,這取決於緩衝區是否已滿。換句話說,如果緩衝區準備好被處理,那麼表示緩衝區滿了。

bufferFull()方法掃描緩衝區,但必須保持在bufferFull()方法被呼叫之前狀態相同。如果沒有,下一個讀入緩衝區的資料可能無法讀到正確的位置。這是不可能的,但卻是需要注意的又一問題。

如果緩衝區已滿,它可以被處理。如果它不滿,並且在你的實際案例中有意義,你或許能處理其中的部分資料。但是許多情況下並非如此。下圖展示了“緩衝區資料迴圈就緒”:
這裡寫圖片描述
從一個通道里讀資料,直到所有的資料都讀到緩衝區裡

總結

如果需要管理同時開啟的成千上萬個連線,這些連線每次只是傳送少量的資料,例如聊天伺服器,實現NIO的伺服器可能是一個優勢。同樣,如果你需要 維持許多開啟的連線到其他計算機上,如P2P網路中,使用一個單獨的執行緒來管理你所有出站連線,可能是一個優勢。一個執行緒多個連線的設計方案如下圖所示:
這裡寫圖片描述
單執行緒管理多個連線

如果你有少量的連線使用非常高的頻寬,一次傳送大量的資料,也許典型的IO伺服器實現可能非常契合。下圖說明了一個典型的IO伺服器設計:
這裡寫圖片描述
一個典型的IO伺服器設計:一個連線通過一個執行緒處理

Channel(通道)
Java NIO的通道類似流,但又有些不同:
- 既可以從通道中讀取資料,又可以寫資料到通道。但流的讀寫通常是單向的。
- 通道可以非同步地讀寫。
- 通道中的資料總是要先讀到一個Buffer,或者總是要從一個Buffer中寫入。
正如上面所說,從通道讀取資料到緩衝區,從緩衝區寫入資料到通道。如下圖所示:
這裡寫圖片描述

Channel的實現
這些是Java NIO中最重要的通道的實現:
- FileChannel:從檔案中讀寫資料。
- DatagramChannel:能通過UDP讀寫網路中的資料。
- SocketChannel:能通過TCP讀寫網路中的資料。
- ServerSocketChannel:可以監聽新進來的TCP連線,像Web伺服器那樣。對每一個新進來的連線都會建立一SocketChannel。

基本的 Channel 示例
下面是一個使用FileChannel讀取資料到Buffer中的示例:

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw"); 
FileChannel inChannel = aFile.getChannel(); 

ByteBuffer buf = ByteBuffer.allocate(48); 

int bytesRead = inChannel.read(buf); 
while (bytesRead != -1) { 

System.out.println("Read " + bytesRead); 
buf.flip(); 

while(buf.hasRemaining()){ 
System.out.print((char) buf.get()); 
} 

buf.clear(); 
bytesRead = inChannel.read(buf); 
} 
aFile.close(); 

注意 buf.flip() 的呼叫,首先讀取資料到Buffer,然後反轉Buffer,接著再從Buffer中讀取資料。下一節會深入講解Buffer的更多細節。

Buffer(快取區)

Buffer的基本用法
使用Buffer讀寫資料一般遵循以下四個步驟:
1. 寫入資料到Buffer
2. 呼叫flip()方法
3. 從Buffer中讀取資料
4. 呼叫clear()方法或者compact()方法

當向buffer寫入資料時,buffer會記錄下寫了多少資料。一旦要讀取資料,需要通過flip()方法將Buffer從寫模式切換到讀模式。在讀模式下,可以讀取之前寫入到buffer的所有資料。

一旦讀完了所有的資料,就需要清空緩衝區,讓它可以再次被寫入。有兩種方式能清空緩衝區:呼叫clear()或compact()方法。 clear()方法會清空整個緩衝區。compact()方法只會清除已經讀過的資料。任何未讀的資料都被移到緩衝區的起始處,新寫入的資料將放到緩衝區 未讀資料的後面。
下面是一個使用Buffer的例子:

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();

//create buffer with capacity of 48 bytes
ByteBuffer buf = ByteBuffer.allocate(48);

int bytesRead = inChannel.read(buf); //read into buffer.
while (bytesRead != -1) {

  buf.flip();  //make buffer ready for read

  while(buf.hasRemaining()){
      System.out.print((char) buf.get()); // read 1 byte at a time
  }

  buf.clear(); //make buffer ready for writing
  bytesRead = inChannel.read(buf);
}
aFile.close();

Buffer的capacity,position和limit
緩衝區本質上是一塊可以寫入資料,然後可以從中讀取資料的記憶體。這塊記憶體被包裝成NIO Buffer物件,並提供了一組方法,用來方便的訪問該塊記憶體。

為了理解Buffer的工作原理,需要熟悉它的三個屬性:
- capacity
- position
- limit

position和limit的含義取決於Buffer處在讀模式還是寫模式。不管Buffer處在什麼模式,capacity的含義總是一樣的。

這裡有一個關於capacity,position和limit在讀寫模式中的說明,詳細的解釋在插圖後面。

這裡寫圖片描述

capacity
作為一個記憶體塊,Buffer有一個固定的大小值,也叫“capacity”.你只能往裡寫capacity個byte、long,char等型別。一旦Buffer滿了,需要將其清空(通過讀資料或者清除資料)才能繼續寫資料往裡寫資料。

position
當你寫資料到Buffer中時,position表示當前的位置。初始的position值為0.當一個byte、long等資料寫到Buffer 後, position會向前移動到下一個可插入資料的Buffer單元。position最大可為capacity – 1.

當讀取資料時,也是從某個特定位置讀。當將Buffer從寫模式切換到讀模式,position會被重置為0. 當從Buffer的position處讀取資料時,position向前移動到下一個可讀的位置。

limit
在寫模式下,Buffer的limit表示你最多能往Buffer裡寫多少資料。 寫模式下,limit等於Buffer的capacity。

當切換Buffer到讀模式時, limit表示你最多能讀到多少資料。因此,當切換Buffer到讀模式時,limit會被設定成寫模式下的position值。換句話說,你能讀到之前 寫入的所有資料(limit被設定成已寫資料的數量,這個值在寫模式下就是position)

Buffer的型別
Java NIO 有以下Buffer型別

  • ByteBuffer
  • MappedByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

p<>
如你所見,這些Buffer型別代表了不同的資料型別。換句話說,就是可以通過char,short,int,long,float 或 double型別來操作緩衝區中的位元組。

MappedByteBuffer 有些特別,在涉及它的專門章節中再講。

Buffer的分配
要想獲得一個Buffer物件首先要進行分配。 每一個Buffer類都有一個allocate方法。下面是一個分配48位元組capacity的ByteBuffer的例子。

ByteBuffer buf = ByteBuffer.allocate(48);

這是分配一個可儲存1024個字元的CharBuffer:

    CharBuffer buf = CharBuffer.allocate(1024);

向Buffer中寫資料

寫資料到Buffer有兩種方式:

  • 從Channel寫到Buffer。
  • 通過Buffer的put()方法寫到Buffer裡。

從Channel寫到Buffer的例子

int bytesRead = inChannel.read(buf); //read into buffer.

通過put方法寫Buffer的例子:

    buf.put(127);

put方法有很多版本,允許你以不同的方式把資料寫入到Buffer中。例如, 寫到一個指定的位置,或者把一個位元組陣列寫入到Buffer。 更多Buffer實現的細節參考JavaDoc。

flip()方法
flip方法將Buffer從寫模式切換到讀模式。呼叫flip()方法會將position設回0,並將limit設定成之前position的值。

換句話說,position現在用於標記讀的位置,limit表示之前寫進了多少個byte、char等 —— 現在能讀取多少個byte、char等。

  1. 從Buffer中讀取資料
  2. 從Buffer中讀取資料有兩種方式:

從Buffer讀取資料到Channel。
使用get()方法從Buffer中讀取資料。
從Buffer讀取資料到Channel的例子:

    //read from buffer into channel.
    int bytesWritten = inChannel.write(buf);

使用get()方法從Buffer中讀取資料的例子

    byte aByte = buf.get();

get方法有很多版本,允許你以不同的方式從Buffer中讀取資料。例如,從指定position讀取,或者從Buffer中讀取資料到位元組陣列。更多Buffer實現的細節參考JavaDoc。

rewind()方法
Buffer.rewind()將position設回0,所以你可以重讀Buffer中的所有資料。limit保持不變,仍然表示能從Buffer中讀取多少個元素(byte、char等)。

clear()與compact()方法
一旦讀完Buffer中的資料,需要讓Buffer準備好再次被寫入。可以通過clear()或compact()方法來完成。

如果呼叫的是clear()方法,position將被設回0,limit被設定成 capacity的值。換句話說,Buffer 被清空了。Buffer中的資料並未清除,只是這些標記告訴我們可以從哪裡開始往Buffer裡寫資料。

如果Buffer中有一些未讀的資料,呼叫clear()方法,資料將“被遺忘”,意味著不再有任何標記會告訴你哪些資料被讀過,哪些還沒有。

如果Buffer中仍有未讀的資料,且後續還需要這些資料,但是此時想要先先寫些資料,那麼使用compact()方法。

compact()方法將所有未讀的資料拷貝到Buffer起始處。然後將position設到最後一個未讀元素正後面。limit屬性依然像clear()方法一樣,設定成capacity。現在Buffer準備好寫資料了,但是不會覆蓋未讀的資料。

mark()與reset()方法
通過呼叫Buffer.mark()方法,可以標記Buffer中的一個特定position。之後可以通過呼叫Buffer.reset()方法恢復到這個position。例如:

    buffer.mark();

    //call buffer.get() a couple of times, e.g. during parsing.

    buffer.reset();  //set position back to mark.

equals()與compareTo()方法
可以使用equals()和compareTo()方法兩個Buffer。

equals()
當滿足下列條件時,表示兩個Buffer相等:

  1. 有相同的型別(byte、char、int等)。
  2. Buffer中剩餘的byte、char等的個數相等。
  3. Buffer中所有剩餘的byte、char等都相同。

如你所見,equals只是比較Buffer的一部分,不是每一個在它裡面的元素都比較。實際上,它只比較Buffer中的剩餘元素。

compareTo()方法
compareTo()方法比較兩個Buffer的剩餘元素(byte、char等), 如果滿足下列條件,則認為一個Buffer“小於”另一個Buffer:

  1. 第一個不相等的元素小於另一個Buffer中對應的元素 。
  2. 所有元素都相等,但第一個Buffer比另一個先耗盡(第一個Buffer的元素個數比另一個少)。
    (譯註:剩餘元素是從 position到limit之間的元素)