Java NIO
NIO 是 New I/O 的簡稱,是 JDK 1.4 新增的功能,之所以稱其爲 New I/O,原因在於它相對於之前的 I/O 類庫是新增的。由於之前老的 I/O 類庫是阻塞 I/O,New I/O 類庫的目標就是要讓 Java 支持非阻塞 I/O,所以也有很多人喜歡稱其爲 Non-block I/O,即非阻塞 I/O。
NIO 的文件讀寫設計顛覆了傳統 IO 的設計,採用『通道』+『緩存區』使得新式的 I/O 操作直接面向緩存區。NIO 彌補了原來同步阻塞 I/O 的不足,它在標準 Java 代碼中提供了高速的、面向塊的 I/O。通過定義包含數據的類,以及通過以塊的形式處理這些數據,NIO 不用使用本機代碼就可以利用低級優化,這是原來的 I/O 包所無法做到的。
通道
在 NIO 中,通道用Channel
表示,網絡數據通過Channel
讀取和寫入。通道與流的不同之處在於通道是雙向的,流只是在一個方向上移動(一個流必須是InputStream
或者OutputStream
的子類),而通道可以用於讀、寫或者二者同時進行。因爲Channel
是全雙工的,所以它可以比流更好地映射底層操作系統的 API。特別地,在 UNIX 網絡編程模型中,底層操作系統的通道都是全雙工的,同時支持讀寫操作。通道的工作模式大致如下:
在這裏,我們要明白一點,通道和流都是需要基於物理文件的,而每個流或者通道都通過文件指針操作文件,這裏說的「通道是雙向的」也是有前提的,那就是通道基於隨機訪問文件RandomAccessFile
的可讀可寫文件指針。RandomAccessFile
是既可讀又可寫的,所以基於它的通道是雙向的,所以「通道是雙向的」這句話是有前提的,不能斷章取義。基本的通道類型包括:
FileChannel
DatagramChannel
SocketChannel
ServerSocketChannel
其中,FileChannel
是基於文件的通道,SocketChannel
和ServerSocketChannel
用於網絡 TCP 套接字數據報讀寫的通道,DatagramChannel
是用於網絡 UDP 套接字數據報讀寫的通道。
通道不能單獨存在,它永遠需要綁定一個緩存區,所有的數據只會存在於緩存區中,無論你是寫或是讀,必然是緩存區通過通道到達磁盤文件,或是磁盤文件通過通道到達緩存區,即緩存區是數據的起點也是終點。
緩衝區
在 NIO 中,緩衝區用Buffer
表示,它包含一些要寫入或者要讀出的數據。Buffer
是所有具體緩存區的基類,是一個抽象類,它的實現類有很多,包含各種類型數據的緩存。
ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer
MappedByteBuffer
在 NIO 類庫中加入Buffer
對象,體現了新庫與老 I/O 的一個重要區別。在面向流的 I/O 中,可以將數據直接寫入或者將數據直接讀到Stream
對象中。在 NIO 庫中,所有數據都是用緩衝區處理的。在讀取數據時,它是直接讀到緩衝區中的;在寫入數據時,寫入到緩衝區中。任何時候訪問 NIO 中的數據,都是通過緩衝區進行操作。緩衝區實質上是一個數組,但它不僅僅是一個數組,緩衝區提供了對數據的結構化訪問以及維護讀寫位置等信息。
我們以ByteBuffer
爲例進行學習,其餘的緩存區也都是基於字節緩存區的,只不過多了一步字節轉換過程而已,MappedByteBuffer
是一個特殊的緩存方式,稍後單獨介紹。Buffer
中有幾個重要的成員屬性,我們先來了解一下:
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
long address;
其中,mark
屬性用於重複讀;capacity
描述緩存區容量,即整個緩存區最大能存儲多少數據量;address
用於操作直接內存,區別於 JVM 內存。而position
和limit
,可以用下面這張圖解釋:
由於緩存區是讀寫共存的,所以不同的模式下,這兩個變量的值也具有不同的意義。
- 寫模式下,所謂寫模式就是將緩存區中的內容寫入通道。
position
代表下一個字節應該被寫出去的字節在緩存區中的位置,limit
表示最後一個待寫字節在緩存區的位置。 - 讀模式下,所謂讀模式就是從通道讀取數據到緩存區。
position
代表下一個讀出來的字節應當存儲在緩存區的位置,limit
等於capacity
。
對於 JVM 我們有一個共識,那就是內存可以劃分爲棧和堆,但其實劃分給 JVM 的還有一塊堆外內存,也就是直接內存。這是一塊物理內存,專門用於 JVM 和 I/O 設備打交道,Java 底層使用 C 語言的 API 調用操作系統與 I/O 設備進行交互。
例如,Java 內存中有一個字節數組,現在調用流將它寫入磁盤文件,那麼 JVM 首先會將這個字節數組先拷貝一份到堆外內存中,然後調用 C 語言 API 指明將某個連續地址範圍的數據寫入磁盤。讀操作也是類似,而 JVM 額外做的拷貝工作也是有意義的,因爲 JVM 是基於自動垃圾回收機制運行的,所有內存中的數據會在 GC 時不停的被移動,如果你調用系統 API 告訴操作系統將內存某某位置的內存寫入磁盤,而此時發生 GC 移動了該部分數據,GC 結束後操作系統是不是就寫錯數據了。
所以,JVM 對於與外圍 I/O 設備交互的情況下,都會將內存數據複製一份到堆外內存中,然後調用系統 API 間接的寫入磁盤,讀也是類似的。由於堆外內存不受 GC 管理,所以用完之後一定要記得釋放。
代碼示例
// Step 1
RandomAccessFile file = new RandomAccessFile("C:\\Users\\niogeek\\Desktop\\testNIO.txt","rw");
FileChannel channel = file.getChannel();
// Step 2
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);
// Step 3
buffer.flip();
byte[] res = new byte[1024];
buffer.get(res, 0, buffer.limit());
System.out.println(new String(res));
// Step 4
channel.close();
我們看這麼一段代碼,這段代碼大致分成了四個部分,第一部分用於獲取文件通道,第二部分用於分配緩存區並完成讀操作,第三部分用於將緩存區中數據進行打印,第四部分爲關閉通道連接。
第一部分
getChannel
方法用於獲取一個文件相關的通道實例,具體實現如下:
public final FileChannel getChannel() {
synchronized (this) {
if (channel == null) {
channel = FileChannelImpl.open(fd, path, true, rw, this);
}
return channel;
}
}
public static FileChannel open(FileDescriptor var0, String var1, boolean var2, boolean var3, Object var4) {
return new FileChannelImpl(var0, var1, var2, var3, false, var4);
}
getChannel
方法會調用FileChannelImpl
的工廠方法構建一個FileChannelImpl
實例,FileChannelImpl
是抽象類FileChannel
的一個子類實現。
構成FileChannelImpl
實例所需的必要參數有,該文件的文件指針,該文件的完整路徑,讀寫權限等。
第二部分
Buffer
的基本結構我們已經介紹過了,這裏不再贅述,所謂的緩存區,本質上就是字節數組。
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
return new HeapByteBuffer(capacity, capacity);
}
ByteBuffer
實例的構建是通過工廠模式產生的,必須指定參數capacity
作爲內部字節數組的容量。HeapByteBuffer
是虛擬機的堆上內存,所有數據都將存儲在堆空間,還有一個相對的DirectByteBuffer
,它被分配在堆外內存中。這個HeapByteBuffer
的構造情況我們不妨跟進去看看:
HeapByteBuffer(int cap, int lim) {
super(-1, 0, lim, cap, new byte[cap], 0);
}
調用父類的構造方法,初始化我們在ByteBuffer
中提過的一些屬性值,如position
、capacity
、mark
、limit
、offset
以及字節數組hb
。接着,我們看看這個read
方法的調用鏈:
public int read(ByteBuffer var1) throws IOException {
this.ensureOpen();
if(!this.readable) {
throw new NonReadableChannelException();
} else {
Object var2 = this.positionLock;
synchronized(this.positionLock) {
int var3 = 0;
int var4 = -1;
byte var5;
try {
this.begin();
var4 = this.threads.add();
if(this.isOpen()) {
do {
var3 = IOUtil.read(this.fd, var1, -1L, this.nd);
} while(var3 == -3 && this.isOpen());
int var12 = IOStatus.normalize(var3);
return var12;
}
var5 = 0;
} finally {
this.threads.remove(var4);
this.end(var3 > 0);
assert IOStatus.check(var3);
}
return var5;
}
}
}
這個read
方法是子類FileChannelImpl
對父類FileChannel.read
方法的重寫。這個方法不是讀操作的核心,我們簡單概括一下,該方法首先會拿到當前通道實例的鎖,如果沒有被其他線程佔有,那麼佔有該鎖,並調用IOUtil
的read
方法。
static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if(var1.isReadOnly()) {
throw new IllegalArgumentException("Read-only buffer");
} else if(var1 instanceof DirectBuffer) {
return readIntoNativeBuffer(var0, var1, var2, var4);
} else {
ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());
int var7;
try {
int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
var5.flip();
if(var6 > 0) {
var1.put(var5);
}
var7 = var6;
} finally {
Util.offerFirstTemporaryDirectBuffer(var5);
}
return var7;
}
}
IOUtil
的read
方法內部也調用了很多方法,有的甚至是本地方法,這裏只簡單介紹一下整個read
方法的大體邏輯,具體細節留待大家自行學習。
- 首先判斷我們的
ByteBuffer
實例是不是一個DirectBuffer
,也就是判斷當前的ByteBuffer
實例是不是被分配在直接內存中,- 如果是,那麼將調用
readIntoNativeBuffer
方法從磁盤讀取數據直接放入ByteBuffer
實例所在的直接內存中。 - 否則,虛擬機將在直接內存區域分配一塊內存,該內存區域的首地址存儲在
var5
實例的address
屬性中。
- 如果是,那麼將調用
- 接着從磁盤讀取數據放入
var5
所代表的直接內存區域中。 - 最後,
put
方法會將var5
所代表的直接內存區域中的數據寫入到var1
所代表的堆內緩存區並釋放臨時創建的直接內存空間。
這樣,我們傳入的緩存區中就成功的被讀入了數據。寫操作是相反的,大家可以自行類比,反正堆內數據想要到達磁盤就必定要經過堆外內存的複製過程。
第三第四部分比較簡單,這裏就不再贅述了。提醒一下,想要更好的使用這個通道和緩存區進行文件讀寫操作,我們就一定得對緩存區的幾個變量的值時刻把握住,position
和limit
當前的值是什麼,大致什麼位置,一定得清晰,否則這個讀寫共存的緩存區可能會讓我們暈頭轉向。
選擇器
Selector
是 Java NIO 的一個組件,它用於監聽多個Channel
的各種狀態,用於管理多個Channel
。但本質上由於FileChannel
不支持註冊選擇器,所以Selector
一般被認爲是服務於網絡套接字通道的。
而大家口中的「NIO 是非阻塞的」,準確來說,指的是網絡編程中客戶端與服務端連接交換數據的過程是非阻塞的。普通的文件讀寫依然是阻塞的,和 IO 是一樣的,這一點可能很多初學者會懵,需要好好理解。
創建一個選擇器一般是通過Selector
的工廠方法,Selector.open
:
Selector selector = Selector.open();
而一個通道想要註冊到某個選擇器中,必須調整模式爲非阻塞模式,例如:
//創建一個 TCP 套接字通道
SocketChannel channel = SocketChannel.open();
//調整通道爲非阻塞模式
channel.configureBlocking(false);
//向選擇器註冊一個通道
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
以上代碼是註冊一個通道到選擇器中的最簡單版本,支持註冊選擇器的通道都有一個register
方法,該方法就是用於註冊當前實例通道到指定選擇器的。該方法的第一個參數就是目標選擇器,第二個參數其實是一個二進制掩碼,它指明當前選擇器感興趣當前通道的哪些事件。以枚舉類型提供了以下幾種取值:
int OP_READ = 1 << 0;
int OP_WRITE = 1 << 2;
int OP_CONNECT = 1 << 3;
int OP_ACCEPT = 1 << 4;
這種用二進制掩碼來表示某些狀態的機制,和虛擬機類文件結構類似,它就是用一個二進制位來描述一種狀態。
register
方法會返回一個SelectionKey
實例,該實例代表的就是選擇器與通道的一個關聯關係。我們可以調用它的selector
方法返回當前相關聯的選擇器實例,也可以調用它的channel
方法返回當前關聯關係中的通道實例。
除此之外,SelectionKey
的readyOps
方法將返回當前選擇感興趣當前通道中事件中準備就緒的事件集合,依然返回的一個整型數值,也就是一個二進制掩碼。例如:
int readySet = selectionKey.readyOps();
假如readySet
的值爲13
,二進制爲0000 1101
,從後向前數,第一位爲1
,第三位爲1
,第四位爲1
,那麼說明選擇器關聯的通道,讀就緒、寫就緒,連接就緒。所以,當我們註冊一個通道到選擇器之後,就可以通過返回的SelectionKey
實例監聽該通道的各種事件。
當然,一旦某個選擇器中註冊了多個通道,我們不可能一個一個的記錄它們註冊時返回的SelectionKey
實例來監聽通道事件,選擇器的selectedKeys
方法可以返回所有註冊成功的通道相關的SelectionKey
實例。
Set<SelectionKey> keys = selector.selectedKeys();
selectedKeys
方法會返回選擇器中註冊成功的所有通道的SelectionKey
實例集合。我們通過這個集合的SelectionKey
實例,可以得到所有通道的事件就緒情況並進行相應的處理操作。下面我們以一個簡單的客戶端服務端連接通訊的實例應用一下上述理論知識:
服務端代碼:
這段小程序的運行的實際效果是這樣的,客戶端建立請求到服務端,待請求完全建立,客戶端會去檢查服務端是否有數據寫回,而服務端的任務就很簡單了,接受任意客戶端的請求連接併爲它寫回一段數據。
別看整個過程很簡單,但只要我們有一點模糊的地方,這個功能就不可能實現。這其實也算一個最最簡單的服務器客戶端請求模型了,理解了這一點相信會有助於理解瀏覽器與 Web 服務器的工作原理。
想必大家也能發現,加了選擇器的代碼會複雜很多,也並不一定高效於原來的代碼,這其實是因爲我們的功能比較簡單,並不涉及大量通道處理,邏輯一旦複雜起來,選擇器給我們帶來的好處會非常明顯。
Socket 處理粘包 & 斷包問題
NIO Socket 是非阻塞的通訊模式,與 IO 阻塞式的通訊不同點在於 NIO 的數據要通過Channel
放到一個緩存池ByteBuffer
中,然後再從這個緩存池中讀出數據,而 IO 的模式是直接從InputStream
中read
。所以對於 NIO,由於存在緩存池的大小限制和網速的不均勻會造成一次讀的操作放入緩存池中的數據不完整,便形成了斷包問題。同理,如果一次性讀入兩個及兩個以上的數據,則無法分辨兩個數據包的界限問題,也就造成了粘包。
對於 NIO 的SocketChannel
每次觸發OP_READ
事件時,發送端不一定僅僅寫入了一次,同理,發送端如果一次發送數據包過大,那麼發送端的一次寫入也可能會被拆分成兩次OP_READ
事件,所以OP_READ
事件和發送端的OP_WRITE
事件並不是一一對應的。
第一個問題:對於粘包問題的解決
粘包問題主要是由於數據包界限不清,所以這個問題比較好解決,最好的解決辦法就是在發送數據包前事先發送一個int
型數據,該數據代表將要發送的數據包的大小,這樣接收端可以每次觸發OP_READ
的時候先接受一個int
大小的數據段到緩存池中,然後,緊接着讀出後續完整的大小的包,這樣就會處理掉粘包問題。因爲channel.read()
方法不能給讀取數據的大小的參數,所以無法手動指定讀取數據段的大小。但每次調用channel.read()
返回的是他實際讀取的大小。
這樣,思路就有了:首先調整緩存池的大小固定爲要讀出數據段的大小,這樣保證不會過量讀出。由於OP_READ
和OP_WRITE
不是一一對應的,所以一次OP_READ
可以while
循環調用channel.read()
不停讀取Channel
中的數據到緩存池,並捕獲其返回值,當返回值累計達到要讀取數據段大小時break
掉循環,這樣保證數據讀取充足。所以這樣就完美解決粘包問題。
第二個問題:對於斷包問題的解決
斷包問題主要是由於數據包過量讀入時,緩存池結尾處只有半個數據包,Channel
裏還有半個數據包,這樣造成了這個包無法處理的問題。這個問題的解決思路是保證每次不過量讀入,這樣也就不存在斷包了。還是因爲channel.read()
的讀取不可控的原因,所以無法從read
函數中控制讀取大小,還是從緩存池入手。方法是調整緩存池的大小爲要讀數據的大小,這樣就不會斷包。
示例代碼
- 發送端
private void sendIntoChannel() {
Runnable run = new Runnable() {
@Override
public void run() {
try {
ByteArrayOutputStream bOut;
ObjectOutputStream out;
CBaseDataBean cbdb;
ByteBuffer bb = ByteBuffer.allocate(MemCache);
while (true) {
cbdb = CloudServer.cdsq.read();//Blocking Method
//處理自我命令:斷開連接 退出線程
if (cbdb.getDataType() == CMsgTypeBean.MSG_TYPE_CUTDOWN) {
break;
}
bOut = new ByteArrayOutputStream();
out = new ObjectOutputStream(bOut);
out.writeObject(cbdb);
out.flush();
//構造發送數據:整型數據頭+有效數據段
byte[] arr = bOut.toByteArray();
final int ObjLength = arr.length; //獲取有效數據段長度
bb.clear();
bb.limit(IntLength + ObjLength); //調整緩存池大小
bb.putInt(ObjLength);
bb.put(arr);
bb.position(0); //調整重置讀寫指針
SocketChannel channel = (SocketChannel) key.channel();
channel.write(bb);
out.close();
bOut.close();
}
} catch (IOException ex) {
}
}
};
CloudServer.cstp.putNewThread(run);
}
- 接收端
/**
* 開闢線程分發消息
*/
private void Dispatcher() {
Runnable run = new Runnable() {
@Override
public void run() {
try {
while (true) {
selector.selectNow();
Thread.sleep(100);
Iterator<SelectionKey> itor = selector.selectedKeys().iterator();
while (itor.hasNext()) {
SelectionKey selKey = itor.next();
itor.remove();
if (selKey.isValid() && selKey.isAcceptable()) {
finshAccept(selKey);
}
if (selKey.isValid() && selKey.isReadable()) {
//消息分發
Processer();
}
}
}
} catch (IOException | InterruptedException ex) {
System.out.println(ex.toString());
}
}
};
CloudServer.cstp.putNewThread(run);
}
/**
* 消息處理器
*/
private void Processer() {
ByteBuffer bbInt = ByteBuffer.allocate(IntLength); //讀取INT頭信息的緩存池
ByteBuffer bbObj = ByteBuffer.allocate(MemCache); //讀取OBJ有效數據的緩存池
SocketChannel channel = (SocketChannel)key.channel();
ByteArrayInputStream bIn;
ObjectInputStream in;
CBaseDataBean cbdb;
//有效數據長度
int ObjLength;
//從NIO信道中讀出的數據長度
int readObj;
try {
//讀出INT數據頭
while (channel.read(bbInt) == IntLength) {
//獲取INT頭中標示的有效數據長度信息並清空INT緩存池
ObjLength = bbInt.getInt(0);
bbInt.clear();
//清空有效數據緩存池設置有效緩存池的大小
bbObj.clear();
bbObj.limit(ObjLength);
//循環讀滿緩存池以保證數據完整性
readObj = channel.read(bbObj);
while (readObj != ObjLength) {
readObj += channel.read(bbObj);
}
bIn = new ByteArrayInputStream(bbObj.array());
in = new ObjectInputStream(bIn);
cbdb = (CBaseDataBean) in.readObject();
switch (cbdb.getDataType()) {
case CMsgTypeBean.MSG_TYPE_COMMAND:
rcv_msg_command(cbdb);
break;
case CMsgTypeBean.MSG_TYPE_CUTDOWN:
rcv_msg_cutdown();
break;
case CMsgTypeBean.MSG_TYPE_VERIFYFILE:
rcv_msg_verifyfile(cbdb);
break;
case CMsgTypeBean.MSG_TYPE_SENDFILE:
rcv_msg_sendfile(cbdb);
break;
case CMsgTypeBean.MSG_TYPE_DISPATCHTASK:
rcv_msg_dispchtask(cbdb);
break;
}
in.close();
}
} catch (ClassNotFoundException | IOException ex) {
}
}
參考文獻: