深入理解 Java IO 操作:徹底了解流、讀寫、序列化與技巧

深入理解 Java IO 操作:徹底了解流、讀寫、序列化與技巧

Overview of Content

在 Java 程式設計中,IO(Input/Output)操作是至關重要的一部分,它涉及資料的輸入和輸出,對於任何程式來說都是不可或缺的,我們常見的使用包括檔案傳輸、串流影音、Socket IO、物件序列化... 等等。

本文將深入探討Java中的IO操作,從流(Stream)的概念與設計開始,並實作大多數常見的 IO 操作,一直到常用的 IO 操作實戰技巧,以及物件的序列化IO。

無論您是初學者還是有經驗的開發人員,都會在本文中找到對IO操作全面理解的必要知識

寫文章分享不易,如有引用參考請詳註出處,如有指導、意見歡迎留言(如果覺得寫得好也請給我一些支持),感謝 😀

以下部分使 Jave7 新語法 try-with-resources,它會協助 IO 自動 Close


了解資料操作:流 Stream

程式說白了,大部分的時間都在操作資料,只是這些資料可能存在不同地方,像是 Register, Memory, Disk... 等等,而每個區域的資料都有不同特性

graph TD; 資料-->Register; 資料-->Memory; 資料-->Disk;

流概念 / 設計

資料儲存地方的特性,並非本章要探討的,可以參考 另一篇文章 CPU、記憶體、快取分頁快取

● 在 Java 中,負責處理裡 I/O 的模塊分配在 java.iojava.nio Package 之下,並且會 將一組有序的資料序列稱為「流」

java.nio 是 JDK 1.4 版本之後導入的新 I/O 庫,為了 IO 增高效率

而流的輸出、輸出導向是站在應用的角度來觀察

● 流入應用的數據代表 input

● 流出應用的數據代表 output

graph LR; 資料-->input; 鍵盤-->input; input-->應用-->output; output-->控制台; output-->記憶體; output-->文件;

● 在 Java IO 流中,最常見的設計有兩種

位元組流 / 字元流

● 我們知道流是一種有序資料的傳遞方式,而其中 Java 又把流為兩個大類別,他們也代表了不同的資料流動方案

位元組流Byte特點IO 庫關鍵字
流類型流動的最小單位沒有分檔案類型,皆是一系列的 Byte 數組InputStream, OutputStream
字元流字元字元,會依照「解碼」方式而有不同的切分方式Reader, Writer
graph LR; Java-->位元組流; Java-->字元流;

InputStream / OutputStream 流

InputStream 是一個抽象類,它底下分別有繼承幾個類,如下圖,之後會依序介紹一些常見的類、如何使用

其中 FilterInputStream 較為特別,它屬於一個裝飾類,用來加強某些 IO 類

graph RL; InputStream; FileInputStream-->InputStream; ByteArrayInputStream-->InputStream; StringBufferInputStream-->InputStream; PipedInputStream-->InputStream; FilterInputStream-->InputStream; ObjectInputStream-->InputStream; BufferedInputStream-->FilterInputStream DataInputStream-->FilterInputStream LinenumberInputStream-->FilterInputStream PushbackInputStream-->FilterInputStream

OutputStream 也是一個抽象類,如下圖,之後會依序介紹一些常見的類、如何使用

其中 FilterOutputStream 較為特別,它屬於一個裝飾類,用來加強某些 IO 類

graph RL; OutputStream; FileOutputStream-->OutputStream; ByteArrayOutputStream-->OutputStream; PipedOutputStream-->OutputStream; FilterOutputStream-->OutputStream; ObjectOutputStream-->OutputStream; BufferedOutputStream-->FilterOutputStream DataOutputStream-->FilterOutputStream PrintInputStream-->FilterOutputStream

ByteArrayInputStream / ByteArrayOutputStream 位元組流

ByteArrayInputStreamByteArrayOutputStream使用 Adapter 模式,組裝 byte[] 將其轉換為流,繼承、組合關係如下圖

classDiagram InputStream <|-- ByteArrayInputStream ByteArrayInputStream *-- byte_array

ByteArrayInputStream 範例如下


class ByteArrayUsage {

    public static void main(String[] args) {

        byte[] source = new byte[] { 1, 2, 3, 4, 5 };

        try(ByteArrayInputStream bais = new ByteArrayInputStream(source)) {
            int readData;

            // read 返回的是結果
            // 如果返回 `-1` 則代表,沒有數據可讀
            while ((readData = bais.read()) != -1) {
                System.out.println("Read Data: " + readData);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

}

ByteArrayOutputStream 範例如下


class ByteArrayUsage {

    public static void main(String[] args) {

        byte[] data;
        try(ByteArrayOutputStream bais = new ByteArrayOutputStream()) {
            // 以 UTF-8 來說,這代表了「安」字 
            bais.write(229);
            bais.write(174);
            bais.write(137);
            // 65 代表 ASCII 「A」
            bais.write(65);

            data = bais.toByteArray();

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

        // 使用 UTF-8 解碼
System.out.println("The data from byte array output stream(UTF-8): " +
        new String(data, StandardCharsets.UTF_8));

        // 使用 ASCII 解碼
System.out.println("The data from byte array output stream(ASCII): " +
        new String(data, StandardCharsets.US_ASCII));
    }

}

● 輸出得資料是以 Byte 為單位,所以如果資料轉為 Byte 後超過 255 就會換到下一個 Byte 輸出

FileInputStream 讀取 / FileOutputStream 輸出檔案資料

Byte 的方式,讀取、輸出檔案資料到到應用中(記憶體中)

FileInputStream 讀取範例

原檔案資料如下(等等將要讀取的檔案)


ABC 安安

● 補充知識,用 Shell 創建隨機檔案


mktemp -t InputSteamFile 

● 這裡要特別注意,由於檔案是 透過 Byte 讀取進應用的記憶體中,所以沒有辦法正確顯示超過 Byte 大小的字元,像是上述的「安」這個字就超過 Byte 大小,在使用 Byte 讀取時就會分開表示


class FileIOSteamUsage {

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

        try(FileInputStream fis = new FileInputStream(targetFile)) {

            int readData;

            while ((readData = fis.read()) != -1) {
                System.out.println("Read Data: " + readData);
            }

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

    }

}

A. "ABC "-> (65, 66, 67, 32)

B. "安" -> (229, 174, 137):字符「安」的 UTF-8 表示形式由三個字節組成,分別是 0xE50xB00x89,轉為 10 進位就是 (229, 174, 137)

C. "安" -> (229, 174, 137)

D. 最後的 10 代表的是換行

Information

● 如果要高效讀取檔案,就應該使用 read(byte[]) 讀取檔案,才不會平凡操作 IO


class FileInputSteamUsage {

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

        try(FileInputStream fis = new FileInputStream(targetFile)) {

            int len;
            byte[] cache = new byte[1024];

            while ((len = fis.read(cache)) != -1) {
                System.out.println(new String(cache, 0, len));
            }

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

    }

}

FileOutputStream 輸出範例

將資料以 Byte 為單位輸出到檔案中


class FileIOSteamUsage {

    public static void main(String[] args) {

        String targetFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Apple.txt";

        try(FileOutputStream fos = new FileOutputStream(targetFile)) {

            fos.write('A');
            fos.write('P');
            fos.write('P');
            fos.write('L');
            fos.write('E');

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

}

被遺棄的 StringBufferInputStream

StringBufInputStreamUsage使用 Adapter 模式,組裝 String 將其轉換為流,繼承、組合關係如下圖

classDiagram InputStream <|-- StringBufInputStreamUsage StringBufInputStreamUsage *-- String

範例如下


class StringBufInputStreamUsage {

    public static void main(String[] args) {
        StringBufferInputStream sbis = new StringBufferInputStream("Hello 安");

        int readData;

        while ((readData = sbis.read()) != -1) {
            System.out.println("Read Data: " + readData);
        }

    }

}

● 被遺棄的原因:

由於 它只使用字元編碼的低 8 位元,所以假如字元編碼超過 8 位元,就無法正常被讀取

在上面範例我們知道「安」的組成是 (229, 174, 137) 才對,但以結果來看,它只讀取了低 8 位元,所以顯示錯誤(只顯示了最後的 137)!

多執行序的管道 PipedInputStream / PipedOutputStream

● 執行序除了可以使用 wait/notify 通訊,也可以使用 I/O 的方式通訊;它的概念就像不同 Thread 對 同一個 文件(管道)進行塞資料

graph LR; ThreadA_輸入 --> 管道_Piped; 管道_Piped --> ThreadB_讀取;

範例如下:

A. 對 Piped 輸入資料方:使用 PipedOutputStream (想像成將資料輸出到應用以外)


static class Sender extends Thread {

    public PipedOutputStream pos;

    @Override
    public void run() {
        // Thread 啟動後,創建 PipedOutputStream
        try(PipedOutputStream pos = new PipedOutputStream()) {
            this.pos = pos;

            System.out.println("Sender write: " + i);

            for (int i = -3; i <= 3; i++) {
                System.out.println("Sender write: " + i);
                // 對其輸出資料
                pos.write(i);
                Thread.sleep(1000);
            }
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

B. 讀取特定 Piped 方:使用 PipedInputStream(想像成將資料讀到應用中)


static class Receiver extends Thread {

    private final Sender sender;

    Receiver(Sender sender) {
        this.sender = sender;
    }

    @Override
    public void run() {
        try(PipedInputStream pis = new PipedInputStream(sender.pos)) {
            System.out.println("Receiver running.");
            
            int data;
            // 讀取管道中的流
            while ((data = pis.read()) != -1) {
                System.out.println("Receiver get: " + data);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

● 使用範例:

這裡測試時特別將發送、接收方得時間錯開(使用 Thread#sleep 不同時間),用來證明 Piped 中是可以儲存資料的


class PipedInputStreamUsage {

    public static void main(String[] args) {
        Sender sender = new Sender();
        Receiver receiver = new Receiver(sender);
        sender.start();
        receiver.start();
    }

}

流的串連 SequenceInputStream

● 內部是使用 Vector 結構來串連兩個 Stream

Vector 是有安全保護的 ArrayList,但又由於它的同步安全機制導致,效能較差

graph LR; StreamA --> SequenceInputStream; StreamB --> SequenceInputStream; SequenceInputStream --> 順序輸出_Stream_A_B

使用範例


class SequenceInputStreamUsage {

    public static void main(String[] args) throws IOException {

        byte[] sourceA = new byte[] { 1, 2, 3, 4, 5 };
        byte[] sourceB = new byte[] { -1, -2, -3, -4, -5 };
        ByteArrayInputStream baisA = new ByteArrayInputStream(sourceA);
        ByteArrayInputStream baisB = new ByteArrayInputStream(sourceB);

        SequenceInputStream sis = new SequenceInputStream(baisA, baisB);

        int readData;
        while ((readData = sis.read()) != -1) {
            System.out.println("Read Data: " + readData);
        }

    }

}

裝飾類型的 FilterInputStream / FilterOutputStream 概述

● 設計模式中的「裝飾模式」特點在於 對原先類型的加強、減弱,而在 IO 中使用,則是 基於基礎的 ByteStream 用來體現(加強)各個 File 不同的讀寫方式

classDiagram InputStream <|-- FilterInputStream FilterInputStream o-- InputStream_準備被加強的類 OutputStream <|-- FilterOutputStream FilterOutputStream o-- OutputStream_準備被加強的類

這有利於提高程式碼的重用性

DataInputStream 讀取 / DataOutputStream 寫入基本類型 & UTF8

DataInputStream 實作 DataInput 介面,用於 讀取基礎資料類型(int, float, long, double... 等等);

另外它還提供兩個特別方法: 1. 能讀取 UTF-8 編碼的 DataInputStream#readUTF 方法2. 能寫入 UTF-8 編碼的 DataOutputStream#writeUTF 方法

UTF-8Unicode 編碼

Unicode 是 Java 大多數預設的編碼方式,而 UTF-8 則是它的變體(特點是它支援作業系統)

Unicode 預設佔用 2 Byte 空間,有時候會浪費空間,而 UTF-8 則會針對不同字元進行編碼,給予適合的空間

eg. UTF-8 對於 ASCII 就採用 1 Byte 空間,而非 ASCII 則給予 2 Byte 空間;想了解更多請看 深入探索 Java 基礎類型、編碼、浮點數、參考類型和變數作用域 | 探討細節

A. DataOutputStream 範例:

輸出一般資料類型、UTF8 資料類型


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

    try(FileOutputStream fos = new FileOutputStream(targetFile);
        DataOutputStream dos = new DataOutputStream(fos)) {

        dos.writeChar('A');
        dos.writeChar('B');
        dos.writeChar('C');
        dos.writeChar(' ');
        // 使用 UTF8 輸出資料
        dos.writeUTF("安安");

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

B. DataOutputStream 範例:

按照順序讀取相同檔案


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

    try(FileInputStream fis = new FileInputStream(targetFile);
        DataInputStream dis = new DataInputStream(fis)) {

        System.out.println("Read Data: " + dis.readChar());
        System.out.println("Read Data: " + dis.readChar());
        System.out.println("Read Data: " + dis.readChar());
        System.out.println("Read Data: " + dis.readChar());
        // 使用 UTF8 讀取資料
        System.out.println("Read Data: " + dis.readUTF());

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

}

● 如果不按照順序讀取,或是使用錯誤方式讀取會拋出 EOFException 異常

BufferedInputStream / BufferedOutputStream 減少密集 IO

● 使用 BufferedInputStream / BufferedOutputStream 這兩個類都可以有效的減少密集讀寫 IO,可以 加快 IO 效率,提升 IO 品質

以也可依照需求,在建構函數時傳入緩衝區大小的設定

使用上述範例進行修改


class BufferedIOStreamUsage {

    public static void main(String[] args) {

        String targetFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello.txt";

        try(FileOutputStream fos = new FileOutputStream(targetFile);
            // 新增
            BufferedOutputStream bos = new BufferedOutputStream(fos);
            DataOutputStream dos = new DataOutputStream(bos)) {

            dos.writeChar('A');
            dos.writeChar('B');
            dos.writeChar('C');
            dos.writeChar(' ');
            dos.writeUTF("安安");

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

        try(FileInputStream fis = new FileInputStream(targetFile);
            // 新增
            BufferedInputStream bis = new BufferedInputStream(fis);
            DataInputStream dis = new DataInputStream(bis)) {

            System.out.println("Read Data: " + dis.readChar());
            System.out.println("Read Data: " + dis.readChar());
            System.out.println("Read Data: " + dis.readChar());
            System.out.println("Read Data: " + dis.readChar());
            System.out.println("Read Data: " + dis.readUTF());

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

    }

}

PrintStream 格式化輸出

● PrintStream 是輸出流(與 DataOutputStream 一樣),可以輸出格式化的資料;PrintStream 特點如下

PrintStream & DataOutputStream 差異?

A. PrintStream 不會拋出異常

須透過 PrintStream#checkError 函數來檢查判斷是否輸出成功

B. 緩衝輸出的控制

PrintStream 可以透過設定來自動輸出,而輸出時機如下表

輸出的時機
PrintStream完整輸出一個 byte[]、換行字元 \n(或是 println
DataOutputStreamBuffer 滿、呼叫 flush 強制刷新

C. 輸出編碼差異

PrintStream#println 等同於 DataOutputStream#writeUTF,但兩者 對於資料的編碼方式不同

方法編碼方式
PrintStream#println本地作業系統預設的編碼(有可能是 BIG5, UTF-8, GBK... 等等)
DataOutputStream#writeUTF預設採用 UTF-8

class PrintStreamUsage {

    public static void main(String[] args) throws IOException {
        usePrintStream();

        System.out.println();

        useDataOutputSteam();
    }

    static void readBuff(byte[] bytes) {
        ByteArrayInputStream bais = new ByteArrayInputStream(bytes);

        int data;
        while ((data = bais.read()) != -1) {
            System.out.println("Data: " + data);
        }
    }

    static void usePrintStream() {
        System.out.println("Use PrintStream ---------");
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        // PrintStream 包裝 ByteArray
        PrintStream ps = new PrintStream(baos, true);
        // 對 PrintStream 寫入「安」,其實最終還是填充到 ByteArray 中
        ps.print("安");

        readBuff(baos.toByteArray());
    }

    static void useDataOutputSteam() throws IOException {
        System.out.println("Use DataOutputStream ---------");
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        // DataOutputStream 包裝 ByteArray
        DataOutputStream ps = new DataOutputStream(baos);
        // 對 DataOutputStream 寫入「安」,其實最終還是填充到 ByteArray 中
        ps.writeUTF("安");

        readBuff(baos.toByteArray());
    }

}            

從結果可以看出,我目前使用的作業系統 (PrintStream) 就是 UTF-8 (與 DataOutputStream 差不多)


Reader / Writer IO 介紹

在程式中作資料輸出較為單純,一般來說只需要輸出 Byte 即可,但是當 在應用場合中 Java 需要注意每個文字檔中的編碼方式,如果使用不對的方式編碼

A. 文字編輯器無法正常閱讀 Java 程式輸出的資料

graph LR; Java_app --> 輸出_UTF8; 輸出_UTF8 --> 文件; 文字編輯器 --> 使用_BIG5_閱讀; 使用_BIG5_閱讀 -.-> |無法正常訪問|文件;

B. Java 程式不知道如何正確的解碼程式

graph LR; 文字編輯器 --> 輸出_BIG5; 輸出_BIG5 --> 文件; Java_app --> 使用_UTF8_閱讀; 使用_UTF8_閱讀 -.-> |無法正常訪問|文件;

自動、指定編碼

● String#getBytes 方法,預設(不帶參數的狀況下)是使用「本地作業系統編碼」… 而本地作業系統的編碼就可能有多種

可能有 UTF-8, UTF-16, GBK, BIG5... 等等

Java 大多預設儲存的編碼方式是 Unicode

使用 Java 查看本地作業系統編碼的方式如下


class LocalSystemEncode {

    public static void main(String[] args) {
        // 方法一
        String localEncode = System.getProperty("file.encoding");
        System.out.println("Get by property: " + localEncode);

        // 方法二
        Charset cs = Charset.defaultCharset();
        System.out.println("Get by Charset: " + cs);
    }

}

Charsetjava.nio.charset 套件中的類

● 而這跟 Reader / Writer 有什麼關係呢?

使用 Java 的 Reader / Writer 類,它會 自動(也可以指定)幫我們切換「程式編碼」與「目標文件」之間的關係(互相轉換)

graph LR; 目標文件_編碼_BIG5 -.->|Java IO Writer| 程式編碼_假設為_UTF-8; 程式編碼_假設為_UTF-8 -.->|Java IO Reader| 目標文件_編碼_BIG5;

● 當然,你也可以對 Java IO 的 Writer / Reader 指定要轉換的編碼

● 由於 JVM 統一採用平台無關編碼,所以也是為何 Java 應用可以跨平台執行的原因(因為面對個平台編碼的責任推到了 JVM 負責)

graph LR; Java_應用 --> JVM; JVM --> Window; JVM --> Linux; JVM --> Unix; JVM --> Mac;

Reader / Writer 類別概述

● Reader 與 InputStream 的差異,從層級結構之下來看有一定的差異InputStream 可以清楚地知道所有 FilterInputStrem 的子類都用來裝飾,而 Reader 則不是,主要區別在於它們的層級結構和功能特性(Reader 層級結構是屬於靜態繼承,並不向 Stream 更多聚合模式)

graph RL; Reader; CharArrayReader --> Reader; BufferedReader --> Reader; StringReader --> Reader; PipedReader --> Reader; FilterReader --> Reader; InputStreamReader --> Reader; LineNumberReader --> BufferedReader; PushBackReader --> FilterReader; FileReader --> InputStreamReader;

● Writer 與 OutputStream 層級結構較為相似(但仍有差異,Write 層級結構是屬於靜態繼承,並不向 Stream 更多聚合模式)

graph RL; Writer; CharArrayWriter --> Writer; BufferedWriter --> Writer; StringWriter --> Writer; PipedWriter --> Writer; FilterWriter --> Writer; OutputStreamWriter --> Writer; FileWriter --> OutputStreamWriter;

CharArrayReader / CharArrayWriter 讀取字元

讀取的是「字符」(一至多個 Byte),不是「字元」(一個 Byte)

CharArrayReaderCharArrayWriter 類別是使用 Adapter 模式,將 字元陣列類型轉換為 Reader、Wirter 類型

classDiagram Reader <|-- CharArrayReader CharArrayReader *-- char_array Writer <|-- CharArrayWriter CharArrayWriter *-- char_array_

使用範例如下

CharArrayReader 類使用


static void readerUsage() throws IOException {
    char[] charArray = new char[] { 'H', 'i', '~', '安', '安'};

    // 正確讀取字符
    CharArrayReader car = new CharArrayReader(charArray);

    int data;
    while ((data = car.read()) != -1) {
        System.out.println("Get data: " + data);
    }
}

我們可以看到,中文字確實就超過一個 Byte 的大小

CharArrayWriter 類使用


static void writerUsage() throws IOException {
    CharArrayWriter caw = new CharArrayWriter();

    // 正確寫入字符
    caw.write("ABC");
    caw.write("你好");

    for (char c : caw.toCharArray()) {
        System.out.println("char value:" + c);
    }
}

StringReader 讀取本地編碼字串

StringReader 可以 解決被遺棄的 StringBufferInputStream 類問題,它會完整讀取 String 字串完整字

classDiagram Reader <|-- StringReader StringReader *-- String

使用範例


public static void main(String[] args) throws IOException {
    // 儲存在記憶體的是 Unicode
    StringReader stringReader = new StringReader("ABC 安安");

    int data;
    while ((data = stringReader.read()) != -1) {
        // 透過 StringReader 讀取
        // 轉換為 Unicode(因為都是在記憶體中,所以不用轉換)
        System.out.println("Get data: " + data);
    }
}

InputStreamReader / OutputStreamWriter 可指定編碼

InputStreamReader 也是使用 Adapter 模式,它可以用來 一次性讀取、輸出本地編碼(預設)的數據,而不會只讀取一個 Byte 數據

classDiagram Reader <|-- InputStreamReader InputStreamReader *-- InputStream Writer <|-- OutputStreamWriter OutputStreamWriter *-- OutputStream

● 並且 InputStreamReaderOutputStreamWriter 操作可以「指定編碼

編碼有像是: GBKUTF-8UTF-16UTF-32ISO-88591Big 5 等等相當的多~

NotePad++ 的可用編碼

使用範例如下

InputStreamReader 使用


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

    FileInputStream fis = new FileInputStream(targetFile);

    InputStreamReader isr = new InputStreamReader(fis);
    int data;
    while ((data = isr.read()) != -1) {
        System.out.println("Get data: " + data);
    }
}

Reader 會依照個平台的編碼方式進行讀取,所以中文字「安安」可以正常作為一個字元輸出(23433, 當前是 UTF-8 編碼)

● 如果單純使用 FileInputStream 讀取,會被一次只能讀取一個 Byte 限制,導致中文字「安安」會分開來輸出


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

    FileInputStream bais = new FileInputStream(targetFile);

    int data;
    while ((data = bais.read()) != -1) {
        System.out.println("Get data: " + data);
    }
}

229174137, 當前是 UTF-8 編碼)

B. Writer 使用


static void outputStreamWriterUsage() throws IOException {
    String targetFile = "/var/folders/qt/468b6d6j6xxffzc4tydgnj800000gq/T/Hello2.txt";

    FileOutputStream fos = new FileOutputStream(targetFile);
    OutputStreamWriter osw = new OutputStreamWriter(fos, StandardCharsets.UTF_16);

    osw.write("Apple 早");
    osw.flush();


    FileInputStream fis = new FileInputStream(targetFile);
    InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_16);
    int data;
    while ((data = isr.read()) != -1) {
        System.out.println("Get data: " + (char) data);
    }
}

● 假設這邊將寫入、讀取的編碼方式修改為不同的編碼(寫 UTF-16, 讀 UTF-8),就會出現讀取的亂碼(但事實上,數據並沒有錯誤)

自動轉本地編碼 FileReader / FileWriter

FileReaderInputStreamReader 的子類 (FileWriterOutputStreamWriter 的子類),它們的特點在於 自動轉換檔案內容為「本地編碼」,不能指定 其他字元編碼類型

使用方式如下 (其實就是簡化,組裝了 )


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

    FileReader fr = new FileReader(targetFile);

    int data;
    while ((data = fr.read()) != -1) {
        System.out.println("Get data: " + data);
    }
}

BufferedReader / BufferedWriter 減少密集 IO

● 這兩個類都帶有 緩衝區,資料會先輸出到緩衝區,當緩衝區滿了之後,才會將「字元」輸出到目標裝置中,這樣可以有效避免密集 IO 帶來的低效率操作


class IOBufferedUsage {

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

        FileWriter fw = new FileWriter(targetFile);
        BufferedWriter bw = new BufferedWriter(fw);
        bw.write("Yo man, what up!");
        bw.flush();

        FileReader fr = new FileReader(targetFile);
        BufferedReader br= new BufferedReader(fr);
        int data;
        while ((data = br.read()) != -1) {
            System.out.println("Get data: " + (char) data);
        }
    }

}

PrintWriter 概述

PrintWriter 的建構函數中,可以接收(裝飾)Writer、OutputStream 兩個類

PrintWriter & PrintStream 差異

兩者的差別在 PrintWriter 可以指定特殊編碼,而 PrintStream 只能用於本地編碼


IO 操作實戰:常用的操作

讀 & 寫 - 複製檔案

● 邊讀邊寫就可以達到 複製檔案 的效果


    public static void readWrite() {

        int len;
        try(FileInputStream fis = new FileInputStream("D:\\JavaIO\\android_3rd_lib.aar");
            FileOutputStream fos = new FileOutputStream("D:\\JavaIO\\android_3rd_lib_copy.aar")) {

            byte[] cacheBuf = new byte[1024];

            while ((len = fis.read(cacheBuf)) != -1) {
                // 讀取檔案
                System.out.println(len);

                // 寫入檔案
                fos.write(cacheBuf);
                fos.flush();    // 真正寫出
            }

            System.out.println("複製成功");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

try-with-resources 可以管理多個資源,不同資源中間使用 ; 分開即可

--實作結果--

● 若是 轉為 String 透過 string#getBytes() 再寫出則會造成 copy 出來的檔案大小錯誤,這部分 可能是 getBytes() 讀取的是本地編碼 造成的

因為你的檔案可能並不是 本地編碼輸出 的檔案


    public static void readWrite() {

        int len;
        try(FileInputStream fis = new FileInputStream("D:\\JavaIO\\android_3rd_lib.aar");
            FileOutputStream fos = new FileOutputStream("D:\\JavaIO\\android_3rd_lib_copy.aar")) {

            byte[] cacheBuf = new byte[1024];

            while ((len = fis.read(cacheBuf)) != -1) {
                // 將檔案資訊轉為 String(不太好!)
                String str = new String(cacheBuf, 0, len);
                System.out.println(len);

                // 寫入檔案(出問題的點)
                fos.write(str.getBytes());
                fos.flush();    // 真正寫出
            }

            System.out.println("複製成功");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

IO 有無 Buffered 差異測試

● 有緩衝的 字節流 IO,這樣對於硬碟較不傷,並且可以 加快速度,以下讀取 android_3rd_lib.aar,一個使用 Buffer 來讀取,另一個則是使用一般的讀取,來查看這兩個對象所使用的時間

以下以讀取並使用有 Buffer、無 Buffer 作為比腳


public class JavaStreamDemo_3 {

    public static void main(String[] str) {
        readFile();
        readFileByBuffer();
    }

    // 無 Buffer
    public static void readFile() {
        long start = System.currentTimeMillis();
        try(FileInputStream fis = new FileInputStream("android_3rd_lib.aar")) {

            // 讀取後的操作相同
            byte[] cacheBuf = new byte[1024];
            int len;
            while ((len = fis.read(cacheBuf)) != -1) {
                String str = new String(cacheBuf, 0, len);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println("使用時間: " + (System.currentTimeMillis() - start));
    }

    // 有 Buffer
    public static void readFileByBuffer() {
        long start = System.currentTimeMillis();
        try(BufferedInputStream bis = new BufferedInputStream(new FileInputStream("android_3rd_lib.aar"))) {

            // 讀取後的操作相同
            byte[] cacheBuf = new byte[1024];
            int len;
            while ((len = bis.read(cacheBuf)) != -1) {
                String str = new String(cacheBuf, 0, len);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println("使用時間: " + (System.currentTimeMillis() - start));
    }

}

--實測結果--

這邊特別展示時間的縮短,BufferOutputStream 就是相同的道理這裡就不贅述了

RandomAccessFile 隨機存取檔案

RandomAccessFile 也是一個相對重要的類,以往我們讀取、寫入檔案時,必須按照 IO 順序來存取,而 RandomAccessFile 則可以透過指標(Pointer)移動來操作檔案

其中,建構函數中有一個 Mode 參數,其常見設置如下

Mode 設置說明
r唯讀模式
rw讀寫模式(多線程不安全)
rws「讀、寫」都同步,效能較低,不果可以保證資料的完整性
rwd延遲讀寫;只有「寫」是同步的,也就是說在讀取數據上可能會發生不及時的狀況(可以用在下載)

● Mode 不可以單獨設置為「w」,否則會拋出異常

RandomAccessFile 實作 DataInput、DataOutput 界面,所以可以做格式化讀取、寫入(本地編碼);

範例如下


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

    try(RandomAccessFile rsf = new RandomAccessFile(targetFile, "rw")) {

        for (int i = 0; i < 5; i++) {
            String writeTarget = i + ",嗨";

            System.out.println("Before write ptr: " + rsf.getFilePointer());
            rsf.writeUTF(writeTarget);
            System.out.println("After write ptr: " + rsf.getFilePointer());

            // 調整 Ptr 位置
            rsf.seek(rsf.getFilePointer() + "\t".getBytes().length);
            System.out.println("---- : " + rsf.getFilePointer() + "\n");
        }

        // 移動 Ptr 到開頭,準備重新讀取
        rsf.seek(0);

        for (int i = 0; i < 5; i++) {
            String readStr = rsf.readUTF();

            System.out.println(readStr);

            // 移動 Ptr,跳過 "\t"
            // 沒跳過的話,讀取會出錯
            rsf.skipBytes("\t".getBytes().length);
        }

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

Object 物件序列化 IO

在需要的時候可以把對象,輸出到外部檔案,並在需要的時候重讀回來


import java.io.Serializable;

public class AccountInfo implements Serializable {
    public final String name;
    public final long id;
    public final String password;

    public AccountInfo(String name, long id, String password) {
        this.name = name;
        this.id = id;
        this.password = password;
    }

    @Override
    public String toString() {
        return "Name: " + name + "\n" +
                "id: " + id + "\n" +
                "password: " + password;
    }
}

● 要輸出的物件 必須要實作 Serializable,這個界面沒有方法,所以又稱為「標示界面」,若是沒有這個界面則會錯誤

ObjectOutputStream / ObjectInputStream 物件序列化、反序列化

● 使用範例


public class ObjectStreamDemo {

    public static void main(String[] str) {
        writeObj();
        readObj();
    }

    public static void writeObj() {
        AccountInfo accountInfo = new AccountInfo("Alien", 9527, "HelloWorld");

        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Account"))){
            oos.writeObject(accountInfo);
            oos.flush();

            System.out.println("寫入對象成功: " + accountInfo.hashCode());
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    private static void readObj() {
        try (ObjectInputStream oos = new ObjectInputStream(new FileInputStream("Account"))){
            AccountInfo accountInfo = (AccountInfo) oos.readObject();

            System.out.println("讀取對象成功: " + accountInfo.toString() + ", " + accountInfo.hashCode());
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

}

● 注意要點:

● Java IO 輸出的物件資料只有 ObjectInputStream 看得懂,普通的文字編輯器打開後只會看到亂碼

● 物件雖然可以在 JVM 中重新被建立,但是 物件的記憶體位置已經不同

觀察 hashcode

transient 忽略 IO

● 從上面輸出的檔案中可以看到密碼,這並不是我們想要的結果,所以必須在序列化時隱藏,這時需要改動的是要序列化的檔案 (AccountInfo.java),並使用關鍵字 transient (短暫的) 描述需要隱藏的參數


public class AccountInfo implements Serializable {
    public final String name;
    public final long id;
    public transient final String password;

    ... 省略部分
}

這時再重新 Run 依次程式 password 輸出結果就是 null

--實作結果--

IO 技巧:寫入多個物件

● 寫入多個對象時通常會出問題,這個時候就 要使用 ArrayList 把多個物件存起來,這樣才能保證序列化 & 反序列化不會出問題


public class ObjectStreamDemo {

    public static void main(String[] str) {
        writeMulObj();
        readMulObj();
    }

    public static void writeMulObj() {
        AccountInfo accountInfo = new AccountInfo("Alien", 9527, "HelloWorld1");
        AccountInfo accountInfo2 = new AccountInfo("Apple", 1111, "HelloWorld2");
        AccountInfo accountInfo4 = new AccountInfo("Banana", 2222, "HelloWorld3");

        List<AccountInfo> list = new ArrayList<>();
        list.add(accountInfo);
        list.add(accountInfo2);
        list.add(accountInfo4);

        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Account"))){
            oos.writeObject(list);
            oos.flush();

            System.out.println("寫入對象成功");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static void readMulObj() {
        try (ObjectInputStream oos = new ObjectInputStream(new FileInputStream("Account"))){
            List<AccountInfo> list = (List<AccountInfo>) oos.readObject();

            for(AccountInfo info : list) {
                System.out.println("讀取對象成功: " + info.toString());
            }
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

}

--實作結果--

隱藏方法:readObject、writerObject

readObjectwriterObject 是序列化、反序列化物件的過程中的隱藏方法,可以在這裡客製化自己需要的序列方案

這要兩方法是藏在你要序列化的物件內,於是做以下修改

在序列化、反序列化時密碼成員 由自己手動寫入、讀取!


class AccountInfo implements Serializable {
    public final String name;
    public final long id;
    // 仍保持不自動序列化
    public transient String password;

    private void changeBytes(byte[] bytes) {
        for (int i = 0; i < bytes.length; i++) {
            bytes[i] = (byte) (~bytes[i]);
        }
    }

    @Serial
    private void writeObject(ObjectOutputStream stream) throws IOException {
        // 先使用預設寫物件
        stream.defaultWriteObject();
        // 手動序列化
        byte[] original = password.getBytes();
        // 反向 Password 再寫入
        changeBytes(original);
        // 手動寫物件
        stream.writeObject(original);
    }

    @Serial
    private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException {
        // 先使用預設讀物件
        stream.defaultReadObject();
        // 手動反序列化(讀物件)
        byte[] readBytes = (byte[]) stream.readObject();
        // 反向 Bytes
        changeBytes(readBytes);
        // 手動建立 Password
        password = new String(readBytes);
    }

    ... 省略部分
}

讀取、寫入物件的 Demo 如下(沒變)


class ObjectStreamHideMethod {

    public static void main(String[] str) {
        writeObj();
        readObj();
    }

    public static void writeObj() {
        AccountInfo accountInfo = new AccountInfo("Alien", 9527, "HelloWorld");

        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Account"))){
            oos.writeObject(accountInfo);
            oos.flush();

            System.out.println("寫入對象成功: " + accountInfo.hashCode());
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    private static void readObj() {
        try (ObjectInputStream oos = new ObjectInputStream(new FileInputStream("Account"))){
            AccountInfo accountInfo = (AccountInfo) oos.readObject();

            System.out.println("讀取對象成功: " + accountInfo.toString() + ", " + accountInfo.hashCode());
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

}

● 其中的 readObjectwriterObject 的名稱、參數必須完全相同

為何不把這兩個方法加入到「Serializable」 界面? 這有關到,介面的特點

● 由於界面中的方法 必須全部公開

而這兩個方法實際上是希望被隱藏的!

● 界面中的方法會 強迫使用界面者一定要全部實現

但這兩個方法並非每個序列化成員都會使用到,這就不符合界面隔離原則

● 藉此,用戶只能靠讀取 JavaDoc、額外文件 來了解序列化、反序列化的所有協定


更多的 Java 語言相關文章

Java 語言深入

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

深入 Java 物件導向

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


Leave a Comment

Comments

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

發表迴響