17 文件 IO
前置知识:16 异常处理
本章目标:掌握 File 类、字节流与字符流两大体系、缓冲流、Files/NIO.2 现代工具、对象序列化、Properties 配置读写,解决中文乱码问题,并用一个 CSV 成绩统计实战收尾入门篇。C 的 fopen/fread 手工循环在 Java 里有从底层到一行流的全套替代。
概述
Java IO 的核心思想是流的抽象:数据源到程序之间的有序传输通道。按单位分字节流(8 位,二进制安全)和字符流(16 位 char,带编码转换)。C 只有一套 FILE* 手工缓冲;Java 提供了「装饰器」式的流组合,能力层层叠加。
flowchart TD ROOT["抽象基类"] --> BIN["字节流(8bit)"] ROOT --> CHR["字符流(char,含编码)"] BIN --> IN1["InputStream 抽象读"] BIN --> OUT1["OutputStream 抽象写"] CHR --> IN2["Reader 抽象读"] CHR --> OUT2["Writer 抽象写"] IN1 --> FIS["FileInputStream<br/>文件字节输入"] IN1 --> BIS["BufferedInputStream<br/>缓冲装饰"] IN1 --> OIS["ObjectInputStream<br/>反序列化"] OUT1 --> FOS["FileOutputStream"] OUT1 --> BOS["BufferedOutputStream"] OUT1 --> OOS["ObjectOutputStream<br/>序列化"] IN2 --> FR["FileReader"] IN2 --> BR["BufferedReader<br/>readLine 按行读"] OUT2 --> FW["FileWriter"] OUT2 --> BW["BufferedWriter"] IN2 -.|"InputStreamReader<br/>字节到字符的桥"| FR OUT2 -.|"OutputStreamWriter"| FW
File 类
File 表示文件或目录的路径抽象(不代表内容),对应 C 里 stat/opendir 那套元信息操作:
import java.io.File;
import java.io.IOException;
public class FileDemo {
public static void main(String[] args) throws IOException {
// 路径分隔符跨平台: Windows 是 \, Linux/macOS 是 /
String sep = File.separator;
System.out.println("分隔符: " + sep);
File dir = new File("demo" + sep + "sub"); // 不要硬编码 / 或 \
File file = new File(dir, "test.txt");
if (!dir.exists()) {
boolean ok = dir.mkdirs(); // 递归建目录,mkdir 只建一层
System.out.println("创建目录: " + ok);
}
if (!file.exists()) {
boolean ok = file.createNewFile(); // 创建空文件
System.out.println("创建文件: " + ok);
}
System.out.println("是否文件: " + file.isFile());
System.out.println("绝对路径: " + file.getAbsolutePath());
System.out.println("大小字节: " + file.length());
// 列出目录内容 —— 对应 C opendir/readdir 循环
File cwd = new File(".");
String[] names = cwd.list();
if (names != null) {
for (String n : names) {
System.out.println(" " + n);
}
}
// 删除
// file.delete();
// dir.delete();
}
}| 对比项 | C | Java |
|---|---|---|
| 元信息 | stat 结构体 + fstat | File 对象的 length/lastModified 等 |
| 目录遍历 | opendir/readdir/closedir | list() / listFiles() |
| 分隔符 | 手写宏判断平台 | File.separator 常量 |
| 创建删除 | open(O_CREAT)/remove | createNewFile/delete |
字节流:InputStream 与 OutputStream
字节流处理一切二进制数据(图片、音频、任意文件)。先看最底层的逐字节读写——等价于 C 的 fgetc/fputc:
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class ByteStreamDemo {
public static void main(String[] args) {
// 复制文件:try-with-resources 管理两个资源
try (FileInputStream in = new FileInputStream("source.bin");
FileOutputStream out = new FileOutputStream("copy.bin")) {
int b; // 注意用 int 接收!-1 表示流结束
while ((b = in.read()) != -1) { // read() 返回 0~255 或 -1
out.write(b); // 逐字节拷贝,效率低但最直观
}
} catch (IOException e) {
System.out.println("复制失败: " + e.getMessage());
}
// try 结束自动关闭两个流,顺序为后开先关 —— 无需手工 fclose
// 实际生产用批量缓冲版:
try (FileInputStream in = new FileInputStream("source.bin");
FileOutputStream out = new FileOutputStream("copy2.bin")) {
byte[] buf = new byte[8192]; // 8KB 缓冲区
int len;
while ((len = in.read(buf)) != -1) { // 读满或读到多少算多少
out.write(buf, 0, len); // 只写实际读到的长度!
}
} catch (IOException e) {
e.printStackTrace();
}
}
}坑点提醒:read(byte[] buf) 返回实际读取长度,write 必须用这个长度而不是 buf.length,否则会写入垃圾尾巴——这是从 C fread 返回值语义直接继承来的细节。
字符流与编码
文本必须关心字符集。char 在 Java 中是 UTF-16 编码的 16 位单元,读写文件时发生「字节序列与字符序列」的转换:
import java.io.*;
import java.nio.charset.StandardCharsets;
public class CharsetDemo {
public static void main(String[] args) throws IOException {
String text = "你好 RootStack";
// 显式指定 UTF-8 写出(默认编码依平台而定,显式指定是最佳实践)
try (Writer w = new OutputStreamWriter(
new FileOutputStream("utf8.txt"), StandardCharsets.UTF_8)) {
w.write(text);
}
// 用 GBK 编码写同一份文本
try (Writer w = new OutputStreamWriter(
new FileOutputStream("gbk.txt"), "GBK")) {
w.write(text);
}
System.out.println(text.getBytes(StandardCharsets.UTF_8).length); // 17
System.out.println(text.getBytes("GBK").length); // 12
// 经典乱码场景: 文件是 GBK 却按 UTF-8 读
try (Reader r = new InputStreamReader(
new FileInputStream("gbk.txt"), StandardCharsets.UTF_8)) {
int c;
StringBuilder sb = new StringBuilder();
while ((c = r.read()) != -1) {
sb.append((char) c);
}
System.out.println("乱码示范: " + sb); // 浣犲ソ... 出现替换符
}
// 正确做法: 按文件真实编码读取
try (Reader r = new InputStreamReader(
new FileInputStream("gbk.txt"), "GBK")) {
int first = r.read();
System.out.println("正确读取首字符: " + (char) first); // 你
}
}
}乱码三定律:
- 读写双方编码不一致必乱码
- Windows 记事本旧默认 GBK,Linux 默认 UTF-8——跨平台文本先确认编码
- 永远显式传 Charset 参数,不依赖平台默认值
缓冲流与逐行读取
逐字节系统调用太慢,Buffered 流在内存里垫一层缓冲区,把成千上万次系统调用合并成少数几次——思想与 C 的 stdio 内置缓冲完全一致,只是 Java 把缓冲做成了可叠加的装饰器:
import java.io.*;
public class BufferedDemo {
public static void main(String[] args) throws IOException {
// 写入: BufferedWriter + 按行
try (BufferedWriter w = new BufferedWriter(new FileWriter("lines.txt"))) {
w.write("第一行");
w.newLine(); // 跨平台换行符,别硬编码 \n
w.write("第二行");
w.newLine();
w.write("第三行");
}
// 读取: BufferedReader.readLine 是文本处理最常用的 API
try (BufferedReader r = new BufferedReader(new FileReader("lines.txt"))) {
String line;
while ((line = r.readLine()) != null) { // null 即文件结束
System.out.println("读到: " + line);
}
}
// 统计一个文件的行数与非空行数 —— 常见笔试题
int total = 0, nonEmpty = 0;
try (BufferedReader r = new BufferedReader(new FileReader("lines.txt"))) {
String line;
while ((line = r.readLine()) != null) {
total++;
if (!line.isBlank()) {
nonEmpty++;
}
}
}
System.out.println("总行数 " + total + ", 非空 " + nonEmpty);
// 字节流的缓冲版: 包装后复制大文件快一个数量级以上
try (InputStream in = new BufferedInputStream(new FileInputStream("source.bin"));
OutputStream out = new BufferedOutputStream(new FileOutputStream("copy3.bin"))) {
in.transferTo(out); // Java 9+ 一行完成高效拷贝
}
}
}Files 与 NIO.2:现代工具类
java.nio.file.Files 把 C 需要循环手工完成的操作全部封装成一行调用,日常 IO 首选它:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
public class Nio2Demo {
public static void main(String[] args) throws IOException {
Path p = Path.of("modern.txt");
// 一行写文件(默认 UTF-8)
Files.writeString(p, "第一行\n第二行\n第三行\n");
// 一行读整个文件为字符串
String all = Files.readString(p);
System.out.println(all.split("\n").length + " 行");
// 一行读所有行为 List<String> —— 对比 C: fopen+fgets+循环+手动管理缓冲
List<String> lines = Files.readAllLines(p);
lines.forEach(l -> System.out.println("NIO 读到: " + l));
// 复制、移动、删除都是一行
Path target = Path.of("modern-backup.txt");
Files.copy(p, target, StandardCopyOption.REPLACE_EXISTING);
// Files.move(target, Path.of("renamed.txt"));
Files.delete(target);
// 判断存在性
System.out.println("存在吗: " + Files.exists(p));
System.out.println("是目录吗: " + Files.isDirectory(Path.of(".")));
System.out.println("大小: " + Files.size(p));
// 遍历目录树 —— 对应 C 的递归 opendir
try (var walk = Files.walk(Path.of("."), 1)) {
walk.forEach(f -> System.out.println("遍历: " + f));
}
}
}| 操作 | C | Java NIO.2 |
|---|---|---|
| 读小文件 | fopen/fread 循环/close | Files.readString(path) |
| 读所有行 | fgets 循环 + realloc | Files.readAllLines(path) |
| 写文件 | fopen/fwrite 循环 | Files.writeString(path, s) |
| 复制 | 读写双循环 | Files.copy(src, dst) |
| 目录递归 | opendir 递归手写 | Files.walk(...) |
注意 readAllLines 会把整个文件载入内存,只适合配置文件等小文件;大文件仍用 BufferedReader 或 Files.lines 流式处理。
序列化 Serializable
把对象转成字节序列存盘或传输。实现标记接口 Serializable 即开启,字段可用 transient 排除:
import java.io.*;
import java.nio.charset.StandardCharsets;
public class SerializeDemo {
public static void main(String[] args) throws IOException, ClassNotFoundException {
User u = new User("张三", 25, "secret-password");
// 序列化到文件
try (ObjectOutputStream out =
new ObjectOutputStream(new FileOutputStream("user.ser"))) {
out.writeObject(u);
}
// 反序列化恢复对象 —— C 中要自己定义二进制格式和解析器
try (ObjectInputStream in =
new ObjectInputStream(new FileInputStream("user.ser"))) {
User restored = (User) in.readObject();
System.out.println(restored.name + " " + restored.age);
System.out.println("password(被transient排除): " + restored.password); // null
}
}
}
class User implements Serializable {
@java.io.Serial
private static final long serialVersionUID = 1L; // 版本号: 类结构变更时防不兼容
String name;
int age;
transient String password; // transient: 不参与序列化,敏感信息不入盘
User(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
}要点:
- serialVersionUID 显式声明,否则编译器自动生成,类一改就反序列化失败
- transient 用于密码、连接句柄等不应持久化的字段
- static 字段不属于对象,天然不被序列化
- 安全提醒:反序列化不可信数据有严重安全风险(对应 CVE 大户),跨服务传输现代更推荐 JSON
Properties 配置读写
键值对配置文件的标准方案,格式即经典的 .properties(key=value),对标 Windows ini 和各种自造格式:
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Properties;
public class PropertiesDemo {
public static void main(String[] args) throws IOException {
Properties props = new Properties();
// 写配置
props.setProperty("db.url", "jdbc:mysql://localhost:3306/test");
props.setProperty("db.user", "root");
props.setProperty("app.name", "RootStack 教程");
try (FileOutputStream out = new FileOutputStream("config.properties")) {
props.store(out, "应用配置"); // 第二参数是注释
}
// 读配置
Properties load = new Properties();
try (FileInputStream in = new FileInputStream("config.properties")) {
load.load(in); // 自动按 ISO-8859-1/Unicode 转义解析
}
System.out.println(load.getProperty("db.url"));
System.out.println(load.getProperty("missing", "默认值")); // 支持默认值
System.out.println(load.getProperty("app.name"));
}
}Scanner 读文件
Scanner 把文件当「带类型的记号流」读,最适合刷题和简单解析——就是前面几章一直在用的 new Scanner(System.in) 换个数据源:
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class ScannerFileDemo {
public static void main(String[] args) {
// 假设 numbers.txt 内容: 3 10 20 30 40
try (Scanner sc = new Scanner(new File("numbers.txt"))) {
if (sc.hasNextInt()) {
int n = sc.nextInt();
long sum = 0;
for (int i = 0; i < n && sc.hasNextInt(); i++) {
sum += sc.nextInt();
}
System.out.println("总和 = " + sum); // 100
}
// 按行读剩余部分
sc.useDelimiter("\n"); // 改用整行为分隔
while (sc.hasNextLine()) {
System.out.println("行: " + sc.nextLine());
}
} catch (FileNotFoundException e) {
System.out.println("文件不存在");
}
}
}对比:BufferedReader 性能更高且能控制字符集;Scanner 胜在直接 nextInt/nextDouble 解析类型。算法输入用 Scanner,工程代码用 BufferedReader/Files。
小实战:CSV 成绩统计程序
综合运用本章知识:读 CSV、按逗号切分、类型解析、异常防御、统计输出。C 里这需要 strtok 手工分割加一堆指针操作:
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class CsvScoreStats {
record Score(String name, int chinese, int math) {
int total() {
return chinese + math;
}
}
public static void main(String[] args) {
Path csv = Path.of("scores.csv");
List<Score> scores = new ArrayList<>();
try {
// 准备测试数据(实际场景这一步由外部文件提供)
if (Files.notExists(csv)) {
Files.write(csv, """
姓名,语文,数学
张三,88,95
李四,92,79
王五,75,100
赵六,66,58
""".getBytes(StandardCharsets.UTF_8));
}
// 逐行读取并解析
List<String> lines = Files.readAllLines(csv, StandardCharsets.UTF_8);
for (int i = 1; i < lines.size(); i++) { // 第一行是表头,跳过
String line = lines.get(i).trim();
if (line.isEmpty()) {
continue;
}
String[] parts = line.split(","); // 按逗号切分
if (parts.length != 3) {
System.out.println("跳过格式错误行: " + line);
continue; // 防御式跳过脏数据
}
try {
int chinese = Integer.parseInt(parts[1].trim());
int math = Integer.parseInt(parts[2].trim());
scores.add(new Score(parts[0].trim(), chinese, math));
} catch (NumberFormatException e) {
System.out.println("分数非法,跳过: " + line);
}
}
if (scores.isEmpty()) {
System.out.println("没有有效数据"); // 空数据处理
return;
}
// 统计
double avgChinese = scores.stream().mapToInt(Score::chinese).average().orElse(0);
double avgMath = scores.stream().mapToInt(Score::math).average().orElse(0);
List<Score> ranking = new ArrayList<>(scores);
ranking.sort(Comparator.comparingInt(Score::total).reversed());
System.out.println("===== 成绩统计 =====");
System.out.printf("人数: %d%n", scores.size());
System.out.printf("语文平均: %.2f 数学平均: %.2f%n", avgChinese, avgMath);
System.out.println("--- 总分排名 ---");
for (int i = 0; i < ranking.size(); i++) {
Score s = ranking.get(i);
System.out.printf("%d. %-6s 总分 %d (语%d 数%d)%n",
i + 1, s.name(), s.total(), s.chinese(), s.math());
}
// 结果写回文件
StringBuilder report = new StringBuilder();
report.append("name,total\n");
for (Score s : ranking) {
report.append(s.name()).append(',').append(s.total()).append('\n');
}
Files.writeString(Path.of("ranking.csv"), report.toString());
System.out.println("排名已写入 ranking.csv");
} catch (IOException e) {
System.out.println("文件读写失败: " + e.getMessage());
}
}
}这个不到百行的程序覆盖了:Files 一行读写、record 数据载体、split 解析、NumberFormatException 防御、Comparator 排序、文本块生成报告——入门篇核心知识的总演练。
本章要点回顾
- File 是路径抽象;Files/NIO.2 是现代首选,小文件一行读写搞定
- 字节流二进制安全,字符流处理编码转换;缓冲流合并系统调用大幅提速
- readLine 返回 null 即 EOF;read(byte[]) 必须用返回长度写盘
- 乱码根因是读写编码不一致,永远显式传 Charset
- 序列化用 serialVersionUID 控版本、transient 排除敏感字段;不可信数据勿反序列化
- Scanner 适合简单解析,BufferedReader/Files 适合工程代码
- try-with-resources 是所有 IO 代码的标准外壳
- 返回目录:06 变量与数据类型
- 相关:NIO 与网络编程(通道、缓冲区与零拷贝等深入主题)
练习
| 题号 | 题目 | 链接 | 知识点 |
|---|---|---|---|
| P1068 | 分数线划定 | https://www.luogu.com.cn/problem/P1068 | 文件读写、排序 |