Java 程式設計要點之 I/O 流詳解

waylau發表於2016-09-20

本文詳細介紹了 Java I/O 流的基礎用法和原理。

位元組流(Byte Streams)

位元組流處理原始的二進位制資料 I/O。輸入輸出的是8位位元組,相關的類為 InputStream 和 OutputStream.

位元組流的類有許多。為了演示位元組流的工作,我們將重點放在檔案 I/O位元組流 FileInputStream 和 FileOutputStream 上。其他種類的位元組流用法類似,主要區別在於它們構造的方式,大家可以舉一反三。

用法

下面一例子 CopyBytes, 從 xanadu.txt 檔案複製到 outagain.txt,每次只複製一個位元組:

public class CopyBytes {
    /**
     * @param args
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {
        FileInputStream in = null;
        FileOutputStream out = null;

        try {
            in = new FileInputStream("resources/xanadu.txt");
            out = new FileOutputStream("resources/outagain.txt");
            int c;

            while ((c = in.read()) != -1) {
                out.write(c);
            }
        } finally {
            if (in != null) {
                in.close();
            }
            if (out != null) {
                out.close();
            }
        }
    }
}

CopyBytes 花費其大部分時間在簡單的迴圈裡面,從輸入流每次讀取一個位元組到輸出流,如圖所示:

記得始終關閉流

不再需要一個流記得要關閉它,這點很重要。所以,CopyBytes 使用 finally 塊來保證即使發生錯誤兩個流還是能被關閉。這種做法有助於避免嚴重的資源洩漏。

一個可能的錯誤是,CopyBytes 無法開啟一個或兩個檔案。當發生這種情況,對應解決方案是判斷該檔案的流是否是其初始 null 值。這就是為什麼 CopyBytes 可以確保每個流變數在呼叫前都包含了一個物件的引用。

何時不使用位元組流

CopyBytes 似乎是一個正常的程式,但它實際上代表了一種低階別的 I/O,你應該避免。因為 xanadu.txt 包含字元資料時,最好的方法是使用字元流,下文會有討論。位元組流應只用於最原始的 I/O。所有其他流型別是建立在位元組流之上的。

字元流(Character Streams)

字元流處理字元資料的 I/O,自動處理與本地字符集轉化。

Java 平臺儲存字元值使用 Unicode 約定。字元流 I/O 會自動將這個內部格式與本地字符集進行轉換。在西方的語言環境中,本地字符集通常是 ASCII 的8位超集。

對於大多數應用,字元流的 I/O 不會比 位元組流 I/O操作複雜。輸入和輸出流的類與本地字符集進行自動轉換。使用字元的程式來代替位元組流可以自動適應本地字符集,並可以準備國際化,而這完全不需要程式設計師額外的工作。

如果國際化不是一個優先事項,你可以簡單地使用字元流類,而不必太注意字符集問題。以後,如果國際化成為當務之急,你的程式可以方便適應這種需求的擴充套件。見國際化獲取更多資訊。

用法

字元流類描述在 Reader 和 Writer。而對應檔案 I/O ,在 FileReader 和 FileWriter,下面是一個 CopyCharacters 例子:

public class CopyCharacters {
    /**
     * @param args
     * @throws IOException 
     */
    public static void main(String[] args) throws IOException {
        FileReader inputStream = null;
        FileWriter outputStream = null;

        try {
            inputStream = new FileReader("resources/xanadu.txt");
            outputStream = new FileWriter("resources/characteroutput.txt");

            int c;
            while ((c = inputStream.read()) != -1) {
                outputStream.write(c);
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
            if (outputStream != null) {
                outputStream.close();
            }
        }
    }
}

CopyCharacters 與 CopyBytes 是非常相似的。最重要的區別在於 CopyCharacters 使用的 FileReader 和 FileWriter 用於輸入輸出,而 CopyBytes 使用 FileInputStream 和FileOutputStream 中的。請注意,這兩個CopyBytes和CopyCharacters使用int變數來讀取和寫入;在 CopyCharacters,int 變數儲存在其最後的16位字元值;在 CopyBytes,int 變數儲存在其最後的8位位元組的值。

字元流使用位元組流

字元流往往是對位元組流的“包裝”。字元流使用位元組流來執行物理I/O,同時字元流處理字元和位元組之間的轉換。例如,FileReader 使用 FileInputStream,而 FileWriter使用的是 FileOutputStream。

有兩種通用的位元組到字元的“橋樑”流:InputStreamReader 和 OutputStreamWriter。當沒有預包裝的字元流類時,使用它們來建立字元流。在 socket 章節中將展示該用法。

面向行的 I/O

字元 I/O 通常發生在較大的單位不是單個字元。一個常用的單位是行:用行結束符結尾。行結束符可以是回車/換行序列(“\r\n”),一個回車(“\r”),或一個換行符(“\n”)。支援所有可能的行結束符,程式可以讀取任何廣泛使用的作業系統建立的文字檔案。

修改 CopyCharacters 來演示如使用面向行的 I/O。要做到這一點,我們必須使用兩個類,BufferedReader 和 PrintWriter 的。我們會在緩衝 I/O 和Formatting 章節更加深入地研究這些類。

該 CopyLines 示例呼叫 BufferedReader.readLine 和 PrintWriter.println 同時做一行的輸入和輸出。

public class CopyLines {
    /**
     * @param args
     * @throws IOException 
     */
    public static void main(String[] args) throws IOException {
        BufferedReader inputStream = null;
        PrintWriter outputStream = null;

        try {
            inputStream = new BufferedReader(new FileReader("resources/xanadu.txt"));
            outputStream = new PrintWriter(new FileWriter("resources/characteroutput.txt"));

            String l;
            while ((l = inputStream.readLine()) != null) {
                outputStream.println(l);
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
            if (outputStream != null) {
                outputStream.close();
            }
        }
    }
}

呼叫 readLine 按行返回文字行。CopyLines 使用 println 輸出帶有當前作業系統的行終止符的每一行。這可能與輸入檔案中不是使用相同的行終止符。

除字元和行之外,有許多方法來構造文字的輸入和輸出。欲瞭解更多資訊,請參閱 Scanning 和 Formatting。

緩衝流(Buffered Streams)

緩衝流通過減少呼叫本地 API 的次數來優化的輸入和輸出。

目前為止,大多數時候我們到看到使用非緩衝 I/O 的例子。這意味著每次讀或寫請求是由基礎 OS 直接處理。這可以使一個程式效率低得多,因為每個這樣的請求通常引發磁碟訪問,網路活動,或一些其它的操作,而這些是相對昂貴的。

為了減少這種開銷,所以 Java 平臺實現緩衝 I/O 流。緩衝輸入流從被稱為緩衝區(buffer)的儲存器區域讀出資料;僅當緩衝區是空時,本地輸入 API 才被呼叫。同樣,緩衝輸出流,將資料寫入到快取區,只有當緩衝區已滿才呼叫本機輸出 API。

程式可以轉換的非緩衝流為緩衝流,這裡用非緩衝流物件傳遞給緩衝流類的構造器。

inputStream = new BufferedReader(new FileReader("xanadu.txt"));
outputStream = new BufferedWriter(new FileWriter("characteroutput.txt"));

用於包裝非快取流的緩衝流類有4個:BufferedInputStream 和 BufferedOutputStream 用於建立位元組緩衝位元組流, BufferedReader 和BufferedWriter 用於建立字元緩衝位元組流。

重新整理緩衝流

重新整理緩衝區是指在某個緩衝的關鍵點就可以將緩衝輸出,而不必等待它填滿。

一些緩衝輸出類通過一個可選的建構函式引數支援 autoflush(自動重新整理)。當自動重新整理開啟,某些關鍵事件會導致緩衝區被重新整理。例如,自動重新整理 PrintWriter 物件在每次呼叫 println 或者 format 時重新整理緩衝區。檢視 Formatting 瞭解更多關於這些的方法。

如果要手動重新整理流,請呼叫其 flush 方法。flush 方法可以用於任何輸出流,但對非緩衝流是沒有效果的。

掃描(Scanning)和格式化(Formatting)

掃描和格式化允許程式讀取和寫入格式化的文字。

I/O 程式設計通常涉及對人類喜歡的整齊的格式化資料進行轉換。為了幫助您與這些瑣事,Java 平臺提供了兩個API。scanning API 使用分隔符模式將其輸入分解為標記。formatting API 將資料重新組合成格式良好的,人類可讀的形式。

掃描

將其輸入分解為標記

預設情況下,Scanner 使用空格字元分隔標記。(空格字元包括空格,製表符和行終止符。為完整列表,請參閱Character.isWhitespace)。示例 ScanXan 讀取 xanadu.txt 的單個詞語並列印他們:

public class ScanXan {
    /**
     * @param args
     * @throws IOException 
     */
    public static void main(String[] args) throws IOException {
        Scanner s = null;

        try {
            s = new Scanner(new BufferedReader(new FileReader("resources/xanadu.txt")));

            while (s.hasNext()) {
                System.out.println(s.next());
            }
        } finally {
            if (s != null) {
                s.close();
            }
        }
    }
}

雖然 Scanner 不是流,但你仍然需要關閉它,以表明你與它的底層流執行完成。

呼叫 useDelimiter() ,指定一個正規表示式可以使用不同的標記分隔符。例如,假設您想要標記分隔符是一個逗號,後面可以跟空格。你會呼叫

s.useDelimiter(",\\s*");

轉換成獨立標記

該 ScanXan 示例是將所有的輸入標記為簡單的字串值。Scanner 還支援所有的 Java 語言的基本型別(除 char),以及 BigInteger 和 BigDecimal 的。此外,數字值可以使用千位分隔符。因此,在一個美國的區域設定,Scanner 能正確地讀出字串“32,767”作為一個整數值。

這裡要注意的是語言環境,因為千位分隔符和小數點符號是特定於語言環境。所以,下面的例子將無法正常在所有的語言環境中,如果我們沒有指定 scanner 應該用在美國地區工作。可能你平時並不用關心,因為你輸入的資料通常來自使用相同的語言環境。可以使用下面的語句來設定語言環境:

s.useLocale(Locale.US);

該 ScanSum 示例是將讀取的 double 值列表進行相加:

public class ScanSum {
    /**
     * @param args
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {
        Scanner s = null;
        double sum = 0;

        try {
            s = new Scanner(new BufferedReader(new FileReader("resources/usnumbers.txt")));
            s.useLocale(Locale.US);

            while (s.hasNext()) {
                if (s.hasNextDouble()) {
                    sum += s.nextDouble();
                } else {
                    s.next();
                }
            }
        } finally {
            s.close();
        }

        System.out.println(sum);
    }
}

輸出為:1032778.74159

格式化

實現格式化流物件要麼是 字元流類的 PrintWriter 的例項,或為位元組流類的 PrintStream 的例項。

注:對於 PrintStream 物件,你很可能只需要 System.out 和 System.err。 (請參閱命令列I/O)當你需要建立一個格式化的輸出流,請例項化 PrintWriter,而不是 PrintStream。

像所有的位元組和字元流物件一樣,PrintStream 和 PrintWriter 的例項實現了一套標準的 write 方法用於簡單的位元組和字元輸出。此外,PrintStream 和 PrintWriter 的執行同一套方法,將內部資料轉換成格式化輸出。提供了兩個級別的格式:

  • print 和 println 在一個標準的方式裡面格式化獨立的值 。
  • format 用於格式化幾乎任何數量的格式字串值,且具有多種精確選擇。

呼叫 print 或 println 輸出使用適當 toString 方法變換後的值的單一值。我們可以看到這 Root 例子:

public class Root {
    /**
     * @param args
     */
    public static void main(String[] args) {
            int i = 2;
        double r = Math.sqrt(i);

        System.out.print("The square root of ");
        System.out.print(i);
        System.out.print(" is ");
        System.out.print(r);
        System.out.println(".");

        i = 5;
        r = Math.sqrt(i);
        System.out.println("The square root of " + i + " is " + r + ".");
    }
}

輸出為:

The square root of 2 is 1.4142135623730951.
The square root of 5 is 2.23606797749979.

在 i 和 r 變數格式化了兩次:第一次在過載的 print 使用程式碼,第二次是由Java編譯器轉換碼自動生成,它也利用了 toString。您可以用這種方式格式化任意值,但對於結果沒有太多的控制權。

format 方法

該 format 方法用於格式化基於 format string(格式字串) 多參。格式字串包含嵌入了 format specifiers (格式說明)的靜態文字;除非使用了格式說明,否則格式字串輸出不變。

格式字串支援許多功能。在本教程中,我們只介紹一些基礎知識。有關完整說明,請參閱 API 規範關於格式字串語法

Root2 示例在一個 format 呼叫裡面設定兩個值:

public class Root2 {
    /**
     * @param args
     */
    public static void main(String[] args) {
        int i = 2;
        double r = Math.sqrt(i);

        System.out.format("The square root of %d is %f.%n", i, r);
    }
}

輸出為:The square root of 2 is 1.414214.

像本例中所使用的格式為:

  • d 格式化整數值為小數
  • f 格式化浮點值作為小數
  • n 輸出特定於平臺的行終止符。

這裡有一些其他的轉換格式:

  • x 格式化整數為十六進位制值
  • s 格式化任何值作為字串
  • tB 格式化整數作為一個語言環境特定的月份名稱。

還有許多其他的轉換。

注意:除了 %% 和 %n,其他格式符都要匹配引數,否則丟擲異常。在 Java 程式語言中,\ n轉義總是產生換行符(\u000A)。不要使用除非你特別想要一個換行符。為了針對本地平臺得到正確的行分隔符,請使用%n

除了用於轉換,格式說明符可以包含若干附加的元素,進一步定製格式化輸出。下面是一個 Format 例子,使用一切可能的一種元素。

public class Format {
    /**
     * @param args
     */
    public static void main(String[] args) {
         System.out.format("%f, %1$+020.10f %n", Math.PI);
    }
}

輸出為:3.141593, +00000003.1415926536

附加元素都是可選的。下圖顯示了長格式符是如何分解成元素

元件必須出現在顯示的順序。從合適的工作,可選的元素是:

  • Precision(精確)。對於浮點值,這是格式化值的數學精度。對於 s 和其他一般的轉換,這是格式化值的最大寬度;該值右截斷,如果有必要的。
  • Width(寬度)。格式化值的最小寬度;如有必要,該值被填充。預設值是左用空格填充。
  • Flags(標誌)指定附加格式設定選項。在 Format 示例中,+ 標誌指定的數量應始終標誌格式,以及0標誌指定0是填充字元。其他的標誌包括 – (墊右側)和(與區域特定的千位分隔符格式號)。請注意,某些標誌不能與某些其他標誌或與某些轉換使用。
  • Argument Index(引數索引)允許您指定的引數明確匹配。您還可以指定<到相同的引數作為前面的說明一致。這樣的例子可以說:System.out.format(“%F,%<+ 020.10f%N”,Math.PI);

命令列 I/O

命令列 I/O 描述了標準流(Standard Streams)和控制檯(Console)物件。

Java 支援兩種互動方式:標準流(Standard Streams)和通過控制檯(Console)。

標準流

標準流是許多作業系統的一項功能。預設情況下,他們從鍵盤讀取輸入和寫出到顯示器。它們還支援對檔案和程式之間的 I/O,但該功能是通過命令列直譯器,而不是由程式控制。

Java平臺支援三種標準流:標準輸入(Standard Input, 通過 System.in 訪問)、標準輸出(Standard Output, 通過System.out 的訪問)和標準錯誤( Standard Error, 通過System.err的訪問)。這些物件被自動定義,並不需要被開啟。標準輸出和標準錯誤都用於輸出;錯誤輸出允許使用者轉移經常性的輸出到一個檔案中,仍然能夠讀取錯誤訊息。

您可能希望標準流是字元流,但是,由於歷史的原因,他們是位元組流。 System.out 和System.err 定義為 PrintStream 的物件。雖然這在技術上是一個位元組流,PrintStream 利用內部字元流物件來模擬多種字元流的功能。

相比之下,System.in 是一個沒有字元流功能的位元組流。若要想將標準的輸入作為字元流,可以包裝 System.in 在 InputStreamReader

InputStreamReader cin = new InputStreamReader(System.in);

Console (控制檯)

更先進的替代標準流的是 Console 。這個單一,預定義的 Console 型別的物件,有大部分的標準流提供的功能,另外還有其他功能。Console 對於安全的密碼輸入特別有用。Console 物件還提供了真正的輸入輸出字元流,是通過 reader 和 writer 方法實現的。

若程式想使用 Console ,它必須嘗試通過呼叫 System.console() 檢索 Console 物件。如果 Console 物件存在,通過此方法將其返回。如果返回 NULL,則 Console 操作是不允許的,要麼是因為作業系統不支援他們或者是因為程式本身是在非互動環境中啟動的。

Console 物件支援通過讀取密碼的方法安全輸入密碼。該方法有助於在兩個方面的安全。第一,它抑制回應,因此密碼在使用者的螢幕是不可見的。第二,readPassword 返回一個字元陣列,而不是字串,所以,密碼可以被覆蓋,只要它是不再需要就可以從儲存器中刪除。

Password 例子是一個展示了更改使用者的密碼原型程式。它演示了幾種 Console 方法

public class Password {
    /**
     * @param args
     */
    public static void main(String[] args) {
        Console c = System.console();
        if (c == null) {
            System.err.println("No console.");
            System.exit(1);
        }

        String login = c.readLine("Enter your login: ");
        char [] oldPassword = c.readPassword("Enter your old password: ");

        if (verify(login, oldPassword)) {
            boolean noMatch;
            do {
                char [] newPassword1 = c.readPassword("Enter your new password: ");
                char [] newPassword2 = c.readPassword("Enter new password again: ");
                noMatch = ! Arrays.equals(newPassword1, newPassword2);
                if (noMatch) {
                    c.format("Passwords don't match. Try again.%n");
                } else {
                    change(login, newPassword1);
                    c.format("Password for %s changed.%n", login);
                }
                Arrays.fill(newPassword1, ' ');
                Arrays.fill(newPassword2, ' ');
            } while (noMatch);
        }

        Arrays.fill(oldPassword, ' ');
    }

    // Dummy change method.
    static boolean verify(String login, char[] password) {
        // This method always returns
        // true in this example.
        // Modify this method to verify
        // password according to your rules.
        return true;
    }

    // Dummy change method.
    static void change(String login, char[] password) {
        // Modify this method to change
        // password according to your rules.
    }
}

上面的流程是:

  • 嘗試檢索 Console 物件。如果物件是不可用,中止。
  • 呼叫 Console.readLine 提示並讀取使用者的登入名。
  • 呼叫 Console.readPassword 提示並讀取使用者的現有密碼。
  • 呼叫 verify 確認該使用者被授權可以改變密碼。(在本例中,假設 verify 是總是返回true )
  • 重複下列步驟,直到使用者輸入的密碼相同兩次:
    • 呼叫 Console.readPassword 兩次提示和讀一個新的密碼。
    • 如果使用者輸入的密碼兩次,呼叫 change 去改變它。 (同樣,change 是一個虛擬的方法)
    • 用空格覆蓋這兩個密碼。
  • 用空格覆蓋舊的密碼。

資料流(Data Streams)

Data Streams 處理原始資料型別和字串值的二進位制 I/O。

支援基本資料型別的值((boolean, char, byte, short, int, long, float, 和 double)以及字串值的二進位制 I/O。所有資料流實現 DataInput 或DataOutput 介面。本節重點介紹這些介面的廣泛使用的實現,DataInputStream 和 DataOutputStream 類。

DataStreams 例子展示了資料流通過寫出的一組資料記錄到檔案,然後再次從檔案中讀取這些記錄。每個記錄包括涉及在發票上的專案,如下表中三個值:

記錄中順序 資料型別 資料描述 輸出方法 輸入方法 示例值
1 double Item price DataOutputStream.writeDouble DataInputStream.readDouble 19.99
2 int Unit count DataOutputStream.writeInt DataInputStream.readInt 12
3 String Item description DataOutputStream.writeUTF DataInputStream.readUTF “Java T-Shirt”

首先,定義了幾個常量,資料檔案的名稱,以及資料。

static final String dataFile = "invoicedata";

static final double[] prices = { 19.99, 9.99, 15.99, 3.99, 4.99 };
static final int[] units = { 12, 8, 13, 29, 50 };
static final String[] descs = {
    "Java T-shirt",
    "Java Mug",
    "Duke Juggling Dolls",
    "Java Pin",
    "Java Key Chain"
};

DataStreams 開啟一個輸出流,提供一個緩衝的檔案輸出位元組流:

out = new DataOutputStream(new BufferedOutputStream(
              new FileOutputStream(dataFile)))

DataStreams 寫出記錄並關閉輸出流:

for (int i = 0; i < prices.length; i ++) {
    out.writeDouble(prices[i]);
    out.writeInt(units[i]);
    out.writeUTF(descs[i]);
}

該 writeUTF 方法寫出以 UTF-8 改進形式的字串值。

現在,DataStreams 讀回資料。首先,它必須提供一個輸入流,和變數來儲存的輸入資料。像 DataOutputStream 、DataInputStream 類,必須構造成一個位元組流的包裝器。

in = new DataInputStream(new
            BufferedInputStream(new FileInputStream(dataFile)));

double price;
int unit;
String desc;
double total = 0.0;

現在,DataStreams 可以讀取流裡面的每個記錄,並在遇到它時將資料包告出來:

try {
    while (true) {
        price = in.readDouble();
        unit = in.readInt();
        desc = in.readUTF();
        System.out.format("You ordered %d" + " units of %s at $%.2f%n",
            unit, desc, price);
        total += unit * price;
    }
} catch (EOFException e) {
}

請注意,DataStreams 通過捕獲 EOFException 檢測檔案結束的條件而不是測試無效的返回值。所有實現了 DataInput 的方法都使用 EOFException 類來代替返回值。

還要注意的是 DataStreams 中的各個 write 需要匹配對應相應的 read。它需要由程式設計師來保證。

DataStreams 使用了一個非常糟糕的程式設計技術:它使用浮點數來表示的貨幣價值。在一般情況下,浮點數是不好的精確數值。這對小數尤其糟糕,因為共同值(如 0.1),沒有一個二進位制的表示。

正確的型別用於貨幣值是 java.math.BigDecimal 的。不幸的是,BigDecimal 是一個物件的型別,因此它不能與資料流工作。然而,BigDecimal 將與物件流工作,而這部分內容將在下一節講解。

物件流(Object Streams)

物件流處理物件的二進位制 I/O。

正如資料流支援的是基本資料型別的 I/O,物件流支援的物件 I/O。大多數,但不是全部,標準類支援他們的物件的序列化,都需要實現 Serializable 介面。

物件流類包括 ObjectInputStream 和 ObjectOutputStream 的。這些類實現的 ObjectInput 與 ObjectOutput 的,這些都是 DataInput 和DataOutput 的子介面。這意味著,所有包含在資料流中的基本資料型別 I/O 方法也在物件流中實現了。這樣一個物件流可以包含基本資料型別值和物件值的混合。該ObjectStreams 例子說明了這一點。ObjectStreams 建立與 DataStreams 相同的應用程式。首先,價格現在是 BigDecimal 物件,以更好地代表分數值。其次,Calendar 物件被寫入到資料檔案中,指示發票日期。

public class ObjectStreams {
    static final String dataFile = "invoicedata";

    static final BigDecimal[] prices = { 
        new BigDecimal("19.99"), 
        new BigDecimal("9.99"),
        new BigDecimal("15.99"),
        new BigDecimal("3.99"),
        new BigDecimal("4.99") };
    static final int[] units = { 12, 8, 13, 29, 50 };
    static final String[] descs = { "Java T-shirt",
            "Java Mug",
            "Duke Juggling Dolls",
            "Java Pin",
            "Java Key Chain" };

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

        ObjectOutputStream out = null;
        try {
            out = new ObjectOutputStream(new
                    BufferedOutputStream(new FileOutputStream(dataFile)));

            out.writeObject(Calendar.getInstance());
            for (int i = 0; i < prices.length; i ++) {
                out.writeObject(prices[i]);
                out.writeInt(units[i]);
                out.writeUTF(descs[i]);
            }
        } finally {
            out.close();
        }

        ObjectInputStream in = null;
        try {
            in = new ObjectInputStream(new
                    BufferedInputStream(new FileInputStream(dataFile)));

            Calendar date = null;
            BigDecimal price;
            int unit;
            String desc;
            BigDecimal total = new BigDecimal(0);

            date = (Calendar) in.readObject();

            System.out.format ("On %tA, %<tB %<te, %<tY:%n", date);

            try {
                while (true) {
                    price = (BigDecimal) in.readObject();
                    unit = in.readInt();
                    desc = in.readUTF();
                    System.out.format("You ordered %d units of %s at $%.2f%n",
                            unit, desc, price);
                    total = total.add(price.multiply(new BigDecimal(unit)));
                }
            } catch (EOFException e) {}
            System.out.format("For a TOTAL of: $%.2f%n", total);
        } finally {
            in.close();
        }
    }
}

如果的 readObject() 不返回預期的物件型別,試圖將它轉換為正確的型別可能會丟擲一個 ClassNotFoundException。在這個簡單的例子,這是不可能發生的,所以我們不要試圖捕獲異常。相反,我們通知編譯器,我們已經意識到這個問題,新增 ClassNotFoundException 到主方法的 throws 子句中的。

複雜物件的 I/O

writeObject 和 readObject 方法簡單易用,但它們包含了一些非常複雜的物件管理邏輯。這不像 Calendar 類,它只是封裝了原始值。但許多物件包含其他物件的引用。如果 readObject 從流重構一個物件,它必須能夠重建所有的原始物件所引用的物件。這些額外的物件可能有他們自己的引用,依此類推。在這種情況下,writeObject 遍歷物件引用的整個網路,並將該網路中的所有物件寫入流。因此,writeObject 單個呼叫可以導致大量的物件被寫入流。

如下圖所示,其中 writeObject 呼叫名為 a 的單個物件。這個物件包含物件的引用 b和 c,而 b 包含引用 d 和 e。呼叫 writeObject(a) 寫入的不只是一個 a,還包括所有需要重新構成的這個網路中的其他4個物件。當通過 readObject 讀回 a 時,其他四個物件也被讀回,同時,所有的原始物件的引用被保留。

如果在同一個流的兩個物件引用了同一個物件會發生什麼?流只包含一個物件的一個拷貝,儘管它可以包含任何數量的對它的引用。因此,如果你明確地寫一個物件到流兩次,實際上只是寫入了2此引用。例如,如果下面的程式碼寫入一個物件 ob 兩次到流:

Object ob = new Object();
out.writeObject(ob);
out.writeObject(ob);

每個 writeObject 都對應一個 readObject, 所以從流裡面讀回的程式碼如下:

Object ob1 = in.readObject();
Object ob2 = in.readObject();

ob1 和 ob2 都是相同物件的引用。

然而,如果一個單獨的物件被寫入到兩個不同的資料流,它被有效地複用 – 一個程式從兩個流讀回的將是兩個不同的物件。

原始碼

本章例子的原始碼,可以在 https://github.com/waylau/essential-java 中 com.waylau.essentialjava.io 包下找到。

相關文章