為什麼要講 Buffer
首先為什麼一個小小的 Buffer 我們需要單獨拎出來聊?或者說,Buffer 具體是在哪些地方被用到的呢?
例如,我們從磁碟上讀取一個檔案,並不是直接就從磁碟載入到記憶體中,而是首先會將磁碟中的資料複製到核心緩衝區中,然後再將資料從核心緩衝區複製到使用者緩衝區內,在圖裡看起來就是這樣:
再比如,我們往磁碟上寫檔案,也不是直接將資料寫到磁碟。而是將資料從使用者緩衝區寫到核心緩衝區,由作業系統擇機將其刷入磁碟,圖跟上面這個差不多,就不畫了,自行理解。
再再比如,伺服器接受客戶端發過來的資料時,也不是直接到使用者態的 Buffer 中。而是會先從網路卡到核心態的 Buffer 中,再從核心態的 Buffer 中複製到使用者態的 Buffer 中。
那為什麼要這麼麻煩呢?複製來複制去的,首先我們用排除法排除這樣做是為了好玩。
Buffer 存在的目的是為了減少與裝置(例如磁碟)的互動頻率,在之前的部落格中也提到過「磁碟的讀寫是很昂貴的操作」。那昂貴在哪裡呢?簡單來說,和裝置的互動(例如和磁碟的IO)會設計到作業系統的中斷。中斷需要儲存之前的程式執行的上下文,中斷結束之後又需要恢復這個上下文,並且還涉及到核心態和使用者態的切換,總體上是個耗時的操作。
看到這裡,不熟悉作業系統的話可能會有點疑惑。例如:
啥是使用者態 啥是核心態
大家可以去看看我之前寫的文章 《簡單聊聊使用者態和核心態的區別》
Buffer 的使用
我們通過 Java 中 NIO 包中實現的 Buffer 來給大家講解,Buffer 總共有 7 種實現,就包含了 Java 中實現的所有資料型別。
本篇文章中,我們使用的是 ByteBuffer,其常用的方法都有:
put get flip rewind mark reset clear
接下來我們就通過實際的例子來了解這些方法。
put
put
就是往 ByteBuffer 裡寫入資料,其有有很多過載的實現:
public ByteBuffer put(ByteBuffer src) {...}
public ByteBuffer put(byte[] src, int offset, int length) {...}
public final ByteBuffer put(byte[] src) {...}
我們可以直接傳入 ByteBuffer
物件,也可以直接傳入原生的 byte
陣列,還可以指定寫入的 offset 和長度等等。接下來看個具體的例子:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'s','h'});
}
為了能讓大家更直觀的看出 ByteBuffer 內部的情況,我將它整理成了圖的形式。當上面的程式碼執行完之後 buffer
的內部長這樣:
當你嘗試使用 System.out.println(buffer)
去列印變數 buffer
的時候,你會看到這樣的結果:
java.nio.HeapByteBuffer[pos=2 lim=16 cap=16]
圖裡、控制檯裡都有 position
和 limit
變數,capacity
大家能理解,就是我們建立這個 ByteBuffer 的制定的大小 16
。
而至於另外兩個變數,相信大家從圖中也可以看出來,position
變數指向的是下一次要寫入的下標,上面的程式碼我們只寫入了 2 個位元組,所以 position
指向的是 2,而這個 limit
就比較有意思了,這個在後面的使用中結合例子一起講。
get
get
是從 ByteBuffer 中獲取資料。
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'s','h'});
System.out.println(buffer.get());
}
如果你執行完上面的程式碼你會發現,列印出來的結果是 0
,並不是我們期望的 s
的 ASCII 碼 115
。
首先告訴大家結論,這是符合預期的,這個時候就不應該能獲取到值。我們來看看 get
的原始碼:
public byte get() { return hb[ix(nextGetIndex())]; }
protected int ix(int i) { return i + offset; }
final int nextGetIndex() {
int p = position;
if (p >= limit)
throw new BufferUnderflowException();
// 這裡 position 會往後移動一位
position = p + 1;
return p;
}
當前 position
是 2,而 limit
是 16,所以最終 nextGetIndex
計算出來的值就是變數 p
的值 2 ,再過一次 ix
,那就是 2 + 0 = 2
,這裡的 offset
的值預設為 0 。
所以簡單來說,最終會取到下標為 2 的資料,也就是下圖這樣。
所以我們當然獲取不到資料。但是這裡需要關注的是,呼叫 get
方法雖然沒有獲取到任何資料,但是會使得 position
指標往後移動。換句話說,會佔用一個位置。如果連續呼叫幾次這種 get
之後,再呼叫 put
方法寫入資料,就會造成有幾個位置沒有賦值。舉個例子,假設我們執行以下程式碼:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'s','h'});
buffer.get();
buffer.get();
buffer.get();
buffer.get();
buffer.put(new byte[]{'e'});
}
資料就會變成下圖這樣,position
會往後移動
那你可能會問,那我真的需要獲取資料咋辦?在這種情況下,可以像這樣獲取:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'s'});
System.out.println(buffer.get(0)); // 115
}
傳入我們想要獲取的下標,就可以直接獲取到,並且不會造成 position
的後移。
看到這那你更懵逼了,合著 get()
就沒法用唄?還必須要給個 index。這就需要聊一下另一個方法 flip
了。
flip
廢話不多說,先看看例子:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'s', 'h'}); // java.nio.HeapByteBuffer[pos=2 lim=16 cap=16]
buffer.flip();
System.out.println(buffer); // java.nio.HeapByteBuffer[pos=0 lim=2 cap=16]
}
有意思的事情發生了,呼叫了 flip
之後,position
從 2 變成了 0,limit
從 16 變成了 2。
這個單詞是「翻動」的意思,我個人的理解是像翻東西一樣把之前存的東西全部翻一遍
你會發現,position
變成了 0,而 limit
變成 2,這個範圍剛好是有值的區間。
接下來就更有意思了:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'s', 'h'});
buffer.flip();
System.out.println((char)buffer.get()); // s
System.out.println((char)buffer.get()); // h
}
呼叫了 flip
之後,之前沒法用的 get()
居然能用了。結合 get
中給的原始碼不難分析出來,由於 position
變成了 0,最終計算出來的結果就是 0,同時使 position
向後移動一位。
終於到這了,你可以理解成 Buffer 有兩種狀態,分別是:
讀模式 寫模式
剛剛建立出來的 ByteBuffer 就處於一個寫模式的狀態,通過呼叫 flip
我們可以將 ByteBuffer 切換成讀模式。但需要注意,這裡講的讀、寫模式只是一個邏輯上的概念。
舉個例子,當呼叫 flip
切換到所謂的寫模式之後,依然能夠呼叫 put
方法向 ByteBuffer 中寫入資料。
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'s', 'h'});
buffer.flip();
buffer.put(new byte[]{'e'});
}
這裡的 put
操作依然能成功,但你會發現最後寫入的 e
覆蓋了之前的資料,現在 ByteBuffer 的值變成了 eh
而不是 sh
了。
所以你現在應該能夠明白,讀模式、寫模式更多的含義應該是:
方便你讀的模式 方便你寫的模式
順帶一提,呼叫 flip 進入寫讀模式之後,後續如果呼叫
get()
導致position
大於等於了limit
的值,程式會丟擲BufferUnderflowException
異常。這點從之前get
的原始碼也可以看出來。
rewind
rewind
你也可以理解成是執行在讀模式下的命令,給大家看個例子:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'s', 'h'});
buffer.flip();
System.out.println((char)buffer.get()); // s
System.out.println((char)buffer.get()); // h
// 從頭開始讀
buffer.rewind();
System.out.println((char)buffer.get()); // s
System.out.println((char)buffer.get()); // h
}
所謂的從頭開始讀就是把 position
給歸位到下標為 0 的位置,其原始碼也很簡單:
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
就是簡單的把 position
賦值為 0,把 mark
賦值為 -1。那這個 mark
又是啥東西?這就是我們下一個要聊的方法。
mark & reset
mark
用於標記當前 postion
的位置,而 reset
之所以要放到一起講是因為 reset
是 reset 到 mark
的位置,直接看例子:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'a', 'b', 'c', 'd'});
// 切換到讀模式
buffer.flip();
System.out.println((char) buffer.get()); // a
System.out.println((char) buffer.get()); // b
// 控記住當前的 position
buffer.mark();
System.out.println((char) buffer.get()); // c
System.out.println((char) buffer.get()); // d
// 將 position reset 到 mark 的位置
buffer.reset();
System.out.println((char) buffer.get()); // c
System.out.println((char) buffer.get()); // d
}
可以看到的是 ,我們在 position 等於 2 的時候,呼叫了 mark
記住了 position
的位置。然後遍歷完了所有的資料。然後呼叫 reset
使得 position 回到了 2 的位置,我們繼續呼叫 get
,c d
就又可以被列印出來了。
clear
clear
表面意思看起來是將 buffer 清空的意思,但其實不是,看這個:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'a', 'b', 'c', 'd'});
}
put
完之後,buffer
的情況是這樣的。
當我們呼叫完 clear
之後,buffer
就會變成這樣。
所以,你可以理解為,呼叫 clear
之後只是切換到了寫模式,因為這個時候往裡面寫資料,會覆蓋之前寫的資料,相當於起到了 clear
作用,再舉個例子:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(new byte[]{'a', 'b', 'c', 'd'});
buffer.clear();
buffer.put(new byte[]{'s','h'});
}
可以看到,執行完之後 buffer
的資料變成了 shcd
,後寫入的資料將之前的資料給覆蓋掉了。
除了 clear
可以切換到寫模式之外,還有另一個方法可以切換,這就是本篇要講的最後一個方法 compact
。
compact
先一句話給出 compact
的作用:將還沒有讀完的資料挪到 Buffer 的首部,並切換到寫模式,程式碼如下:
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put("abcd".getBytes(StandardCharsets.UTF_8));
// 切換到讀模式
buffer.flip();
System.out.println((char) buffer.get()); // a
// 將沒讀過的資料, 移到 buffer 的首部
buffer.compact(); // 此時 buffer 的資料就會變成 bcdd
}
當執行完 flip
之後,buffer
的狀態應該沒什麼問題了:
而 compact
之後發生了什麼呢?簡單來說就兩件事:
將 position
移動至對應的位置將沒有讀過的資料移動到 buffer
的首部
這個對應是啥呢?先給大家舉例子;例如沒有讀的資料是 bcd
,那麼 position
就為 3;如果沒有讀的資料為 cd
,position
就為 2。所以你發現了,position
的值為沒有讀過的資料的長度。
從 buffer 內部實現機制來看,凡是在 position - limit 這個區間內的,都算沒有讀過的資料
所以,當執行完 compact
之後,buffer
長這樣:
limit 為 16 是因為 compact 使 buffer 進入了所謂的寫模式。
EOF
還有一些其他的方法就不在這裡列舉了,大家感興趣可以自己去玩玩,都沒什麼理解上的難度了。之後可能會再專門寫一寫 Channel 和 Selector,畢竟 Java 的 nio 三劍客,感興趣的可以關注一下。
歡迎微信搜尋關注【SH的全棧筆記】,如果你覺得這篇文章對你有幫助,還麻煩點個贊,關個注,分個享,留個言。