← 返回题目列表

Java 中 Path 和 Files 怎么用?如何高效处理大文件?

高频 中等 第 7 / 22 题 更新于 2026/07/25
PathFiles大文件Files.linesFileChannel

简化版

Path 表示文件系统中的路径,Files 提供读写、复制、移动、遍历和属性访问等操作。小文件可用 readStringreadAllBytes;大文件不应一次性装入内存,文本按行用 BufferedReaderFiles.lines,二进制按块用缓冲流或 FileChannel,大范围随机访问才考虑内存映射。

详细版

java.nio.file 是 Java 7 引入的现代文件 API:

  • Path 是路径的抽象,支持 resolverelativizenormalizetoAbsolutePath 等组合与转换。
  • Files 是静态工具类,大多数操作会委托给 Path 所属文件系统的 provider。
  • 相比旧 File,NIO.2 能提供更丰富的属性、目录遍历、符号链接选项和更具体的 I/O 异常。

大文件处理的核心是“有界内存”:每次只保留一行、一个数据块或一个映射窗口。Files.readAllBytesreadStringreadAllLines 都会将全部内容保存在内存中,官方 API 也明确说它们不适合超大文件。

完整版教学

一、Path 负责表示,Files 负责操作

Path base = Path.of("data").toAbsolutePath().normalize();
Path input = base.resolve("input/orders.csv").normalize();

if (!input.startsWith(base)) {
    throw new IllegalArgumentException("路径越界");
}

Files.createDirectories(input.getParent());

Path 对象本身不保证文件存在。normalize() 只会在路径语法上消除冗余的 . 和可折叠的 ..,不会访问文件系统,也不会解析符号链接。toRealPath() 会访问文件系统并返回真实路径,安全场景中还要考虑符号链接、检查与使用之间的竞态条件,不能把上面的字符串式检查当成完整的文件沙箱。

二、按行处理大文本

try (BufferedReader reader = Files.newBufferedReader(
        input, StandardCharsets.UTF_8)) {
    String line;
    while ((line = reader.readLine()) != null) {
        process(line);
    }
}

这种写法的峰值内存主要取决于缓冲区、当前行和业务处理中保留的对象,而不是文件总大小。如果单行自身就可能有数百 MB,“按行”仍不是有界内存,还需要按字符块或协议片段解析。

Files.lines(path, charset) 返回惰性 Stream<String>,也可按需处理:

try (Stream<String> lines = Files.lines(input, StandardCharsets.UTF_8)) {
    long validCount = lines
            .filter(line -> !line.isBlank())
            .map(this::parse)
            .filter(Record::isValid)
            .count();
}

这个 Stream 持有打开的文件资源,必须放进 try-with-resources。不要在 Stream 关闭后再试图消费它,也不要因为看到 Stream 就盲目调用 parallel():单磁盘吞吐、行分隔和有序处理都可能限制并行收益。

三、按块处理大型二进制文件

try (InputStream in = new BufferedInputStream(Files.newInputStream(input));
     OutputStream out = new BufferedOutputStream(Files.newOutputStream(output))) {

    byte[] buffer = new byte[64 * 1024];
    int count;
    while ((count = in.read(buffer)) != -1) {
        out.write(buffer, 0, count);
    }
}

每次必须按 count 写出,不能每次都写整个数组,否则最后一块会把旧数据也写进文件。缓冲区大小要结合存储、文件系统和业务处理测试,64 KiB 只是示例,不是普适最优值。

如果只是原样复制文件,先考虑 Files.copy();在通道之间传输大块数据,可考虑 FileChannel.transferTo() / transferFrom()。需要随机访问大文件时,可以分窗口使用 FileChannel.map(),而不是将无界大小的文件一次映射并长期持有。

四、目录遍历也要关闭

Files.list()Files.walk()Files.find() 返回的 Stream 都可能持有打开的目录资源:

try (Stream<Path> paths = Files.walk(base)) {
    paths.filter(Files::isRegularFile)
         .filter(path -> path.toString().endsWith(".log"))
         .forEach(this::processFile);
}

深层目录、循环符号链接、权限不足和遍历期间文件被删除都需要明确的异常策略。需要细粒度控制访问失败、进入目录和退出目录事件时,使用 Files.walkFileTree() 更合适。

五、文件更新要考虑失败中间态

重写重要文件时,不要直接清空原文件再慢慢写。更稳妥的模式是在同一文件系统中写临时文件,完成并校验后再移动替换。StandardCopyOption.ATOMIC_MOVE 只是请求原子移动,是否支持由底层 provider 和文件系统决定,不支持时会抛出 AtomicMoveNotSupportedException

六、处理大文件还要考虑字符、并发与原子性

“大文件按行读”只解决了总文件大小问题,不保证单行大小有界,也不保证业务对象不会累积。若解析 100 GiB 日志却把每个结果都收集到 List,最终仍会 OOM;流水线的每一级都要限制保留量,并让写出速度跟得上读取速度。

文件属性检查与实际打开之间还存在 TOCTOU 竞态:检查 isRegularFile() 后,路径目标可能被替换。安全敏感代码应尽量依赖受控根目录、最小权限、文件描述符语义及平台能力,不能只靠 normalize() 防御符号链接攻击。

任务内存特征推荐起点主要风险
读取 20 KiB 配置一次载入可控readString字符集/校验
扫描 100 GiB 日志保留当前行BufferedReader超长单行、结果累积
复制 8 GiB 文件固定块或通道传输Files.copy/FileChannel部分传输、磁盘空间
随机查大型索引映射窗口map缺页延迟、生命周期
峰值内存 ≈ 读缓冲 + 当前记录 + 业务并发中的对象 + 写缓冲

记忆钩子:所谓“大文件友好”不是换一个 API 名字,而是证明从读取、解析到输出的每一级都有明确内存上界。

七、常见误区与追问

  • 误区:Path.normalize() 会访问磁盘并消除符号链接。 它只是语法归一化;toRealPath() 才访问文件系统,但仍要考虑检查与使用之间的竞态。
  • 误区:Files.lines() 会立刻把所有行装进内存。 它是惰性 Stream,但持有打开的文件资源,必须及时关闭。
  • 误区:使用 Stream 就应该直接 parallel() 单一磁盘、字符解码、顺序要求和下游处理都可能使并行无收益甚至更慢。
  • 误区:按行读取一定是有界内存。 单行可能极大,而且业务若持续收集结果,内存仍会随数据量增长。
  • 追问:为什么复制循环必须使用实际 count 最后一次读取通常小于数组长度,写整个数组会把上轮残留字节写入目标。
  • 追问:ATOMIC_MOVE 是否在任何文件系统都可用? 不是,provider 不支持时会抛 AtomicMoveNotSupportedException,跨文件系统移动尤其不能假设原子。
  • 追问:什么时候使用 mmap? 大文件随机访问或共享映射可能受益;短小顺序 I/O 常规 read/write 通常更简单,映射还要承担缺页与生命周期成本。

八、加强记忆

Path 表示路径,Files 执行文件操作。处理大文件时先确保内存上界与文件总大小解耦:文本按行、二进制按块、随机访问考虑分窗口映射、原样传输考虑 Files.copy 或通道传输,不要用 readAllBytes 之类的 API 一次装入无界大小的内容。