Java-NIO之Buffer(緩衝區)

竹根七發表於2022-03-31

Buffer 是什麼

Buffer(緩衝區)本質上是一個由基本型別陣列構成的容器。

我們先看看Buffer類的基本構成:

public abstract class Buffer {
    // Invariants: mark <= position <= limit <= capacity
    private int mark = -1;
    private int position = 0;
    private int limit;
    private int capacity;
}

再看看子類ByteBuffer 的構成:

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>{
    // These fields are declared here rather than in Heap-X-Buffer in order to
    // reduce the number of virtual method invocations needed to access these
    // values, which is especially costly when coding small buffers.
    //
    final byte[] hb;                  // Non-null only for heap buffers
    final int offset;
    boolean isReadOnly;
}

因此一個ByteBuffer 物件由基本的五大屬性組成:
核心屬性:
● mark 初始值為-1,用以標記當前position的位置。對應方法為 mark()。
● position 初始值為0,讀、寫資料的起點位置。對應方法為 position()。
● limit 界限,和position 組成可讀、可寫的資料操作區間。對應方法為 limit()。
● capacity 緩衝區的大小。對應方法為capacity()。

資料儲存:
● hb 一個基本型別構成的資料,大小等於capacity。

Buffer 如何使用

核心方法:
● put() 寫資料。
● get() 讀資料。
● flip() 翻轉。如當 put 完資料之後,呼叫flip s 是為了告知下次 get 資料需要讀取資料區間。反過來也是一樣的道理。

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

● clear() 清空。不會清除資料,但會各個屬性迴歸初始值。

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

● rewind 倒帶。當需要重讀、重寫的時候可以使用。

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

● remaning() 返回剩餘未被處理的數量。

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

假設我們宣告瞭一個 capacity 為 5 的位元組緩衝區:
ByteBuffer buf = ByteBuffer.allocate(4);
那麼,緩衝區的初始狀態就是如下圖所示:
image

Buffer 用來幹什麼

Buffer(緩衝區) 常常用來於NIO的Channel進行互動。資料從緩衝區進行存放和讀取。

1:傳統的IO流讀取、寫入都是直接基於IO流。
2:而使用了buffer後,資料的寫入、讀取是基於buffer,然後再經由IO流進行寫入、讀取。
3:防止記憶體佔用過大,分段的讀取、寫入資料。


Buffer 讀檔案

這裡對比了兩種讀檔案的方式。


BIO讀檔案(不用Buffer):

    public void ioRead() {
        FileInputStream fileInputStream = null;
        try {
            fileInputStream = new FileInputStream(new File("src/test/java/com/loper/mine/SQLParserTest.java"));
            byte[] receive = new byte[8];
            // IO 流讀檔案的時候不會管 byte 中的資料是否已被處理過,下一次讀取直接覆蓋
            while (fileInputStream.read(receive) > 0) {
                System.out.println(new String(receive));
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (fileInputStream != null)
                    fileInputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

BufferReader讀檔案(用Buffer):

    public void bufferRead() {
        int capacity = 8;
        FileInputStream fileInputStream = null;
        InputStreamReader inputStreamReader = null;
        BufferedReader bufferedReader = null;
        try {
            fileInputStream = new FileInputStream(new File("src/test/java/com/loper/mine/SQLParserTest.java"));
            inputStreamReader = new InputStreamReader(fileInputStream);
            bufferedReader = new BufferedReader(inputStreamReader, capacity);

            CharBuffer receive = CharBuffer.allocate(capacity);
            char[] data = new char[capacity];
            // buffer reader 在讀取資料的時候會判斷buffer 中的資料是否已被清理
            while (bufferedReader.read(receive) > 0) {
                receive.flip();
                receive.get(data);
                receive.flip();
                System.out.println(new String(data));
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (bufferedReader != null)
                    bufferedReader.close();
                if (inputStreamReader != null)
                    inputStreamReader.close();
                if (fileInputStream != null)
                    fileInputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

可以看到,當我們使用BIO時,從檔案流中讀取的資料使用 byte陣列接收就可以了。
而使用 BufferReader之後,讀取檔案返回的是一個 ByteBuffer,那為什麼要這麼做呢?
1:使用byte[] 接收資料,我們讀取之後下次再進行寫入的時候是不知道是否已經讀取完畢了的。下次的寫入會將原本的資料直接覆蓋掉。
2:使用ByteBuffer 接收檔案流中的資料,在下一次資料寫入前不進行 flip 或 clear 操作,那麼下次寫入資料時並不會更新 ByteBuffer 中的資料。


試想多執行緒情況下,一個執行緒寫資料,另一個執行緒讀資料,若資料還在未確保讀完的情況下就進行下一步寫入了,那麼勢必會丟失資料。
而使用Buffer 則很好的避免了這種情況,無論是寫還是讀,都需要告訴下一次讀或寫資料時的操作區間。byte[] 本身則是不支援這種情況的。


Buffer 與多執行緒

多執行緒下模擬資料分段讀、寫:

    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 2, 1L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10));

        String bufferData = "hello world";
        int capacity = 4;
        // 預設使用分配堆記憶體分配緩衝區空間(非直接緩衝區)
        //ByteBuffer buffer = ByteBuffer.allocate(capacity);
        // 使用直接記憶體分配緩衝區空間(直接緩衝區)
        ByteBuffer buffer = ByteBuffer.allocateDirect(capacity);

        Semaphore semaphore1 = new Semaphore(0);
        Semaphore semaphore2 = new Semaphore(0);
        // 寫操作
        executor.execute(() -> {
            int index = 0, len = bufferData.length();
            while (index < len) {
                try {
                    System.out.println("put資料開始----------------");
                    print(buffer);
                    int endIndex = index + capacity;
                    if (endIndex > len)
                        endIndex = len;

                    // 存之前先清空buffer
                    buffer.clear();
                    buffer.put(bufferData.substring(index, endIndex).getBytes());

                    System.out.println("put資料結束----------------");
                    print(buffer);
                    System.out.println("\n");
                    // 存完告訴讀執行緒可讀區域大小
                    buffer.flip();

                    index += capacity;
                } catch (Exception e) {
                    e.printStackTrace();
                    break;
                } finally {
                    semaphore2.release();
                    try {
                        semaphore1.tryAcquire(3, TimeUnit.SECONDS);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });

        // 讀操作
        executor.execute(() -> {
            StringBuilder value = new StringBuilder();
            int i = 0;
            while (i < bufferData.length()) {
                try {
                    semaphore2.tryAcquire(3, TimeUnit.SECONDS);
                    System.out.println("get資料開始----------------");
                    print(buffer);

                    byte[] bytes = new byte[buffer.limit()];
                    buffer.get(bytes);
                    value.append(new String(bytes));

                    System.out.println("get資料結束----------------");
                    print(buffer);
                    System.out.println("\n");

                    i += bytes.length;
                } catch (Exception e) {
                    e.printStackTrace();
                    break;
                } finally {
                    semaphore1.release();
                }
            }

            // 完整讀取到的buffer資料
            System.out.println("完整讀取到的buffer資料:" + value.toString());
            buffer.clear();
            print(buffer);
        });

        executor.shutdown();
    }

    private static void print(Buffer buffer) {
        System.out.println("position=" + buffer.position());
        System.out.println("limit   =" + buffer.limit());
        System.out.println("capacity=" + buffer.capacity());
        System.out.println("mark    :" + buffer.mark());
    }

日誌太長,就不全截圖了,如下為最終輸出:
image

以上程式碼模擬了寫執行緒需要往 buffer 中分段寫入 ‘hello word’,而讀執行緒則需要從 buffer 中分段讀取,並輸出最終的資料。


個人思考:
從這也聯想到了ftp傳輸資料時也是分段、按序進行傳輸的,也不是一次性將資料一股腦全部丟過去的,這應該就是 Buffer(緩衝區)的作用吧。

Buffer 緩衝區型別

非直接緩衝區

緩衝區空間由JVM記憶體進行分配。

非直接緩衝區屬於常規操作,傳統的 IO 流和 allocate() 方法分配的緩衝區都是非直接緩衝區,建立在 JVM 記憶體中。

    public static ByteBuffer allocate(int capacity) {
        if (capacity < 0)
            throw new IllegalArgumentException();
        return new HeapByteBuffer(capacity, capacity);
    }

這種常規的非直接緩衝區會將核心地址空間中的內容拷貝到使用者地址空間(中間緩衝區)後再由程式進行讀或寫操作,換句話說,磁碟上的檔案在與應用程式互動的過程中會在兩個快取中來回進行復制拷貝。
如圖:
image

直接緩衝區

緩衝區空間由實體記憶體直接分配。

直接緩衝區絕大多數情況用於顯著提升效能,緩衝區直接建立在實體記憶體(相對於JVM 的記憶體空間)中,省去了在兩個儲存空間中來回複製的操作,可以通過呼叫 ByteBuffer 的 allocateDirect() 工廠方法來建立。

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

直接緩衝區中的內容可以駐留在常規的垃圾回收堆之外,因此它們對應用程式的記憶體需求量造成的影響可能並不明顯。
另外,直接緩衝區還可以通過 FileChannel 的 map() 方法將檔案直接對映到記憶體中來建立,
該方法將返回 MappedByteBuffer(DirectByteBuffer extends MappedByteBuffer)。

直接或非直接緩衝區只針對位元組緩衝區而言。位元組緩衝區是那種型別可以通過 isDirect() 方法來判斷。
如圖:
image

問答區域

1:DirectByteBuffer 比 HeapByteBuffer 更快嗎?

不是。
image

本文參考文章:
1:面試官:Java NIO 的 Buffer 緩衝區,你瞭解多少?
2:Java NIO direct buffer的優勢在哪兒?
3:基於NIO的Socket通訊(使用Java NIO的綜合示例講解)

相關文章