《Java 编程入门》14.1 Path 与 Files:比 File 更完整的文件 API

从 File 的布尔返回值讲起,说明 Path 如何表达路径、Files 如何完成读写与属性查询;用 library 的 CSV 落盘串起 resolve、relativize、createDirectories、临时文件与 ATOMIC_MOVE,并给出多进程下避免半个文件的写法与常见边界。

本节目标:能用 Path 和 Files 完成路径拼接、目录创建、读写、复制、移动与属性查询,并知道什么时候必须用临时文件加原子移动,而不是直接覆盖目标文件。
适用版本:JDK 25(对照 JDK 21);java.nio.file 从 JDK 7 起就在标准库里。

上一节留下的问题

第 13 章把异常分成了「程序 bug」和「用户可见错误」两类,library 的借阅规则已经能给出清楚的错误信息。但项目还有一个更基础的问题没解决:数据只存在内存里,程序一退出,录入的图书就没了。

最直接的想法是写文件。早期 Java 教程会从 File 开始:

File file = new File("data/books.csv");
if (!file.exists()) {
    file.mkdirs();
}
FileWriter writer = new FileWriter(file);
writer.write("isbn,title\n");
writer.close();

这段代码能跑,但几乎每一行都埋着问题:mkdirs() 返回 false 时你不知道是「已经存在」还是「没有权限」;FileWriter 用平台默认编码;close() 抛异常时前面的写入可能丢了;路径用字符串拼,在 Windows 上会把 data/books.csv 当成一个文件名。File 不是不能用了,而是它把太多失败原因压缩成了一个 boolean。

java.nio.file 换了两个抽象:Path 表示路径,Files 表示对路径的操作。 路径是值对象,操作是静态方法,失败时抛 IOException 子类,能带原因。这一节把这两个类型用熟,下一节再处理字节、字符和编码。

Path 是路径,不是文件

Path 是路径的抽象,本身不代表文件存在。创建它不会碰磁盘:

Path data = Path.of("/tmp/java_agent_e/data");
Path file = data.resolve("2026").resolve("10").resolve("books.csv");

resolve 把子路径接在父路径后面。它不检查目录是否存在,也不做字符串拼接,所以不会受分隔符影响。等价的写法是 data.resolve(Path.of("2026", "10", "books.csv")),在 Windows 上会自动使用 \。

本机 JDK 25 上运行下面的探针:

package com.plume.library.io;

import java.nio.file.Path;

public final class PathProbe {
    public static void main(String[] args) {
        Path data = Path.of("/tmp/java_agent_e/data");
        Path file = data.resolve("2026").resolve("10").resolve("books.csv");
        System.out.println("file       = " + file);
        System.out.println("nameCount  = " + file.getNameCount());
        System.out.println("fileName   = " + file.getFileName());
        System.out.println("parent     = " + file.getParent());
        System.out.println("root       = " + file.getRoot());

        Path messy = Path.of("/tmp/java_agent_e/data/../data/./books.csv");
        System.out.println("messy      = " + messy);
        System.out.println("normalized = " + messy.normalize());

        Path relative = data.relativize(file);
        System.out.println("relative   = " + relative);
        System.out.println("resolved   = " + data.resolve(relative));

        Path portable = Path.of("data", "books.csv");
        System.out.println("portable   = " + portable);
        System.out.println("startsWith = " + file.startsWith(data));
    }
}

本机输出:

file       = /tmp/java_agent_e/data/2026/10/books.csv
nameCount  = 6
fileName   = books.csv
parent     = /tmp/java_agent_e/data/2026/10
root       = /
messy      = /tmp/java_agent_e/data/../data/./books.csv
normalized = /tmp/java_agent_e/data/books.csv
relative   = 2026/10/books.csv
resolved   = /tmp/java_agent_e/data/2026/10/books.csv
portable   = data/books.csv
startsWith = true

几个容易忽略的点:

  • getNameCount() 数的是路径段,不是字符。/tmp 下的一段是 tmp,根 / 不算。
  • normalize() 只做字符串层面的 . 和 .. 折叠,不访问文件系统,也不解析符号链接。真要拿到真实路径,用 toRealPath()。
  • relativize 要求两个路径都是绝对路径或都是相对路径,混用会抛 IllegalArgumentException。
  • Path.of("data", "books.csv") 是跨平台的写法,不要写 "data/" + name。

Path 是不可变的,resolve、normalize、toAbsolutePath 都返回新对象。这一点和 String 一样,可以放心传递,不必担心某个方法偷偷改掉调用方的路径。

Files 的读写与检查

Files 是一组静态方法。最常用的读写是 readString / writeString,适合小文件:

Path file = Path.of("/tmp/java_agent_e/data/2026/10/books.csv");
Files.createDirectories(file.getParent());
Files.writeString(file, "isbn,title\n", StandardOpenOption.CREATE,
        StandardOpenOption.TRUNCATE_EXISTING);
Files.writeString(file, "9780134685991,Effective Java\n", StandardOpenOption.APPEND);
System.out.println(Files.readString(file));

writeString 不传 OpenOption 时默认是 CREATE、TRUNCATE_EXISTING、WRITE,也就是「不存在就建、存在就清空」。要追加必须显式传 APPEND,否则旧内容会被覆盖。这个默认值坑过很多人:日志写入循环里调用 writeString,最后只剩最后一行。

检查文件时,Files.exists 和 Files.notExists 都可能返回 false:

  • exists 为 false 可能是文件不存在,也可能是没有权限访问。
  • notExists 为 false 同样不能推出「存在」。
  • 需要区分时,用 Files.isReadable、Files.isRegularFile,或者直接尝试打开并捕获 IOException。

本机探针的其余输出:

exists     = true
regular    = true
size       = 40
content    = isbn,title\n9780134685991,Effective Java\n
created    = true
modified   = true
copy size  = 40
copy gone  = true
temp name  = library-N.txt

时间戳只能判断非空,具体值每次运行不同;上面的临时文件名把随机数字替换成了 N。这些值不重要,重要的是 Files.readAttributes(file, BasicFileAttributes.class) 能一次拿到创建时间、修改时间、大小、是否为目录等属性,而不用像 File 那样多次调用、每次都可能得到不一致的快照。

原子写入:避免半个文件

library 会把图书列表写到 data/books.csv。如果程序在写入过程中崩溃,或者两个进程同时写,直接 Files.writeString(target, ...) 可能留下一个被截断的文件:表头在,数据只写了一半。下次启动读取时,BookCsv.fromLine 会在一行中间抛异常。

解决办法是先写同目录下的临时文件,写完再原子替换目标文件:

private void writeLinesAtomically(List<String> lines) throws IOException {
    Path target = file.toAbsolutePath();
    Path directory = target.getParent();
    Files.createDirectories(directory);
    Path temp = Files.createTempFile(directory, target.getFileName().toString(), ".tmp");
    try {
        try (BufferedWriter writer = Files.newBufferedWriter(
                temp, StandardCharsets.UTF_8,
                StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
            for (String line : lines) {
                writer.write(line);
                writer.newLine();
            }
        }
        moveIntoPlace(temp, target);
    } finally {
        Files.deleteIfExists(temp);
    }
}

private static void moveIntoPlace(Path temp, Path target) throws IOException {
    try {
        Files.move(temp, target,
                StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
    } catch (AtomicMoveNotSupportedException e) {
        Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
    }
}

三个细节决定了这段代码是否可靠:

  1. 临时文件必须和目标文件在同一个目录。 跨文件系统的 ATOMIC_MOVE 通常会失败,因为原子重命名只在同一个文件系统内有意义。Files.createTempFile(directory, ...) 满足这一点。
  2. ATOMIC_MOVE 和 REPLACE_EXISTING 一起用。 只写 ATOMIC_MOVE 时,目标已存在会抛 FileAlreadyExistsException;两个一起写,语义是「原子地替换」。
  3. finally 里删除临时文件。 移动成功后临时文件已经不存在,deleteIfExists 是空操作;移动失败时它负责清理,不留垃圾。

ATOMIC_MOVE 不是所有文件系统都支持,网络挂载、某些容器卷会抛 AtomicMoveNotSupportedException。上面的降级分支换成普通 REPLACE_EXISTING 移动,至少避免出现「目标文件写了一半」,代价是替换瞬间可能短暂不可见。

在 library 里落地

library 的文件存储类只做三件事:读 CSV、写 CSV、把 IOException 留给调用方翻译成用户可读的错误。它不负责业务校验,也不负责决定文件放在哪。把上一节的 writeLinesAtomically 放进同一个类,再加上读写方法:

package com.plume.library.io;

import com.plume.library.domain.Book;

import java.io.BufferedReader;
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.List;

public final class BookFileStore {

    private final Path file;

    public BookFileStore(Path file) {
        this.file = file;
    }

    public List<Book> loadCsv() throws IOException {
        List<Book> books = new ArrayList<>();
        try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
            String line = stripBom(reader.readLine());
            if (line == null || !line.equals(BookCsv.HEADER)) {
                throw new IOException("CSV 表头不正确: " + line);
            }
            while ((line = reader.readLine()) != null) {
                if (!line.isBlank()) {
                    books.add(BookCsv.fromLine(line));
                }
            }
        }
        return books;
    }

    public void saveCsv(List<Book> books) throws IOException {
        List<String> lines = new ArrayList<>(books.size() + 1);
        lines.add(BookCsv.HEADER);
        for (Book book : books) {
            lines.add(BookCsv.toLine(book));
        }
        writeLinesAtomically(lines);
    }

    private static String stripBom(String line) {
        if (line != null && !line.isEmpty() && line.charAt(0) == '') {
            return line.substring(1);
        }
        return line;
    }
}

调用方只需要给一个路径:

Path dataFile = Path.of("data", "books.csv");
BookFileStore store = new BookFileStore(dataFile);
store.saveCsv(service.allBooks());

本机把三本书写进去再读回来,CSV 内容是:

isbn,title,author,year
9780134685991,"Effective Java, 3rd Edition",Joshua Bloch,2018
9787111213826,深入理解计算机系统,Randal E. Bryant,2016
9780262033848,Introduction to Algorithms,Thomas H. Cormen,2009

注意第一本书的标题里有逗号,被双引号包起来了。CSV 的引号规则不是「看起来整齐」,而是「字段里出现逗号、双引号、换行时必须加引号,内部的双引号写成两个」。BookCsv 和 Csv 负责这件事,存储类不重复实现。

JSON Lines:一行一个对象

CSV 适合表格,但字段一多、类型一变,列顺序就成了隐式契约。JSON Lines(JSONL)换了一种组织方式:每行一个完整的 JSON 对象,行之间用换行分隔。 它比一个大 JSON 数组更容易追加和逐行处理:读一行解析一行,不用把整个文件载入内存。

library 的 JSONL 写入:

public static String toJson(Book book) {
    return """
            {"isbn":"%s","title":"%s","author":"%s","year":%d}"""
            .formatted(
                    Json.escape(book.isbn()),
                    Json.escape(book.title()),
                    Json.escape(book.author()),
                    book.year());
}

Json.escape 处理双引号、反斜杠、换行和控制字符。书名里出现 " 时,如果直接拼进去,整行 JSON 就坏了。本机写入三本书后,文件内容是:

{"isbn":"9780134685991","title":"Effective Java, 3rd Edition","author":"Joshua Bloch","year":2018}
{"isbn":"9787111213826","title":"深入理解计算机系统","author":"Randal E. Bryant","year":2016}
{"isbn":"9780262033848","title":"Introduction to Algorithms","author":"Thomas H. Cormen","year":2009}

读取时逐行调用 BookJsonl.fromJson。这个解析器只支持「扁平对象、字符串或数字值」,不支持嵌套对象、数组、null、科学计数法。它足够读自己写出的文件,但不足以解析任意 JSON。这也是为什么真实项目通常引入成熟的 JSON 库:手写解析器的边界比想象中多。 卷一不引第三方依赖,所以明确限制格式,并在解析失败时给出带行号的错误。

BookFileStore 的 saveJsonl 和 saveCsv 共用同一个 writeLinesAtomically:临时文件、UTF-8、原子移动。格式不同,落盘策略相同。选 CSV 还是 JSONL 取决于使用方:给 Excel 看用 CSV,给程序读、字段可能增加用 JSONL。

常见错误与边界

用字符串拼路径。 "data/" + name 在 Windows 上可能变成 data/books.csv 一个文件名,取决于后续 API。统一用 Path.of("data").resolve(name)。

把 Files.exists 当权限判断。 目录不可读时它返回 false,你会误判为「文件不存在」,然后创建失败。需要权限信息时用 Files.isReadable / Files.isWritable,或者直接操作并处理异常。

直接覆盖正在被读取的文件。 读者可能读到一半。写入方用临时文件加原子替换;读取方接受「要么旧版本,要么新版本」,不要假设文件永远完整。

跨文件系统 ATOMIC_MOVE。 /tmp 和项目目录可能在不同卷上。临时文件要放在目标目录里,不要用系统临时目录再移动过去。

normalize 当安全检查。 normalize 不会阻止 ../../etc/passwd。如果路径来自用户输入,必须校验它解析后仍在允许的根目录下:root.resolve(input).normalize().startsWith(root),并且注意符号链接要用 toRealPath 再比一次。

Files.readString 读大文件。 它把整个文件读进内存。几百 MB 的日志会直接 OutOfMemoryError。大文件用 Files.lines 或 BufferedReader 逐行处理,下一节会展开。

小结

  • Path 是不可变的路径值对象,resolve、relativize、normalize 都不访问文件系统。
  • Files 用异常报告失败,比 File 的 boolean 返回值多出原因和类型。
  • writeString 默认覆盖,追加要显式传 APPEND;小文件才适合一次读入内存。
  • 可靠落盘用「同目录临时文件 + ATOMIC_MOVE + REPLACE_EXISTING」,失败时清理临时文件。
  • Files.exists 为 false 不等于不存在,权限问题也会得到 false。
  • library 的 BookFileStore 只负责 CSV 读写,路径由调用方决定,异常由上层翻译。

路径和文件操作有了,下一个问题是文件里的内容本身:字节、字符、编码之间到底怎么转换,为什么中文 CSV 到了 Excel 里会乱码。下一节拆开流和字符集。

阅读导航:上一节:13.3 异常转换与用户可见错误 · 下一节:14.2 字节流、字符流与编码 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发