讓我們一起玩轉 ByteBuffer
本文轉載自微信公眾號「SH的全棧筆記」,作者SH的全棧筆記。轉載本文請聯系SH的全棧筆記公眾號。
為什么要講 Buffer
首先為什么一個小小的 Buffer 我們需要單獨拎出來聊?或者說,Buffer 具體是在哪些地方被用到的呢?
例如,我們從磁盤上讀取一個文件,并不是直接就從磁盤加載到內存中,而是首先會將磁盤中的數據復制到內核緩沖區中,然后再將數據從內核緩沖區復制到用戶緩沖區內,在圖里看起來就是這樣:
從磁盤讀取文件
再比如,我們往磁盤上寫文件,也不是直接將數據寫到磁盤。而是將數據從用戶緩沖區寫到內核緩沖區,由操作系統擇機將其刷入磁盤,圖跟上面這個差不多,就不畫了,自行理解。
再再比如,服務器接受客戶端發過來的數據時,也不是直接到用戶態的 Buffer 中。而是會先從網卡到內核態的 Buffer 中,再從內核態的 Buffer 中復制到用戶態的 Buffer 中。
那為什么要這么麻煩呢?復制來復制去的,首先我們用排除法排除這樣做是為了好玩。
Buffer 存在的目的是為了減少與設備(例如磁盤)的交互頻率,在之前的博客中也提到過「磁盤的讀寫是很昂貴的操作」。那昂貴在哪里呢?簡單來說,和設備的交互(例如和磁盤的IO)會設計到操作系統的中斷。中斷需要保存之前的進程運行的上下文,中斷結束之后又需要恢復這個上下文,并且還涉及到內核態和用戶態的切換,總體上是個耗時的操作。
看到這里,不熟悉操作系統的話可能會有點疑惑。例如:
- 啥是用戶態
- 啥是內核態
大家可以去看看我之前寫的文章 《簡單聊聊用戶態和內核態的區別》
Buffer 的使用
我們通過 Java 中 NIO 包中實現的 Buffer 來給大家講解,Buffer 總共有 7 種實現,就包含了 Java 中實現的所有數據類型。
Buffer的種類 (1)
本篇文章中,我們使用的是 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 的內部長這樣:
put
當你嘗試使用 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_put
所以你現在應該能夠明白,讀模式、寫模式更多的含義應該是:
- 方便你讀的模式
- 方便你寫的模式
順帶一提,調用 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;
- }
rewind
就是簡單的把 position 賦值為 0,把 mark 賦值為 -1。那這個 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 的狀態應該沒什么問題了:
運行完 flip 之后
而 compact 之后發生了什么呢?簡單來說就兩件事:
- 將 position 移動至對應的位置
- 將沒有讀過的數據移動到 buffer 的首部
這個對應是啥呢?先給大家舉例子;例如沒有讀的數據是 bcd,那么 position 就為 3;如果沒有讀的數據為 cd,position 就為 2。所以你發現了,position 的值為沒有讀過的數據的長度。
從 buffer 內部實現機制來看,凡是在 position - limit 這個區間內的,都算沒有讀過的數據
所以,當運行完 compact 之后,buffer 長這樣:
運行完 compact 之后
limit 為 16 是因為 compact 使 buffer 進入了所謂的寫模式。
EOF
還有一些其他的方法就不在這里列舉了,大家感興趣可以自己去玩玩,都沒什么理解上的難度了。之后可能會再專門寫一寫 Channel 和 Selector,畢竟 Java 的 nio 三劍客,感興趣的可以關注一下。