讓我們一起玩轉(zhuǎn) ByteBuffer
本文轉(zhuǎn)載自微信公眾號「SH的全棧筆記」,作者SH的全棧筆記。轉(zhuǎn)載本文請聯(lián)系SH的全棧筆記公眾號。
為什么要講 Buffer
首先為什么一個小小的 Buffer 我們需要單獨拎出來聊?或者說,Buffer 具體是在哪些地方被用到的呢?
例如,我們從磁盤上讀取一個文件,并不是直接就從磁盤加載到內(nèi)存中,而是首先會將磁盤中的數(shù)據(jù)復(fù)制到內(nèi)核緩沖區(qū)中,然后再將數(shù)據(jù)從內(nèi)核緩沖區(qū)復(fù)制到用戶緩沖區(qū)內(nèi),在圖里看起來就是這樣:
從磁盤讀取文件
再比如,我們往磁盤上寫文件,也不是直接將數(shù)據(jù)寫到磁盤。而是將數(shù)據(jù)從用戶緩沖區(qū)寫到內(nèi)核緩沖區(qū),由操作系統(tǒng)擇機(jī)將其刷入磁盤,圖跟上面這個差不多,就不畫了,自行理解。
再再比如,服務(wù)器接受客戶端發(fā)過來的數(shù)據(jù)時,也不是直接到用戶態(tài)的 Buffer 中。而是會先從網(wǎng)卡到內(nèi)核態(tài)的 Buffer 中,再從內(nèi)核態(tài)的 Buffer 中復(fù)制到用戶態(tài)的 Buffer 中。
那為什么要這么麻煩呢?復(fù)制來復(fù)制去的,首先我們用排除法排除這樣做是為了好玩。
Buffer 存在的目的是為了減少與設(shè)備(例如磁盤)的交互頻率,在之前的博客中也提到過「磁盤的讀寫是很昂貴的操作」。那昂貴在哪里呢?簡單來說,和設(shè)備的交互(例如和磁盤的IO)會設(shè)計到操作系統(tǒng)的中斷。中斷需要保存之前的進(jìn)程運行的上下文,中斷結(jié)束之后又需要恢復(fù)這個上下文,并且還涉及到內(nèi)核態(tài)和用戶態(tài)的切換,總體上是個耗時的操作。
看到這里,不熟悉操作系統(tǒng)的話可能會有點疑惑。例如:
- 啥是用戶態(tài)
- 啥是內(nèi)核態(tài)
大家可以去看看我之前寫的文章 《簡單聊聊用戶態(tài)和內(nèi)核態(tài)的區(qū)別》
Buffer 的使用
我們通過 Java 中 NIO 包中實現(xiàn)的 Buffer 來給大家講解,Buffer 總共有 7 種實現(xiàn),就包含了 Java 中實現(xiàn)的所有數(shù)據(jù)類型。
Buffer的種類 (1)
本篇文章中,我們使用的是 ByteBuffer,其常用的方法都有:
- put
- get
- flip
- rewind
- mark
- reset
- clear
接下來我們就通過實際的例子來了解這些方法。
put
put 就是往 ByteBuffer 里寫入數(shù)據(jù),其有有很多重載的實現(xiàn):
- public ByteBuffer put(ByteBuffer src) {...}
- public ByteBuffer put(byte[] src, int offset, int length) {...}
- public final ByteBuffer put(byte[] src) {...}
我們可以直接傳入 ByteBuffer 對象,也可以直接傳入原生的 byte 數(shù)組,還可以指定寫入的 offset 和長度等等。接下來看個具體的例子:
- public static void main(String[] args) {
- ByteBuffer buffer = ByteBuffer.allocate(16);
- buffer.put(new byte[]{'s','h'});
- }
為了能讓大家更直觀的看出 ByteBuffer 內(nèi)部的情況,我將它整理成了圖的形式。當(dāng)上面的代碼運行完之后 buffer 的內(nèi)部長這樣:
put
當(dāng)你嘗試使用 System.out.println(buffer) 去打印變量 buffer 的時候,你會看到這樣的結(jié)果:
- java.nio.HeapByteBuffer[pos=2 lim=16 cap=16]
圖里、控制臺里都有 position 和 limit 變量,capacity 大家能理解,就是我們創(chuàng)建這個 ByteBuffer 的制定的大小 16。
而至于另外兩個變量,相信大家從圖中也可以看出來,position 變量指向的是下一次要寫入的下標(biāo),上面的代碼我們只寫入了 2 個字節(jié),所以 position 指向的是 2,而這個 limit 就比較有意思了,這個在后面的使用中結(jié)合例子一起講。
get
get 是從 ByteBuffer 中獲取數(shù)據(jù)。
- public static void main(String[] args) {
- ByteBuffer buffer = ByteBuffer.allocate(16);
- buffer.put(new byte[]{'s','h'});
- System.out.println(buffer.get());
- }
如果你運行完上面的代碼你會發(fā)現(xiàn),打印出來的結(jié)果是 0 ,并不是我們期望的 s 的 ASCII 碼 115。
首先告訴大家結(jié)論,這是符合預(yù)期的,這個時候就不應(yīng)該能獲取到值。我們來看看 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;
- }
當(dāng)前 position 是 2,而 limit 是 16,所以最終 nextGetIndex 計算出來的值就是變量 p 的值 2 ,再過一次 ix ,那就是 2 + 0 = 2,這里的 offset 的值默認(rèn)為 0 。
所以簡單來說,最終會取到下標(biāo)為 2 的數(shù)據(jù),也就是下圖這樣。
所以我們當(dāng)然獲取不到數(shù)據(jù)。但是這里需要關(guān)注的是,調(diào)用 get 方法雖然沒有獲取到任何數(shù)據(jù),但是會使得 position 指針往后移動。換句話說,會占用一個位置。如果連續(xù)調(diào)用幾次這種 get 之后,再調(diào)用 put 方法寫入數(shù)據(jù),就會造成有幾個位置沒有賦值。舉個例子,假設(shè)我們運行以下代碼:
- 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'});
- }
數(shù)據(jù)就會變成下圖這樣,position 會往后移動
那你可能會問,那我真的需要獲取數(shù)據(jù)咋辦?在這種情況下,可以像這樣獲?。?/p>
- public static void main(String[] args) {
- ByteBuffer buffer = ByteBuffer.allocate(16);
- buffer.put(new byte[]{'s'});
- System.out.println(buffer.get(0)); // 115
- }
傳入我們想要獲取的下標(biāo),就可以直接獲取到,并且不會造成 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]
- }
有意思的事情發(fā)生了,調(diào)用了 flip 之后,position 從 2 變成了 0,limit 從 16 變成了 2。
這個單詞是「翻動」的意思,我個人的理解是像翻東西一樣把之前存的東西全部翻一遍
你會發(fā)現(xiàn),position 變成了 0,而 limit 變成 2,這個范圍剛好是有值的區(qū)間。
接下來就更有意思了:
- 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
- }
調(diào)用了 flip 之后,之前沒法用的 get() 居然能用了。結(jié)合 get 中給的源碼不難分析出來,由于 position 變成了 0,最終計算出來的結(jié)果就是 0,同時使 position 向后移動一位。
終于到這了,你可以理解成 Buffer 有兩種狀態(tài),分別是:
- 讀模式
- 寫模式
剛剛創(chuàng)建出來的 ByteBuffer 就處于一個寫模式的狀態(tài),通過調(diào)用 flip 我們可以將 ByteBuffer 切換成讀模式。但需要注意,這里講的讀、寫模式只是一個邏輯上的概念。
舉個例子,當(dāng)調(diào)用 flip 切換到所謂的寫模式之后,依然能夠調(diào)用 put 方法向 ByteBuffer 中寫入數(shù)據(jù)。
- 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 操作依然能成功,但你會發(fā)現(xiàn)最后寫入的 e 覆蓋了之前的數(shù)據(jù),現(xiàn)在 ByteBuffer 的值變成了 eh 而不是 sh 了。
flip_put
所以你現(xiàn)在應(yīng)該能夠明白,讀模式、寫模式更多的含義應(yīng)該是:
- 方便你讀的模式
- 方便你寫的模式
順帶一提,調(diào)用 flip 進(jìn)入寫讀模式之后,后續(xù)如果調(diào)用 get() 導(dǎo)致 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 給歸位到下標(biāo)為 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
- // 控記住當(dāng)前的 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 的時候,調(diào)用了 mark 記住了 position 的位置。然后遍歷完了所有的數(shù)據(jù)。然后調(diào)用 reset 使得 position 回到了 2 的位置,我們繼續(xù)調(diào)用 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 的情況是這樣的。
當(dāng)我們調(diào)用完 clear 之后,buffer 就會變成這樣。
所以,你可以理解為,調(diào)用 clear 之后只是切換到了寫模式,因為這個時候往里面寫數(shù)據(jù),會覆蓋之前寫的數(shù)據(jù),相當(dāng)于起到了 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 的數(shù)據(jù)變成了 shcd,后寫入的數(shù)據(jù)將之前的數(shù)據(jù)給覆蓋掉了。
除了 clear 可以切換到寫模式之外,還有另一個方法可以切換,這就是本篇要講的最后一個方法 compact。
compact
先一句話給出 compact 的作用:將還沒有讀完的數(shù)據(jù)挪到 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
- // 將沒讀過的數(shù)據(jù), 移到 buffer 的首部
- buffer.compact(); // 此時 buffer 的數(shù)據(jù)就會變成 bcdd
- }
當(dāng)運行完 flip 之后,buffer 的狀態(tài)應(yīng)該沒什么問題了:
運行完 flip 之后
而 compact 之后發(fā)生了什么呢?簡單來說就兩件事:
- 將 position 移動至對應(yīng)的位置
- 將沒有讀過的數(shù)據(jù)移動到 buffer 的首部
這個對應(yīng)是啥呢?先給大家舉例子;例如沒有讀的數(shù)據(jù)是 bcd,那么 position 就為 3;如果沒有讀的數(shù)據(jù)為 cd,position 就為 2。所以你發(fā)現(xiàn)了,position 的值為沒有讀過的數(shù)據(jù)的長度。
從 buffer 內(nèi)部實現(xiàn)機(jī)制來看,凡是在 position - limit 這個區(qū)間內(nèi)的,都算沒有讀過的數(shù)據(jù)
所以,當(dāng)運行完 compact 之后,buffer 長這樣:
運行完 compact 之后
limit 為 16 是因為 compact 使 buffer 進(jìn)入了所謂的寫模式。
EOF
還有一些其他的方法就不在這里列舉了,大家感興趣可以自己去玩玩,都沒什么理解上的難度了。之后可能會再專門寫一寫 Channel 和 Selector,畢竟 Java 的 nio 三劍客,感興趣的可以關(guān)注一下。