玩轉 ByteBuffer

detectiveHLH發表於2021-12-29

為什麼要講 Buffer

首先為什麼一個小小的 Buffer 我們需要單獨拎出來聊?或者說,Buffer 具體是在哪些地方被用到的呢?

例如,我們從磁碟上讀取一個檔案,並不是直接就從磁碟載入到記憶體中,而是首先會將磁碟中的資料複製到核心緩衝區中,然後再將資料從核心緩衝區複製到使用者緩衝區內,在圖裡看起來就是這樣:

從磁碟讀取檔案
從磁碟讀取檔案

再比如,我們往磁碟上寫檔案,也不是直接將資料寫到磁碟。而是將資料從使用者緩衝區寫到核心緩衝區,由作業系統擇機將其刷入磁碟,圖跟上面這個差不多,就不畫了,自行理解。

再再比如,伺服器接受客戶端發過來的資料時,也不是直接到使用者態的 Buffer 中。而是會先從網路卡到核心態的 Buffer 中,再從核心態的 Buffer 中複製到使用者態的 Buffer 中。

那為什麼要這麼麻煩呢?複製來複制去的,首先我們用排除法排除這樣做是為了好玩。

Buffer 存在的目的是為了減少與裝置(例如磁碟)的互動頻率,在之前的部落格中也提到過「磁碟的讀寫是很昂貴的操作」。那昂貴在哪裡呢?簡單來說,和裝置的互動(例如和磁碟的IO)會設計到作業系統的中斷。中斷需要儲存之前的程式執行的上下文,中斷結束之後又需要恢復這個上下文,並且還涉及到核心態和使用者態的切換,總體上是個耗時的操作。

看到這裡,不熟悉作業系統的話可能會有點疑惑。例如:

  • 啥是使用者態
  • 啥是核心態

大家可以去看看我之前寫的文章 《簡單聊聊使用者態和核心態的區別》

Buffer 的使用

我們通過 Java 中 NIO 包中實現的 Buffer 來給大家講解,Buffer 總共有 7 種實現,就包含了 Java 中實現的所有資料型別。

Buffer的種類 (1)
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
put

當你嘗試使用 System.out.println(buffer) 去列印變數 buffer 的時候,你會看到這樣的結果:

java.nio.HeapByteBuffer[pos=2 lim=16 cap=16]

圖裡、控制檯裡都有 positionlimit 變數,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_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
rewind

就是簡單的把 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 的位置,我們繼續呼叫 getc 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 之後
執行完 flip 之後

compact 之後發生了什麼呢?簡單來說就兩件事:

  1. position 移動至對應的位置
  2. 將沒有讀過的資料移動到 buffer 的首部

這個對應是啥呢?先給大家舉例子;例如沒有讀的資料是 bcd,那麼 position 就為 3;如果沒有讀的資料為 cdposition 就為 2。所以你發現了,position值為沒有讀過的資料的長度

從 buffer 內部實現機制來看,凡是在 position - limit 這個區間內的,都算沒有讀過的資料

所以,當執行完 compact 之後,buffer 長這樣:

執行完 compact 之後
執行完 compact 之後

limit 為 16 是因為 compact 使 buffer 進入了所謂的寫模式

EOF

還有一些其他的方法就不在這裡列舉了,大家感興趣可以自己去玩玩,都沒什麼理解上的難度了。之後可能會再專門寫一寫 ChannelSelector,畢竟 Java 的 nio 三劍客,感興趣的可以關注一下。

歡迎微信搜尋關注【SH的全棧筆記】,如果你覺得這篇文章對你有幫助,還麻煩點個贊關個注分個享留個言