學完本章你將能夠:
分辨傳統 java.io 與現代 java.nio.file 的分工,用 Path 與 Files 操作檔案。
依「方向」與「單位」為 I/O 流分類,說出四個基底抽象類別。
用位元組流搭配緩衝區複製檔案,解釋緩衝流的效能意義。
用字元流讀寫文字檔,並明確指定 UTF-8 編碼避免亂碼。
以 try-with-resources 管理所有 I/O 資源。
序列化與反序列化物件,說明 serialVersionUID 與 transient 的作用。
到目前為止,程式的所有資料都活在記憶體裡,程式一結束就煙消雲散。要讓資料活得比程式久,就得寫進檔案;要處理外部世界的資料,就得從檔案讀進來。本章的主角是 Java 的 I/O(Input/Output)體系:從檔案的中繼資料操作、位元組與字元兩套流、緩衝的效能原理,到物件序列化,最後用一個 CSV 成績單處理程式串起全部。
Java 的檔案 API 有兩個世代並存:|java.io| 是 1.0 時代的元老(|File| 類別與各種流),|java.nio.file| 是 Java 7 引入的現代化改版(|Path|、|Files|)。實務準則很簡單:路徑與檔案管理用新版,資料的流式讀寫兩代並用——新版的工具方法內部包的正是舊版的流。
檔案的中繼資料:File 與 Path
|java.io.File| 物件代表的是一個路徑,不是檔案內容——建立 |File| 物件不會建立檔案。現代寫法把「路徑」與「操作」拆開:|Path| 是路徑的抽象(用工廠方法 |Path.of| 取得),|Files| 是操作的靜態工具箱。兩代對照:
import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path;
/** File(傳統)與 Path、Files(現代)的對照 */ public class FileInfoDemo
public static void main(String[] args) throws IOException
// 先用現代 API 建立測試檔(內容是 16 個 ASCII 字元) Path path = Path.of("demo.txt"); Files.writeString(path, "Hello, File I/O!", StandardCharsets.UTF_8);
// —- 傳統 java.io.File:物件代表「路徑」而非內容 —- File file = new File("demo.txt"); System.out.println("存在嗎?" + file.exists()); System.out.println("是目錄嗎?" + file.isDirectory()); System.out.println("大小:" + file.length() + " 位元組");
// —- 現代 java.nio.file:Path 表路徑,Files 提供操作 —- System.out.println("存在嗎?" + Files.exists(path)); System.out.println("大小:" + Files.size(path) + " 位元組"); System.out.println("檔名:" + path.getFileName());
Files.delete(path); // 收尾:刪除測試檔 System.out.println("刪除後還存在嗎?" + Files.exists(path));
存在嗎?true 是目錄嗎?false 大小:16 位元組 存在嗎?true 大小:16 位元組 檔名:demo.txt 刪除後還存在嗎?false
留意 |main| 簽章上的 |throws IOException|:檔案操作幾乎都會拋這個受檢例外(第 3 章),示範程式直接上拋,正式程式則應該視情境處理。傳統 |File| 仍常見於舊程式與部分 API(例如 |listFiles()| 列目錄),但新程式建議以 |Path| 為中心。
I/O 流的分類
Java 把所有輸入輸出抽象成流(stream):資料像水流一樣,從來源流進程式(輸入),或從程式流向目的地(輸出)。所有流類別依兩個維度分類——方向(輸入或輸出)與單位(位元組或字元),交叉出四個基底抽象類別:
| 位元組流(處理任何檔案) | 字元流(只處理文字) | |
|---|---|---|
| 輸入 | |InputStream| | |Reader| |
| 輸出 | |OutputStream| | |Writer| |
所有具體流類別都是這四位始祖的後代,名字直接透露血統:|FileInputStream| 是「檔案的位元組輸入流」,|BufferedReader| 是「帶緩衝的字元輸入流」。選邊的準則:圖片、壓縮檔、執行檔等二進位資料用位元組流;文字內容用字元流,讓編碼轉換有人代勞。
位元組流與緩衝
位元組流的代表任務是檔案複製。核心模式值得背下來:準備一塊緩衝區陣列,|read| 回傳「實際讀到的位元組數」,讀到檔案結尾(EOF)時回傳 |-1|:
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path;
/** 用位元組流複製檔案:一次搬一桶(緩衝區),不是一滴一滴搬 */ public class FileCopyDemo
/** 核心邏輯:來源讀進緩衝區,再寫到目的地 */ static void copy(String from, String to) throws IOException
// try-with-resources:兩個流都保證會被關閉 try (FileInputStream in = new FileInputStream(from); FileOutputStream out = new FileOutputStream(to))
byte[] buffer = new byte[4096]; // 4 KB 緩衝區 int count; // 本輪實際讀到的位元組數
// read 回傳 -1 代表已到檔案結尾(EOF) while ((count = in.read(buffer)) != -1) // 只寫出「實際讀到」的部分:最後一輪常常裝不滿 out.write(buffer, 0, count);
public static void main(String[] args) throws IOException
// 準備一個 10000 位元組的來源檔 Path source = Path.of("source.bin"); Files.write(source, new byte[10000]);
copy("source.bin", "target.bin"); // 執行複製
System.out.println("來源大小:" + Files.size(Path.of("source.bin")) + " 位元組"); System.out.println("目標大小:" + Files.size(Path.of("target.bin")) + " 位元組");
Files.delete(Path.of("source.bin")); // 清理測試檔 Files.delete(Path.of("target.bin"));
來源大小:10000 位元組 目標大小:10000 位元組
兩個必懂細節:第一,|write(buffer, 0, count)| 只寫出實際讀到的部分——最後一輪緩衝區通常裝不滿,寫整塊會混入上一輪的舊資料,這是檔案複製最常見的臭蟲。第二,資源宣告在 try 的小括號中,無論成功失敗都會自動關閉(第 3 章的 try-with-resources),本章所有範例一體適用。
每一次 |read| 與 |write| 呼叫底層都可能是一次系統呼叫,成本高昂:一個位元組一個位元組地讀寫,速度可能差上百倍。兩個對策擇一即可:像本例自備緩衝區陣列;或把流包進緩衝流——|BufferedInputStream| 與 |BufferedOutputStream| 內建一塊緩衝區,攔下大部分的實體讀寫,例如 |new BufferedInputStream(new FileInputStream("a.bin"))|。這種「用一個流包住另一個流、加上新能力」的手法是 I/O 體系的招牌設計(裝飾者模式,進階篇詳述)。習慣上:只要是檔案流,先包一層緩衝再說。
字元流與編碼
文字檔的本質仍是位元組,「字元如何變成位元組」由編碼(encoding)決定:UTF-8 之下一個中文字佔 3 個位元組,Big5 之下佔 2 個。字元流(|Reader| 與 |Writer|)替你做這層轉換,但用哪種編碼必須說清楚。
歷史上 |FileReader| 與 |FileWriter| 的無參數建構子使用「平台預設編碼」——同一個檔案在 Windows(傳統中文版預設 MS950)寫入、在 macOS 或 Linux(預設 UTF-8)讀取,中文就成了亂碼。這類問題往往在跨機器部署時才爆發,極難回溯。鐵律:讀寫文字檔一律明確指定編碼,首選 |StandardCharsets.UTF_8|。Java 11 起 |FileReader| 有了帶 |Charset| 參數的建構子,Java 18 起平台預設編碼統一為 UTF-8,但「明講」永遠是最保險的作法。
實務上更常用 |Files| 的工廠方法直接取得「帶緩衝、指定編碼」的字元流。|BufferedReader| 的招牌方法是 |readLine()|:一次讀一行(不含換行符號),讀到結尾回傳 |null|:
import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path;
/** 字元流讀寫文字檔:務必明確指定 UTF-8 編碼 */ public class TextFileDemo
public static void main(String[] args) throws IOException
Path path = Path.of("notes.txt");
// 寫入:newBufferedWriter 建立「緩衝 + UTF-8」的字元流 try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) writer.write("第一行:Java 檔案處理"); writer.newLine(); // 跨平台的換行寫法 writer.write("第二行:中文需要正確編碼"); writer.newLine(); writer.write("第三行:完"); writer.newLine(); // 離開 try 時自動 flush 並關閉
// 讀取:readLine 一次讀一行,讀到結尾回傳 null try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) String line; int lineNo = 1; while ((line = reader.readLine()) != null) System.out.println(lineNo + " | " + line); lineNo++; // 行號遞增
Files.delete(path); // 清理測試檔
1 | 第一行:Java 檔案處理 2 | 第二行:中文需要正確編碼 3 | 第三行:完
|while ((line = reader.readLine()) != null)| 這個「邊讀邊判斷」的慣用句型,是 Java 文字處理的標準開場白。輸出端除了 |BufferedWriter|,還有一位老朋友 |PrintWriter|——它提供你熟悉的 |println| 與 |printf|,適合產生格式化報表,本章結尾的綜合範例會用到。
NIO.2 的 Files 工具箱
小檔案的讀寫,|Files| 的便利方法一行就能解決,內部自動開流、自動關閉、預設 UTF-8:
import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.List;
/** NIO.2 的 Files 工具方法:一行搞定常見檔案操作 */ public class FilesToolboxDemo
public static void main(String[] args) throws IOException
Path path = Path.of("todo.txt");
// 一行寫入多行文字(自動加換行,預設 UTF-8) Files.write(path, List.of("買牛奶", "繳電費", "回覆信件"));
// readString:整個檔案讀成一個字串(小檔案最方便) String all = Files.readString(path); System.out.println("字元總數:" + all.length());
// readAllLines:整個檔案讀成 List<String> List<String> lines = Files.readAllLines(path); System.out.println("共 " + lines.size() + " 行,第二行是:" + lines.get(1));
// copy:複製檔案(REPLACE_EXISTING:目標存在就覆蓋) Path backup = Path.of("todo-backup.txt"); Files.copy(path, backup, StandardCopyOption.REPLACE_EXISTING); System.out.println("備份存在:" + Files.exists(backup));
Files.delete(path); // delete:刪除檔案 Files.delete(backup); System.out.println("清理完成:" + !Files.exists(path));
字元總數:13 共 3 行,第二行是:繳電費 備份存在:true 清理完成:true
驗算第一行輸出:三行各為 3、3、4 個中文字,加上三個換行符號,共 \(3+1+3+1+4+1=13\) 個字元。要注意 |readString| 與 |readAllLines| 會把整個檔案載入記憶體,只適合小檔案;大檔案請回到 |BufferedReader| 逐行處理,或用 |Files.lines|(回傳逐行的 |Stream<String>|,進階篇詳述)。搬移與刪除則有 |Files.move| 與 |Files.delete|。走訪目錄的現代寫法如下:
import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.stream.Stream;
/** 走訪目錄內容 */ public class ListDirDemo
public static void main(String[] args) throws IOException
// 建立測試目錄與三個檔案 Path dir = Files.createDirectories(Path.of("data-dir")); Files.writeString(dir.resolve("a.txt"), "A"); Files.writeString(dir.resolve("b.csv"), "B"); Files.writeString(dir.resolve("c.log"), "C");
// Files.list 回傳 Stream<Path>(Stream 於進階篇詳述) // 作業系統列出的順序不保證,先排序讓輸出可預測 try (Stream<Path> entries = Files.list(dir)) entries.sorted().forEach(p -> System.out.println("發現檔案:" + p.getFileName()));
// 清理:先刪檔案再刪目錄(目錄必須是空的才刪得掉) Files.delete(dir.resolve("a.txt")); Files.delete(dir.resolve("b.csv")); Files.delete(dir.resolve("c.log")); Files.delete(dir); System.out.println("清理完成");
發現檔案:a.txt 發現檔案:b.csv 發現檔案:c.log 清理完成
|dir.resolve("a.txt")| 把子路徑接到目錄後面,是拼接路徑的正規方式(比手動串字串可靠,不必煩惱斜線)。傳統寫法 |file.listFiles()| 回傳 |File[]|,在舊程式碼中仍隨處可見。注意 |Files.list| 回傳的 Stream 也要用 try-with-resources 關閉,它底層握著作業系統的目錄資源。
物件序列化
前面存的都是文字與位元組,能不能把整個物件直接存檔?可以——序列化(serialization)把物件轉成位元組串流,反序列化把它還原。只要類別實作 |Serializable| 這個標記介面(沒有任何方法,純粹表態「我可以被序列化」),就能交給 |ObjectOutputStream| 與 |ObjectInputStream| 處理:
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.nio.file.Files; import java.nio.file.Path;
/** 可序列化的會員檔案:實作 Serializable 這個「標記介面」 */ class Profile implements Serializable
// 序列化版本號:類別演進時用來判斷新舊版本是否相容 private static final long serialVersionUID = 1L;
private final String name; // 會被寫入檔案 private final int level; // 會被寫入檔案
// transient:敏感或可重建的欄位,序列化時直接跳過 private transient String sessionToken;
Profile(String name, int level, String sessionToken) this.name = name; this.level = level; this.sessionToken = sessionToken;
@Override public String toString() return "Profilename=" + name + ", level=" + level + ", sessionToken=" + sessionToken + "";
/** 序列化與反序列化的完整流程 */ public class SerializationDemo
public static void main(String[] args) throws IOException, ClassNotFoundException
Profile original = new Profile("小明", 5, "TOKEN-9527"); System.out.println("存檔前:" + original);
// 序列化:把物件轉成位元組寫入檔案 try (ObjectOutputStream out = new ObjectOutputStream( new FileOutputStream("profile.ser"))) out.writeObject(original);
// 反序列化:從位元組還原物件(readObject 回傳 Object,需轉型) Profile restored; try (ObjectInputStream in = new ObjectInputStream( new FileInputStream("profile.ser"))) restored = (Profile) in.readObject();
// transient 欄位不會被還原:參考型別會是 null System.out.println("還原後:" + restored);
Files.delete(Path.of("profile.ser")); // 清理
存檔前:Profilename=小明, level=5, sessionToken=TOKEN-9527 還原後:Profilename=小明, level=5, sessionToken=null
|transient| 修飾的欄位不參與序列化:適合放密碼、憑證等不該落地的敏感資料,或快取這類可重建的資料——還原後參考型別欄位是 |null|、數值型別是 0,由程式自行補建。序列化會連同整張物件圖(欄位參考到的物件)一起處理,因此那些物件也必須可序列化。
|serialVersionUID| 是類別的版本指紋。反序列化時 JVM 會比對「檔案裡記錄的版本號」與「目前類別的版本號」,不一致就拋出 |InvalidClassException|。若不明確宣告,編譯器會依類別結構自動產生一個——只要類別改了一個欄位,自動值就變,舊檔案立刻全部讀不回來。因此凡是實作 |Serializable| 的類別,都應該手動宣告 |serialVersionUID|,並在確認新舊版本相容時保持不變。另外提醒:Java 原生序列化格式綁定 JVM、跨語言不通,對外交換資料實務上多改用 JSON 或 CSV 等文字格式——下一節就是示範。
綜合範例:CSV 成績檔處理
收尾範例模擬真實工作流程:讀取 CSV(逗號分隔)成績檔、解析成物件、計算統計,再把報表寫成另一個檔案。讀取用 |BufferedReader|、寫入用 |PrintWriter|、解析用 |String.split|、統計用集合,一次動員三章的知識:
import java.io.BufferedReader; import java.io.IOException; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List;
/** * CSV 成績檔處理:讀取、解析、統計、輸出報表。 * 整合本章的檔案讀寫與第 4 章的集合知識。 */ public class GradeReport
/** 成績紀錄:一列 CSV 對應一個物件 */ static class Record
final String name; final int score;
Record(String name, int score) this.name = name; this.score = score;
public static void main(String[] args) throws IOException
Path csv = Path.of("scores.csv"); Path report = Path.of("report.txt");
// 步驟零:準備測試用的 CSV 檔(實務上檔案已經存在) Files.write(csv, List.of( "姓名,分數", "小明,88", "小華,95", "小強,61", "小美,73"));
// 步驟一:逐行讀取並解析成物件 List<Record> records = new ArrayList<>(); try (BufferedReader reader = Files.newBufferedReader(csv, StandardCharsets.UTF_8)) String line = reader.readLine(); // 先讀掉標題列 while ((line = reader.readLine()) != null) String[] cols = line.split(","); // 以逗號切出欄位 records.add(new Record(cols[0], Integer.parseInt(cols[1])));
// 步驟二:計算統計數字 int total = 0; int highest = Integer.MIN_VALUE; String topStudent = ""; for (Record r : records) total += r.score; if (r.score > highest) // 追蹤最高分 highest = r.score; topStudent = r.name; double average = (double) total / records.size();
// 步驟三:把報表寫入檔案(PrintWriter 提供 printf 等便利方法) try (PrintWriter writer = new PrintWriter( Files.newBufferedWriter(report, StandardCharsets.UTF_8))) writer.println("成績統計報表"); writer.println("人數:" + records.size()); writer.printf("平均: writer.println("最高分:" + topStudent + "(" + highest + " 分)");
// 步驟四:把報表讀回來印到螢幕,確認結果 for (String line : Files.readAllLines(report)) System.out.println(line);
Files.delete(csv); // 清理測試檔 Files.delete(report);
成績統計報表 人數:4 平均:79.25 最高分:小華(95 分)
驗算:總分 \(88+95+61+73=317\),平均 \(317 \div 4 = 79.25\),最高分小華 95。幾個實戰要點:讀掉標題列的技巧是「先呼叫一次 |readLine| 不使用其值」;|(double) total| 的強制轉型避免整數除法把小數吃掉;|PrintWriter| 包住 |Files.newBufferedWriter| 的組合同時獲得緩衝、UTF-8 與 |printf| 三種好處。真實世界的 CSV 還有引號包裹、欄位內含逗號等變化,屆時建議使用成熟的解析函式庫,但「逐行讀、切欄位、轉型別」的骨架不變。
本章小結
兩代 API 分工:|java.io| 提供流;|java.nio.file| 的 |Path| 表路徑、|Files| 提供現代化操作;新程式以 |Path| 為中心。
四個基底抽象類別:位元組流 |InputStream| 與 |OutputStream|;字元流 |Reader| 與 |Writer|。二進位用位元組流,文字用字元流。
檔案複製的標準模式:緩衝區陣列加 |while ((count = in.read(buffer)) != -1)|,寫出時務必只寫 |count| 個位元組。
|read| 回傳 \(-1\) 代表檔案結尾;|readLine| 回傳 |null| 代表讀完。
檔案流務必包緩衝(|BufferedInputStream| 等)或自備緩衝區,避免逐位元組的系統呼叫。
文字檔一律明確指定編碼(首選 |StandardCharsets.UTF_8|),依賴平台預設編碼是亂碼的頭號來源。
所有 I/O 資源都交給 try-with-resources 自動關閉。
|Files| 工具箱:|readString|、|readAllLines|、|write|、|copy|、|move|、|delete|、|exists|、|list|;整檔載入的方法只適合小檔案。
序列化:實作 |Serializable|、用 |ObjectOutputStream| 存檔;|transient| 欄位不落地;務必手動宣告 |serialVersionUID|。
本章練習
觀念題:複製圖片檔時誤用字元流(|FileReader| 加 |FileWriter|),檔案會發生什麼事?為什麼?(提示:字元流會對位元組做編碼解讀,二進位資料不是合法文字。)
觀念題:|out.write(buffer)| 與 |out.write(buffer, 0, count)| 差在哪裡?什麼情況下前者會寫出錯誤的資料?(提示:最後一輪讀取通常裝不滿緩衝區。)
實作題:寫一個程式統計指定文字檔的行數、非空白行數與總字元數,檔案不存在時給出友善的錯誤訊息而非堆疊追蹤。(提示:|Files.exists| 先檢查,或捕獲 |IOException|。)
實作題:寫一個「日記程式」:每次執行從鍵盤讀一段文字,連同當天日期附加到 diary.txt 末尾(不覆蓋既有內容)。(提示:|Files.newBufferedWriter| 可加參數 |StandardOpenOption.APPEND| 與 |CREATE|。)
實作題:讓第 4 章的 |Member| 類別實作 |Serializable|,把一個 |ArrayList<Member>| 整批序列化到檔案再讀回來,驗證內容一致。(提示:集合本身就是可序列化的,直接 |writeObject| 整個清單。)
挑戰題:擴充本章的 |GradeReport|:改成從命令列引數接收輸入檔名,加入「不及格名單」段落,並處理「分數欄位不是數字」的髒資料列(跳過並記錄警告,不讓程式中斷)。(提示:對 |Integer.parseInt| 包 try-catch,第 3 章。)