博客 / 詳情

返回

一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同字節序下的設計與實現

讓我們來到微觀世界重新認識 Netty

在前面 Netty 源碼解析系列 《聊聊 Netty 那些事兒》中,筆者帶領大家從宏觀世界詳細剖析了 Netty 的整個運轉流程。從一個網絡數據包在內核中的收發過程開始聊起,總體介紹了 Netty 的 IO 線程模型,後面我們圍繞着這個 IO 模型又詳細介紹了整個 Reactor 模型在 Netty 中的實現。

這個宏觀流程包括:Reactor模型的創建,啓動,運轉架構,網絡連接的接收和關閉,網絡數據的接收和發送,利用 pipeline 對 IO 處理邏輯的編排,Netty 的優雅關閉。

Netty 的源碼解析系列寫到這裏,筆者算是帶着大家在 Netty 的宏觀世界中翱翔了一圈,但筆者還是不捨得和大家説再見,於是決定在帶領大家到 Netty 的微觀世界中一探究竟,這個系列的目的就是想讓大家從內核層面深入地搞透 Netty。

在 Netty 的微觀世界系列中,筆者會為大家講述 Netty 中的高性能組件的相關設計和實現以及應用。內容包括:

  • Netty 中的網絡數據容器 ByteBuf 的整個設計體系的實現。
  • Netty 中的內存池設計與實現,在這個過程中,筆者會把 Linux 內核中內存管理子系統相關源碼帶大家走讀一遍,讓大家從內核層面到應用層面徹底搞透徹高性能內存分配的原理及其實現。
  • Netty 中用於執行海量延時任務的時間輪相關設計與實現,並與 Kafka 中的時間輪設計做出詳細對比。
  • Netty 中用到的零拷貝技術在內核中的實現。
  • Netty 中用到的 MPSC (多生產者單消費者)隊列的設計與實現以及應用場景。
  • Netty 中實現無鎖化併發的關鍵組件 FastThreadLocal 的設計與實現,並詳細對比 FastThreadLocal 究竟比 JDK 中 ThreadLocal 快在了哪裏。
  • 理論講完了,實踐是必不可少的,最後筆者會帶大家剖析 Netty 在各個著名中間件中是如何使用的,進一步加深大家對 Netty 的理解。

筆者的這個 Netty 微觀世界系列會涉及大量豐富的細節描述,對於喜歡細節控的同學一定不要錯過~~

寫在本文開始之前.....

本文我們開始 Netty 微觀世界系列第一部分的內容,聊聊 Netty 中的網絡數據容器 ByteBuf ,對於 ByteBuf 我想大家一定不會陌生,它曾多次出現在前面的系列文章中,比如在《Netty如何高效接收網絡數據 | 一文聊透ByteBuffer動態自適應擴縮容機制》和《一文搞懂Netty發送數據全流程 | 你想知道的細節全在這裏》這兩篇文章中提到的 Netty 接收網絡數據和發送網絡數據時用到的ByteBuf。

ByteBuf 是 Netty 中的數據容器,Netty 在接收網絡數據和發送網絡數據時,都會首先將這些網絡數據事先緩存在 ByteBuf 中,然後在將它們丟給 pipeline 處理或者發送給 Socket ,這樣做的目的是防止在接收網絡數據的過程中網絡數據一直積壓在 Socket 的接收緩衝區中使得接收緩衝區的數據越來越多,導致對端 TCP 協議中的窗口關閉(滑動窗口),影響到了整個 TCP 通信的速度。而有了 ByteBuf,我們可以先將讀取的數據緩存在 ByteBuf 中,提高 TCP 的通信能力。

而在 Netty 發送數據的時候,也可以事先將數據緩存在 ByteBuf 中,如果 Socket 發送緩衝區已滿變為不可寫狀態時,由於數據我們已經緩存在 ByteBuf 中了,用户的發送線程不需要阻塞等待,當 Socket 發送緩衝區再次變得可寫時,Netty 會將 ByteBuf 中的數據寫入到 Socket 中。這也是 Netty 實現異步發送數據的核心所在。

而 Netty 中的 ByteBuf 底層依賴了JDK NIO 中的 ByteBuffer 。眾所周知 JDK NIO 中的 ByteBuffer 設計的非常複雜而且提供的相關 API 使用起來也很反人類,易用性不是很好,所以 Netty 的 ByteBuf 針對 JDK NIO ByteBuffer 進行了優化,再此基礎上重新設計出了一套簡潔易用的 API 出來。

熟悉筆者寫作風格的讀者朋友都知道,筆者一向是喜歡把技術的脈絡給大家鋪展開來講解,一層一層地介紹技術的演變過程,力求給大家清晰地展現出整個技術的全貌。通過技術的演變過程,我們不僅可以知道這個技術點最初的樣貌,它的優缺點是什麼?瓶頸是什麼?我們還可以針對這些缺點和瓶頸觸發自己的思考,如何優化?如何演變?通過這個過程的洗禮,我們才能夠對現有技術理解的清晰透徹。

根據這個思路,在介紹 Netty 的 ByteBuf 設計之前,筆者想專門用一篇文章來為大家介紹下 JDK NIO Buffer 的設計,看一下 NIO ByteBuffer 是如何設計的,它有哪些缺點。針對這些缺點,Netty 又是如何優化的。徹底理解 Netty 數據載體 ByteBuf 的前世今生。

image.png

1. JDK NIO 中的 Buffer

在 NIO 沒有出現之前,Java 傳統的 IO 操作都是通過流的形式實現的(包括網絡 IO 和文件 IO ),也就是我們常見的輸入流 InputStream 和輸出流 OutputStream。

但是 Java 傳統 IO 的 InputStream 和 OutputStream 的相關操作全部都是阻塞的,比如我們使用 InputStream 的 read 方法從流中讀取數據時,如果此時流中沒有數據,那麼用户線程就必須阻塞等待。

還有一點就是傳統的這些輸入輸出流在處理字節流的時候一次只能處理一個字節,這樣在處理網絡 IO 的時候讀取 Socket 緩衝區中的數據效率就會很低,而且在操作字節流的時候只能線性的處理流中的字節,不能來回移動字節流中的數據。這樣導致我們在處理字節流中的數據的時候就顯得不是很靈活。

所以綜上所述,Java 傳統 IO 是面向流的,流的處理是單向,阻塞的,而且無論是從輸入流中讀取數據還是向輸出流中寫入數據都是一個字節一個字節來處理的。通常都是從輸入流中邊讀取數據邊處理數據,這樣 IO 處理效率就會很低,

基於上述原因,JDK1.4 引入了 NIO,而 NIO 是面向 Buffer 的,在處理 IO 操作的時候,會一次性將 Channel 中的數據讀取到 Buffer 中然後在做後續處理,向 Channel 中寫入數據也是一樣,也是需要一個 Buffer 做中轉,然後將 Buffer 中的數據批量寫入 Channel 中。這樣一來我們可以利用 Buffer 將裏面的字節數據來回移動並根據我們想要的處理方式靈活處理。

除此之外,Nio Buffer 還提供了堆外的直接內存和內存映射相關的訪問方式,來避免內存之間的來回拷貝,所以即使在傳統 IO 中用到了 BufferedInputStream 也還是沒辦法和 Nio Buffer 相匹敵。

那麼接下來就讓我們正式進入JDK NIO Buffer 如何設計與實現的相關主題

2. NIO 對 Buffer 的頂層抽象

JDK NIO 提供的 Buffer 其實本質上是一塊內存,大家可以把它簡單想象成一個數組,JDK 將這塊內存在語言層面封裝成了 Buffer 的形式,我們可以通過 Buffer 對這塊內存進行讀取或者寫入數據,以及執行各種騷操作。

如下圖中所示,Buffer 類是JDK NIO 定義的一個頂層抽象類,對於緩衝區的所有基本操作和基礎屬性全部定義在頂層 Buffer 類中,在 Java 中一共有八種基本類型,JDK NIO 也為這八種基本類型分別提供了其對應的 Buffer 類,大家可以把這些 Buffer 類當做成對應基礎類型的數組,我們可以利用這些基礎類型相關的 Buffer 類對數組進行各種操作。

image.png

在為大家解析具體的緩衝區實現之前,我們先來看下這個緩衝區的頂層抽象類 Buffer 中到底定義規範了哪些抽象操作,具有哪些屬性,這些屬性分別是用來幹什麼的?先帶大家從總體上認識一下JDK NIO 中的 Buffer 設計。

2.1 Buffer 中的屬性

public abstract class Buffer {

    private int mark = -1;
    private int position = 0;
    private int limit;
    private int capacity;
    
             .............
}

首先我們先來介紹下 Buffer 中最重要的這三個屬性,後面即將介紹的關於 Buffer 的各種騷操作均依賴於這三個屬性的動態變化。

image.png

  • capacity:這個很好理解,它規定了整個 Buffer 的容量,具體可以容納多少個元素。capacity 指針之前的元素均是 Buffer 可操作的空間。
  • position:用於指向 Buffer 中下一個可操作性的元素,初始值為 0。在 Buffer 的寫模式下,position 指針用於指向下一個可寫位置。在讀模式下,position 指針指向下一個可讀位置。
  • limit:表示 Buffer 可操作元素的上限。什麼意思呢?比如在 Buffer 的寫模式下,可寫元素的上限就是 Buffer 的整體容量也就是 capacity ,capacity - 1 即為 Buffer 最後一個可寫位置。在讀模式下,Buffer 中可讀元素的上限即為上一次 Buffer 在寫模式下最後一個寫入元素的位置。也就是上一次寫模式中的 position。
  • mark:用於標記 Buffer 當前 position 的位置。這個字段在我們對網絡數據包解碼的時候非常有用,在我們使用 TCP 協議進行網絡數據傳輸的時候經常會出現粘包拆包的現象,所以為了應對粘包拆包的問題,在解碼之前都需要先調用
    mark 方法將 Buffer 的當前 position 指針保存至 mark 屬性中,如果 Buffer 中的數據足夠我們解碼為一個完整的包,我們就執行解碼操作。如果 Buffer 中的數據不夠我們解碼為一個完整的包(也就是半包),我們就調用 reset 方法,將 position 還原到原來的位置,等待剩下的網絡數據到來。

image.png

在我們理解了 Buffer 中這幾個重要屬性的含義之後,接下來我們就來看一看 JDK NIO 在 Buffer 頂層設計類中定義規範的那些抽象操作。

2.2 Buffer 中定義的核心抽象操作

本小節中介紹的這幾個關於 Buffer 的核心操作均是基於上小節中介紹的那些核心指針的動態調整實現的。

2.2.1 Buffer 的構造

構造 Buffer 的主要邏輯就是根據用户指定的參數來初始化 Buffer 中的這四個重要屬性:mark,position,limit,capacity。它們之間的關係為:mark <= position <= limit <= capacity 。其中 mark 初始默認為 -1,position 初始默認為 0。

image.png

public abstract class Buffer {

    private int mark = -1;
    private int position = 0;
    private int limit;
    private int capacity;

    Buffer(int mark, int pos, int lim, int cap) {     
        if (cap < 0)
            throw new IllegalArgumentException("Negative capacity: " + cap);
        this.capacity = cap;
        limit(lim);
        position(pos);
        if (mark >= 0) {
            if (mark > pos)
                throw new IllegalArgumentException("mark > position: ("
                                                   + mark + " > " + pos + ")");
            this.mark = mark;
        }
    }

    public final Buffer limit(int newLimit) {
        if ((newLimit > capacity) || (newLimit < 0))
            throw new IllegalArgumentException();
        limit = newLimit;
        if (position > limit) position = limit;
        if (mark > limit) mark = -1;
        return this;
    }

    public final Buffer position(int newPosition) {
        if ((newPosition > limit) || (newPosition < 0))
            throw new IllegalArgumentException();
        position = newPosition;
        if (mark > position) mark = -1;
        return this;
    }
}

2.2.2 獲取 Buffer 下一個可讀取位置

當我們在 Buffer 的讀模式下,需要從 Buffer 中讀取數據時,需要首先知道當前 Buffer 中 position 的位置,然後根據 position 的位置讀取 Buffer 中的元素。隨後 position 向後移動指定的步長 nb。

image.png

nextGetIndex() 方法首先獲取 Buffer 當前 position 的位置作為 readIndex 返回給用户,然後 position 向後移動一位。這裏的步長 nb 默認為1。


    final int nextGetIndex() {                        
        if (position >= limit)
            throw new BufferUnderflowException();
        return position++;
    }

nextGetIndex(int nb) 方法的邏輯和 nextGetIndex() 方法一樣,唯一不同的是該方法指定了position 向後移動的步長 nb。

    final int nextGetIndex(int nb) {          
        if (limit - position < nb)
            throw new BufferUnderflowException();
        int p = position;
        position += nb;
        return p;
    }

大家這裏可能會感到好奇,為什麼會增加一個指定 position 移動步長的 nextGetIndex(int nb) 方法呢

在《2. NIO 對 Buffer 的頂層抽象》小節的開始,我們介紹了 JDK NIO 中 Buffer 頂層設計體系,除了 boolean 這個基本類型,NIO 為幾乎所有的 Java 基本類型定義了對應的 Buffer 類。

image.png

假如我們從一個 ByteBuffer 中讀取一個 int 類型的數據時,我們就需要在讀取完畢後將 position 的位置向後移動 4 位。在這種情況下 nextGetIndex(int nb) 方法的步長 nb 就應該指定為 4.

   public int getInt() {
        return getInt(ix(nextGetIndex((1 << 2))));
    }

2.2.3 獲取 Buffer 下一個可寫入位置

同獲取 readIndex 的過程一樣,當我們處於 Buffer 的寫模式下,向 Buffer 寫入數據時,首先也需要獲取 Buffer 當前 position 的位置(writeIndex),當寫入元素後,position 向後移動指定的步長 nb。

同樣的道理,我們可以向 ByteBuffer 中寫入一個 int 型的數據,這時候指定的步長 nb 也是 4 。


    final int nextPutIndex() {                        
        if (position >= limit)
            throw new BufferOverflowException();
        return position++;
    }

    final int nextPutIndex(int nb) {                  
        if (limit - position < nb)
            throw new BufferOverflowException();
        int p = position;
        position += nb;
        return p;
    }

2.2.4 Buffer 讀模式的切換

當我們在 Buffer 的寫模式下向 Buffer 寫入數據之後,接下來我們就需要從 Buffer 中讀取剛剛寫入的數據。由於 NIO 在對 Buffer 的設計中讀寫模式是混用一個 position 屬性,所以我們需要做讀模式的切換。

image.png

    public final Buffer flip() {
        limit = position;
        position = 0;
        mark = -1;
        return this;
    }

我們看到 flip() 方法是對 Buffer 中的這四個指針做了一些調整達到了讀模式切換的目的:

  1. 將下一個可寫入位置 position 作為讀模式下的上限 limit。
  2. position設置為 0 。這樣使得我們可以從頭開始讀取 Buffer 中寫入的數據。

2.2.5 Buffer 寫模式的切換

有讀模式的切換肯定就會有對應的寫模式切換,當我們在讀模式下以將 Buffer 中的數據讀取完畢之後,這時候如果再次向 Buffer 寫入數據的話,就需要切換到 Buffer 的寫模式下。

image.png

    public final Buffer clear() {
        position = 0;
        limit = capacity;
        mark = -1;
        return this;
    }

我們看到調用 clear() 方法之後,Buffer 中各個指針的狀態又回到了最初的狀態:

  1. position 位置重新指向起始位置 0 處。寫入上限 limit 重新指向了 capacity 的位置。
  2. 這時向 Buffer 中寫入數據時,就會從 Buffer 的開頭處依次寫入,新寫入的數據就會把已經讀取的那部分數據覆蓋掉。

但是這裏就會有一問題,當我們在讀模式下將 Buffer 中的數據全部讀取完畢時,調用 clear() 方法開啓寫模式,是沒有問題的。

如果我們只是讀取了 Buffer 中的部分數據,但是還有一部分數據沒有讀取,這時候,調用 clear() 方法開啓寫模式向 Buffer 中寫入數據的話,就會出問題,因為這會覆蓋掉我們還沒有讀取的數據部分。

image.png

針對這種情況,我們就不能簡單粗暴的設置 position 指針了,為了保證未讀取的數據部分不被覆蓋,我們就需要先將不可覆蓋的數據部分移動到 Buffer 的最前邊,然後將 position 指針指向可覆蓋數據區域的第一個位置。

image.png

由於 Buffer 是頂層設計只是負責定義 Buffer 相關的操作規範,並未定義具體的數據存儲方式,因為 compact() 涉及到移動數據,所以實現在了 Buffer 具體子類中,這裏我們以 HeapByteBuffer 舉例説明:

class HeapByteBuffer extends ByteBuffer {

    //HeapBuffer中底層負責存儲數據的數組
    final byte[] hb; 

    public ByteBuffer compact() {
        System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
        position(remaining());
        limit(capacity());
        discardMark();
        return this;
    }

    public final int remaining() {
        return limit - position;
    }

   final void discardMark() {                          
        mark = -1;
    }

}

2.2.6 重新讀取 Buffer 中的數據 rewind

rewind() 方法可以幫助我們重新讀取 Buffer 中的數據,它會將 position 的值重新設置為 0,並丟棄 mark。

image.png

    public final Buffer rewind() {
        position = 0;
        mark = -1;
        return this;
    }

3. NIO Buffer 背後的存儲機制

在《2. NIO 對 Buffer 的頂層抽象》小節的開頭提到我們可以把 Buffer 簡單的看做是一個數組,然後基於前邊介紹的四個指針:mark,position,limit,capacity 的動態調整來實現對 Buffer 的各種操作。

同時我們也提到了除了 boolean 這種基本類型之外,NIO 為其他幾種 Java 基本類型都提供了其對應的 Buffer 類。

image.png

而針對每一種基本類型的 Buffer ,NIO 又根據 Buffer 背後的數據存儲內存不同分為了:HeapBuffer,DirectBuffer,MappedBuffer。

HeapBuffer 顧名思義它背後的存儲內存是在 JVM 堆中分配,在堆中分配一個數組用來存放 Buffer 中的數據。

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer> {
    //在堆中使用一個數組存放Buffer數據
    final byte[] hb;  
}

DirectBuffer 背後的存儲內存是在堆外內存中分配,MappedBuffer 是通過內存文件映射將文件中的內容直接映射到堆外內存中,其本質也是一個 DirectBuffer 。

由於 DirectBuffer 和 MappedBuffer 背後的存儲內存是在堆外內存中分配,不受 JVM 管理,所以不能用一個 Java 基本類型的數組表示,而是直接記錄這段堆外內存的起始地址。

public abstract class Buffer {
    //堆外內存地址
    long address;
}
筆者後面還會為大家詳細講解 DirectBuffer 和 MappedBuffer。這裏提前引出只是讓大家理解這三種不同類型的 Buffer 背後內存區域的不同。

綜上所述,HeapBuffer 背後是有一個對應的基本類型數組作為存儲的。而 DirectBuffer 和 MappedBuffer 背後是一塊堆外內存做存儲。並沒有一個基本類型的數組。

hasArray() 方法 就是用來判斷一個 Buffer 背後是否有一個 Java 基本類型的數組做支撐。

 public abstract boolean hasArray();

如果 hasArray() 方法返回 true,我們就可以調用 Object array() 方法獲取 Buffer 背後的支撐數組。

 public abstract Object array();

其中 Buffer 中還有一個不太好理解的屬性是 offset,而這個 offset 到底是用來幹什麼的呢

4. Buffer 的視圖

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer> {
    //在堆中使用一個數組存放Buffer數據
    final byte[] hb;  
    // 數組中的偏移,用於指定數組中的哪一段數據是被 Buffer 包裝的
    final int offset;
}

事實上我們可以根據一段連續的內存地址或者一個數組創建出不同的 Buffer 視圖出來。

image.png

如上圖所示,我們可以根據原生 Buffer 中的部分數據(比如圖中的未處理數據部分)創建出一個新的 Buffer 視圖出來。

這個新的視圖 Buffer 本質上也是一個 Buffer ,擁有獨立的 mark,position,limit,capacity 指針。這個四個指針會在新的 Buffer 視圖下重新被創建賦值。所以在新的視圖 Buffer 下和操作普通 Buffer 是一樣的,也可以使用 《2.2 Buffer 中定義的核心抽象操作》小節中介紹的那些方法。只不過操作的數據範圍不一樣罷了。

新的視圖 Buffer 和原生 Buffer 共享一個存儲數組或者一段連續內存

站在新的視圖 Buffer 角度來説,它的存儲數組範圍:0 - 6,所以再此視圖下 position = 0,limit = capacity = 7 。這其實是一個障眼法,真實情況是新的視圖 Buffer 其實是複用原生 Buffer 中的存儲數組中的 6 - 12 這塊區域。

所以在新視圖 Buffer 中訪問元素的時候,就需要加上一個偏移 offset : position + offset 才能正確的訪問到真實數組中的元素。這裏的 offset = 6。

我們可以通過 arrayOffset() 方法獲取視圖 Buffer 中的 offset。

 public abstract int arrayOffset();

以上內容就是筆者要為大家介紹的 NIO Buffer 的頂層設計,下面我們來看下 Buffer 下具體的這些實現類。對於 Buffer 視圖相關的創建和操作,筆者會把這部分內容放到具體的 Buffer 實現類中為大家介紹,這裏大家只需要理解 Buffer 視圖的概念即可~~~

5. 抽象 Buffer 的具體實現類 ByteBuffer

image.png

通過前面小節內容的介紹,我們知道了JDK NIO Buffer 為 Java 中每種基本類型都設計了對應的 Buffer 實現(除了 boolean 類型)。

而我們本系列的主題是 Netty 網絡通訊框架的源碼解析,在網絡 IO 處理中出鏡率最高的當然是 ByteBuffer,所以在下面的例子中筆者均已 ByteBuffer 作為講解主線。相信大家在理解了 ByteBuffer 的整體脈絡設計之後,在看其他基本類型的 Buffer 實現就能非常容易理解,基本上大同小異。

下面我們就來正式開始 ByteBuffer 的介紹~~~

在前邊《3. NIO Buffer 背後的存儲機制》小節的介紹中,我們知道 NIO 中的 ByteBuffer 根據其背後內存分配的區域不同,分為了:HeapByteBuffer,MappedByteBuffer,DirectByteBuffer 這三種類型。

而這三種類型的 ByteBuffer 肯定會有一些通用的屬性以及方法,所以 ByteBuffer 這個類被設計成了一個抽象類,用來封裝這些通用的屬性和方法作為 ByteBuffer 這個基本類型 Buffer 的頂層規範。

image.png

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer> {
    // Buffer背後的數組
    final byte[] hb;  
    // 數組 offset,用於創建 Buffer 視圖                
    final int offset;
    // 標識 Buffer 是否是隻讀的
    boolean isReadOnly;                

    ByteBuffer(int mark, int pos, int lim, int cap,  
                 byte[] hb, int offset)
    {
        super(mark, pos, lim, cap);
        this.hb = hb;
        this.offset = offset;
    }

    ByteBuffer(int mark, int pos, int lim, int cap) { 
        this(mark, pos, lim, cap, null, 0);
    }

}

ByteBuffer 中除了之前介紹的 Buffer 類中定義的四種重要屬性之外,又額外定義了三種屬性;

image.png

  1. byte[] hb:ByteBuffer 中背後依賴的用於存儲數據的數組,該字段只適用於 HeapByteBuffer ,而 DirectByteBuffer 和 MappedByteBuffer 背後依賴於堆外內存。這塊堆外內存的起始地址存儲於 Buffer 類中的 address 字段中。
  2. int offset:ByteBuffer 中的內存偏移,用於創建新的 ByteBuffer 視圖。詳情可回看《4. Buffer 的視圖》小節。
  3. boolean isReadOnly:用於標識該 ByteBuffer 是否是隻讀的。

5.1 創建具體存儲類型的 ByteBuffer

創建 DirectByteBuffer:

    public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }

創建 HeapByteBuffer:

    public static ByteBuffer allocate(int capacity) {
        if (capacity < 0)
            throw new IllegalArgumentException();
        return new HeapByteBuffer(capacity, capacity);
    }
由於 MappedByteBuffer 背後涉及到的原理比較複雜(雖然 API 簡單),所以筆者後面會有一篇專門講解 MappedByteBuffer 的文章,為了不使本文過於複雜,這裏就不列出了。

5.2 將字節數組映射成 ByteBuffer

經過前邊的介紹,我們知道 Buffer 其實本質上就是一個數組,在 Buffer 中封裝了一些對這個數組的便利操作方法。既然 Buffer 已經為數組操作提供了便利,所以大家基本都不會願意去直接操作原生字節數組。這樣一來將一個原生字節數組映射成一個 ByteBuffer 的需求就誕生了。

    public static ByteBuffer wrap(byte[] array, int offset, int length) {
        try {
            return new HeapByteBuffer(array, offset, length);
        } catch (IllegalArgumentException x) {
            throw new IndexOutOfBoundsException();
        }
    }

ByteBuffer 中的 wrap 方法提供了這樣的映射實現,該方法可以將字節數組全部映射成一個 ByteBuffer,或者將字節數組中的部分字節數據靈活映射成一個 ByteBuffer 。

  • byte[] array:需要映射成 ByteBuffer 的原生字節數組 array。
  • int offset:用於指定映射之後 Buffer 的 position。 position = offset。注意此處的 offset 並不是 Buffer 視圖中的 offset 。
  • int length:用於計算映射之後 Buffer 的 limit。 limit = offset + length,capacity = array,length。
映射後的 ByteBuffer 中 Mark = -1,offset = 0。此處的 offset 才是 Buffer 視圖中的 offset。
    HeapByteBuffer(byte[] buf, int off, int len) { // package-private
        super(-1, off, off + len, buf.length, buf, 0);
    }

image.png

以上介紹的 wrap 映射方法是根據用户自己指定的 position 和 limit 對原生字節數組進行靈活映射。當然 NIO 中還提供了一個方法是直接對原生字節數組 array 進行默認全部映射。映射之後的Buffer :position = 0,limit = capacity = array.length。


 public static ByteBuffer wrap(byte[] array) {
        return wrap(array, 0, array.length);
    }

5.3 定義 ByteBuffer 視圖相關操作

在前邊《4. Buffer 的視圖》小節的介紹中,筆者介紹頂層抽象類 Buffer 中定義的 offset 屬性的時候,我們提到過這個 offset 屬性就是用來創建 Buffer 視圖的。在該小節中筆者其實已經將 Buffer 創建視圖的相關原理和過程已經給大家詳細的介紹完了。而視圖創建的相關操作就定義在 ByteBuffer 這個抽象類中,分別為 slice() 方法和 duplicate() 方法。

這裏還是需要再次和大家強調的是我們基於原生 ByteBuffer 創建出來新的 ByteBuffer 視圖其實是 NIO 設計的一個障眼法。原生的 ByteBuffer 和它的視圖 ByteBuffer 其實本質上共用的是同一塊內存。對於 HeapByteBuffer 來説這塊共用的內存就是 JVM 堆上的一個字節數組,而對於 DirectByteBuffer 和 MappedByteBuffer 來説這塊共用的內存是堆外內存中的同一塊內存區域。

ByteBuffer 的視圖本質上也是一個 ByteBuffer,原生的 ByteBuffer 和它的視圖 ByteBuffer 擁有各自獨立的 mark,position,limit,capacity 指針。只不過背後依靠的內存空間是一樣的。所以在視圖 ByteBuffer 做的任何內容上的改動,原生 ByteBuffer 是看得見的。同理在原生 ByteBuffer 上做的任何內容改動,視圖 ByteBuffer 也是看得見的。它們是相互影響的,這點大家需要注意。

5.3.1 slice()

 public abstract ByteBuffer slice();

調用 slice() 方法創建出來的 ByteBuffer 視圖內容是從原生 ByteBufer 的當前位置 position 開始一直到 limit 之間的數據。也就是説通過 slice() 方法創建出來的視圖裏邊的數據是原生 ByteBuffer 中還未處理的數據部分。

image.png

如上圖所屬,調用 slice() 方法創建出來的視圖 ByteBuffer 它的存儲數組範圍:0 - 6,所以再此視圖下 position = 0,limit = capacity = 7。這其實是一個障眼法,真實情況是新的視圖 ByteBuffer 其實是複用原生 ByteBuffer 中的存儲數組中的 6 - 12 這塊區域(未處理的數據部分)。

所以在視圖 ByteBuffer 中訪問元素的時候,就需要 position + offset 來訪問才能正確的訪問到真實數組中的元素。這裏的 offset = 6。

下面是 HeapByteBuffer 中關於 slice() 方法的具體實現:

class HeapByteBuffer extends ByteBuffer {

    public ByteBuffer slice() {
        return new HeapByteBuffer(hb,
                                        -1,
                                        0,
                                        this.remaining(),
                                        this.remaining(),
                                        this.position() + offset);
    }

}

5.3.2 duplicate()

而由 duplicate() 方法創建出來的視圖相當於就是完全復刻原生 ByteBuffer。它們的 offset,mark,position,limit,capacity 變量的值全部是一樣的,這裏需要注意雖然值是一樣的,但是它們各自之間是相互獨立的。用於對同一字節數組做不同的邏輯處理。

public abstract ByteBuffer duplicate();

image.png

下面是 HeapByteBuffer 中關於 duplicate() 方法的具體實現:

class HeapByteBuffer extends ByteBuffer {

    public ByteBuffer duplicate() {
        return new HeapByteBuffer(hb,
                                        this.markValue(),
                                        this.position(),
                                        this.limit(),
                                        this.capacity(),
                                        offset);
    }

}

5.3.3 asReadOnlyBuffer()

public abstract ByteBuffer asReadOnlyBuffer();

通過 asReadOnlyBuffer() 方法我們可以基於原生 ByteBuffer 創建出一個只讀視圖。對於只讀視圖的 ByteBuffer 只能讀取不能寫入。對只讀視圖進行寫入操作會拋出 ReadOnlyBufferException 異常。

下面是 HeapByteBuffer 中關於 asReadOnlyBuffer() 方法的具體實現:

class HeapByteBuffer extends ByteBuffer {

   public ByteBuffer asReadOnlyBuffer() {

        return new HeapByteBufferR(hb,
                                     this.markValue(),
                                     this.position(),
                                     this.limit(),
                                     this.capacity(),
                                     offset);
    }

}

NIO 中專門設計了一個只讀 ByteBufferR 視圖類。它的 isReadOnly 屬性為 true。

class HeapByteBufferR extends HeapByteBuffer {

   protected HeapByteBufferR(byte[] buf,
                                   int mark, int pos, int lim, int cap,
                                   int off)
    {
        super(buf, mark, pos, lim, cap, off);
        this.isReadOnly = true;

    }

}

5.4 定義 ByteBuffer 讀寫相關操作

ByteBuffer 中定義了四種針對 Buffer 讀寫的基本操作方法,由於 ByteBuffer 這個抽象類是一個頂層設計類,只是規範定義了針對 ByteBuffer 操作的基本行為,它並不負責具體數據的存儲,所以這四種基本操作方法會在其具體的實現類中實現,這個我們後面會一一介紹。這裏只是向大家展示 NIO 針對 ByteBuffer 的頂層設計。


 //從ByteBuffer中讀取一個字節的數據,隨後position的位置向後移動一位
 public abstract byte get();

 //向ByteBuffer中寫入一個字節的數據,隨後position的位置向後移動一位
 public abstract ByteBuffer put(byte b);

 //按照指定index從ByteBuffer中讀取一個字節的數據,position的位置保持不變
 public abstract byte get(int index);

 //按照指定index向ByteBuffer中寫入一個字節的數據,position的位置保持不變
 public abstract ByteBuffer put(int index, byte b);

ByteBuffer 類中除了定義了這四種基本的讀寫操作,還依據這四個基本操作衍生出了幾種通用操作,下面筆者來為大家介紹下這幾種通用的操作:

1. 將 ByteBuffer中的字節轉移到指定的字節數組 dst 中

  • offset:dst 數組存放轉移數據的起始位置。
  • length:從 ByteBuffer 中轉移字節數。
   public ByteBuffer get(byte[] dst, int offset, int length) {
         //檢查指定index的邊界,確保不能越界
        checkBounds(offset, length, dst.length);
        //檢查ByteBuffer是否有足夠的轉移字節
        if (length > remaining())
            throw new BufferUnderflowException();
        int end = offset + length;
        // 從當前ByteBuffer中position開始轉移length個字節 到dst數組中
        for (int i = offset; i < end; i++)
            dst[i] = get();
        return this;
    }

2. 將指定字節數組 src 中的數據轉移到 ByteBuffer中

  • offset:從字節數組中的 offset 位置處開始轉移。
  • length:向 ByteBuffer轉移字節個數。
    public ByteBuffer put(byte[] src, int offset, int length) {
        //檢查指定index的邊界,確保不能越界
        checkBounds(offset, length, src.length);
        //檢查ByteBuffer是否能夠容納得下
        if (length > remaining())
            throw new BufferOverflowException();
        int end = offset + length;
        //從字節數組的offset處,轉移length個字節到ByteBuffer中
        for (int i = offset; i < end; i++)
            this.put(src[i]);
        return this;
    }

在為大家介紹完 ByteBuffer 的抽象設計之後,筆者相信大家現在已經對 NIO 的 ByteBuffer 有了一個整體上的認識。

接下來的內容,筆者將會為大家詳細介紹之前多次提到的這三種 ByteBuffer 的具體實現類型:

image.png

讓我們從 HeapByteBuffer 開始,HeapByteBuffer 的相關實現最簡單最容易理解的,我們會在 HeapByteBuffer 的介紹中,詳細介紹 Buffer 操作的實現。理解了 HeapByteBuffer 的相關實現,剩下的 Buffer 實現類就更容易理解了,都是大同小異。

6. HeapByteBuffer 的相關實現

image.png

經過前邊幾個小節的介紹,大家應該對 HeapByteBuffer 的結構很清楚了,HeapByteBuffer 背後主要是依賴於 JVM 堆中的一個字節數組 byte[] hb。

在這個 JVM 堆中的字節數組的基礎上,實現了在 Buffer 類和 ByteBuffer類中定義的抽象方法。

6.1 HeapByteBuffer 的構造

在 HeapByteBuffer 的構造過程中首先就會根據用户指定的 Buffer 容量 cap,在 JVM 堆中創建一個容量大小為 cap 的字節數組出來作為 HeapByteBuffer 底層存儲數據的容器。


class HeapByteBuffer extends ByteBuffer {

   HeapByteBuffer(int cap, int lim) {      
        super(-1, 0, lim, cap, new byte[cap], 0);
   }

}
public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer> {

    ByteBuffer(int mark, int pos, int lim, int cap,   
                 byte[] hb, int offset)
    {
        super(mark, pos, lim, cap);
        this.hb = hb;
        this.offset = offset;
    }

}

還有我們《5.2 將字節數組映射成 ByteBuffer》小節介紹的用於將原生字節數組映射成 ByteBuffer 的 wrap 方法中用到的構造函數:

    public static ByteBuffer wrap(byte[] array, int offset, int length) {
        try {
            return new HeapByteBuffer(array, offset, length);
        } catch (IllegalArgumentException x) {
            throw new IndexOutOfBoundsException();
        }
    }
    HeapByteBuffer(byte[] buf, int off, int len) { 
        super(-1, off, off + len, buf.length, buf, 0);
    }

以及我們在《5.3 定義 ByteBuffer 視圖相關操作》小節介紹的用於創建 ByteBuffer 視圖的兩個方法 slice() 和 duplicate() 方法中用到的構造函數:

   protected HeapByteBuffer(byte[] buf,
                                   int mark, int pos, int lim, int cap,
                                   int off)
    {
        super(mark, pos, lim, cap, buf, off);
    }

6.2 從 HeapByteBuffer 中讀取字節

6.2.1 根據 position 的位置讀取一個字節

  • 首先會通過《2.2.2 獲取 Buffer 下一個可讀取位置》小節介紹的 nextGetIndex() 方法獲取當前 HeapByteBuffer 中的 position 位置,根據 position 的位置讀取字節。
  • 為了兼容 Buffer 視圖的相關操作,定位讀取位置 position 都會加上 offset。原生 Buffer 中的 offset = 0。
  • 通過 position + offset 確定好訪問 Index 之後,就是數組的普通操作了,直接通過這個 Index 從 hb 字節數組中獲取字節。隨後 Buffer 中的 position 向後移動一個位置。
class HeapByteBuffer extends ByteBuffer {

    protected final byte[] hb;
    protected final int offset;

    public byte get() {
        return hb[ix(nextGetIndex())];
    }
   // 確定訪問 index 
   protected int ix(int i) {
        return i + offset;
    }
}

6.2.2 根據指定的 Index 讀取一個字節

我們除了可以根據 Buffer 的 position 位置讀取字節,還可以指定具體的 Index 來從 Buffer 中讀取字節:

  • 檢查 Index 是否超出 Buffer 的邊界範圍,通過檢查之後 Index + offset 確定讀取位置。
注意這個方法讀取字節之後,position 的位置是不會改變的。
public byte get(int i) {
        return hb[ix(checkIndex(i))];
    }

6.2.3 將 HeapByteBuffer 中的字節轉移到指定的字節數組中

這個方法其實筆者在《5.4 定義 ByteBuffer 讀寫相關操作》小節中介紹 ByteBuffer 的頂層規範設計時已經提到過了,由於 ByteBuffer 只是一個抽象類負責頂層操作規範的定義,本身並不具備具體存儲數據的能力,所以在 ByteBuffer 中只是提供了一個通用的實現。ByteBuffer 中的實現是通過在一個for () {....} 循環中不停的根據原生 Buffer 中的 position 指針(前邊介紹的 get() 方法)遍歷底層數組並一個一個的拷貝到目標字節數組 dst 中。這樣的拷貝操作無疑是效率低下的。

而在 HeapByteBuffer 這個具體的 ByteBuffer 實現類中已經定義了具體的存儲方式,所以根據具體的存儲方式能夠做一下拷貝上的優化:

    public ByteBuffer get(byte[] dst, int offset, int length) {
        checkBounds(offset, length, dst.length);
        if (length > remaining())
            throw new BufferUnderflowException();
        System.arraycopy(hb, ix(position()), dst, offset, length);
        position(position() + length);
        return this;
    }

HeapByteBuffer 中對於拷貝字節數組中的數據使用了 System.arraycopy 方法,該方法在 JVM 中是一個 intrinsic method,是經過 JVM 編譯器特殊優化的,比通過 JNI 調用 native 方法的性能還要高。

利用 System.arraycopy 方法將 HeapByteBuffer 中的字節數據從 position 開始,拷貝 length 個字節到目標字節數組 dst 中。

6.3 向HeapByteBuffer中寫入字節

6.3.1 根據 position 的位置寫入一個字節

  • 首先會通過《2.2.3 獲取 Buffer 下一個可寫入位置》小節中介紹的 nextPutIndex() 方法獲取當前 HeapByteBuffer 中的 position 位置,根據position的位置寫入字節。
  • 通過 position + offset 定位到寫入位置 Index,然後向 HeapByteBuffer 底層的字節數組 hb 直接寫入字節數據。隨後 position 向後移動一個位置。

    public ByteBuffer put(byte x) {
        hb[ix(nextPutIndex())] = x;
        return this;
    }

   protected int ix(int i) {
        return i + offset;
    }

6.3.2 根據指定的 Index 寫入一個字節

注意通過這個方法根據指定 Index 寫入字節之後,position 的位置是不會改變的。
    public ByteBuffer put(int i, byte x) {
        hb[ix(checkIndex(i))] = x;
        return this;
    }

6.3.3 將指定字節數組轉移到 HeapByteBuffer 中

同理和《6.2.3 將 HeapByteBuffer 中的字節轉移到指定的字節數組中》小節中介紹的相關方法一樣,HeapByteBuffer 也是採用了 JVM 中的 System.arraycopy 方法(intrinsic method )從而更加高效地進行字節數組的拷貝操作。

從字節數組 src 中的 offset 位置開始拷貝 length 個字節到 HeapByteBuffer中


   public ByteBuffer put(byte[] src, int offset, int length) {

        checkBounds(offset, length, src.length);
        if (length > remaining())
            throw new BufferOverflowException();
        System.arraycopy(src, offset, hb, ix(position()), length);
        position(position() + length);
        return this;
    }

HeapByteBuffer 背後依靠的字節數組存儲的是一個一個的字節,以上操作全部針對的是單個字節來的,所以並不需要考慮字節序的影響,但是如果我們想從 HeapByteBuffer 中讀取寫入一個 int 或者一個 double 類型的數據,那麼我們就需要考慮字節序的問題了。

在介紹如何從 HeapByteBuffer 中讀取或者寫入一個指定基本類型數據之前,筆者先來為大家介紹一下:

  • 到底什麼是字節序?
  • 為什麼會有字節序的存在?
  • 字節序對 Buffer 的操作會有什麼影響?

7. 字節序

談起字節序來大家可能都會有這樣的感觸就是記了忘,忘了記,記了又忘。所以為了讓大家清晰地理解字節序並且深深地刻入腦海中,筆者挖空心思終於想出了一個生活中的例子來為大家説明字節序。

筆者平時有健身的習慣,已經堅持擼鐵四年多了,為了給身體補充蛋白質增加肌肉量,每天打底至少 15 個雞蛋,所以剝雞蛋就成為了筆者日常的一個重要任務。

image.png

那麼問題來了,在我們剝雞蛋的時候,我們到底是該從雞蛋大的一端剝起還是從雞蛋小的的一端剝起呢?

這還真是一個問題,有的人喜歡從小端剝起,但是筆者習慣從大端開始剝起。於是就有了大端-小端的剝法。

大端小端.png

既然剝雞蛋有大端-小端的分歧在,那麼在計算機網絡傳輸數據時也會存在這樣的問題,計算機中是怎麼扯出大端-小端的分歧呢?請耐心聽筆者接着講下去~~

我們都知道在計算機中存儲數據,字符編碼以及網絡中傳輸數據時都是通過一個 bit 一個 bit 組成的 010101 這樣的二進制形式傳輸存儲的。由於本系列的主題是關於網絡 IO 的處理,所以筆者這裏以網絡傳輸中的字節序舉例:

比如現在我們要傳輸一個 int 型的整數 5674 到對端主機中。int 型的變量 5674 對應的二進制是 1011000101010 。如下圖所示:

image.png

剝雞蛋的分歧在於是從大的一端開始剝還是從小的一端開始剝,從大的一端開始剝我們叫做大端剝法,而從小的一端開始剝我們叫做小端剝法。

同樣的道理,我們在網絡傳輸二進制數據的時候也有分歧:我們是從二進制的高位開始傳輸呢(圖中綠色區域)?還是從二進制的低位開始傳輸呢(圖中黃色區域)?

如果我們從二進制數據的高位(類比雞蛋的大端)開始傳輸我們就叫大端字節序,如果我們從二進制的低位(類比雞蛋的小端)開始傳輸就叫小端字節序

網絡協議採用的是大端字節序傳輸

好了,現在關於網絡傳輸字節的順序問題,我們闡述清楚了,那麼接下來我們看下當網絡字節傳輸到對端時,對端如何接收?

當網絡字節按照大端字節序傳輸到對端計算機時,對端會在操作系統的堆中開闢一塊內存用來接收網絡字節。而在操作系統的虛擬內存佈局中,堆空間的地址增長方向是從低地址向高地址增長,而棧空間的地址是從高地址向低地址增長。

image.png

現在我們假設如果當網絡字節傳輸到對端計算機中,我們在對端使用 HeapByteBuffer 去接收網絡字節(這裏只是假設,實踐上都是使用 DirectByteBuffer ),經過前邊內容的介紹我們知道,HeapByteBuffer 背後其實依靠一個字節數組來存儲字節。如圖中所示,字節數組從索引 0 開始到索引 6 它們在內存中的地址是從低地址到高地址。

理解了這些,下面我們就來看下字節在不同字節序下是如何接收存儲的。

7.1 大端字節序

image.png

如圖中所示,在大端字節序下 int 型變量 5674 它的字節高位被存儲在了字節數組中的低地址中,字節的低位被存儲在字節數組的高地址中。這就是大端字節序,也是比較符合人類的直觀感受。

7.2 小端字節序

image.png

然而在小端字節序下,int 型變量 5674 它的字節高位被存儲在了字節數組中的高地址中,字節的低位被存儲在字節數組的低地址中。這就是小端字節序,正好和正常人類直觀感受是相反的。


到現在,我想大家應該最起碼從概念上知道什麼是大端字節序?什麼是小端字節序了吧?

下面筆者在帶大家到實戰中,再去體驗一把大端字節序和小端字節序的不同。徹底讓大家理解清楚。

8. 向 HeapByteBuffer 中寫入指定基本類型

HeapByteBuffer 背後是一個在 JVM 堆中開闢的一個字節數組,裏邊存放的是一個一個的字節,當我們以單個字節的形式操作 HeapByteBuffer 的時候並沒有什麼問題,可是當我們向 HeapByteBuffer 寫入一個指定的基本類型數據時,比如寫入一個 int 型 (佔用 4 個字節),寫入一個 double 型 (佔用 8 個字節),就必須要考慮字節序的問題了。

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer> {

   boolean bigEndian = true;
   boolean nativeByteOrder = (Bits.byteOrder() == ByteOrder.BIG_ENDIAN);

}

我們可以強制網絡協議傳輸使用大端字節序,但是我們無法強制主機中採用的字節序,所以我們需要經常在網絡 IO 場景下做一些字節序的轉換工作。

JDK NIO ByteBuffer 默認的字節序為大端模式,我們可以通過 NIO 提供的操作類 Bits 獲取主機字節序 Bits.byteOrder(),或者直接獲取 NIO ByteBuffer 中的 nativeByteOrder 字段判斷主機字節序:true 表示主機字節序為大端模式,false 表示主機字節序為小端模式。

當然我們也可以通過 ByteBuffer 中的 order 方法來指定我們想要的字節序:


    public final ByteBuffer order(ByteOrder bo) {
        bigEndian = (bo == ByteOrder.BIG_ENDIAN);
        nativeByteOrder =
            (bigEndian == (Bits.byteOrder() == ByteOrder.BIG_ENDIAN));
        return this;
    }

下面筆者就帶大家分別從大端模式和小端模式下來看一下如何向 HeapByteBuffer 寫入一個指定基本類型的數據。我們以 int 型數據舉例,假設要寫入的 int 值 為 5674。

8.1 大端字節序

class HeapByteBuffer extends ByteBuffer {

    public ByteBuffer putInt(int x) {
        Bits.putInt(this, ix(nextPutIndex(4)), x, bigEndian);
        return this;
    }
}

首先我們會獲取當前 HeapByteBuffer 的寫入位置 position,因為我們需要寫入的是一個 int 型的數據,所以當寫入完畢之後 position 的位置需要向後移動 4 位。nextPutIndex 方法的邏輯筆者在之前的內容中已經詳細介紹過了,這裏不在贅述。

class Bits { 

    static void putInt(ByteBuffer bb, int bi, int x, boolean bigEndian) {
        if (bigEndian)
            // 採用大端字節序寫入 int 數據
            putIntB(bb, bi, x);
        else
            // 採用小端字節序寫入 int 數據
            putIntL(bb, bi, x);
    }

    static void putIntB(ByteBuffer bb, int bi, int x) {
        bb._put(bi    , int3(x));
        bb._put(bi + 1, int2(x));
        bb._put(bi + 2, int1(x));
        bb._put(bi + 3, int0(x));
    }
}

大家看到了嗎,這裏就是按照我們之前介紹的大端字節序,從 int 值 5674 的二進制高位字節到低位字節依次寫入 HeapByteBuffer中字節數組的低地址中。

這裏的 int3(x) 方法就是負責獲取寫入數據 x 的最高位字節,並將最高位字節(下圖中綠色部分)寫入字節數組中的低地址中(下圖中對應綠色部分)。

同理 int2(x),int1(x),int0(x) 方法依次獲取 x 的次高位字節,依次寫入字節數組中的低地址中。

image.png

那麼我們如何依次獲得一個 int 型數據的高位字節呢?大家接着跟着筆者往下走~

8.1.1 int3(x) 獲取 int 型最高位字節

class Bits { 

 private static byte int3(int x) { return (byte)(x >> 24); }

}

image.png

8.1.2 int2(x) 獲取 int 型次高位字節

class Bits { 

 private static byte int2(int x) { return (byte)(x >> 16); }

}

image.png

8.1.3 int1(x) 獲取 int 型第三高位字節

class Bits { 

 private static byte int1(int x) { return (byte)(x >> 8); }

}

image.png

8.1.4 int0(x) 獲取 int 型最低位字節

class Bits { 

 private static byte int0(int x) { return (byte)(x      ); }

}

image.png

最終 int 型變量 5764 按照大端字節序寫入到 HeapByteBuffer之後的字節數組結構如下:

image.png

8.2 小端字節序

在我們徹底理解了大端字節序的操作之後,小端字節序的相關操作就很好理解了。

    static void putIntL(ByteBuffer bb, int bi, int x) {
        bb._put(bi + 3, int3(x));
        bb._put(bi + 2, int2(x));
        bb._put(bi + 1, int1(x));
        bb._put(bi    , int0(x));
    }

根據我們之前介紹的小端字節序的定義,在小端模式下二進制數據的高位是存儲在字節數組中的高地址中,二進制數據的低位是存儲在字節數組中的低地址中。

image.png

9. 從 HeapByteBuffer 中讀取指定基本類型

當我們清楚了在不同的字節序下如何向 HeapByteBuffer 中寫入指定基本類型數據的過程之後,那麼在不同字節序下向 HeapByteBuffer 讀取指定基本類型數據的過程,我想大家就能很容易理解了。

我們還是以 int 型數據舉例,假設要從 HeapByteBuffer 中讀取一個 int 型的數據。

首先我們還是獲取當前 HeapByteBuffer 中的讀取位置 position,從 position 位置開始讀取四個字節出來,然後通過這四個字節組裝成一個 int 數據返回。

class HeapByteBuffer extends ByteBuffer {

    public int getInt() {
        return Bits.getInt(this, ix(nextGetIndex(4)), bigEndian);
    }

}

class Bits { 

  static int getInt(ByteBuffer bb, int bi, boolean bigEndian) {
        return bigEndian ? getIntB(bb, bi) : getIntL(bb, bi) ;
    }

}

我們還是先來介紹大端模式下的讀取過程:

9.1 大端字節序

class Bits { 

    static int getIntB(ByteBuffer bb, int bi) {
        return makeInt(bb._get(bi    ),
                       bb._get(bi + 1),
                       bb._get(bi + 2),
                       bb._get(bi + 3));
    }

}

image.png

由於在大端模式下,二進制數據的高位是存放於字節數組中的低地址中,我們需要從字節數組中的低地址中依次讀取二進制數據的高位出來。

然後我們從高位開始依次組裝 int 型數據,正好和寫入過程相反。

    static private int makeInt(byte b3, byte b2, byte b1, byte b0) {
        return (((b3       ) << 24) |
                ((b2 & 0xff) << 16) |
                ((b1 & 0xff) <<  8) |
                ((b0 & 0xff)      ));
    }

9.2 小端字節序

image.png

class Bits { 

    static int getIntL(ByteBuffer bb, int bi) {
        return makeInt(bb._get(bi + 3),
                       bb._get(bi + 2),
                       bb._get(bi + 1),
                       bb._get(bi    ));
    }

}

而在小端模式下,我們則需要先從字節數組中的高地址中將二進制數據的高位依次讀取出來,然後在從高位開始依次組裝 int 型數據。

在筆者介紹完了關於 int 數據的讀寫過程之後,相信大家可以很輕鬆的理解其他基本類型在不同字節序下的讀寫操作過程了。

10. 將 HeapByteBuffer 轉換成指定基本類型的 Buffer

在《2. NIO 對 Buffer 的頂層抽象》小節一開始就介紹到,NIO 其實為我們提供了多種基本類型的 Buffer 實現。

image.png

NIO 允許我們將 ByteBuffer 轉換成任意一種基本類型的 Buffer,這裏我們以轉換 IntBuffer 為例説明:

class HeapByteBuffer extends ByteBuffer {

    public IntBuffer asIntBuffer() {
        int size = this.remaining() >> 2;
        int off = offset + position();
        return (bigEndian
                ? (IntBuffer)(new ByteBufferAsIntBufferB(this,
                                                             -1,
                                                             0,
                                                             size,
                                                             size,
                                                             off))
                : (IntBuffer)(new ByteBufferAsIntBufferL(this,
                                                             -1,
                                                             0,
                                                             size,
                                                             size,
                                                             off)));
    }

}

IntBuffer 底層其實依託了一個 ByteBuffer,當我們向 IntBuffer 讀取一個 int 數據時,其實是從底層依託的這個 ByteBuffer 中讀取 4 個字節出來然後組裝成 int 數據返回。

class ByteBufferAsIntBufferB extends IntBuffer {

    protected final ByteBuffer bb;

    public int get() {
        return Bits.getIntB(bb, ix(nextGetIndex()));
    }
}
class Bits { 

    static int getIntB(ByteBuffer bb, int bi) {
        return makeInt(bb._get(bi    ),
                       bb._get(bi + 1),
                       bb._get(bi + 2),
                       bb._get(bi + 3));
    }

    static private int makeInt(byte b3, byte b2, byte b1, byte b0) {
        return (((b3       ) << 24) |
                ((b2 & 0xff) << 16) |
                ((b1 & 0xff) <<  8) |
                ((b0 & 0xff)      ));
    }

}

同理,我們向 IntBuffer 中寫入一個int數據時,其實是想底層依託的這個 ByteBuffer 寫入 4 個字節。

IntBuffer 底層依託的這個 ByteBuffer ,會根據字節序的不同分為:ByteBufferAsIntBufferB(大端實現)和 ByteBufferAsIntBufferL(小端實現)。

在我們詳細介紹完 HeapByteBuffer 的實現之後,筆者這裏就不在為大家詳細介紹 ByteBufferAsIntBufferB 和 ByteBufferAsIntBufferL 了。操作全部是一樣的,感興趣的大家可以自行查看一下。

總結

image.png

本文我們以 JDK NIO Buffer 中最簡單的一個實現類 HeapByteBuffer 為主線從 NIO 對 Buffer 的頂層抽象設計開始從整體上為大家介紹了 Buffer 的設計。

在這個過程中,我們可以體會到 NIO 對 Buffer 的設計還是比較複雜的,尤其是我們針對裸 NIO 進行編程的時候會有非常多的反人類操作,一不小心就會出錯。

比如:用於 Buffer 讀模式切換 flip() 方法,寫模式切換的 clear() 方法和 compact() 方法以及用於重新處理 Buffer 中數據的 rewind() 方法。在我們使用這些方法處理字節數據的時候需要時刻清楚 Buffer 中的數據分佈情況,一不小心就會造成數據的覆蓋和丟失。

後面我們又介紹了 Buffer 中視圖的概念和相關操作 slice() 方法和 duplicate() 方法,以及關於視圖 Buffer 和原生 Buffer 之間的區別和聯繫。

我們以 HeapByteBuffer 為例,介紹了 NIO Buffer 相關頂層抽象方法的實現,並再次基礎上更進一步介紹了在不同字節序下 ByteBuffer 相關的讀取寫入操作的詳細過程。

最後我們介紹了 ByteBuffer 與相關指定基本類型 Buffer (比如 IntBuffer,LongBuffer)在不同字節序下的轉換。

另外我們還穿插介紹了:到底什麼是字節序? 為什麼會有字節序的存在? 字節序對 Buffer 的操作會有什麼影響?

因為 HeapByteBuffer 足夠簡單,所以利用它能夠把整個 NIO 對 Buffer 的設計與實現串聯起來,但是根據 Buffer 背後的存儲機制不同,還有 DirectByteBuffer 和 MappedByteBuffer ,它們的 API 在使用上基本和 HeapByteBuffer 是一致的。但是它們背後涉及到的原理卻是非常複雜的(尤其是 MappedByteBuffer)。

所以筆者後面會單獨寫兩篇文章來詳細分別為大家介紹 DirectByteBuffer 和 MappedByteBuffer 背後涉及到的複雜原理,目的是讓大家不僅會使用而且還要把它們背後涉及到的複雜原理徹底搞透徹弄清楚,要知其然並且還要知其所以然~~~

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.