Buffer和Channel

N1ce2cu發表於2024-08-17

IO 和 NIO 區別:

  • 可簡單認為:IO 是面向流的處理,NIO 是面向塊(緩衝區)的處理
  • 面向流的 I/O 系統一次一個位元組地處理資料
  • 一個面向塊(緩衝區)的 I/O 系統以塊的形式處理資料

NIO 主要有兩個核心部分組成

  • Buffer 緩衝區
  • Channel 通道

相對於傳統 IO 而言,流是單向的。對於 NIO 而言,有了 Channel 通道這個概念,讀寫都是雙向

Buffer 緩衝區


Buffer 是緩衝區的抽象類,其中 ByteBuffer 是用得最多的實現類(在通道中讀寫位元組資料),其餘還有 IntBuffer、CharBuffer、LongBuffer。

成員變數

Buffer 類維護了 4 個核心變數來提供關於其所包含的陣列資訊。

// Invariants: mark <= position <= limit <= capacity
// 一個備忘位置。用於記錄上一次讀寫的位置
private int mark = -1;
// 下一個要被讀或寫的元素的位置。position 會自動由相應的 get() 和 put() 函式更新
private int position = 0;
// 緩衝區裡的資料的總數,代表了當前緩衝區中一共有多少資料,位元組為單位
private int limit;
// 緩衝區能夠容納的資料元素的最大數量。容量在緩衝區建立時被設定,並且永遠不能被改變。(底層是陣列)
private int capacity;
public static void main(String[] args) {
    // 建立一個緩衝區
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    // 看一下初始時4個核心變數的值
    System.out.println("初始時:");
    System.out.println("limit = " + byteBuffer.limit());
    System.out.println("position = " + byteBuffer.position());
    System.out.println("capacity = " + byteBuffer.capacity());
    System.out.println("mark = " + byteBuffer.mark());

    // 新增一些資料到緩衝區中
    String s = "嘻哈";
    byteBuffer.put(s.getBytes());

    // 看一下初始時4個核心變數的值
    System.out.println("put完之後:");
    System.out.println("limit = " + byteBuffer.limit());
    System.out.println("position = " + byteBuffer.position());
    System.out.println("capacity = " + byteBuffer.capacity());
    System.out.println("mark = " + byteBuffer.mark());
}
初始時:
limit = 1024
position = 0
capacity = 1024
mark = java.nio.HeapByteBuffer[pos=0 lim=1024 cap=1024]
put完之後:
limit = 1024
position = 6
capacity = 1024
mark = java.nio.HeapByteBuffer[pos=6 lim=1024 cap=1024]

flip、clear、rewind

flip()方法:使緩衝區為新的通道寫入或相對獲取操作序列做好準備:它將 limit 設定為 position,然後將 position 設定為零。

// flip()方法
byteBuffer.flip();
System.out.println("flip()方法之後:");
System.out.println("limit = "+byteBuffer.limit());
System.out.println("position = "+byteBuffer.position());
System.out.println("capacity = "+byteBuffer.capacity());
System.out.println("mark = " + byteBuffer.mark());
flip()方法之後:
limit = 6
position = 0
capacity = 1024
mark = java.nio.HeapByteBuffer[pos=0 lim=6 cap=1024]

當切換成讀模式之後,就可以讀取緩衝區的資料了:

// 建立一個 limit() 大小的位元組陣列
byte[] bytes = new byte[byteBuffer.limit()];
// 裝進位元組陣列
byteBuffer.get(bytes);
// 輸出
System.out.println(new String(bytes, 0, bytes.length));

讀完後 position 會更新到6。

讀完後:
limit = 6
position = 6
capacity = 1024
mark = java.nio.HeapByteBuffer[pos=6 lim=6 cap=1024]

clear() 方法,使緩衝區為新的通道讀取或相對放置操作序列做好準備:它將 limit 設定為 capacity 並把 position 設定為零。

clear後:
limit = 1024
position = 0
capacity = 1024
mark = java.nio.HeapByteBuffer[pos=0 lim=1024 cap=1024]

rewind() 方法,limit 不變,position 設定為零

Channel 通道


Channel 通道只負責傳輸資料、不直接運算元據。運算元據都是透過 Buffer 緩衝區來進行操作!通常,通道可以分為兩大類:檔案通道和套接字通道。

FileChannel:用於檔案 I/O 的通道,支援檔案的讀、寫和追加操作。FileChannel 允許在檔案的任意位置進行資料傳輸,支援檔案鎖定以及記憶體對映檔案等高階功能。FileChannel 無法設定為非阻塞模式,因此它只適用於阻塞式檔案操作。

SocketChannel:用於 TCP 套接字 I/O 的通道。SocketChannel 支援非阻塞模式,可以與 Selector 一起使用,實現高效的網路通訊。SocketChannel 允許連線到遠端主機,進行資料傳輸。

與之匹配的有ServerSocketChannel:用於監聽 TCP 套接字連線的通道。與 SocketChannel 類似,ServerSocketChannel 也支援非阻塞模式,並可以與 Selector 一起使用。ServerSocketChannel 負責監聽新的連線請求,接收到連線請求後,可以建立一個新的 SocketChannel 以處理資料傳輸。

DatagramChannel:用於 UDP 套接字 I/O 的通道。DatagramChannel 支援非阻塞模式,可以傳送和接收資料包包,適用於無連線的、不可靠的網路通訊。

檔案通道 FileChannel

  1. 開啟一個通道
FileChannel.open(Paths.get("docs/xx.md"), StandardOpenOption.WRITE);
  1. 使用 FileChannel 配合 ByteBuffer 緩衝區實現檔案複製的功能
public static void main(String[] args) throws IOException {
    try (FileChannel sourceChannel = FileChannel.open(Paths.get("hello.txt"), StandardOpenOption.READ);
         FileChannel destinationChannel = FileChannel.open(Paths.get("hello2.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
        // 建立緩衝區
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        // 當 read() 方法返回 -1 時,表示已經到達檔案末尾
        while (sourceChannel.read(buffer) != -1) {
            // limit 設定為 position,並將 position 置零
            buffer.flip();
            destinationChannel.write(buffer);
            // limit 設定為 capacity,並將 position 置零
            buffer.clear();
        }
    }
}
  1. 使用記憶體對映檔案(MappedByteBuffer)的方式實現檔案複製的功能(直接操作緩衝區)
public static void main(String[] args) throws IOException {
    try (FileChannel sourceChannel = FileChannel.open(Paths.get("hello.txt"), StandardOpenOption.READ);
         FileChannel destinationChannel = FileChannel.open(Paths.get("hello2.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.READ)) {

        // 返回該通道檔案的當前大小,位元組為單位
        long fileSize = sourceChannel.size();
        // 呼叫 FileChannel 的 map() 方法建立 MappedByteBuffer 物件
        MappedByteBuffer sourceMappedBuffer = sourceChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileSize);
        // map() 方法接受三個引數:對映模式(FileChannel.MapMode)、對映起始位置、對映的長度。
        // 對映模式包括只讀模式(READ_ONLY)、讀寫模式(READ_WRITE)和專用模式(PRIVATE)
        MappedByteBuffer destinationMappedBuffer = destinationChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileSize);

        // 逐位元組地從原始檔的 MappedByteBuffer 讀取資料並將其寫入目標檔案的 MappedByteBuffer
        for (int i = 0; i < fileSize; i++) {
            byte b = sourceMappedBuffer.get(i);
            destinationMappedBuffer.put(i, b);
        }

        // 資料的修改可能不會立即寫入磁碟。可以透過呼叫 MappedByteBuffer 的 force() 方法將資料立即寫回磁碟
        destinationMappedBuffer.force();
    }
}

MappedByteBuffer 是 Java NIO 中的一個類,它繼承自 java.nio.ByteBuffer。MappedByteBuffer 用於表示一個記憶體對映檔案,即將檔案的一部分或全部對映到記憶體中,以便透過直接操作記憶體來實現對檔案的讀寫。這種方式可以提高檔案 I/O 的效能,因為作業系統可以直接在記憶體和磁碟之間傳輸資料,無需透過 Java 應用程式進行額外的資料複製。

  1. 通道之間透過transfer()實現資料的傳輸(直接操作緩衝區)
public static void main(String[] args) throws IOException {
    try (FileChannel sourceChannel = FileChannel.open(Paths.get("hello.txt"), StandardOpenOption.READ);
         FileChannel destinationChannel = FileChannel.open(Paths.get("hello2.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.READ)) {
        // 三個引數:原始檔中開始傳輸的位置、要傳輸的位元組數、接收資料的目標通道
        sourceChannel.transferTo(0, sourceChannel.size(), destinationChannel);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

FileChannel 的 transferTo() 方法是一個高效的檔案傳輸方法,它允許將檔案的一部分或全部內容直接從原始檔通道傳輸到目標通道(通常是另一個檔案通道或網路通道)。這種傳輸方式可以避免將檔案資料在使用者空間和核心空間之間進行多次複製,提高了檔案傳輸的效能。

transferTo() 方法可能無法一次傳輸所有請求的位元組。在實際應用中,需要使用迴圈來確保所有位元組都被傳輸

public static void main(String[] args) throws IOException {
    Path sourcePath = Paths.get("hello.txt");
    Path destinationPath = Paths.get("hello2.txt");

    // 使用 try-with-resources 語句確保通道資源被正確關閉
    try (FileChannel sourceChannel = FileChannel.open(sourcePath, StandardOpenOption.READ);
         FileChannel destinationChannel = FileChannel.open(destinationPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
        long position = 0;
        long count = sourceChannel.size();

        // 迴圈傳輸,直到所有位元組都被傳輸
        while (position < count) {
            // 返回實際傳輸的位元組數,可能為零
            long transferred = sourceChannel.transferTo(position, count - position, destinationChannel);
            position += transferred;
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

此外,transferTo() 方法在底層使用了作業系統提供的零複製功能(如 Linux 的 sendfile() 系統呼叫),可以大幅提高檔案傳輸效能。但是,不同作業系統和 JVM 實現可能會影響零複製的可用性和效能,因此實際效能可能因環境而異。

零複製(Zero-Copy)是一種最佳化資料傳輸效能的技術,它最大限度地減少了在資料傳輸過程中的 CPU 和記憶體開銷。在傳統的資料傳輸過程中,資料通常需要在使用者空間和核心空間之間進行多次複製,這會導致額外的 CPU 和記憶體開銷。零複製技術透過避免這些多餘的複製操作,實現了更高效的資料傳輸。

在 Java 中,零複製技術主要應用於檔案和網路 I/O。FileChannel 類的 transferTo()transferFrom() 方法就利用了零複製技術,可以在檔案和網路通道之間高效地傳輸資料。

直接和非直接緩衝區

非直接緩衝區:

  • 分配在 JVM 堆記憶體中
  • 受到垃圾回收的管理
  • 在讀寫操作時,需要將資料從堆記憶體複製到作業系統的本地記憶體,再進行 I/O 操作
  • 建立: ByteBuffer.allocate(int capacity)

直接緩衝區:

  • 分配在作業系統的本地記憶體中
  • 不受垃圾回收的管理
  • 在讀寫操作時,直接在本地記憶體中進行,避免了資料複製,提高了效能
  • 建立: ByteBuffer.allocateDirect(int capacity)
  • FileChannel.map() 方法,會返回一個型別為 MappedByteBuffer 的直接緩衝區。

ByteBuffer.allocate和ByteBuffer.allocateDirect直接的差異:

// position 置零,limit 設為 capacity,mark 未定義,所有元素初始化為0
public static ByteBuffer allocate(int capacity) {
    // 緩衝區容量位元組數
    if (capacity < 0)
        throw new IllegalArgumentException();
    // 非直接緩衝區
    return new HeapByteBuffer(capacity, capacity);
}
// position 置零,limit 設為 capacity,mark 未定義,所有元素初始化為0
public static ByteBuffer allocateDirect(int capacity) {
    // 直接緩衝區
    return new DirectByteBuffer(capacity);
}

非直接緩衝區儲存在JVM內部,資料需要從應用程式(Java)複製到非直接緩衝區,再複製到核心緩衝區,最後傳送到裝置(磁碟/網路)。而對於直接緩衝區,資料可以直接從應用程式(Java)複製到核心緩衝區,無需經過JVM的非直接緩衝區。

非同步檔案通道 AsynchronousFileChannel

AsynchronousFileChannel 是 Java 7 引入的一個非同步檔案通道類,提供了對檔案的非同步讀、寫、開啟和關閉等操作。

可以透過 AsynchronousFileChannel.open() 方法開啟一個非同步檔案通道,該方法接受一個 Path 物件和一組開啟選項(如 StandardOpenOption.READ、StandardOpenOption.WRITE 等)作為引數。

Path file = Paths.get("example.txt");
AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE);

AsynchronousFileChannel 提供了兩種非同步操作的方式:

Future 方式

使用 Future 物件來跟蹤非同步操作的完成情況。當我們呼叫一個非同步操作(如 read()write())時,它會立即返回一個 Future 物件。可以使用這個物件來檢查操作是否完成,以及獲取操作的結果。這種方式適用於不需要在操作完成時立即執行其他操作的場景。

public static void main(String[] args) throws IOException, ExecutionException, InterruptedException {
    Path path = Paths.get("hello.txt");

    try (AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        long position = 0;

        while (true) {
            Future<Integer> result = fileChannel.read(buffer, position);

            while (!result.isDone()) {
                // 在這裡可以執行其他任務,例如處理其他 I/O 操作
            }

            // 獲取實際讀取的位元組數
            int bytesRead = result.get();
            if (bytesRead <= 0) break;

            position += bytesRead;
            buffer.flip();
            byte[] data = new byte[buffer.limit()];
            buffer.get(data);
            System.out.println(new String(data));

            buffer.clear();
        }
    }
}

CompletionHandler 方式

使用一個實現了 CompletionHandler 介面的物件來處理非同步操作的完成。我們需要提供一個 CompletionHandler 實現類,重寫 completed()failed() 方法,分別處理操作成功和操作失敗的情況。當非同步操作完成時,系統會自動呼叫相應的方法。這種方式適用於需要在操作完成時立即執行其他操作的場景。

public class Main {
    public static void main(String[] args) throws IOException, InterruptedException {
        readAllBytes(Paths.get("hello.txt"));
    }

    public static void readAllBytes(Path path) throws IOException, InterruptedException {
        AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        // 記錄當前讀取的檔案位置
        AtomicLong position = new AtomicLong(0);
        // 非同步操作完成時通知主執行緒
        CountDownLatch latch = new CountDownLatch(1);

        // 非同步讀取
        // 引數包括:用於儲存資料的緩衝區、當前讀取位置、附加物件(在這個例子中不需要,所以傳遞 null)以及一個實現了 CompletionHandler 介面的物件,用於在讀取操作完成時回撥。
        fileChannel.read(buffer, position.get(), null, new CompletionHandler<Integer, Object>() {
            @Override
            public void completed(Integer bytesRead, Object attachment) {
                // 大於 0,說明還有資料需要讀取
                if (bytesRead > 0) {
                    position.addAndGet(bytesRead);
                    buffer.flip();
                    byte[] data = new byte[buffer.limit()];
                    buffer.get(data);
                    System.out.print(new String(data));
                    buffer.clear();

                    // 再次呼叫 fileChannel.read() 方法,以繼續從檔案中讀取資料
                    fileChannel.read(buffer, position.get(), attachment, this);
                } else {
                    // 如果 bytesRead 等於或小於 0,說明我們已經讀取完檔案中的所有資料。
                    // 此時呼叫 latch.countDown() 方法,以通知主執行緒非同步操作已完成。關閉 fileChannel
                    latch.countDown();
                    try {
                        fileChannel.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                System.out.println("Error: " + exc.getMessage());
                latch.countDown();
            }
        });

        // 主執行緒將在此處阻塞,直到 latch 的計數變為 0
        latch.await();
    }
}

相關文章