詳解Java中的IO輸入輸出流!

天喬巴夏丶發表於2020-12-18

什麼是流?流表示任何有能力產生資料的資料來源物件或者是有能力接收資料的接收端物件,它遮蔽了實際的I/O裝置中處理資料的細節。

IO流是實現輸入輸出的基礎,它可以很方便地實現資料的輸入輸出操作,即讀寫操作。

本片要點

  • 介紹流的定義和基本分類。
  • 介紹檔案字元流、位元組流、轉換流、合併流、列印流等使用。
  • 介紹序列化的意義。
  • 介紹兩種自定義序列化方式。

基本分類

  • 根據方向
    • 輸入流:資料從外部流向程式,例如從檔案中讀取資料
    • 輸出流:資料從程式流向外部,例如向檔案中寫資料
  • 根據形式
    • 字元流:字元類檔案,【如 txt、 java、 html】,操作16位的字元。
    • 位元組流:【圖片、視訊、音訊】 ,操作8位的位元組。
  • 根據功能
    • 節點流:直接從/向資料來源【如磁碟、網路】進行資料讀寫
    • 處理流:封裝其他的流,來提供增強流的功能。
輸入流 輸出流
字元流 Reader Writer
位元組流 InputStream OutputStream
  • 上面四大基本流都是抽象類,都不能直接建立例項物件。
  • 資料的來源/目的地:磁碟、網路、記憶體、外部裝置。

發展史

  • java1.0版本中,I/O庫中與輸入有關的所有類都將繼承InputStream,與輸出有關的所有類繼承OutputStream,用以操作二進位制資料。

  • java1.1版本對I/O庫進行了修改:

    • 在原先的庫中新增了新類,如ObjectInputStreamObjectOutputStream
    • 增加了Reader和Writer,提供了相容Unicode與面向字元的I/O功能。
    • 在Reader和Writer類層次結構中,提供了使字元與位元組相互轉化的類,OutputStreamWriterInputStreamReader
  • 兩個不同的繼承層次結構擁有相似的行為,它們都提供了讀(read)和寫(write)的方法,針對不同的情況,提供的方法也是類似的。

  • java1.4版本的java.nio.*包中引入新的I/O類庫,這部分以後再做學習。

檔案字元流

  • 檔案字元輸出流 FileWriter自帶緩衝區,資料先寫到到緩衝區上,然後從緩衝區寫入檔案。
  • 檔案字元輸入流 FileReader:沒有緩衝區,可以單個字元的讀取,也可以自定義陣列緩衝區。

輸出的基本結構

在實際應用中,異常處理的方式都需要按照下面的結構進行,本篇為了節約篇幅,之後都將採用向上丟擲的方式處理異常。

    //將流物件放在try之外宣告,並附為null,保證編譯,可以呼叫close
    FileWriter writer = null;
    try {
        //將流物件放在裡面初始化
        writer = new FileWriter("D:\\b.txt");
        writer.write("abc");
        
        //防止關流失敗,沒有自動沖刷,導致資料丟失
        writer.flush();
        
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        //判斷writer物件是否成功初始化
        if(writer!=null) {
            //關流,無論成功與否
            try {
                writer.close();
            } catch (IOException e) {
                e.printStackTrace();
            }finally {
                //無論關流成功與否,都是有意義的:標為垃圾物件,強制回收
                writer = null;
            }
        }
    }
  • 並不會直接將資料寫入檔案中,而是先寫入緩衝區,待緩衝區滿了之後才將緩衝區的資料寫入檔案。
  • 假設資料寫入緩衝區時且緩衝區還沒滿,資料還沒能夠寫入檔案時,程式就已經結束,會導致資料慘死緩衝區,這時需要手動沖刷緩衝區,將緩衝區內的資料沖刷進檔案中。writer.flush();
  • 資料寫入完畢,釋放檔案以允許別的流來操作該檔案。關閉流可以呼叫close()方法,值得注意的是,在close執行之前,流會自動進行一次flush的操作以避免資料還殘存在緩衝區中,但這並不意味著flush操作是多餘的。

流中的異常處理

  • 無論流操作成功與否,關流操作都需要進行,所以需要將關流操作放到finally程式碼塊中
  • 為了讓流物件在finally中依然能夠使用,所以需要將流物件放在try之外宣告並且賦值為null,然後在try之內進行實際的初始化過程。
  • 在關流之前要判斷流物件是否初始化成功,實際就是判斷流物件是否為nullwriter!=null時才執行關流操作。
  • 關流可能會失敗,此時流依然會佔用檔案,所以需要將流物件置為null,標記為垃圾物件進行強制回收以釋放檔案。
  • 如果流有緩衝區,為了防止關流失敗導致沒有進行自動沖刷,所以需要手動沖刷一次,以防止有資料死在緩衝區而產生資料的丟失。

異常處理新方式

JDK1.7提出了對流進行異常處理的新方式,任何AutoClosable型別的物件都可以用於try-with-resourses語法,實現自動關閉。

要求處理的物件的宣告過程必須在try後跟的()中,在try程式碼塊之外。

try(FileWriter writer = new FileWriter("D:\\c.txt")){
    writer.write("abc");
}catch (IOException e){
    e.printStackTrace();
}

讀取的基本結構

    public static void main(String[] args) throws IOException {
        FileReader reader = new FileReader("D:\\b.txt");
        //定義陣列作為緩衝區
        char[] cs = new char[5];
        //定義一個變數記錄每次讀取的字元
        int hasRead;
        //讀取到末尾為-1
        while ((hasRead = reader.read(cs)) != -1) {
            System.out.println(new String(cs, 0, hasRead));
        }
        reader.close();
    }
  • read方法可以傳入字元陣列,每次讀取一個字元陣列的長度。
  • 定義變數m記錄讀取的字元,以達到末尾為終止條件。m!=-1時,終止迴圈。
  • 讀取結束,執行關流操作。

運用輸入與輸出完成複製效果

運用檔案字元輸入與輸出的小小案例:

public static void copyFile(FileReader reader, FileWriter writer) throws IOException {
    //利用字元陣列作為緩衝區
    char[] cs = new char[5];
    //定義變數記錄讀取到的字元個數
    int hasRead;
    while((hasRead = reader.read(cs)) != -1){
        //將讀取到的內容寫入新的檔案中
        writer.write(cs, 0, hasRead));

    }
    reader.close();
    writer.close();
}

檔案位元組流

  • 檔案位元組輸出流 FileOutputStream 在輸出的時候沒有緩衝區,所以不需要進行flush操作。
    public static void main(String[] args) throws Exception {
        FileOutputStream out = new FileOutputStream("D:\\b.txt");
        //寫入資料
        //位元組輸出流沒有緩衝區
        out.write("天喬巴夏".getBytes());
        //關流是為了釋放檔案
        out.close();
    }
  • 檔案位元組輸入流 FileInputStream,可以定義位元組陣列作為緩衝區。
    public static void main(String[] args) throws Exception{
        FileInputStream in = new FileInputStream("E:\\1myblog\\Node.png");
       //1.讀取位元組
       int i;
       while((i = in.read()) ! =-1)
           System.out.println(i);
       //2.定義位元組陣列作為緩衝區
       byte[] bs = new byte[10];
       //定義變數記錄每次實際讀取的位元組個數
       int len;
       while((len = in.read(bs)) != -1){
           System.out.println(new String(bs, 0, len));
       }
       in.close();

    }

緩衝流

字元緩衝流

  • BufferedReader:在構建的時候需要傳入一個Reader物件,真正讀取資料依靠的是傳入的這個Reader物件BufferedReadReader物件中獲取資料提供緩衝區
    public static void main(String[] args) throws IOException {
        //真正讀取檔案的流是FileReader,它本身並沒有緩衝區
        FileReader reader = new FileReader("D:\\b.txt");
        BufferedReader br = new BufferedReader(reader);
        //讀取一行
        //String str = br.readLine();
        //System.out.println(str);

        //定義一個變數來記錄讀取的每一行的資料(回車)
        String str;
        //讀取到末尾返回null
        while((str = br.readLine())!=null){
            System.out.println(str);
        }
        //關外層流即可
        br.close();
    }
  • BufferedWriter:提供了一個更大的緩衝區,提供了一個newLine的方法用於換行,以遮蔽不同作業系統的差異性
    public static void main(String[] args) throws Exception {
        //真正向檔案中寫資料的流是FileWriter,本身具有緩衝區
        //BufferedWriter 提供了更大的緩衝區
        BufferedWriter writer = new BufferedWriter(new FileWriter("E:\\b.txt"));
        writer.write("天喬");
        //換行: Windows中換行是 \r\n   linux中只有\n
        //提供newLine() 統一換行
        writer.newLine();
        writer.write("巴夏");
        writer.close();
    }

裝飾設計模式

緩衝流基於裝飾設計模式,即利用同類物件構建本類物件,在本類中進行功能的改變或者增強。

例如,BufferedReader本身就是Reader物件,它接收了一個Reader物件構建自身,自身提供緩衝區其他新增方法,通過減少磁碟讀寫次數來提高輸入和輸出的速度。

除此之外,位元組流同樣也存在緩衝流,分別是BufferedInputStreamBufferedOutputStream

轉換流(介面卡)

利用轉換流可以實現字元流和位元組流之間的轉換

  • OutputStreamWriter
    public static void main(String[] args) throws Exception {
        //在構建轉換流時需要傳入一個OutputStream  位元組流
        OutputStreamWriter ow = 
                new OutputStreamWriter(
                        new FileOutputStream("D:\\b.txt"),"utf-8");
        //給定字元--> OutputStreamWriter轉化為位元組-->以位元組流形式傳入檔案FileOutputStream
        //如果沒有指定編碼,預設使用當前工程的編碼
        ow.write("天喬巴夏");
        ow.close();
    }

最終與檔案接觸的是位元組流,意味著將傳入的字元轉換為位元組


  • InputStreamReader
    public static void main(String[] args) throws IOException {
        //以位元組形式FileInputStream讀取,經過轉換InputStreamReader -->字元
        //如果沒有指定編碼。使用的是預設的工程的編碼
        InputStreamReader ir = 
                new InputStreamReader(
                        new FileInputStream("D:\\b.txt"));
        char[] cs = new char[5];
        int len;
        while((len=ir.read(cs))!=-1){
            System.out.println(new String(cs,0,len));
        }
        ir.close();
    }

最初與檔案接觸的是位元組流,意味著將讀取的位元組轉化為字元

介面卡設計模式

緩衝流基於介面卡設計模式,將某個類的介面轉換另一個使用者所希望的類的介面,讓原本由於介面不相容而不能在一起工作的類可以在一起進行工作。

OutputStreamWriter為例,構建該轉換流時需要傳入一個位元組流,而寫入的資料最開始是由字元形式給定的,也就是說該轉換流實現了從字元向位元組的轉換,讓兩個不同的類在一起共同辦事。

標準流/系統流

程式的所有輸入都可以來自於標準輸入,所有輸出都可以傳送到標準輸出,所有錯誤資訊都可以傳送到標準錯誤

標準流分類

物件 解釋 封裝型別
System.in 標準輸入流 InputStream
System.out 標準輸出流 PrintStream
System.err 標準錯誤流 PrintStream

可以直接使用System.outSystem.err,但是在讀取System.in之前必須對其進行封裝,例如我們之前經常會使用的讀取輸入:Scanner sc = new Scanner(System.in);實際上就封裝了System.in物件。

  • 標準流都是位元組流
  • 標準流對應的不是類而是物件。
  • 標準流在使用的時候不用關閉。
    /**
     * 從控制檯獲取一行資料
     * @throws IOException  readLine 可能會丟擲異常
     */
    public static void getLine() throws IOException {
        //獲取一行字元資料 -- BufferedReader
        //從控制檯獲取資料 -- System.in
        //System是位元組流,BufferedReader在構建的時候需要傳入字元流
        //將位元組流轉換為字元流
        BufferedReader br =
                new BufferedReader(
                        new InputStreamReader(System.in));
        //接收標準輸入並轉換為大寫
        String str = br.readLine().toUpperCase();
        //傳送到標準輸出
        System.out.println(str);
    }

通過轉換流,將System.in讀取的標準輸入位元組流轉化為字元流,傳送到標準輸出,列印顯示。

列印流

列印流只有輸出流沒有輸入流

  • PrintStream: 列印位元組流
    public static void main(String[] args) throws IOException {
        //建立PrintStream物件
        PrintStream p = new PrintStream("D:\\b.txt");
        p.write("abc".getBytes());
        p.write("def".getBytes());
        p.println("abc");
        p.println("def");
        //如果列印物件,預設呼叫物件身上的toString方法
        p.println(new Object());
        p.close();
    }
  • PrintWriter:列印字元流
    //將System.out轉換為PrintStream
    public static void main(String[] args) {
        //第二個引數autoFlash設定為true,否則看不到結果
        PrintWriter p = new PrintWriter(System.out,true);
        p.println("hello,world!");
    }

合併流

  • SequenceInputStream用於將多個位元組流合併為一個位元組流的流。
  • 有兩種構建方式:
    • 將多個合併的位元組流放入一個Enumeration中來進行。
    • 傳入兩個InputStream物件。
  • 合併流只有輸入流沒有輸出流。

以第一種構建方式為例,我們之前說過,Enumeration可以通過Vector容器的elements方法建立。

    public static void main(String[] args) throws IOException {
        FileInputStream in1 = new FileInputStream("D:\\1.txt");
        FileInputStream in2 = new FileInputStream("D:\\a.txt");
        FileInputStream in3 = new FileInputStream("D:\\b.txt");
        FileInputStream in4 = new FileInputStream("D:\\m.txt");

        FileOutputStream out = new FileOutputStream("D:\\union.txt");
        //準備一個Vector儲存輸入流
        Vector<InputStream> v = new Vector<>();
        v.add(in1);
        v.add(in2);
        v.add(in3);
        v.add(in4);

        //利用Vector產生Enumeration物件
        Enumeration<InputStream> e = v.elements();
        //利用迭代器構建合併流
        SequenceInputStream s = new SequenceInputStream(e);

        //讀取
        byte[] bs = new byte[10];
        int len;
        while((len = s.read(bs))!=-1){
            out.write(bs,0,len);
        }
        out.close();
        s.close();
    }

序列化/反序列化流

  • 序列化:將物件轉化為位元組陣列的過程。
  • 反序列化:將位元組陣列還原回物件的過程。

序列化的意義

物件序列化的目標是將物件儲存在磁碟中,或允許在網路中直接傳輸物件。物件序列化機制允許把記憶體中的Java物件轉換成平臺無關的二進位制流,從而允許把這種二進位制流持久地儲存在磁碟上,通過網路將這種二進位制流傳輸到另一個網路節點。其他程式一旦獲得了這種流,都可以將這種二進位制流恢復為原來的Java物件。

讓某個物件支援序列化的方法很簡單,讓它實現Serializable介面即可:

public interface Serializable {
}

這個介面沒有任何的方法宣告,只是一個標記介面,表明實現該介面的類是可序列化的。

我們通常在Web開發的時候,JavaBean可能會作為引數或返回在遠端方法呼叫中,如果物件不可序列化會出錯,因此,JavaBean需要實現Serializable介面。

序列化物件

建立一個Person類。

//必須實現Serializable介面
class Person implements Serializable {
    //序列化ID serialVersionUID
    private static final long serialVersionUID = 6402392549803169300L;
    private String name;
    private int age;

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

建立序列化流,將物件轉化為位元組,並寫入"D:\1.data"。

public class ObjectOutputStreamDemo {
    public static void main(String[] args) throws IOException {
        Person p = new Person();
        p.setAge(18);
        p.setName("Niu");
        //建立序列化流
        //真正將資料寫出的流是FileOutputStream
        //ObjectOutputStream將物件轉化為位元組
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("D:\\1.data"));
        out.writeObject(p);
        out.close();
    }
}

建立反序列化流,將從"D:\1.data"中讀取的位元組轉化為物件。

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        //建立反序列化流
        //真正讀取檔案的是FileInputStream
        //ObjectInputStream將讀取的位元組轉化為物件
        ObjectInputStream in = new ObjectInputStream(new FileInputStream("D:\\1.data"));
        //讀取資料必須進行資料型別的強制轉換
        Person p = (Person)in.readObject();
        in.close();
        System.out.println(p.getName());//Niu
        System.out.println(p.getAge());//18

    }

需要注意的是:

  • 如果一個物件要想被序列化,那麼對應的類必須實現介面serializable,該介面沒有任何方法,僅僅作為標記使用。
  • statictransient修飾的屬性不會進行序列化。如果屬性的型別沒有實現serializable介面但是也沒有用這兩者修飾,會丟擲NotSerializableException
  • 在物件序列化的時候,版本號會隨著物件一起序列化出去,在反序列化的時候,物件中的版本號和類中的版本號進行比較,如果版本號一致,則允許反序列化。如果不一致,則丟擲InvalidClassException
  • 集合允許被整體序列化 ,集合及其中元素會一起序列化出去。
  • 如果物件的成員變數是引用型別,這個引用型別也需要是可序列化的。
  • 當一個可序列化類存在父類時,這些父類要麼有無參構造器,要麼是需要可序列化的,否則將丟擲InvalidClassException的異常。

關於版本號

  • 一個類如果允許被序列化,那麼這個類中會產生一個版本號 serialVersonUID
    • 如果沒有手動指定版本號,那麼在編譯的時候自動根據當前類中的屬性和方法計算一個版本號,也就意味著一旦類中的屬性發生改變,就會重新計算新的,導致前後不一致。
    • 但是,手動指定版本號的好處就是,不需要再計算版本號。
  • 版本號的意義在於防止類產生改動導致已經序列化出去的物件無法反序列化回來。版本號必須用static final修飾,本身必須是long型別。

自定義序列化的兩種方法

Serializable自定義

// 實現writeObject和readObject兩個方法
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Person implements Serializable {

    private String name;
    private int age;

    // 將name的值反轉後寫入二進位制流
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.writeObject(new StringBuffer(name).reverse());
        out.writeInt(age);
    }

    // 將讀取的字串反轉後賦給name
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        this.name = ((StringBuffer) in.readObject()).reverse().toString();
        this.age = in.readInt();
    }
}

還有一種更加徹底的自定義機制,直接將序列化物件替換成其他的物件,需要定義writeReplace

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Person implements Serializable {

    private String name;
    private int age;

    private Object writeReplace(){
        ArrayList<Object> list = new ArrayList<>();
        list.add(name);
        list.add(age);
        return list;
    }
}

Externalizable自定義

Externalizable實現了Seriablizable介面,並規定了兩個方法:

public interface Externalizable extends java.io.Serializable {

    void writeExternal(ObjectOutput out) throws IOException;

    void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
}

實現該介面,並給出兩個方法的實現,也可以實現自定義序列化。

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements Externalizable {

    String name;
    int age;

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeObject(new StringBuffer(name).reverse());
        out.writeInt(age);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        this.name = ((StringBuffer) in.readObject()).reverse().toString();
        this.age = in.readInt();
    }
}

參考閱讀


寫在最後:如果本文有敘述錯誤之處,還望評論區批評指正,共同進步。

參考資料:《Java 程式設計思想》、《Java語言程式設計》、《大話設計模式》、《瘋狂Java講義》

相關文章