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();
    }
}
对比项CJava
元信息stat 结构体 + fstatFile 对象的 length/lastModified 等
目录遍历opendir/readdir/closedirlist() / listFiles()
分隔符手写宏判断平台File.separator 常量
创建删除open(O_CREAT)/removecreateNewFile/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);   // 你
        }
    }
}

乱码三定律:

  1. 读写双方编码不一致必乱码
  2. Windows 记事本旧默认 GBK,Linux 默认 UTF-8——跨平台文本先确认编码
  3. 永远显式传 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));
        }
    }
}
操作CJava NIO.2
读小文件fopen/fread 循环/closeFiles.readString(path)
读所有行fgets 循环 + reallocFiles.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 代码的标准外壳


练习

题号题目链接知识点
P1068分数线划定https://www.luogu.com.cn/problem/P1068文件读写、排序