深入理解 Java NIO:緩衝、通道與編碼 | Buffer、Channel、Charset

深入理解 Java NIO:緩衝、通道與編碼 | Buffer、Channel、Charset

認識 NIO 類

從 JDK 1.4 開始,Java 有提供一個位於 java.nio Package 的 IO 類別庫,其目的是為了提高 IO 操作效率,其關鍵類型有四個

NIO 相關類功能概述
Buffer可控制的緩衝區,存放臨時資料
Charset具有將 JVM 預設的 Unicode 編碼轉換為其他編碼的類(或反轉為 Unicode)
Channel資料傳輸通道,能將多個不同 Buffer 的資料彙聚(讀取、寫入)
Selector支援 非同步 IO,也就是「Not Blocking IO」,在撰寫伺服器程式時常被使用到

Selector 使用的範例請看另一篇「Socket 通訊 - TCP、UDP 應用 / NIO Socket


Buffer 概述:提高 IO 效率

以往資料的輸出、輸入往往比較耗時,如果有配合使用緩衝區 Buffer,就可提高 I/O 的操作效率,其功能有:

A. 減少實際的物理讀寫次數

B. 緩衝區的區塊記憶體被重復使用,減少動態分配、回收的次數

● 它與傳統的 BufferedInputStreamBufferedOutputStream ... 等等(Buffered 開頭的類)相比起來差在哪?

以往的緩衝類無法讓使用者直接操縱 Buffer(傳統類別綁定類、Buffer 較不利於重用),而新的 Buffer 可以讓使用者直接操縱,解耦了資料、資料操控

Buffer 類關係:屬性、方法、創建

● Buffer 類關係圖如下

graph RL; Buffer; ByteBuffer-->Buffer; CharBuffer-->Buffer; DoubleBuffer-->Buffer; FloatBuffer-->Buffer; IntBuffer-->Buffer; LongBuffer-->Buffer; ShortBuffer-->Buffer; MappedByteBuffer-->ByteBuffer

● Buffer 類的共通屬性、方法如下

共通屬性

屬性功能概述
capacity標明該 緩衝區的大小
limit該緩衝區的 終點(可以不與 capacity 相同),該值是可以修改變動的
position緩衝區的指標,指向下一個讀寫單元的位置

三者關係:capacity >= limit >= position >= 0

graph RL; subgraph Buffer_緩衝區 capacity limit position end 當前容量 -->|100| capacity 當前極限 -->|80| limit 當前位置 -->|20| position

共通方法

方法名影響 position影響 limit影響 capacity
clearposition 修改為 0limit 改為 capacity-
flipposition 修改為 0limit 改為 position-
rewindposition 修改為 0--

其中還有幾個是緩衝類都具有的方法

方法名功能概述
get可以用來相對讀取、絕對讀取
put可以用來相對寫入、絕對寫入

● 而 Buffer 並未公開建構函數,必須 透過指定靜態函數取得 Buffer 物件

靜態方法特色
allocate產生指定容量的 Buffer
directAllocate同上,不過它的緩衝區屬於「直接緩衝區」,與作業系統有更好的耦合,可進一步提供 IO 速度

● 但相對的 directAllocate 的代價是較高的,通常只有在 緩衝區大、並長期存在、不斷重用 的狀況下會去使用

代價如下

A. 消耗 Native 內存

B. 不受 JVM 管控

C. 銷毀、創建需要與 Native 作業系統通訊


Channel 概述:通道

通道是用來連接緩衝區(Buffer)與資料來源、資料輸出終點的通道

graph LR; 資料來源 --> Channel_Input; Channel_Input --> Buffer; Buffer --> Channel_Output; Channel_Output --> 資料輸出終點

Channel 類關係:方法

Channel 界面指宣告兩個方法

Channel 方法說明
close關閉通道
isOpen判斷通道是否打開

● JDK 在實作時,通道會在建立時開啟,一但關閉通道,就不會開啟

Channel 類關係如下

Channel 拓展介面功能概述
ReadableByteChannel唯讀的方法;read(ByteBuffer)
WritableByteChannel唯寫的方法;write(ByteBuffer)
ByteChannel支持讀、寫的類並擴充以上介面
ScatteringByteChannel可以有多個 ByteBuffer,將資料源頭讀取到的資料依序填充到指定的 ByteBuffer;read(ByteBuffer[])
GatheringByteChannel可以有多個 ByteBuffer,將多個 ByteBuffer 輸出到最終檔案中;write(ByteBuffer[])
FileChannel與「檔案」產生相關聯的類,代表一個與檔案相連的通道
graph RL; Channel; ReadableByteChannel-->Channel; WritableByteChannel-->Channel; ScatteringByteChannel-->ReadableByteChannel ByteChannel-->ReadableByteChannel; ByteChannel-->WritableByteChannel; GatheringByteChannel-->WritableByteChannel FileChannel-->ByteChannel; FileChannel-->ScatteringByteChannel; FileChannel-->GatheringByteChannel;

FileChannel 類也不公開建構函數,所以需要透過以往 IO 類獲得

FileInputStreamFileOutputStreamRandomAcessFile 類別中的 getChannel 方法來取得實體物件


Charset 概述:指定編碼

Charset 類別的每個實體代表「特定的字元編碼類型」,它是個抽象類,JVM 支持的字元編碼都會繼承該類

Charset 轉換編碼

● 使用 Charset 轉換編碼的方式如下,它會透過 Charset 類返回一個轉換過得 ByteBuffer

A. 轉換 JVM 的 Unicode 為特定字元編碼


// 使用 UTF-8 編碼
ByteBuffer utf8Encode = StandardCharsets.UTF_8.encode("123");

// 使用 ASCII 編碼
CharBuffer asciiDecoded = StandardCharsets.US_ASCII.decode("123");

StandardCharsets 提供簡易的標準編碼,它們都是 Charset 的實作類

B. 將特定編碼轉為 JVM 的 Unicode 編碼


// 使用 UTF-8 編碼
ByteBuffer utf8Encode = StandardCharsets.UTF_8.encode(originalText);
// 使用 UTF-8 解碼
CharBuffer utf8Decode = StandardCharsets.UTF_8.decode(utf8Encode);

// 使用 ASCII 編碼
ByteBuffer asciiEncode = StandardCharsets.US_ASCII.encode(originalText);
// 使用 ASCII 解碼
CharBuffer asciiDecode = StandardCharsets.UTF_8.decode(asciiEncode);

NIO 使用介紹

字元編碼轉換 Charset

● 首先先來看 Buffer、Charset 編碼的關係;ByteBuffer 可以存放 Byte 資料,但它並不關心放入的數據是怎樣被編碼,所以取出數據時要注意編碼轉換

Charset 範例如下

A. 將資料以 UTF-8 編碼放入 ByteBuffer,列印會變成亂碼


static void bufferPutUtf8() {
    // 將資料以 UTF-8 編碼放入 ByteBuffer
    ByteBuffer byteBuffer = ByteBuffer.wrap("安安"
            .getBytes(StandardCharsets.UTF_8));
    
    // 轉換 CharBuffer,CharBuffer 會轉換錯誤
    CharBuffer charBuffer = byteBuffer.asCharBuffer();
    
    System.out.println(charBuffer);
}

正確方式

如果 ByteBuffer 中存放的是 Unicode 字元,在透過 asCharBuffer 方法轉換時,就可以轉換為正確字元;

錯誤(亂碼)原因: 當前範例存放的卻是 UTF-8 編碼,所以會出錯

B. 使用 Unicode 編碼

由於 Java String 採用 Unicode 編碼,所以可以正確打印並顯示


static void bufferPutUnicode() {
    ByteBuffer byteBuffer = ByteBuffer.wrap("安安"
            .getBytes(StandardCharsets.UTF_16BE));  // UTF_16BE 就是 Unicode
    CharBuffer charBuffer = byteBuffer.asCharBuffer();
    System.out.println(charBuffer);
}

C. Charset 使用 decode

使用 Charset 指定方式解碼後,取得的 CharBuffer 就會是正確的


static void bufferPutUtf8UseDecode() {
    ByteBuffer byteBuffer = ByteBuffer.wrap("安安"
            .getBytes(StandardCharsets.UTF_8));

    Charset charset = Charset.forName("UTF-8");
    CharBuffer charBuffer = charset.decode(byteBuffer);

    System.out.println(charBuffer);
}

● 使用 Charset 手動設定編碼(decodeencode)範例:


static void useCharset() {
    // 取得 Big5 編碼方起是
    Charset charset = Charset.forName("Big5");

    // 使用 Big5 編碼
    ByteBuffer byteBuffer = charset.encode("安安你好呀!");
    // 轉換 Big5 編碼為
    CharBuffer charBuffer = charset.decode(byteBuffer);

    System.out.println(charBuffer);
}

FileChannel 讀寫文件

● 接下來我們會使用 FileChannel 來操控文件,跟以往的 IO 比較起來會多出 Channel、Buffer、Charset 這些角色

寫入資料

A. FileChannel

使用 FileOutputStreamRandomAccessFile 取得 FileChannel

graph LR; User-->|create|FileOutputStream User-->|create|RandomAccessFile FileOutputStream-->|getChannel|FileChannel; RandomAccessFile-->|getChannel|FileChannel; FileChannel-->|輸出|目標檔案

B. ByteBuffer

由於對 Channel 寫入資料需要使用 ByteBuffer 類,所以使用 ByteBuffer#wrap 取得 ByteBuffer

graph LR; User-->|wrap|ByteBuffer ByteBuffer-->|write|FileChannel FileChannel-->|輸出|目標檔案

讀取資料

A. FileChannel

使用 FileInputStream 取得 FileChannel

graph LR; User-->|create|FileInputStream FileInputStream-->|getChannel|FileChannel; FileChannel-->|取得|目標檔案

B. ByteBuffer

由於對 Channel 讀取資料需要使用 ByteBuffer 類,所以使用 ByteBuffer#allocate 創建一塊 ByteBuffer 區域

graph LR; User-->|allocate|ByteBuffer ByteBuffer-->|給予|FileChannel FileChannel-->|1. 讀取|目標檔案 FileChannel-.->|2. 放入|ByteBuffer

C. Charset

由於從檔案中讀取的資料為 Byte,並非系統可認得的數據,所以使用 Charset#defaultCharset 取得當前平台的編碼;並使用 decode 解碼 Byte 數據


static void readFile(String fileName) {
    try(FileInputStream fis = new FileInputStream(fileName)) {

        FileChannel fc = fis.getChannel();
        // 創建 ByteBuffer 區塊 (1024 Byte 大小)
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        fc.read(byteBuffer);
        byteBuffer.flip();      // limit 改為 position

        Charset cs = Charset.defaultCharset();
        System.out.println(cs.decode(byteBuffer));

    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}
graph LR; Charset-->|取得本地編碼|defaultCharset defaultCharset-->本地編碼 本地編碼-->decode decode-.->ByteBuffer

● 使用範例、結果:


class FileChannelUsage {

    public static void main(String[] args) {
        String targetFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello5.txt";

        writeFile(targetFile);
        readFile(targetFile);

    }

}

控制 Buffer 緩衝區

● 我們前面有說到 Buffer 與傳統 IO 的差異在於,可以讓使用者手動操控 Buffer;以下來個範例,並觀察如何使用 Buffer,並觀察 Buffer 的狀態


class BufferUsage {

    static void showBufferInfo(ByteBuffer byteBuffer) {
        // 讀取 position, limit, capacity 資訊
        
        System.out.println("position: " + byteBuffer.position()
                + ", limit: " + byteBuffer.limit()
                + ", capacity: " + byteBuffer.capacity());
    }

    public static void main(String[] args) {
        String existFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello5.txt";
        String targetFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello6.txt";

        try(FileInputStream fis = new FileInputStream(existFile);
            FileOutputStream fos = new FileOutputStream(targetFile)) {

            FileChannel inputChannel = fis.getChannel();
            FileChannel outputChannel = fos.getChannel();

            int limitVal = 5;
            
            // 設定 Buffer 大小為 10 Byte
            ByteBuffer byteBuffer = ByteBuffer.allocate(10);
            while (inputChannel.read(byteBuffer) != -1) {

                // 設定上限值
                byteBuffer.limit(limitVal++);
                
                System.out.print("Start - ");
                showBufferInfo(byteBuffer);
                byteBuffer.flip(); // position 改為 0, limit 改為 position
                System.out.print("After flip - ");
                showBufferInfo(byteBuffer);

                int written = outputChannel.write(byteBuffer);
                System.out.println("The byte be written=(" + written + ")");

                byteBuffer.clear(); // position 改為 0, limit 改為 capacity
                System.out.print("After clear - ");
                showBufferInfo(byteBuffer);
                System.out.println("--------------------------");
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

從結果,可以發現幾件事

A. Buffer 的 limit 值預設與 capacity 相同

B. 每次 read 都會自動填滿 position 到 limit 之間的數據空間,並且 不會超出 limit!

C. 每次 write 也只會將 position 到 limit 之間的數據寫入,並且 不會超出 limit!

FileInputStream、FileOutputStream 快速複製

Channel 的子類 FileChannel 有提供兩個方便的方法,可以透過 Channel 快速複製檔案

A. transferTo 方法:

WritableByteChannel 資料寫入到預計要輸出的 Channel 中

B. transferFrom 方法:

ReadableByteChannel 資料寫入到預計要輸出的 Channel 中


class FastCopy {

    public static void main(String[] args) {
        String existFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello5.txt";
        String targetFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello6.txt";

        try(FileInputStream fis = new FileInputStream(existFile);
            FileOutputStream fos = new FileOutputStream(targetFile)) {

            FileChannel inputChannel = fis.getChannel();
            FileChannel outputChannel = fos.getChannel();

            // 將 inputChannel 資料傳遞到 outputChannel
            long copySize = inputChannel.transferTo(0, inputChannel.size(), outputChannel);
            System.out.println("Copy to outputChannel=(" + copySize + ")");

            // 將 inputChannel 資料傳遞到 outputChannel
            copySize = outputChannel.transferFrom(inputChannel, 0, inputChannel.size());
            System.out.println("Copy from inputChannel=(" + copySize + ")");

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

常見的 NIO 操作

ByteBuffer 產生:緩衝區視圖

● ByteBuffer 提供 asCharBufferasIntBufferasFloatBuffer... 等等方法,這些方法可以用來生成緩衝區視圖

● 緩衝區視圖是什麼?(簡單來說,就是讓我看見記憶體的數據)

緩衝區視圖 允許程式設計師以抽象的方式查看記憶體中的數據,而不需要直接操作內存,從而提高了程式碼的 可讀性可維護性

這種方法通常用於低級別的編程,如網絡編程、文件I/O和編碼/解碼操作,但在高級別的應用程式中也可以有用

範例如下

A. ByteBuffer 轉換後的視圖緩衝區,也會重新分配每格數據的大小


class BufferView {

    static void showBufferInfo(Buffer buffer) {
        System.out.println("position: " + buffer.position()
                + ", limit: " + buffer.limit()
                + ", capacity: " + buffer.capacity());
    }

    public static void main(String[] args) {
        // 空間為 8 Byte
        ByteBuffer byteBuffer = ByteBuffer.allocate(4);

        // hasRemaining: 檢查當前 position ~ limit 之間是否還有數據
        while (byteBuffer.hasRemaining()) {
            System.out.println(byteBuffer.get());
        }
        System.out.println("--- Byte After get ---");
        showBufferInfo(byteBuffer);

        // 將 Position 設置為 0
        byteBuffer.rewind();

        System.out.println("--- AByte fter rewind ---");
        showBufferInfo(byteBuffer);

        // 轉換為 char 緩衝視圖
        CharBuffer charBuffer = byteBuffer.asCharBuffer();
        System.out.println("--- Byte After asCharBuffer ---");
        showBufferInfo(charBuffer);

        // 放入數據
        charBuffer.put("你好");

        while (byteBuffer.hasRemaining()) {
            System.out.println(byteBuffer.get());
        }
        System.out.println("--- Char After get ---");
        showBufferInfo(charBuffer);
    }

}

從結果可以發現,對於 4 byte 空間數據,不同緩衝區會有不同解釋

● ByteBuffer 解釋為 4 capacity,代表一個數據擁有 1 Byte 空間

● CharBuffer 則解釋為 2 capacity,代表一個數據擁有 2 Byte 空間

B. ByteBuffer 可以取出指定數據的格式


class BufferView2 {

    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(10);

        byteBuffer.asCharBuffer().put("安");
        System.out.println(byteBuffer.getChar());

        byteBuffer.rewind();

        byteBuffer.asIntBuffer().put(666);
        System.out.println(byteBuffer.getInt());

        byteBuffer.rewind();

        byteBuffer.asFloatBuffer().put(222F);
        System.out.println(byteBuffer.getFloat());
    }

}

文件應設緩衝區:MappedByteBuffer

MappedByteBuffer 用來建立、修改那些因「太大不能放入記憶體的檔案」!(類似於 mmap 的功能)

MappedByteBuffer 是透過檔案映射記憶體的方式來達到這個操作,也就是說該檔案會與某塊物理記憶體產生關聯

● 而 MappedByteBuffer 也不能直接創建,必須透過 FileChannel#map 方法取得實例

其中的 MapMode 參數會對應到該緩衝區的相關權限

MapMode概述
READ_ONLY只能讀取該映射記憶體區塊
READ_WRITE可對該映射記憶體區塊進行讀寫
PRIVATE這次修改不會被保存到檔案中、並且其他程式不可見(也就是操作不會影響原文件)

PRIVATE 時,會使用 Copy-on-Write 技術,對於文件的存取速度會提高許多

使用範例如下


class MappedByteBufferUsage {

    public static void main(String[] args) {
        String targetFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello7.txt";

        try(RandomAccessFile raf = new RandomAccessFile(targetFile, "rw")) {
            int capacity = 0x7A12000;   // 128MB

            MappedByteBuffer mbb = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, capacity);
            mbb.put("安安".getBytes(StandardCharsets.UTF_8));
            // limit fix to current position
            mbb.flip();

            System.out.println(StandardCharsets.UTF_8.decode(mbb));

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

檔案大小也確實是 128M

文件同步:分區加鎖 🔒

● 在多執行序(線程)中如果只是單單讀取文件,那是安全操作,但如果 有涉及到文件的「寫」,那就是不安全操作

● NIO 庫則有引入檔案加鎖 🔒 機制,允許程式 同步存取共享檔案

● Java 使用的檔案鎖,對於作業系統的其他執行序(Thread)是可見的操作

因為這種檔案鎖直接映射到本地作業系統的加鎖 🔒 工具,這個鎖由作業系提供,並非 Java 自身提供

其中,關於鎖有提供幾個方法,如下表

方法說明
tryLock嘗試獲取鎖,如果無法獲取,則直接返回
lock同樣是獲取鎖,但是如果無法獲取它會一直等待(該線程會被掛起 Blocking)

並且這兩個方法都有以下參數,這些參數較為特別

參數說明
position、size可以對同一個文件的不同區塊進行鎖定,不會全部鎖住;範圍是 position ~ position+size
shared: Boolean決定是否使用共享鎖

共享鎖、排他鎖

一個檔案中有兩把不同的鎖

graph TD; subgraph 檔案 共享鎖 排他鎖 end

● 共享鎖:

當一個執行序獲得該檔案的共享鎖時,其他執行序仍可獲得該檔案的共享鎖,但不能獲得該檔案的排他鎖

● 排他鎖:

當一個執行序已經獲得檔案的排他鎖時,其他執行不允許獲得 共享鎖、排他鎖

● 分區加鎖 🔒 範例如下

詳細說明請看註解


class SynAccessFile {

    static class Modifier extends Thread {

        private final ByteBuffer byteBuffer;
        private final FileChannel fileChannel;
        private final int lockStart, lockEnd;

        Modifier(FileChannel fileChannel, ByteBuffer byteBuffer, int start, int end) {
            this.fileChannel = fileChannel;
            this.lockStart = start;
            this.lockEnd = end;
            // 設定 Buffer
            byteBuffer.limit(end);
            byteBuffer.position(start);
            // 獲得處理區域緩衝區,該緩衝區對應檔案的映射區
            this.byteBuffer = byteBuffer.slice();
            // Start thread
            start();
        }

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + ", Lock range=(" + lockStart + "~" + lockEnd + ")");

            try {
                // 這裡記得鎖定區塊
                FileLock fileLock = fileChannel.lock(lockStart, lockEnd, false);

                while (byteBuffer.position() < byteBuffer.limit() - 1) {
                    // ByteBuffer#put, ByteBuffer#get
                    // 都會移動 position
                    byteBuffer.put((byte) (byteBuffer.get() + 1));
                }

                fileLock.release();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

    }

    public static void main(String[] args) {
        String targetFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello8.txt";

        // 這裡不能使用 try-with-resouces, 
        // 否則會造成檔案還沒寫入就被 close 掉
//        try(RandomAccessFile raf = new RandomAccessFile(targetFile, "rw")) {
        try {
            RandomAccessFile raf = new RandomAccessFile(targetFile, "rw");

            FileChannel fileChannel = raf.getChannel();

            int capacity = 0x2710;   // 10KB

            MappedByteBuffer mbb = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, capacity);
            // 一半寫 A
            for (int i = 0; i < capacity / 2; i++) {
                mbb.put((byte) 'A');
            }

            // 一半寫 B
            for (int i = capacity / 2; i < capacity; i++) {
                mbb.put((byte) 'B');
            }

            // Create Thread, 訪問上半
            new Modifier(fileChannel, mbb, 0, capacity / 2 - 1);

            // Create Thread, 訪問下半
            new Modifier(fileChannel, mbb, capacity / 2, capacity);

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

更多的 Java 語言相關文章

Java 語言深入

● 在這個系列中,我們全方位地探討了 Java 語言的各個核心主題,旨在幫助你徹底掌握這門強大的編程語言。無論你是想深入理解 Java 的基礎類型與變數作用域,還是探索異常處理與運算子的細節,這些文章都將為您提供寶貴的知識

深入 Java 物件導向

● 探索 Java 物件導向的奧妙,掌握介面、抽象類、繼承等重要概念!幫助你針對物件導向設計有更深入的了解!


Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響