引言:为什么我们要重新认识 Java 文件操作

在 Java 开发的日常工作中,文件操作几乎无处不在:读取配置文件、生成报表、上传附件的临时存储、日志归档、批量导入导出数据等等。很长一段时间里,开发者手中的标准答案只有一个,那就是 java.io.File。它诞生于 JDK 1.0,陪伴了一代又一代 Java 程序员,功不可没。但随着应用规模的增长和操作系统的演进,File 类的诸多不足逐渐暴露:错误信息含糊不清、符号链接处理不完善、缺少统一的文件属性访问方式、目录遍历性能不佳、无法优雅地监控文件变化,也不容易与新的异步 IO 体系协作。

为了解决这些问题,Java 在 JDK 1.7 中正式引入了全新的 NIO.2 API,也就是 java.nio.file 包。它围绕三个核心组件构建起了现代文件操作体系:Path 接口、Paths 工具类,以及功能极其丰富的 Files 工具类。这三者共同构成了一套更安全、更清晰、更强大的文件处理方案。

本文将用 2 万字左右的篇幅,系统拆解 PathPathsFiles 的设计思想和实用细节。我们会从基本概念出发,逐步深入到路径解析、文件属性、目录遍历、文件监视、流式操作等高级主题,并穿插大量可以直接运行的代码示例。无论你是刚接触 Java 文件操作的初学者,还是希望摆脱传统 File 惯性的老手,都可以从本文中获得清晰、可落地的知识脉络。

1. 从 java.io.File 到 NIO.2:一场迟来的升级

1.1 传统 File 类的痛点

在讨论新 API 之前,我们先看看旧方案究竟有哪些让人头疼的地方。传统 java.io.File 本质上更像一个“路径名的抽象表示”,它既可以表示文件,也可以表示目录,但很多操作的行为取决于底层操作系统的实现。主要体现在以下几个方面:

  • 错误信息不友好:例如删除一个不存在的文件,File.delete() 只返回 false,开发者很难知道究竟是权限不足、文件被占用,还是路径本身不合法。
  • 符号链接支持薄弱:在 Unix/Linux 系统中,符号链接非常常见,但 File 对链接的处理方式不一致,容易引发隐蔽的 bug。
  • 无法批量读取文件属性:想要同时获取文件大小、创建时间、修改时间、权限等属性,往往需要多次系统调用,效率低且 API 分散。
  • 目录遍历能力有限File.listFiles() 一次性返回所有子项,对于包含海量文件的目录,内存压力很大,也难以优雅地进行递归遍历。
  • 缺少文件变更监视:如果想要监听某个目录的变化,开发者通常只能自己写轮询线程,既浪费资源,实时性又差。

这些问题不是 File 本身的设计缺陷,而是它诞生时代的局限。随着操作系统功能越来越丰富,Java 需要一套更能贴近现代文件系统的抽象。

1.2 NIO.2 的三大支柱

JDK 7 引入的 java.nio.file 包,被统称为 NIO.2。它的核心设计目标是为文件系统操作提供统一的、面向对象的、可扩展的 API。整个体系可以用下面这张思维导图来理解:

java.nio.file 核心组件
├── Path
│   └── 表示文件系统中的路径,平台无关,可组合、比较、解析
├── Paths
│   └── 静态工厂类,用于创建 Path 实例
├── Files
│   ├── 文件读写:readAllBytes / write / newBufferedReader / newBufferedWriter 等
│   ├── 文件复制移动:copy / move
│   ├── 文件删除:delete / deleteIfExists
│   ├── 文件属性:readAttributes / getAttribute / setAttribute
│   ├── 目录遍历:list / walk / find / newDirectoryStream
│   └── 文件监视:newWatchService
└── FileSystem / FileStore
    └── 表示文件系统及存储设备

可以看到,Path 负责“表示路径”,Paths 负责“创建路径”,Files 负责“操作路径”。三者各司其职,又紧密配合,构成了一个完整的文件操作生态。

1.3 名称辨析:NIO 与 NIO.2

很多初学者容易混淆“NIO”和“NIO.2”的概念。简单来说:

  • NIO:JDK 1.4 引入的 java.nio 包,主要解决的是通道(Channel)和缓冲区(Buffer)带来的高性能 IO,属于“新 IO”。
  • NIO.2:JDK 1.7 引入的 java.nio.file 包,重点关注文件系统操作,是“第二代新 IO”。

本文讨论的 PathPathsFiles 都属于 NIO.2 的范畴。它们与缓冲区、通道那套 API 并没有直接耦合,而是独立的一套文件系统抽象。

2. Path 接口:面向对象的路径抽象

2.1 Path 是什么

在 Java NIO.2 中,java.nio.file.Path 是一个接口,它所代表的是文件系统中的一个路径。这个路径可以指向文件,也可以指向目录;可以实际存在,也可以尚未被创建。Path 本身只是“位置的描述”,并不保证该位置一定有东西。

File 不同,Path 被设计为“不可变对象”。这意味着一旦创建,Path 实例所代表的路径就固定不变,任何返回新路径的方法,例如 resolve()normalize()toAbsolutePath(),都只会返回一个新的 Path 对象,而不会修改原对象。这种不可变性带来了线程安全和易于推理的优势。

2.2 Path 的组成部分

在不同操作系统中,路径的表示方式差异很大。Windows 使用反斜杠 \ 作为分隔符,并以盘符开头,例如 C:\Users\admin\data.txt;Unix/Linux 使用正斜杠 /,例如 /home/admin/data.txtPath 通过抽象隐藏了这些差异,但了解其内部结构仍然很有必要。

一个 Path 通常由以下部分组成:

组成部分说明Windows 示例Unix/Linux 示例
根目录路径的最高层级C:\/
名称元素路径的中间层级Usersadminhomeadmin
文件名路径最后一级data.txtdata.txt

Path API 中,可以使用 getRoot() 获取根路径,使用 getNameCount() 获取名称元素数量,使用 getName(int index) 获取指定层级的名称元素,使用 getFileName() 获取最后一级名称。

下面这段代码演示了如何拆解一个路径:

import java.nio.file.Path;
import java.nio.file.Paths;

public class PathComponentsDemo {
    public static void main(String[] args) {
        Path path = Paths.get("/home/admin/data.txt");

        System.out.println("完整路径: " + path);
        System.out.println("根路径: " + path.getRoot());
        System.out.println("文件名: " + path.getFileName());
        System.out.println("名称元素数量: " + path.getNameCount());

        System.out.println("逐层遍历名称元素:");
        for (int i = 0; i < path.getNameCount(); i++) {
            System.out.println("  [" + i + "] " + path.getName(i));
        }
    }
}

运行结果大致如下:

完整路径: /home/admin/data.txt
根路径: /
文件名: data.txt
名称元素数量: 3
逐层遍历名称元素:
  [0] home
  [1] admin
  [2] data.txt

这个例子清晰地展示了 Path 的分层结构。注意 getFileName() 返回的是最后一级名称,无论它是文件名还是目录名。

2.3 相对路径与绝对路径

路径可以分为相对路径和绝对路径。绝对路径从根目录开始完整描述位置;相对路径则相对于某个基准位置进行解释,通常是当前工作目录。Path 提供了两个关键方法:

  • isAbsolute():判断当前路径是否是绝对路径。
  • toAbsolutePath():把当前路径转换为绝对路径,不会访问文件系统,只是在逻辑上拼接。

示例代码如下:

import java.nio.file.Path;
import java.nio.file.Paths;

public class AbsoluteRelativeDemo {
    public static void main(String[] args) {
        Path relative = Paths.get("docs/readme.md");
        System.out.println("是否绝对路径: " + relative.isAbsolute());
        System.out.println("转换为绝对路径: " + relative.toAbsolutePath());

        Path absolute = Paths.get("/var/log/app.log");
        System.out.println("是否绝对路径: " + absolute.isAbsolute());
    }
}

还有一个需要注意的是 toRealPath() 方法。它与 toAbsolutePath() 不同,会真正访问文件系统,把符号链接解析为实际路径,并要求文件必须存在。因此在需要确定“文件真实位置”时,应该使用 toRealPath(),但要准备好处理 IOException

2.4 路径拼接、归一化与相对化

Path 提供了一系列方法用于路径之间的运算,这是它相比字符串拼接的巨大优势。

resolve(String other) 是最常用的拼接方法。它会返回一个新的 Path,其规则是:如果传入的字符串是绝对路径,则直接返回该绝对路径;否则把当前路径作为前缀进行拼接。例如:

import java.nio.file.Path;
import java.nio.file.Paths;

public class PathResolveDemo {
    public static void main(String[] args) {
        Path base = Paths.get("/home/admin");

        Path file = base.resolve("data.txt");
        System.out.println("拼接相对路径: " + file);

        Path otherAbsolute = base.resolve("/tmp/cache.log");
        System.out.println("拼接绝对路径: " + otherAbsolute);

        Path sibling = base.resolveSibling("backup");
        System.out.println("同级路径: " + sibling);
    }
}

输出结果:

拼接相对路径: /home/admin/data.txt
拼接绝对路径: /tmp/cache.log
同级路径: /home/backup

resolveSibling() 是一个实用技巧,它把当前路径的最后一级替换为指定字符串,适合在同一个目录下查找“兄弟文件”。

normalize() 用于消除路径中的冗余部分,例如 ...。需要注意,它也不访问文件系统,因此如果路径中包含过多的 .. 以至于已经越过根目录,其结果可能与预期不完全一致。

relativize()resolve() 的逆运算,用于计算两个路径之间的相对关系。例如:

import java.nio.file.Path;
import java.nio.file.Paths;

public class PathRelativizeDemo {
    public static void main(String[] args) {
        Path from = Paths.get("/data/project");
        Path to = Paths.get("/data/project/src/main/App.java");

        Path relative = from.relativize(to);
        System.out.println("相对路径: " + relative);

        Path roundTrip = from.resolve(relative).normalize();
        System.out.println("还原路径: " + roundTrip);
    }
}

运行结果为:

相对路径: src/main/App.java
还原路径: /data/project/src/main/App.java

需要注意的是,relativize() 要求两个路径要么都是绝对路径,要么都是相对路径,否则会抛出 IllegalArgumentException

2.5 路径之间的比较

Path 实现了 Comparable 接口,因此可以直接排序或比较。它的比较逻辑是平台相关的:比较时会逐层比较名称元素,在某些系统中比较是大小写敏感的,在另一些系统中可能不敏感。

此外,Path 还提供了 startsWith()endsWith() 方法,用于判断路径的前缀或后缀。这两个方法支持传入字符串或另一个 Path 对象。示例:

import java.nio.file.Path;
import java.nio.file.Paths;

public class PathCompareDemo {
    public static void main(String[] args) {
        Path path = Paths.get("/data/project/src/App.java");

        System.out.println(path.startsWith("/data"));
        System.out.println(path.startsWith(Paths.get("/data/project")));
        System.out.println(path.endsWith("App.java"));
        System.out.println(path.endsWith("src/App.java"));
    }
}

运行结果:

true
true
true
true

有一个容易踩坑的地方:endsWith() 是按完整的名称元素匹配的。例如 path.endsWith("p.java") 会返回 false,因为不存在一个完整的名称为 p.java 的元素。理解这一点可以避免写出错误的判断逻辑。

2.6 Path 与 File 的互转

虽然推荐使用新的 NIO.2 API,但现实项目中仍然存在大量以 File 为参数或返回值的第三方库。好在 JDK 提供了便捷的互转方法:

  • File.toPath():把 File 转换为 Path
  • Path.toFile():把 Path 转换为 File

示例:

import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;

public class FilePathConvertDemo {
    public static void main(String[] args) {
        File legacyFile = new File("/tmp/data.txt");
        Path modernPath = legacyFile.toPath();
        System.out.println("File -> Path: " + modernPath);

        Path path = Paths.get("/tmp/data.txt");
        File backToFile = path.toFile();
        System.out.println("Path -> File: " + backToFile.getAbsolutePath());
    }
}

不过要提醒的是,File 是非空的旧 API 入口,在纯新项目里建议从源头就使用 Path,避免在两种抽象之间频繁切换。

3. Paths 工具类:Path 的专属工厂

3.1 为什么需要 Paths

Path 是接口,不能直接实例化。虽然可以通过具体的 FileSystem 对象调用 getPath() 来创建,但每次都要先拿到文件系统对象,流程相当繁琐。因此 JDK 提供了一个静态工具类 java.nio.file.Paths,它内部使用默认文件系统,让开发者可以用最简短的方式创建 Path

可以把 Paths 理解为“Path 的便捷工厂”。它的实现非常轻量,核心职责就是把字符串转换为 Path 对象。

3.2 get() 方法详解

Paths.get()Paths 中最核心、使用频率最高的方法。它存在两个重载版本:

  • get(String first, String... more):把第一个字符串和后续字符串拼接成一个路径。
  • get(URI uri):从统一资源标识符创建路径。

先看字符串版本:

import java.nio.file.Path;
import java.nio.file.Paths;

public class PathsGetDemo {
    public static void main(String[] args) {
        Path single = Paths.get("/tmp");
        System.out.println("单段路径: " + single);

        Path multi = Paths.get("/tmp", "logs", "app.log");
        System.out.println("多段拼接: " + multi);

        Path withSeparator = Paths.get("/tmp", "logs/app.log");
        System.out.println("包含分隔符的拼接: " + withSeparator);
    }
}

输出结果:

单段路径: /tmp
多段拼接: /tmp/logs/app.log
包含分隔符的拼接: /tmp/logs/app.log

从结果可以看出,Paths.get() 会按照当前平台的分隔符自动拼接各个段落。即使某个段落中已经包含了路径分隔符,也能正确处理。

再看 URI 版本:

import java.net.URI;
import java.nio.file.Path;
import java.nio.file.Paths;

public class PathsFromUriDemo {
    public static void main(String[] args) {
        URI uri = URI.create("file:///tmp/logs/app.log");
        Path path = Paths.get(uri);
        System.out.println("从 URI 创建: " + path);
    }
}

从 URI 创建路径时,需要特别注意格式。对于本地文件,标准的 file 协议的 URI 形如 file:///tmp/logs/app.log,其中前两个斜杠属于协议标识,第三个斜杠才是路径的根。如果拼接错误,可能得到不符预期的结果。

3.3 Paths 与默认文件系统

Paths.get() 等价于先获取默认文件系统,再调用其 getPath() 方法。归根结底,代码中的 Paths.get("a", "b") 相当于:

import java.nio.file.FileSystems;
import java.nio.file.Path;

public class DefaultFileSystemDemo {
    public static void main(String[] args) {
        Path path = FileSystems.getDefault().getPath("a", "b");
        System.out.println(path);
    }
}

对于绝大多数应用场景,默认文件系统就是当前操作系统挂载的文件系统,因此直接使用 Paths.get() 完全足够。只有在处理 ZIP 文件系统、自定义内存文件系统等特殊需求时,才需要显式操作 FileSystem 对象。

3.4 常见误区与最佳实践

第一,不要用字符串拼接代替 Paths.get()。例如:

String dir = "/tmp/logs";
String file = "app.log";
// 不推荐:手动拼分隔符
String wrong = dir + "\\" + file;
// 推荐:交给 Paths 处理
Path right = Paths.get(dir, file);

手动拼接分隔符会导致跨平台兼容性问题。Windows 使用反斜杠,Unix/Linux 使用正斜杠,硬编码分隔符的代码换一个环境就可能出错。

第二,Paths.get() 不做路径的合法性校验。即使传入的字符串包含非法字符,或者写成了明显不合理的组合,Paths.get() 也只会原样接受并构造 Path 对象,而不会立即抛异常。真正的校验往往发生在后续执行文件操作时。

第三,理解“路径生成不访问文件系统”这一特性。调用 Paths.get() 只是构造表示路径的对象,并不会检查该路径是否存在。这对于提高程序启动速度是有益的,但也要求开发者在真正读写前自行判断文件的可用性,例如使用 Files.exists() 检查。

4. Files 工具类:文件操作的瑞士军刀

4.1 Files 的整体设计

java.nio.file.Files 是一个完全由静态方法组成的工具类,可以视为 NIO.2 对文件操作的统一入口。它几乎涵盖了日常开发所需的全部文件能力:存在性判断、创建删除、复制移动、属性读取、内容读写、目录遍历、符号链接处理、文件监视等。

下面按功能域逐一展开。每个小节都会给出典型方法签名、代码示例和关键注意事项。

4.2 文件与目录的存在性判断

在操作文件前,通常需要先确认文件是否存在。传统做法是调用 File.exists(),但这个方法无法区分“文件不存在”和“无法访问”等复杂情况。NIO.2 提供了更丰富的选择:

  • Files.exists(Path path, LinkOption... options):判断路径是否存在。
  • Files.notExists(Path path, LinkOption... options):判断路径是否不存在。
  • Files.isRegularFile(Path path, LinkOption... options):判断是否为常规文件。
  • Files.isDirectory(Path path, LinkOption... options):判断是否为目录。
  • Files.isReadable(Path path):判断当前 JVM 是否有读权限。
  • Files.isWritable(Path path):判断是否有写权限。
  • Files.isExecutable(Path path):判断是否有执行权限。

示例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class FileCheckDemo {
    public static void main(String[] args) {
        Path path = Paths.get("/tmp/data.txt");

        System.out.println("是否存在: " + Files.exists(path));
        System.out.println("是否不存在: " + Files.notExists(path));
        System.out.println("是否常规文件: " + Files.isRegularFile(path));
        System.out.println("是否目录: " + Files.isDirectory(path));
        System.out.println("是否可读: " + Files.isReadable(path));
        System.out.println("是否可写: " + Files.isWritable(path));
        System.out.println("是否可执行: " + Files.isExecutable(path));
    }
}

这里有一个容易混淆的点:exists()notExists() 并不是简单的互补关系。当文件存在且可以访问时,exists() 返回 true;当文件确定不存在时,notExists() 返回 true;但如果因为权限等问题无法确定文件状态,两个方法都可能返回 false。因此严谨的判断应该结合错误处理一起考虑。

4.3 文件与目录的创建、删除

4.3.1 创建文件

Files.createFile(Path path, FileAttribute<?>... attrs) 可以创建一个空文件。如果目标文件已经存在,会抛出 FileAlreadyExistsException;如果父目录不存在,会抛出 NoSuchFileException

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class CreateFileDemo {
    public static void main(String[] args) {
        Path path = Paths.get("/tmp/new-file.txt");
        try {
            Files.createFile(path);
            System.out.println("文件创建成功: " + path);
        } catch (FileAlreadyExistsException e) {
            System.out.println("文件已存在: " + path);
        } catch (IOException e) {
            System.out.println("创建失败: " + e.getMessage());
        }
    }
}
4.3.2 创建目录

创建目录有两个常用方法:

  • Files.createDirectory(Path dir, FileAttribute<?>... attrs):创建单层目录,要求父目录必须已经存在。
  • Files.createDirectories(Path dir, FileAttribute<?>... attrs):递归创建所有不存在的父目录。

示例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class CreateDirectoryDemo {
    public static void main(String[] args) throws Exception {
        Path single = Paths.get("/tmp/chapter4/single");
        Files.createDirectories(single);
        System.out.println("目录创建成功: " + single);

        Path nested = Paths.get("/tmp/chapter4/a/b/c");
        Files.createDirectories(nested);
        System.out.println("多层目录创建成功: " + nested);
    }
}

createDirectories() 非常实用,它内部做了幂等处理:如果目录已经存在,也不会抛异常,而是直接跳过。相比传统手工递归创建目录,代码简洁得多。

4.3.3 删除文件与目录

删除操作同样有两个方法:

  • Files.delete(Path path):删除文件或空目录,目标不存在时抛 NoSuchFileException
  • Files.deleteIfExists(Path path):目标存在才删除,不存在则静默返回 false

示例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class DeleteDemo {
    public static void main(String[] args) {
        Path path = Paths.get("/tmp/chapter4/single");

        try {
            boolean deleted = Files.deleteIfExists(path);
            System.out.println("删除结果: " + deleted);
        } catch (IOException e) {
            System.out.println("删除失败: " + e.getMessage());
        }
    }
}

值得特别注意的是,Files.delete() 只能删除空目录。如果目录中还有子文件或子目录,会抛出 DirectoryNotEmptyException。那么如何递归删除非空目录呢?可以借助 Files.walk() 来实现,这部分会在目录遍历章节中详细说明。

4.3.4 创建临时文件与目录

临时文件和目录在测试、缓存、上传处理等场景中非常常用。NIO.2 提供了四个方法:

  • Files.createTempFile(String prefix, String suffix, FileAttribute<?>... attrs):在系统默认临时目录创建文件。
  • Files.createTempFile(Path dir, String prefix, String suffix, FileAttribute<?>... attrs):在指定目录创建文件。
  • Files.createTempDirectory(String prefix, FileAttribute<?>... attrs):在系统默认临时目录创建目录。
  • Files.createTempDirectory(Path dir, String prefix, FileAttribute<?>... attrs):在指定目录创建目录。

示例:

import java.nio.file.Files;
import java.nio.file.Path;

public class TempFileDemo {
    public static void main(String[] args) throws Exception {
        Path tempFile = Files.createTempFile("app-", ".tmp");
        System.out.println("临时文件: " + tempFile);

        Path tempDir = Files.createTempDirectory("app-dir-");
        System.out.println("临时目录: " + tempDir);

        Path custom = Files.createTempFile(tempDir, "data-", ".log");
        System.out.println("指定目录下的临时文件: " + custom);
    }
}

实际项目中,临时文件常用于把内存中的大对象落盘,然后再提供给外部系统消费。用完记得删除,避免磁盘空间被持续占用。

4.4 文件复制与移动

4.4.1 复制文件与目录

Files.copy(Path source, Path target, CopyOption... options) 用于复制文件或目录。常见的复制选项有:

  • StandardCopyOption.REPLACE_EXISTING:如果目标已存在则替换。
  • StandardCopyOption.COPY_ATTRIBUTES:连同文件属性一起复制,例如权限、时间戳等。
  • LinkOption.NOFOLLOW_LINKS:不跟随符号链接,复制链接本身。

示例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;

public class CopyDemo {
    public static void main(String[] args) {
        Path source = Paths.get("/tmp/source.txt");
        Path target = Paths.get("/tmp/target.txt");

        try {
            // 确保源文件存在
            if (Files.notExists(source)) {
                Files.createFile(source);
            }

            Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("复制成功: " + target);
        } catch (IOException e) {
            System.out.println("复制失败: " + e.getMessage());
        }
    }
}

如果复制的目标是一个目录,Files.copy() 会在该目录下创建一个与源文件同名的新文件,但不会递归复制整个目录。要递归复制目录,需要结合目录遍历自行实现。

4.4.2 移动文件与目录

Files.move(Path source, Path target, CopyOption... options) 用于移动或重命名文件。移动操作底层可能会尝试原子地进行,这一点对于某些需要保证一致性的场景很重要。StandardCopyOption.ATOMIC_MOVE 可以让移动操作具备原子性,但并不是所有文件系统都支持,不支持时会抛出 AtomicMoveNotSupportedException

示例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;

public class MoveDemo {
    public static void main(String[] args) {
        Path source = Paths.get("/tmp/source.txt");
        Path target = Paths.get("/tmp/archive/source-renamed.txt");

        try {
            if (Files.notExists(target.getParent())) {
                Files.createDirectories(target.getParent());
            }
            Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("移动成功: " + target);
        } catch (IOException e) {
            System.out.println("移动失败: " + e.getMessage());
        }
    }
}

需要注意的是,移动和复制在处理符号链接时语义不同。move() 移动的是链接本身还是链接所指向的目标,取决于具体实现和传递给它的选项。对符号链接场景敏感的项目,应当仔细阅读相关文档并做充分测试。

4.5 文件内容的读写

这是 Files 最常用的功能域,也是它相对传统 IO 提升最明显的地方之一。传统方式下,读取一个文件往往需要写好几行样板代码:创建 FileInputStream、包装 BufferedReader、循环读取、关闭流,还要处理各种异常。Files 把常见操作浓缩成了一行调用。

4.5.1 一次性读取全部内容

对于小文件或者需要一次性拿到完整内容的场景,可以这样写:

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;

public class ReadAllDemo {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("/tmp/source.txt");
        Files.writeString(path, "第一行\n第二行\n第三行", StandardCharsets.UTF_8);

        // 读取全部字节
        byte[] bytes = Files.readAllBytes(path);
        System.out.println("字节数: " + bytes.length);

        // 读取全部字符串(JDK 11+)
        String content = Files.readString(path, StandardCharsets.UTF_8);
        System.out.println("文件内容:");
        System.out.println(content);

        // 按行读取全部内容
        List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
        System.out.println("总行数: " + lines.size());
        lines.forEach(System.out::println);
    }
}

其中 readString() 是从 JDK 11 开始引入的便捷方法。如果项目还运行在 JDK 8 或更早版本,可以使用 new String(Files.readAllBytes(path), StandardCharsets.UTF_8) 替代。

需要提醒的是,`readAllBytes()` 和 `readAllLines()` 都会把整个文件加载进内存。对于几百 MB 甚至更大的文件,这种方式可能导致 OutOfMemoryError。大文件应该使用流式读取。

4.5.2 一次性写入内容

写入同样简洁:

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;

public class WriteAllDemo {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("/tmp/write-demo.txt");

        // 写入字符串(JDK 11+)
        Files.writeString(path, "Hello NIO.2\n你好 NIO.2", StandardCharsets.UTF_8);

        // 写入字节数组
        byte[] data = "another line\n".getBytes(StandardCharsets.UTF_8);
        Files.write(path, data);

        // 写入多行内容
        List<String> lines = Arrays.asList("line1", "line2", "line3");
        Files.write(path, lines, StandardCharsets.UTF_8);
    }
}

以上三个写入方法都会覆盖已有内容。如果希望以追加方式写入,需要传入 StandardOpenOption.APPEND,这部分会在后面的文件流章节展开。

4.5.3 使用 BufferedReader 和 BufferedWriter

对于大型文本文件的分行处理,使用 BufferedReaderBufferedWriter 是更合适的方式。传统上需要手动创建 FileReader,而 NIO.2 通过 Files.newBufferedReader()Files.newBufferedWriter() 让过程更清爽:

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class BufferedReadWriteDemo {
    public static void main(String[] args) {
        Path source = Paths.get("/tmp/read-me.txt");
        Path target = Paths.get("/tmp/read-me-upper.txt");

        try {
            Files.writeString(source, "java nio\npath and files\n", StandardCharsets.UTF_8);

            try (BufferedReader reader = Files.newBufferedReader(source, StandardCharsets.UTF_8);
                 BufferedWriter writer = Files.newBufferedWriter(
                         target,
                         StandardCharsets.UTF_8,
                         StandardOpenOption.CREATE,
                         StandardOpenOption.TRUNCATE_EXISTING)) {

                String line;
                while ((line = reader.readLine()) != null) {
                    String upper = line.toUpperCase();
                    writer.write(upper);
                    writer.newLine();
                }
            }
            System.out.println("逐行处理完毕: " + target);
        } catch (IOException e) {
            System.out.println("处理失败: " + e.getMessage());
        }
    }
}

注意这里使用了 `try-with-resources`,它会自动关闭 ReaderWriter,省去了繁琐的 finally 块。这是一个与 Files 配合使用的好习惯。

4.5.4 使用输入输出流

当需要处理二进制文件,或者需要完全控制读写方式时,可以使用 Files.newInputStream()Files.newOutputStream()。示例:

import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class StreamDemo {
    public static void main(String[] args) {
        Path source = Paths.get("/tmp/stream-source.bin");
        Path target = Paths.get("/tmp/stream-target.bin");

        try {
            byte[] data = {0x48, 0x65, 0x6C, 0x6C, 0x6F};
            Files.write(source, data);

            try (InputStream in = Files.newInputStream(source);
                 OutputStream out = Files.newOutputStream(
                         target,
                         StandardOpenOption.CREATE,
                         StandardOpenOption.WRITE)) {

                byte[] buffer = new byte[1024];
                int length;
                while ((length = in.read(buffer)) != -1) {
                    out.write(buffer, 0, length);
                }
            }
            System.out.println("流式复制成功: " + target);
        } catch (IOException e) {
            System.out.println("流式处理失败: " + e.getMessage());
        }
    }
}
4.5.5 StandardOpenOption 详解

在读写文件时,StandardOpenOption 枚举控制着打开方式,理解各个选项的语义非常重要:

选项说明
READ以读取方式打开
WRITE以写入方式打开
APPEND写入时追加到文件末尾
CREATE文件不存在时创建,已有则不创建
CREATE_NEW文件必须不存在才创建,已存在则抛异常
TRUNCATE_EXISTING清空已存在文件的内容
DELETE_ON_CLOSE流关闭时删除文件
SPARSE提示创建稀疏文件,适合大文件占位场景
SYNC每次写入都同步到存储设备
DSYNC只同步文件内容,不同步元数据

一个经典的坑是:只指定 CREATE 而忘记指定 WRITE 时,打开的输出流并不具备写权限。另外,CREATE_NEW 非常适合作为并发下的“锁文件”机制——如果文件已存在就说明有其它进程在运行,从而阻止重复任务。

4.6 文件属性管理

4.6.1 基础属性

文件的基础属性包括大小、创建时间、最后修改时间、最后访问时间等。Files 提供了一组便捷方法:

  • Files.size(Path path):获取文件大小,单位是字节。
  • Files.getLastModifiedTime(Path path, LinkOption... options):获取最后修改时间。
  • Files.setLastModifiedTime(Path path, FileTime time):设置最后修改时间。

示例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileTime;

public class BasicAttrDemo {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("/tmp/source.txt");
        Files.writeString(path, "hello nio");

        System.out.println("文件大小: " + Files.size(path) + " 字节");

        FileTime lastModified = Files.getLastModifiedTime(path);
        System.out.println("最后修改时间: " + lastModified);

        FileTime now = FileTime.fromMillis(System.currentTimeMillis());
        Files.setLastModifiedTime(path, now);
        System.out.println("更新后的修改时间: " + Files.getLastModifiedTime(path));
    }
}
4.6.2 批量读取属性

如果希望一次性获取多个属性,可以使用 Files.readAttributes()。它返回一个视图对象,不同平台有不同实现。最通用的是 BasicFileAttributes

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;

public class BatchAttrDemo {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("/tmp/source.txt");

        BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);

        System.out.println("创建时间: " + attrs.creationTime());
        System.out.println("最后修改时间: " + attrs.lastModifiedTime());
        System.out.println("最后访问时间: " + attrs.lastAccessTime());
        System.out.println("是否目录: " + attrs.isDirectory());
        System.out.println("是否常规文件: " + attrs.isRegularFile());
        System.out.println("是否符号链接: " + attrs.isSymbolicLink());
        System.out.println("大小: " + attrs.size());
        System.out.println("文件标识: " + attrs.fileKey());
    }
}

一次 readAttributes() 调用会通过单次系统调用读取全部属性,在需要频繁获取元数据的场景下,性能明显优于逐个调用。

4.6.3 平台特有属性视图

除了通用的 BasicFileAttributes,NIO.2 还提供了针对操作系统的属性视图:

  • DosFileAttributes:Windows 特有,可读取“只读、隐藏、归档、系统”等属性。
  • PosixFileAttributes:Unix/Linux 特有,可读取文件所有者、组、权限位等。

以 POSIX 权限为例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Set;

public class PosixAttrDemo {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("/tmp/source.txt");

        try {
            PosixFileAttributes attrs = Files.readAttributes(path, PosixFileAttributes.class);
            System.out.println("所有者: " + attrs.owner());
            System.out.println("组: " + attrs.group());

            Set<PosixFilePermission> perms = attrs.permissions();
            System.out.println("权限: " + perms);
        } catch (UnsupportedOperationException e) {
            System.out.println("当前系统不支持 POSIX 属性视图");
        }
    }
}

在 Windows 上运行 POSIX 属性相关代码会抛出 UnsupportedOperationException,因此在编写跨平台程序时需要做好降级处理。

4.6.4 设置属性

可以通过 Files.setAttribute() 设置指定视图下的属性,也可以通过 Files.setPosixFilePermissions() 设置 POSIX 权限。示例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermission;
import java.util.HashSet;
import java.util.Set;

public class SetAttrDemo {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("/tmp/source.txt");

        Set<PosixFilePermission> perms = new HashSet<>();
        perms.add(PosixFilePermission.OWNER_READ);
        perms.add(PosixFilePermission.OWNER_WRITE);
        perms.add(PosixFilePermission.GROUP_READ);
        perms.add(PosixFilePermission.OTHERS_READ);

        try {
            Files.setPosixFilePermissions(path, perms);
            System.out.println("权限设置成功:r w - r - - r - -");

            Set<PosixFilePermission> actual = Files.getPosixFilePermissions(path);
            System.out.println("实际权限: " + actual);
        } catch (UnsupportedOperationException e) {
            System.out.println("当前系统不支持 POSIX 权限操作");
        }
    }
}

权限管理在多用户系统、安全审计等场景中非常重要。与手工执行 shell 命令相比,通过 API 修改权限可以避免命令注入风险,也更易于跨平台维护。

4.7 符号链接与硬链接

符号链接(Symbolic Link)是指向另一个文件或目录的特殊文件。在许多服务器的部署目录中,符号链接被广泛用于“当前版本”指向“具体版本目录”等场景。NIO.2 提供了创建和识别符号链接的能力:

  • Files.createSymbolicLink(Path link, Path target, FileAttribute<?>... attrs):创建符号链接。
  • Files.isSymbolicLink(Path path):判断是否为符号链接。
  • Files.readSymbolicLink(Path link):读取符号链接指向的目标。

示例:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class SymbolicLinkDemo {
    public static void main(String[] args) throws Exception {
        Path real = Paths.get("/tmp/real-file.txt");
        Files.writeString(real, "real content");

        Path link = Paths.get("/tmp/sym-link.txt");
        try {
            Files.createSymbolicLink(link, real);
            System.out.println("符号链接创建成功");

            System.out.println("是否符号链接: " + Files.isSymbolicLink(link));
            Path target = Files.readSymbolicLink(link);
            System.out.println("链接目标: " + target);
        } catch (UnsupportedOperationException e) {
            System.out.println("当前系统不支持符号链接");
        }
    }
}

硬链接(Hard Link)是共享同一底层 inode 的目录项。创建硬链接使用 Files.createLink()

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class HardLinkDemo {
    public static void main(String[] args) throws Exception {
        Path existing = Paths.get("/tmp/real-file.txt");
        Path hardLink = Paths.get("/tmp/hard-link.txt");

        Files.createLink(hardLink, existing);
        System.out.println("硬链接创建成功");
        System.out.println("两个路径大小一致: " + (Files.size(existing) == Files.size(hardLink)));
    }
}

需要留意,硬链接通常不允许跨文件系统创建,且不能用于目录。另外大多数平台也不允许为目录创建硬链接,相关需求应优先选择符号链接。

4.8 目录遍历与文件查找

4.8.1 列出目录项

传统上使用 File.listFiles() 获取目录内容,但该方法一次性返回所有子项,缺少灵活性。NIO.2 提供了 Files.newDirectoryStream(),它返回一个 DirectoryStream,实现按需访问:

import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class DirectoryListDemo {
    public static void main(String[] args) throws IOException {
        Path dir = Paths.get("/tmp");

        try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
            for (Path entry : stream) {
                System.out.println(entry.getFileName());
            }
        }
    }
}

还支持通配符过滤,例如只列出 .txt 文件:

String pattern = "*.txt";
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, pattern)) {
    for (Path entry : stream) {
        System.out.println(entry.getFileName());
    }
}

通配符语法支持 *(匹配任意字符序列)、?(匹配单个字符)等常见模式,但支持的规则比正则表达式简单。

4.8.2 递归遍历目录树

Files.walk() 是递归遍历目录树的利器。它返回一个惰性的 Stream<Path>,可以按深度优先或广度优先遍历。示例:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class WalkDemo {
    public static void main(String[] args) {
        Path root = Paths.get("/tmp/chapter4");

        try (Stream<Path> paths = Files.walk(root)) {
            paths.forEach(System.out::println);
        } catch (IOException e) {
            System.out.println("目录遍历失败: " + e.getMessage());
        }
    }
}

由于 Files.walk() 返回的是流,必须在 try-with-resources 中关闭,否则可能导致底层文件句柄泄漏。此外,递归遍历时要特别注意符号链接循环,避免陷入死循环。可以通过参数控制最大深度:Files.walk(root, 3) 只遍历到第 3 层。

4.8.3 基于条件查找文件

Files.find()walk() 的基础上增加了谓词过滤,适合快速定位符合条件的文件。例如查找所有大于 1KB 的 .log 文件:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class FindDemo {
    public static void main(String[] args) {
        Path root = Paths.get("/tmp");

        try (Stream<Path> paths = Files.find(
                root,
                5,
                (path, attrs) -> attrs.isRegularFile()
                        && path.getFileName().toString().endsWith(".log")
                        && attrs.size() > 1024)) {

            paths.forEach(p -> System.out.println("找到文件: " + p));
        } catch (IOException e) {
            System.out.println("查找失败: " + e.getMessage());
        }
    }
}
4.8.4 递归删除非空目录

前面提到 Files.delete() 无法删除非空目录。结合 walk() 可以优雅地解决:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.stream.Stream;

public class DeleteTreeDemo {
    public static void main(String[] args) {
        Path dir = Paths.get("/tmp/chapter4");

        try (Stream<Path> paths = Files.walk(dir)) {
            paths.sorted(Comparator.reverseOrder())
                 .forEach(path -> {
                     try {
                         Files.deleteIfExists(path);
                     } catch (IOException e) {
                         System.out.println("删除失败: " + path + " - " + e.getMessage());
                     }
                 });
            System.out.println("目录删除完成: " + dir);
        } catch (IOException e) {
            System.out.println("目录遍历失败: " + e.getMessage());
        }
    }
}

核心思路是先递归遍历包含根目录在内的所有路径,然后按“从深到浅”的顺序删除。因为 walk() 默认按深度优先顺序返回,所以反序排序后就可以保证子项先于父目录被删除。

4.9 文件监视服务 WatchService

4.9.1 WatchService 的工作原理

文件监视是 NIO.2 的一大亮点。它基于操作系统底层的事件通知机制,可以高效地监听指定目录下文件的创建、修改和删除事件,从而避免了传统轮询方式的资源浪费。核心类为 java.nio.file.WatchService

基本步骤如下:

  1. 通过 FileSystems.getDefault().newWatchService() 创建 WatchService
  2. 在需要监视的目录上注册感兴趣的事件,例如 ENTRY_CREATEENTRY_MODIFYENTRY_DELETE
  3. 调用 watchService.take() 阻塞等待事件。
  4. 处理事件,并调用 watchKey.reset() 重新注册以接收后续事件。
4.9.2 完整示例
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;

public class WatchServiceDemo {
    public static void main(String[] args) throws Exception {
        Path dir = Paths.get("/tmp/watch-demo");
        Files.createDirectories(dir);

        try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
            dir.register(
                    watchService,
                    StandardWatchEventKinds.ENTRY_CREATE,
                    StandardWatchEventKinds.ENTRY_MODIFY,
                    StandardWatchEventKinds.ENTRY_DELETE);

            System.out.println("开始监视目录: " + dir);

            while (true) {
                WatchKey key = watchService.take();
                for (WatchEvent<?> event : key.pollEvents()) {
                    WatchEvent.Kind<?> kind = event.kind();

                    if (kind == StandardWatchEventKinds.OVERFLOW) {
                        System.out.println("事件溢出,部分事件可能丢失");
                        continue;
                    }

                    Path fileName = (Path) event.context();
                    System.out.println("事件类型: " + kind + " | 文件: " + fileName);
                }

                boolean valid = key.reset();
                if (!valid) {
                    System.out.println("目录被删除,监视结束");
                    break;
                }
            }
        }
    }
}

这段程序会持续运行,直到手动停止或者被监视的目录被删除。实际应用中,通常会把它放入独立的线程,并结合任务队列做后续处理。

4.9.3 WatchService 的局限与对策

WatchService 虽然好用,但也有一些需要注意的地方:

  • 只能监视目录,不能直接监视单个文件。通常的变通方法是监视文件所在的父目录,再过滤出目标文件的事件。
  • 无法递归监视子目录。如果希望监视整棵目录树,必须手动为每个子目录注册,并在新目录创建事件发生时动态注册。
  • 事件的原子性无法保证。某些系统可能将一次写操作拆分为多个事件,或者当事件量过大时产生 OVERFLOW
  • 跨平台差异较大。Linux 下基于 inotify,Windows 下基于 ReadDirectoryChangesW,行为细节有所不同。

对于简单的配置热加载、文件上传目录监听等场景,WatchService 是合适的选择。如果需求极为复杂,也可以考虑 Apache Commons IO 的 FileAlterationMonitor 或引入 JNotify 等第三方库,但在多数情况下手写 WatchService 包装已经足够。

4.10 其他实用方法

4.10.1 文件行流式处理

Files.lines() 可以把文件按行转换为 Stream<String>,从而与 Lambda、Stream API 无缝协作。下面统计一个大文本中包含关键词的行数:

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class LinesDemo {
    public static void main(String[] args) throws Exception {
        Path logFile = Paths.get("/tmp/app.log");
        Files.writeString(logFile,
                "INFO  start\nERROR fail\nINFO  ok\nERROR timeout\n",
                StandardCharsets.UTF_8);

        try (Stream<String> lines = Files.lines(logFile, StandardCharsets.UTF_8)) {
            long errorCount = lines.filter(line -> line.contains("ERROR")).count();
            System.out.println("包含 ERROR 的行数: " + errorCount);
        }
    }
}

Files.lines() 是惰性读取的,因此比 `readAllLines()` 更适合处理大文件。但同样必须在 try-with-resources 中使用,确保底层流被关闭。

4.10.2 读取文件为字节通道

如果需要对文件进行随机读写、内存映射等高级操作,可以借助 SeekableByteChannelFileChannel

import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class ChannelDemo {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("/tmp/channel-demo.bin");

        try (FileChannel channel = FileChannel.open(
                path,
                StandardOpenOption.CREATE,
                StandardOpenOption.READ,
                StandardOpenOption.WRITE)) {

            ByteBuffer buffer = ByteBuffer.wrap("hello channel".getBytes());
            channel.write(buffer);
            System.out.println("通道写入完成,文件大小: " + channel.size());
        }
    }
}

通道和缓冲区是 NIO 高性能 IO 的基石,文件通道特别适合大文件复制、零拷贝传输等场景。本文不展开讨论,但值得了解其入口。

4.10.3 文件分区与磁盘空间

部署应用时,检查磁盘剩余空间是个常见需求。NIO.2 通过 FileStore 提供了相关能力:

import java.nio.file.Files;
import java.nio.file.FileStore;
import java.nio.file.Path;
import java.nio.file.Paths;

public class DiskSpaceDemo {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get("/tmp");

        FileStore store = Files.getFileStore(path);
        System.out.println("文件系统名称: " + store.name());
        System.out.println("总空间: " + store.getTotalSpace() / (1024 * 1024) + " MB");
        System.out.println("可用空间: " + store.getUsableSpace() / (1024 * 1024) + " MB");
        System.out.println("已使用空间: " +
                (store.getTotalSpace() - store.getUnallocatedSpace()) / (1024 * 1024) + " MB");
    }
}
4.10.4 文件 MIME 类型探测

Files.probeContentType() 可以探测文件的 MIME 类型。它依赖平台的文件类型识别机制,在 Web 上传下载、内容安全校验等场景中非常有用:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class ContentTypeDemo {
    public static void main(String[] args) throws Exception {
        Path html = Paths.get("/tmp/index.html");
        Files.writeString(html, "<html></html>");

        String contentType = Files.probeContentType(html);
        System.out.println("探测到的 MIME 类型: " + contentType);
    }
}

需要注意的是,不同平台的探测结果可能不同:Windows 上可能返回 null,Linux 上可能返回 text/html。如果业务对一致性要求很高,建议结合文件名后缀或魔数自行实现兜底策略。

5. 综合实战:构建一个文件整理工具

5.1 需求描述

理论知识讲了不少,下面通过一个完整的实战案例,把 PathPathsFiles 串起来。我们要实现一个“文件整理工具”,具有以下功能:

  • 扫描指定目录下的所有常规文件。
  • 按照文件扩展名分类,把相同扩展名的文件移动到对应子目录。
  • 支持保留或忽略隐藏文件。
  • 支持生成整理报告。

这个案例涵盖了路径解析、目录遍历、文件移动、属性判断和批量操作的典型组合,能够帮助读者把零散的知识点拼装成实际生产力。

5.2 代码实现

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;

public class FileOrganizer {

    private final List<String> report = new ArrayList<>();

    public static void main(String[] args) {
        if (args.length < 1) {
            System.out.println("用法: java FileOrganizer <要整理的目录>");
            return;
        }

        Path root = Paths.get(args[0]);
        if (Files.notExists(root) || !Files.isDirectory(root)) {
            System.out.println("目录不存在或不是有效目录: " + root);
            return;
        }

        FileOrganizer organizer = new FileOrganizer();
        organizer.organize(root);
    }

    public void organize(Path root) {
        try (Stream<Path> files = Files.list(root)) {
            files.filter(Files::isRegularFile)
                 .filter(this::notHidden)
                 .forEach(this::moveToCategoryDir);
        } catch (IOException e) {
            System.out.println("扫描目录失败: " + e.getMessage());
            return;
        }

        report.forEach(System.out::println);
        System.out.println("整理完成,共处理 " + report.size() + " 个文件。");
    }

    private boolean notHidden(Path path) {
        return !path.getFileName().toString().startsWith(".");
    }

    private void moveToCategoryDir(Path file) {
        String fileName = file.getFileName().toString();
        String extension = getExtension(fileName);
        if (extension.isEmpty()) {
            extension = "no-extension";
        }

        Path categoryDir = file.getParent().resolve(extension);
        try {
            if (Files.notExists(categoryDir)) {
                Files.createDirectories(categoryDir);
            }

            Path target = categoryDir.resolve(fileName);

            if (Files.exists(target)) {
                target = resolveNameConflict(categoryDir, fileName);
            }

            Files.move(file, target, StandardCopyOption.REPLACE_EXISTING);
            report.add(fileName + " -> " + extension + "/" + target.getFileName());
        } catch (IOException e) {
            report.add("[失败] " + fileName + " - " + e.getMessage());
        }
    }

    private String getExtension(String fileName) {
        int dotIndex = fileName.lastIndexOf('.');
        if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
            return fileName.substring(dotIndex + 1).toLowerCase();
        }
        return "";
    }

    private Path resolveNameConflict(Path dir, String fileName) {
        String base = fileName;
        String ext = getExtension(fileName);

        int index = 1;
        Path candidate;
        do {
            String suffix = ext.isEmpty() ? "" : "." + ext;
            String baseName = fileName.substring(0, fileName.length() - suffix.length());
            String newName = baseName + "(" + index + ")" + suffix;
            candidate = dir.resolve(newName);
            index++;
        } while (Files.exists(candidate));

        return candidate;
    }

    @SuppressWarnings("unused")
    private static void sortDemo() {
        // 按文件大小排序示例
        Comparator<Path> bySize = Comparator.comparingLong(path -> {
            try {
                return Files.size(path);
            } catch (IOException e) {
                return 0L;
            }
        });
        System.out.println("排序器已创建: " + bySize);
    }
}

5.3 实现要点解析

这个工具虽然代码量不大,但覆盖了多个关键知识点:

  • 路径解析:使用 file.getParent().resolve(extension) 动态构造分类目录,避免了手工拼接分隔符。
  • 目录遍历:使用 Files.list() 获取目录下的直接子项,并结合 Stream 过滤出常规文件。
  • 存在性与隐藏文件判断:通过 Files::isRegularFile 和自定义的 notHidden() 方法排除不需要处理的文件。
  • 目录创建:使用 Files.createDirectories() 幂等创建分类目录。
  • 文件移动:使用 Files.move() 并传入 REPLACE_EXISTING,同时做了重名冲突处理。
  • 报告输出:把处理结果收集到 List 中,最后统一打印,便于后续扩展为日志或者报表。

这个案例可以根据实际业务需求进一步扩展,例如增加“按月份归档”“按文件大小过滤”“只移动超过 N 天未修改的文件”等规则,核心骨架基本不变。

6. 常见问题与避坑指南

6.1 路径分隔符与平台差异

无论开发环境是 Windows 还是 Linux,都应该尽量避免硬编码分隔符。使用 Paths.get()path.resolve() 等 API 来构造路径,才能写出可移植的代码。同时,在测试文件路径相关逻辑时,建议覆盖 Windows 与 Unix 两种风格,或者至少保持对路径大小写敏感性的关注。

6.2 未关闭的流与句柄泄漏

Files.walk()Files.list()Files.lines() 等返回 StreamDirectoryStream 的方法,内部都持有底层文件句柄。如果不及时关闭,高并发或批处理场景下可能导致“Too many open files”错误。养成在 try-with-resources 中使用的习惯。

6.3 检查 exists 后直接使用的竞态

Files.exists()Files.createFile() 的写法,在多进程或并发环境下存在竞态:两个线程可能同时看到文件不存在,然后都去创建,最终一个成功一个抛异常。更稳妥的方式是直接用 CREATE_NEW,通过异常来识别文件已存在。

6.4 大文件一次性读取的内存风险

readAllBytes()readAllLines()readString() 对于小文件非常方便,但会一次性把内容加载进内存。处理日志、数据导出等大文件时,应当选择 Files.lines()newBufferedReader()InputStream 流式处理。

6.5 符号链接导致的循环遍历

递归遍历目录树时,如果存在指向父目录的符号链接,可能导致无限循环。可以通过 Files.walk() 的深度参数限制层级,或者在递归前自行用 Files.isSymbolicLink() 判断并跳过链接。

6.6 文件权限与跨平台兼容

POSIX 权限、DOS 属性等平台特有功能在另一平台上会抛出 UnsupportedOperationException。如果程序需要跨平台运行,应该对这些特性的调用进行能力探测或异常兜底。

6.7 删除目录前未清空内容

初学者最常犯的错误之一是直接对非空目录调用 Files.delete(),结果收到 DirectoryNotEmptyException。需要牢记:delete() 只能删除空目录,删除非空目录要使用递归方式,或者借助 Files.walk() 反序删除。

7. 性能优化建议

7.1 选择合适的读取方式

文件大小和业务需求不同,最佳读取方式也不同。一般来说:

  • 小于 1MB 的配置文件、模板文件:readAllLines()readString() 即可,代码最简洁。
  • 几 MB 到几百 MB 的日志、数据文件:使用 BufferedReader 逐行处理,避免内存峰值过高。
  • 更大的二进制文件:使用 FileChannelInputStream 配合缓冲区,必要时使用内存映射。

7.2 批量复制与传输优化

对于普通文件复制,Files.copy() 内部已经做了较为合理的缓冲处理,不需要额外包装 BufferedInputStream。但如果是网络传输或跨文件系统转移,建议使用 FileChannel.transferTo()transferFrom(),利用零拷贝机制降低 CPU 开销。

7.3 避免频繁的系统调用

每个文件操作最终都会转变为系统调用。在批量处理大量小文件时,逐文件调用 Files.exists()Files.readAttributes() 的代价可能很高。可以先用 Files.walk() 一次性遍历,再在内存中筛选处理,减少不必要的 IO。

7.4 善用 FileChannel 与内存映射

对于需要随机访问大文件的场景,可以尝试 FileChannel.map() 创建 MappedByteBuffer,让操作系统把文件映射到虚拟内存,从而获得接近内存读写的性能。但内存映射也有页面缓存管理的成本,需要结合具体场景评估。

8. 与 Spring Boot、云原生场景的结合

8.1 Spring Boot 中的资源加载

现代 Web 应用中,文件上传、模板加载、静态资源读取等需求非常常见。Spring Boot 对资源抽象有自己的一套设计,最核心的是 org.springframework.core.io.Resource。在 FileSystemResource 的底层,往往可以看到 PathFiles 的影子。

例如,在 Spring Boot 的配置类中把一个上传目录暴露为资源:

import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;

import java.nio.file.Path;
import java.nio.file.Paths;

public class UploadResourceConfig {

    public Resource uploadDirectory() {
        Path uploadPath = Paths.get("/data/uploads");
        return new FileSystemResource(uploadPath);
    }
}

Spring 对 Path 的支持很完善,FileSystemResource 可以直接通过 Path 构造,因此日常开发中可以放心使用 NIO.2 的路径抽象。

8.2 云原生环境中的临时文件与容器卷

在容器化部署中,文件系统生命周期与容器紧密相关。临时文件应当写入 /tmp 或挂载的临时卷,避免写入不可持久化的镜像层。NIO.2 的 Files.createTempFile() 默认写入系统临时目录,符合容器安全最佳实践。同时,检查可用磁盘空间时,应使用 FileStore.getUsableSpace() 检查实际挂载卷,而不是仅依赖 df 命令。

8.3 配置热加载与文件监听

很多云原生应用支持“修改配置后自动生效”。实现这一功能的经典方案就是 WatchService。例如监听配置目录的变化,触发 Spring 容器刷新配置:

import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;

public class ConfigWatcher {

    public void watch(Path configDir) throws Exception {
        try (WatchService service = FileSystems.getDefault().newWatchService()) {
            configDir.register(service, StandardWatchEventKinds.ENTRY_MODIFY);

            while (true) {
                WatchKey key = service.take();
                // 通知外部监听器配置已变化
                onConfigChanged(configDir);
                key.reset();
            }
        }
    }

    private void onConfigChanged(Path configDir) {
        System.out.println("配置目录发生变化: " + configDir);
        // 实际项目中可触发 Spring Environment 刷新逻辑
    }
}

此类模式在分布式配置中心、网关规则热更新等场景中应用广泛。

9. 总结与学习路线

9.1 核心知识回顾

至此,我们已经系统地走完了 PathPathsFiles 的主要能力。回顾一下核心结论:

  • Path 是平台无关的路径抽象,拥有丰富的组合与分解能力,替代了传统字符串拼接。
  • PathsPath 的轻量工厂,负责用最快的方式从字符串或 URI 构造路径。
  • Files 是文件操作的一站式入口,提供了检查、创建、删除、复制、移动、读写、属性、遍历、监视等全方位能力。
  • 与传统 File 相比,NIO.2 的错误信息更明确,符号链接、文件属性、目录监视等能力更强大。

在实际项目中,应该优先使用 NIO.2 编写新的文件处理代码,仅在必须兼容老旧 API 时,才通过 toPath()toFile() 进行转换。

9.2 推荐学习路线

如果你希望继续深入,可以参考以下路线:

  • 第一步:把本文中的所有代码示例在本地跑一遍,尤其关注异常处理分支和行为差异。
  • 第二步:阅读官方 JavaDoc,重点查看 java.nio.file 包中对 PathFiles 的完整方法列表。
  • 第三步:研究 FileChannel 与内存映射,理解零拷贝和大文件处理原理。
  • 第四步:深入 WatchService,实现一个真正可用的配置热加载或日志归档服务。
  • 第五步:结合 Spring Boot、Kubernetes 等生态,把文件操作能力融入真实业务系统。

9.3 写在最后

文件操作看似基础,却往往隐藏着大量平台差异、资源管理和并发安全细节。掌握了 PathPathsFiles,不仅能写出更简洁的代码,更能构建出健壮、可移植、易维护的文件处理方案。希望你通过本文真正建立起对 Java NIO.2 的系统认知,并在日常开发中逐渐用新 API 替换那些陈旧的 File 样板代码。

文件系统是操作系统世界的一个投影,而 NIO.2 则是 Java 用来观察、理解和改造这个投影的透镜。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐