淺析Java NIO

zhong0316發表於2019-03-03

NIO概述

Java NIO全稱為Non-blocking IO或者New IO,從名字我們知道NIO是非阻塞的IO,而Java IO則是阻塞的IO。在一般的情況下阻塞是低效率的,特別是在高併發的場景下面,因此Java引入了NIO。NIO相比IO來說主要有以下幾個區別:

  1. NIO是面向緩衝區的,IO則面向流。
  • 標準的IO程式設計介面是面向位元組流和字元流的。而NIO是面向通道和緩衝區的,資料總是從通道中讀到buffer緩衝區內,或者從buffer緩衝區寫入到通道中;( NIO中的所有I/O操作都是通過一個通道開始的。)
  • Java IO面向流意味著每次從流中讀一個或多個位元組,直至讀取所有位元組,它們沒有被快取在任何地方;
  • Java NIO是面向快取的I/O方法。 將資料讀入緩衝器,使用通道進一步處理資料。 在NIO中,使用通道和緩衝區來處理I/O操作。
  1. NIO是非阻塞的,IO是阻塞的。
  • Java NIO使我們可以進行非阻塞IO操作。比如說,單執行緒中從通道讀取資料到buffer,同時可以繼續做別的事情,當資料讀取到buffer中後,執行緒再繼續處理資料。寫資料也是一樣的。另外,非阻塞寫也是如此。一個執行緒請求寫入一些資料到某通道,但不需要等待它完全寫入,這個執行緒同時可以去做別的事情。
  • Java IO的各種流是阻塞的。這意味著,當一個執行緒呼叫read() 或 write()時,該執行緒被阻塞,直到有一些資料被讀取,或資料完全寫入。該執行緒在此期間不能再幹任何事情了
  1. NIO有Selectors(多路複用器),而IO沒有Selectors。
  • 選擇器用於使用單個執行緒處理多個通道。因此,它需要較少的執行緒來處理這些通道。
  • 執行緒之間的切換對於作業系統來說是昂貴的。 因此,為了提高系統效率選擇器是有用的

NIO中主要有以下三個概念:通道、緩衝區和Selectors。

通道

Java NIO Channel通道和流非常相似,主要有以下幾點區別:

  • 通道可以讀也可以寫,流一般來說是單向的(只能讀或者寫)。
  • 通道可以非同步讀寫。
  • 通道總是基於緩衝區Buffer來讀寫。
nio-channel

Java的NIO讀寫都是在通道中進行的,通道涵蓋了網路UDP,TCP網路IO和檔案IO:

  • DatagramChannel
  • SocketChannel
  • FileChannel
  • ServerSocketChannel
    各個Channel的UML類圖如下:
NIO-Channels

DatagramChannel

DatagramChannel用於處理UDP連線。

開啟一個DatagramChannel

DatagramChannel channel = DatagramChannel.open();
channel.socket().bind(new InetSocketAddress(8888));
複製程式碼

讀取資料

ByteBuffer buf = ByteBuffer.allocate(1024);
buf.clear();
channel.receive(buf);
複製程式碼

傳送資料

String msg = "Current time is: " + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(1024);
buf.clear();
buf.put(msg.getBytes());
buf.flip();
int bytesSent = channel.send(buf, new InetSocketAddress("host", port));
複製程式碼

SocketChannel

開啟 SocketChannel

SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("host", 80));
複製程式碼

讀取資料

ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);
複製程式碼

如果 read()返回 -1, 表明連線已經中斷。

寫入資料

String msg = "Current Time is: " + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(msg.getBytes());

buf.flip();

while(buf.hasRemaining()) {
    channel.write(buf);
}
複製程式碼

非阻塞模式

socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("host", 80));

while (!socketChannel.finishConnect()) {
      
}
複製程式碼

我們可以設定 SocketChannel 為非同步模式, 這樣 connect, read, write 都是非同步的了。在非同步模式中, 或許連線還沒有建立, connect 方法就返回了, 因此我們需要檢查當前是否是連線到了主機,因此通過一個 while 迴圈來判斷。

FileChannel

開啟

RandomAccessFile aFile = new RandomAccessFile("test.txt", "rw");
FileChannel inChannel = aFile.getChannel();
複製程式碼

讀取

ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);
複製程式碼

寫入

String newData = "Current time is: " + System.currentTimeMillis();

ByteBuffer buf = ByteBuffer.allocate(1024);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while (buf.hasRemaining()) {
    channel.write(buf);
}
複製程式碼

關閉

channel.close();
複製程式碼

設定 position

long pos = channel.position();
channel.position(pos + 123);
複製程式碼

檔案大小

我們可以通過 channel.size()獲取關聯到這個 Channel 中的檔案的大小。注意, 這裡返回的是檔案的大小, 而不是 Channel 中剩餘的元素個數。

截斷檔案

channel.truncate(1024);
複製程式碼

將檔案的大小截斷為1024位元組。

強制寫入

channel.force(true);
複製程式碼

強制將快取中的資料寫入檔案中:

ServerSocketChannel

ServerSocketChannel顧名思義,它是用來監聽server端的socket連線。
開啟和關閉

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.close();
複製程式碼

監聽連線

使用ServerSocketChannel的accept()方法來監聽客戶端的TCP連線請求,accept()方法是阻塞的,直到有連線進來。

while(true){
    SocketChannel socketChannel = serverSocketChannel.accept();
    //do something with socketChannel...
}
複製程式碼

非阻塞模式

如果設定ServerSocketChannel是非阻塞的,則accept()方法不會阻塞。如果返回的是null證明沒有新的連線,如果不是null,則有新的連線請求。

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);
while (true) {
    SocketChannel socketChannel = serverSocketChannel.accept();
    if(socketChannel != null) {
        // do something with socketChannel...
    }
}
複製程式碼

Buffer緩衝區

Java NIO Buffers用於和NIO Channel互動。正如你已經知道的,我們從channel中讀取資料到buffers裡,從buffer把資料寫入到channels。

buffer本質上就是一塊記憶體區,可以用來寫入資料,並在稍後讀取出來。這塊記憶體被NIO Buffer包裹起來,對外提供一系列的讀寫方便開發的介面。

Buffer基本用法

利用Buffer讀寫資料,通常遵循四個步驟:

  • 把資料寫入buffer;
  • 呼叫flip;
  • 從Buffer中讀取資料;
  • 呼叫buffer.clear()或者buffer.compact()

當寫入資料到buffer中時,buffer會記錄已經寫入的資料大小。當需要讀資料時,通過flip()方法把buffer從寫模式調整為讀模式;在讀模式下,可以讀取所有已經寫入的資料。

當讀取完資料後,需要清空buffer,以滿足後續寫入操作。清空buffer有兩種方式:呼叫clear()或compact()方法。clear會清空整個buffer,compact則只清空已讀取的資料,未被讀取的資料會被移動到buffer的開始位置,寫入位置則近跟著未讀資料之後。

這裡有一個簡單的buffer案例,包括了write,flip和clear操作:

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的容量,位置,上限(Buffer Capacity, Position and Limit)
buffer緩衝區實質上就是一塊記憶體,用於寫入資料,也供後續再次讀取資料。這塊記憶體被NIO Buffer管理,並提供一系列的方法用於更簡單的操作這塊記憶體。

一個Buffer有三個屬性是必須掌握的,分別是:

  • capacity容量
  • position位置
  • limit限制

position和limit的具體含義取決於當前buffer的模式。capacity在兩種模式下都表示容量。

下面有張示例圖,描訴了不同模式下position和limit的含義:

nio-buffers-modes

容量(Capacity)

作為一塊記憶體,buffer有一個固定的大小,叫做capacity容量。也就是最多隻能寫入容量值得位元組,整形等資料。一旦buffer寫滿了就需要清空已讀資料以便下次繼續寫入新的資料。

位置(Position)

當寫入資料到Buffer的時候需要中一個確定的位置開始,預設初始化時這個位置position為0,一旦寫入了資料比如一個位元組,整形資料,那麼position的值就會指向資料之後的一個單元,position最大可以到capacity – 1。

當從Buffer讀取資料時,也需要從一個確定的位置開始。buffer從寫入模式變為讀取模式時,position會歸零,每次讀取後,position向後移動。

上限(Limit)

在寫模式,limit的含義是我們所能寫入的最大資料量。它等同於buffer的容量。

一旦切換到讀模式,limit則代表我們所能讀取的最大資料量,他的值等同於寫模式下position的位置。

資料讀取的上限時buffer中已有的資料,也就是limit的位置(原position所指的位置)。

Buffer Types

Java NIO有如下具體的Buffer型別:

ByteBuffer
MappedByteBuffer
CharBuffer
DoubleBuffer
FloatBuffer
IntBuffer
LongBuffer
ShortBuffer
正如你看到的,Buffer的型別代表了不同資料型別,換句話說,Buffer中的資料可以是上述的基本型別;

分配一個Buffer(Allocating a Buffer)

為了獲取一個Buffer物件,你必須先分配。每個Buffer實現類都有一個allocate()方法用於分配記憶體。下面看一個例項,開闢一個48位元組大小的buffer:

ByteBuffer buf = ByteBuffer.allocate(48);
開闢一個1024個字元的CharBuffer:

CharBuffer buf = CharBuffer.allocate(1024);

寫入資料到Buffer(Writing Data to a Buffer)

寫資料到Buffer有兩種方法:

  • 從Channel中寫資料到Buffer。
  • 手動寫資料到Buffer,呼叫put方法。
    下面是一個例項,演示從Channel寫資料到Buffer:
    int bytesRead = inChannel.read(buf); // read into buffer
    通過put寫資料:

buf.put(127);
put方法有很多不同版本,對應不同的寫資料方法。例如把資料寫到特定的位置,或者把一個位元組資料寫入buffer。看考JavaDoc文件可以查閱的更多資料。

翻轉(flip())

flip()方法可以吧Buffer從寫模式切換到讀模式。呼叫flip方法會把position歸零,並設定limit為之前的position的值。也就是說,現在position代表的是讀取位置,limit表示的是已寫入的資料位置。

從Buffer讀取資料(Reading Data from a Buffer)
衝Buffer讀資料也有兩種方式。

  • 從buffer讀資料到channel
  • 從buffer直接讀取資料,呼叫get方法
    讀取資料到channel的例子:
// read from buffer into channel.
int bytesWritten = inChannel.write(buf);
複製程式碼

呼叫get讀取資料的例子:

byte aByte = buf.get();
get也有諸多版本,對應了不同的讀取方式。

rewind()

Buffer.rewind()方法將position置為0,這樣我們可以重複讀取buffer中的資料。limit保持不變。

clear() and compact()

一旦我們從buffer中讀取完資料,需要複用buffer為下次寫資料做準備。只需要呼叫clear或compact方法。

clear方法會重置position為0,limit為capacity,也就是整個Buffer清空。實際上Buffer中資料並沒有清空,我們只是把標記為修改了。

如果Buffer還有一些資料沒有讀取完,呼叫clear就會導致這部分資料被“遺忘”,因為我們沒有標記這部分資料未讀。

針對這種情況,如果需要保留未讀資料,那麼可以使用compact。 因此compact和clear的區別就在於對未讀資料的處理,是保留這部分資料還是一起清空。

mark() and reset()

通過mark方法可以標記當前的position,通過reset來恢復mark的位置,這個非常像canvas的save和restore:

buffer.mark();

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

buffer.reset();  // set position back to mark.    
複製程式碼

Selector

Selector是Java NIO中的一個元件,用於檢查一個或多個NIO Channel的狀態是否處於可讀、可寫。如此可以實現單執行緒管理多個channels,也就是可以管理多個網路連結。

為什麼使用Selector(Why Use a Selector?)

用單執行緒處理多個channels的好處是我需要更少的執行緒來處理channel。實際上,你甚至可以用一個執行緒來處理所有的channels。從作業系統的角度來看,切換執行緒開銷是比較昂貴的,並且每個執行緒都需要佔用系統資源,因此暫用執行緒越少越好。

需要留意的是,現代作業系統和CPU在多工處理上已經變得越來越好,所以多執行緒帶來的影響也越來越小。如果一個CPU是多核的,如果不執行多工反而是浪費了機器的效能。不過這些設計討論是另外的話題了。簡而言之,通過Selector我們可以實現單執行緒操作多個channel。

這有一幅示意圖,描述了單執行緒處理三個channel的情況:

nio-selector

建立Selector(Creating a Selector)

建立一個Selector可以通過Selector.open()方法:
Selector selector = Selector.open();

註冊Channel到Selector上(Registering Channels with the Selector)

為了同Selector掛了Channel,我們必須先把Channel註冊到Selector上,這個操作使用SelectableChannel.register():

channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
複製程式碼

Channel必須是非阻塞的。所以FileChannel不適用Selector,因為FileChannel不能切換為非阻塞模式。Socket channel可以正常使用。

注意register的第二個引數,這個引數是一個“關注集合”,代表我們關注的channel狀態,有四種基礎型別可供監聽:

  1. Connect
  2. Accept
  3. Read
  4. Write

一個channel觸發了一個事件也可視作該事件處於就緒狀態。因此當channel與server連線成功後,那麼就是“連線就緒”狀態。server channel接收請求連線時處於“可連線就緒”狀態。channel有資料可讀時處於“讀就緒”狀態。channel可以進行資料寫入時處於“寫就緒”狀態。

上述的四種就緒狀態用SelectionKey中的常量表示如下:

  1. SelectionKey.OP_CONNECT
  2. SelectionKey.OP_ACCEPT
  3. SelectionKey.OP_READ
  4. SelectionKey.OP_WRITE

如果對多個事件感興趣可利用位的或運算結合多個常量,比如:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

SelectionKey`s

在上一小節中,我們利用register方法把Channel註冊到了Selectors上,這個方法的返回值是SelectionKeys,這個返回的物件包含了一些比較有價值的屬性:

  • The interest set
  • The ready set
  • The Channel
  • The Selector
  • An attached object (optional)

Interest Set

這個“關注集合”實際上就是我們希望處理的事件的集合,它的值就是註冊時傳入的引數,我們可以用按為與運算把每個事件取出來:

int interestSet = selectionKey.interestOps();

boolean isInterestedInAccept  = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE; 
複製程式碼

Ready Set

“就緒集合”中的值是當前channel處於就緒的值,一般來說在呼叫了select方法後都會需要用到就緒狀態,select的介紹在鬍鬚文章中繼續展開。

int readySet = selectionKey.readyOps();

從“就緒集合”中取值的操作類似於“關注集合”的操作,當然還有更簡單的方法,SelectionKey提供了一系列返回值為boolean的的方法:

selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();
複製程式碼

Channel + Selector

從SelectionKey操作Channel和Selector非常簡單:

Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector();   
複製程式碼

Attaching Objects

我們可以給一個SelectionKey附加一個Object,這樣做一方面可以方便我們識別某個特定的channel,同時也增加了channel相關的附加資訊。例如,可以把用於channel的buffer附加到SelectionKey上:

selectionKey.attach(theObject);

Object attachedObj = selectionKey.attachment();
複製程式碼

附加物件的操作也可以在register的時候就執行:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

從Selector中選擇channel(Selecting Channels via a Selector)

一旦我們向Selector註冊了一個或多個channel後,就可以呼叫select來獲取channel。select方法會返回所有處於就緒狀態的channel。 select方法具體如下:

  • int select()
  • int select(long timeout)
  • int selectNow()
    select()方法在返回channel之前處於阻塞狀態。 select(long timeout)和select做的事一樣,不過他的阻塞有一個超時限制。

selectNow()不會阻塞,根據當前狀態立刻返回合適的channel。

select()方法的返回值是一個int整形,代表有多少channel處於就緒了。也就是自上一次select後有多少channel進入就緒。舉例來說,假設第一次呼叫select時正好有一個channel就緒,那麼返回值是1,並且對這個channel做任何處理,接著再次呼叫select,此時恰好又有一個新的channel就緒,那麼返回值還是1,現在我們一共有兩個channel處於就緒,但是在每次呼叫select時只有一個channel是就緒的。

selectedKeys()

在呼叫select並返回了有channel就緒之後,可以通過選中的key集合來獲取channel,這個操作通過呼叫selectedKeys()方法:

Set<SelectionKey> selectedKeys = selector.selectedKeys();
還記得在register時的操作吧,我們register後的返回值就是SelectionKey例項,也就是我們現在通過selectedKeys()方法所返回的SelectionKey。

遍歷這些SelectionKey可以通過如下方法:

Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {
        // a connection was established with a remote server.
    } else if (key.isReadable()) {
        // a channel is ready for reading
    } else if (key.isWritable()) {
        // a channel is ready for writing
    }
    keyIterator.remove();
}
複製程式碼

上述迴圈會迭代key集合,針對每個key我們單獨判斷他是處於何種就緒狀態。

注意keyIterator.remove()方法的呼叫,Selector本身並不會移除SelectionKey物件,這個操作需要我們收到執行。當下次channel處於就緒是,Selector任然會吧這些key再次加入進來。

SelectionKey.channel返回的channel例項需要強轉為我們實際使用的具體的channel型別,例如ServerSocketChannel或SocketChannel.

wakeUp()

由於呼叫select而被阻塞的執行緒,可以通過呼叫Selector.wakeup()來喚醒即便此時已然沒有channel處於就緒狀態。具體操作是,在另外一個執行緒呼叫wakeup,被阻塞與select方法的執行緒就會立刻返回。

close()

當操作Selector完畢後,需要呼叫close方法。close的呼叫會關閉Selector並使相關的SelectionKey都無效。channel本身不管被關閉。

完整的Selector案例

這有一個完整的案例,首先開啟一個Selector,然後註冊channel,最後檢測Selector的狀態:

Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while (true) {
    int readyChannels = selector.select();
    if (readyChannels == 0) continue;
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
    while (keyIterator.hasNext()) {
        SelectionKey key = keyIterator.next();
        if (key.isAcceptable()) {
            // a connection was accepted by a ServerSocketChannel.
        } else if (key.isConnectable()) {
            // a connection was established with a remote server.
        } else if (key.isReadable()) {
            // a channel is ready for reading
        } else if (key.isWritable()) {
            // a channel is ready for writing
        }
        keyIterator.remove();
    }
}
複製程式碼

參考資料

tutorials.jenkov.com/java-nio/in…

相關文章