MarcoBehler 2020-12-09
你可以使用本指南学习如何通过 Java 的 Path API 操作文件,包括读写文件、监听目录以及使用内存文件系统。
Java 的文件 API
Java 提供了两种文件 API:
- 原始的
java.io.FileAPI,自 Java 1.0(1996 年)起可用。 - 较新的
java.nio.file.PathAPI,自 Java 1.7(2011 年)起可用。
File API 与 Path API 有何区别?
旧的 File API 在大量老项目、框架和库中被广泛使用。尽管它年代久远,但并未被弃用(将来也不太可能被弃用),你仍然可以在任何最新版 Java 中使用它。
然而,java.nio.file.Path 能完成 java.io.File 所能做的所有事情,并且通常做得更好,还提供了更多功能。例如:
- 文件特性支持:新类支持符号链接、完整的文件属性和元数据(如
PosixFileAttributes)、ACL 等。 - 更好的使用体验:例如删除文件时,你会收到带有明确错误信息的异常(如“文件不存在”、“文件被锁定”等),而不是一个简单的布尔值
false。 - 解耦设计:支持内存文件系统(我们稍后会讲到)。
(有关两个 API 差异的完整列表,请参阅这篇文章:Oracle 官方文档 - NIO 文件 API)
我该使用哪个文件 API?
基于上述原因,如果你正在启动一个新的 Java 项目,强烈建议使用 Paths API 而不是 File API。(虽然 file 读起来确实比 path 更顺口,不是吗?)
因此,本文将只关注 Paths API。
Paths API
要在 Java 中操作文件,首先你需要一个对文件的引用(这并不意外!)。正如上面提到的,从 Java 7 开始,你应该使用 Paths API 来引用文件,一切从构建 Path 对象开始。
来看一些代码:
public static void main(String[] args) throws URISyntaxException {
// Java 11+:使用 Path.of()
Path path = Path.of("c:\\dev\\licenses\\windows\\readme.txt");
System.out.println(path);
path = Path.of("c:/dev/licenses/windows/readme.txt");
System.out.println(path);
path = Path.of("c:", "dev", "licenses", "windows", "readme.txt");
System.out.println(path);
path = Path.of("c:", "dev", "licenses", "windows").resolve("readme.txt"); // resolve == getChild()
System.out.println(path);
path = Path.of(new URI("file:///c:/dev/licenses/windows/readme.txt"));
System.out.println(path);
// Java < 11 的等效写法:Paths.get()
path = Paths.get("c:/dev/licenses/windows/readme.txt");
System.out.println(path);
}
逐行解析
Path path = Path.of("c:\\dev\\licenses\\windows\\readme.txt");
System.out.println(path);
path = Path.of("c:/dev/licenses/windows/readme.txt");
System.out.println(path);
从 Java 11 开始,应使用静态方法 Path.of 构造路径(稍后我们会介绍 Java 7–10 的等效方式)。
无论你在 Windows 上使用正斜杠 / 还是反斜杠 \,Path API 都足够智能,能够根据操作系统自动构造正确的路径。
因此,以上两行在 Windows 上运行时都会输出:
c:\dev\licenses\windows\readme.txt
c:\dev\licenses\windows\readme.txt
你还可以有更多构造路径的方式,不必把完整路径写成一个长字符串:
path = Path.of("c:", "dev", "licenses", "windows", "readme.txt");
System.out.println(path);
path = Path.of("c:", "dev", "licenses", "windows").resolve("readme.txt");
System.out.println(path);
你可以传入多个字符串参数给 Path.of,或者先构造父目录,再用 .resolve(child) 获取子文件(相当于 getChild())。
输出结果仍相同:
c:\dev\licenses\windows\readme.txt
c:\dev\licenses\windows\readme.txt
最后,你甚至可以传入 URI:
path = Path.of(new URI("file:///c:/dev/licenses/windows/readme.txt"));
System.out.println(path);
输出依然不变:
c:\dev\licenses\windows\readme.txt
重要提示
- 构造
Path对象或调用resolve并不意味着文件或目录实际存在。Path只是对潜在文件的引用,你需要单独验证其是否存在。 - Java 11 之前,
Path.of被称为Paths.get。如果你使用的是较旧的 Java 版本或需要向后兼容,就用Paths.get。从 Java 11 起,Paths.get内部其实调用了Path.of。
// Java < 11 等效写法
path = Paths.get("c:/dev/licenses/windows/readme.txt");
一旦你有了 Path 对象,就可以对它进行各种操作了。下一节我们将介绍具体能做什么。
常见文件操作
操作文件或路径时,你很可能会用到 java.nio.file.Files 类。它包含大量用于操作文件和目录的静态方法。
本节可作为快速备忘清单,标题已足够清晰。
如何检查文件是否存在
Path path = Path.of("c:\\dev\\licenses\\windows\\readme.txt");
boolean exists = Files.exists(path);
System.out.println("exists = " + exists);
检查文件或目录是否存在。还可指定额外参数来控制是否跟随符号链接(默认跟随)。
输出示例:
exists = true
如何获取文件的最后修改时间
Path path = Path.of("c:\\dev\\licenses\\windows\\readme.txt");
FileTime lastModifiedTime = Files.getLastModifiedTime(path);
System.out.println("lastModifiedTime = " + lastModifiedTime);
返回 FileTime 对象:
lastModifiedTime = 2020-05-20T08:41:30.905176Z
如何比较两个文件(Java 12+)
Path path = Path.of("c:\\dev\\licenses\\windows\\readme.txt");
long mismatchIndex = Files.mismatch(path, Paths.get("c:\\dev\\whatever.txt"));
System.out.println("mismatch = " + mismatchIndex);
Java 12 新增功能:比较两个文件的字节,返回第一个不匹配字节的位置;若完全相同则返回 -1L。
若两个文件完全不同,输出可能是:
mismatch = 0
如何获取文件所有者
Path path = Path.of("c:\\dev\\licenses\\windows\\readme.txt");
UserPrincipal owner = Files.getOwner(path);
System.out.println("owner = " + owner);
返回 UserPrincipal(继承自 Principal)。在 Windows 上会是 WindowsUserPrincipal,包含用户名和 SID:
owner = DESKTOP-168M0IF\marco_local (User)
如何创建临时文件
Path tempFile1 = Files.createTempFile("somePrefixOrNull", ".jpg");
System.out.println("tempFile1 = " + tempFile1);
Path tempFile2 = Files.createTempFile(path.getParent(), "somePrefixOrNull", ".jpg");
System.out.println("tempFile2 = " + tempFile2);
Path tmpDirectory = Files.createTempDirectory("prefix");
System.out.println("tmpDirectory = " + tmpDirectory);
说明:
createTempFile(prefix, suffix):前缀可为null,后缀即扩展名(若省略,默认为.tmp)。- 文件默认创建在系统的临时目录(如
C:\Users\...\AppData\Local\Temp)。 - 第二种方式允许指定自定义目录。
createTempDirectory(prefix)创建临时目录。
输出示例:
tempFile1 = C:\Users\marco\AppData\Local\Temp\somePrefixOrNull8747488053128491901.jpg
tempFile2 = c:\dev\licenses\windows\somePrefixOrNull11086918945318459411.jpg
tmpDirectory = C:\Users\marco\AppData\Local\Temp\prefix9583768274092262832
⚠️ 注意:临时文件不会自动删除!你必须显式删除它们,尤其是在单元测试或生产环境中。
如何创建普通文件和目录
Path newDirectory = Files.createDirectories(path.getParent().resolve("some/new/dir"));
System.out.println("newDirectory = " + newDirectory);
Path newFile = Files.createFile(newDirectory.resolve("emptyFile.txt"));
System.out.println("newFile = " + newFile);
注意:.resolve() 不会创建文件,只是返回一个指向(子)文件的引用。
输出示例:
newDirectory = c:\dev\licenses\windows\some\new\dir
newFile = c:\dev\licenses\windows\some\new\dir\emptyFile.txt
如何获取 POSIX 权限(仅限 Unix-like 系统)
Path path = Path.of("/home/user/file.txt");
try {
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
System.out.println("permissions = " + permissions);
} catch (UnsupportedOperationException e) {
System.err.println("看起来你不在 POSIX 文件系统上运行");
}
在 Linux 或 macOS 上输出类似:
[OWNER_WRITE, OWNER_READ, GROUP_WRITE, OTHERS_READ, ...]
写入与读取文件
如何将字符串写入文件
Path utfFile = Files.createTempFile("some", ".txt");
Files.writeString(utfFile, "this is my string ää öö üü"); // 默认 UTF-8
System.out.println("utfFile = " + utfFile);
Path iso88591File = Files.createTempFile("some", ".txt");
Files.writeString(iso88591File, "this is my string ää öö üü", StandardCharsets.ISO_8859_1);
System.out.println("iso88591File = " + iso88591File);
从 Java 11(确切说是 11.0.2 / 12.0,因早期版本有 bug)开始,推荐使用
Files.writeString。
如何将字节数组写入文件
Path anotherIso88591File = Files.createTempFile("some", ".txt");
Files.write(anotherIso88591File, "this is my string ää öö üü".getBytes(StandardCharsets.ISO_8859_1));
在 Java 11 之前,这是写入字符串的唯一方式。
写入文件时的选项
Files.writeString(anotherUtf8File, "content", StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
默认行为:自动创建文件,若已存在则截断。
若不想覆盖,可传入其他 OpenOption(如 CREATE_NEW 会在文件存在时抛异常)。
使用 Writer 和 OutputStream
try (BufferedWriter writer = Files.newBufferedWriter(utfFile)) {
// 使用 writer
}
try (OutputStream os = Files.newOutputStream(utfFile)) {
// 使用输出流
}
务必使用 Files 提供的方法创建流,而非手动构造。
如何从文件读取字符串
String s = Files.readString(utfFile); // 默认 UTF-8
s = Files.readString(utfFile, StandardCharsets.ISO_8859_1);
Java 11+ 推荐使用 Files.readString。
如何读取字节数组
byte[] bytes = Files.readAllBytes(utfFile);
String s = new String(bytes, StandardCharsets.UTF_8);
Java 11 之前这是读取字符串的常用方式。
使用 Reader 和 InputStream
try (BufferedReader reader = Files.newBufferedReader(utfFile)) {
// 使用 reader
}
try (InputStream is = Files.newInputStream(utfFile)) {
// 使用输入流
}
📌 温馨提醒:文件编码
务必显式指定编码!虽然 Java 11 的新方法默认使用 UTF-8(而非平台默认编码),但仍需注意编码一致性。
移动、删除与列出文件
如何移动文件
// ❌ 错误:不能直接移动到目录
Files.move(utfFile, Path.of("c:\\dev")); // 抛异常!
// ✅ 正确:目标必须包含完整文件名
Files.move(utfFile, Path.of("c:\\dev").resolve(utfFile.getFileName()));
移动选项
// 覆盖已存在的文件
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING);
// 原子移动(保证完整性)
Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE);
如何删除文件
try {
Files.delete(tmpDir); // 仅当目录为空时成功
} catch (DirectoryNotEmptyException e) {
e.printStackTrace();
}
⚠️ Files.delete 无法删除非空目录。
如何删除非空目录
try (Stream<Path> walk = Files.walk(tmpDir)) {
walk.sorted(Comparator.reverseOrder())
.forEach(path -> {
try {
Files.delete(path);
} catch (IOException e) {
e.printStackTrace();
}
});
}
原理:深度优先遍历,倒序删除(先删子文件,再删父目录)。
如何列出目录中的文件(非递归)
// 列出所有文件
try (var files = Files.list(tmpDirectory)) {
files.forEach(System.out::println);
}
// 使用 glob 模式(如 *.txt)
try (var files = Files.newDirectoryStream(tmpDirectory, "*.txt")) {
files.forEach(System.out::println);
}
⚠️ 必须用
try-with-resources关闭流,否则在 Windows 上会锁住目录!
如何递归列出所有文件
try (var files = Files.walk(tmpDirectory)) {
files.forEach(System.out::println);
}
同样需要关闭流。
绝对路径、相对路径与规范路径
相对路径
Path p = Paths.get("./src/main/java/../resources/some.properties");
System.out.println("p.isAbsolute() = " + p.isAbsolute()); // false
绝对路径
Path p2 = p.toAbsolutePath();
// 输出:C:\dev\java-file-article\.\src\main\java\..\resources\some.properties
// 注意:仍包含 . 和 ..
规范路径(标准化)
Path p3 = p.normalize().toAbsolutePath();
// 输出:C:\dev\java-file-article\src\main\resources\some.properties
这就是所谓的“规范路径”。
转为相对路径
Path base = Paths.get("C:\\dev\\java-file-article\\");
Path relativized = base.relativize(p3);
// 输出:src\main\resources\some.properties
监听文件与目录
Java WatchService(Java 7+)
低级别 API,依赖原生文件事件(Windows/Linux 支持,macOS 回退到轮询)。
WatchService watcher = FileSystems.getDefault().newWatchService();
Path dir = Path.of("c:\\someDir\\");
dir.register(watcher, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
for (;;) {
WatchKey key = watcher.take();
for (WatchEvent<?> event : key.pollEvents()) {
Path filename = ((WatchEvent<Path>) event).context();
Path changedFile = dir.resolve(filename);
// 处理文件
}
if (!key.reset()) break;
}
⚠️ 注意事项:
- 一次保存可能触发多个事件(内容修改 + 时间戳更新)。
- 编辑器(如 Notepad++)可能通过临时文件间接保存,导致多次事件。
- 建议加入去抖逻辑(如
Thread.sleep(100)后再处理)。
Apache Commons IO
更简单的 API,但基于轮询(定期扫描目录):
FileAlterationObserver observer = new FileAlterationObserver(folder);
FileAlterationMonitor monitor = new FileAlterationMonitor(5000); // 5秒轮询
observer.addListener(new FileAlterationListenerAdaptor() {
@Override
public void onFileCreate(File file) {
// 处理新建文件
}
});
monitor.start();
缺点:仅支持 java.io.File,不支持 Path。
内存文件系统
无需真实磁盘 I/O,非常适合测试。
Memory File System
try (FileSystem fs = MemoryFileSystemBuilder.newMacOs().build()) {
Path file = fs.getPath("/somefile.txt");
Files.writeString(file, "Hello World");
System.out.println(Files.readString(file));
}
JimFS(Google)
try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) {
Path file = fs.getPath("/tmp/somefile.txt");
Files.writeString(file, "Hello World");
System.out.println(Files.readString(file));
}
关键点
不要使用 Path.of() 或 Paths.get()!
因为它们内部调用的是 FileSystems.getDefault(),即真实文件系统。
正确做法:始终通过 fileSystem.getPath(...) 获取路径。
结语
现在你应该掌握了:
- 基本文件操作(读、写、列、移、删)
- 路径类型(相对/绝对/规范)
- 目录监听
- 内存文件系统用于测试