NIO概述
Java NIO全稱為Non-blocking IO或者New IO,從名字我們知道NIO是非阻塞的IO,而Java IO則是阻塞的IO。在一般的情況下阻塞是低效率的,特別是在高併發的場景下面,因此Java引入了NIO。NIO相比IO來說主要有以下幾個區別:
- NIO是面向緩衝區的,IO則面向流。
- 標準的IO程式設計介面是面向位元組流和字元流的。而NIO是面向通道和緩衝區的,資料總是從通道中讀到buffer緩衝區內,或者從buffer緩衝區寫入到通道中;( NIO中的所有I/O操作都是通過一個通道開始的。)
- Java IO面向流意味著每次從流中讀一個或多個位元組,直至讀取所有位元組,它們沒有被快取在任何地方;
- Java NIO是面向快取的I/O方法。 將資料讀入緩衝器,使用通道進一步處理資料。 在NIO中,使用通道和緩衝區來處理I/O操作。
- NIO是非阻塞的,IO是阻塞的。
- Java NIO使我們可以進行非阻塞IO操作。比如說,單執行緒中從通道讀取資料到buffer,同時可以繼續做別的事情,當資料讀取到buffer中後,執行緒再繼續處理資料。寫資料也是一樣的。另外,非阻塞寫也是如此。一個執行緒請求寫入一些資料到某通道,但不需要等待它完全寫入,這個執行緒同時可以去做別的事情。
- Java IO的各種流是阻塞的。這意味著,當一個執行緒呼叫read() 或 write()時,該執行緒被阻塞,直到有一些資料被讀取,或資料完全寫入。該執行緒在此期間不能再幹任何事情了
- NIO有Selectors(多路複用器),而IO沒有Selectors。
- 選擇器用於使用單個執行緒處理多個通道。因此,它需要較少的執行緒來處理這些通道。
- 執行緒之間的切換對於作業系統來說是昂貴的。 因此,為了提高系統效率選擇器是有用的
NIO中主要有以下三個概念:通道、緩衝區和Selectors。
通道
Java NIO Channel通道和流非常相似,主要有以下幾點區別:
- 通道可以讀也可以寫,流一般來說是單向的(只能讀或者寫)。
- 通道可以非同步讀寫。
- 通道總是基於緩衝區Buffer來讀寫。
Java的NIO讀寫都是在通道中進行的,通道涵蓋了網路UDP,TCP網路IO和檔案IO:
- DatagramChannel
- SocketChannel
- FileChannel
- ServerSocketChannel
各個Channel的UML類圖如下:
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的含義:
容量(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的情況:
建立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狀態,有四種基礎型別可供監聽:
- Connect
- Accept
- Read
- Write
一個channel觸發了一個事件也可視作該事件處於就緒狀態。因此當channel與server連線成功後,那麼就是“連線就緒”狀態。server channel接收請求連線時處於“可連線就緒”狀態。channel有資料可讀時處於“讀就緒”狀態。channel可以進行資料寫入時處於“寫就緒”狀態。
上述的四種就緒狀態用SelectionKey中的常量表示如下:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- 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();
}
}
複製程式碼