跳转至

文件操作

Java 程序经常需要和文件系统打交道:读写文件内容、创建目录、遍历文件树、复制移动文件……Java 先后提供了两套文件操作 API:

  • java.io.File(JDK 1.0)——老牌 API,功能有限,错误处理靠返回 boolean
  • java.nio.file(Java 7+)——现代 API,功能强大,通过异常报告错误,与 Stream API 无缝集成

本文你会学到

  • 📁 File 类的基本用法——旧 API 快速上手
  • Path 类——更优雅地表示文件路径
  • 🔧 Files 工具类——一行代码读写文件、遍历目录
  • 🗂️ 目录遍历与文件查找——walk()walkFileTree()PathMatcher
  • 🔄 FilePath 的对比与互转——新旧 API 如何共存
  • 👁️ WatchService——监控目录中的文件变化

📁 java.io.File——旧版文件 API

File 类是 Java 最早的文件系统抽象——它可以指向一个文件,也可以指向一个目录,但它本身**不负责读写数据**(读写是 IO 流的职责,详见「IO 流」)。

如何创建 File 对象?

创建 File 对象的三种方式
    /**
     * 创建 File 对象的三种方式
     */
    @Test
    void testCreateFileObject(@TempDir Path tempDir) {
        // 方式一:通过路径字符串创建
        File file1 = new File(tempDir + "/hello.txt");

        // 方式二:通过父目录字符串 + 子路径创建
        File file2 = new File(tempDir.toString(), "hello.txt");

        // 方式三:通过父 File 对象 + 子路径创建
        File parent = tempDir.toFile();
        File file3 = new File(parent, "hello.txt");

        // 三种方式指向同一个路径
        assertEquals(file1.getAbsolutePath(), file2.getAbsolutePath());
        assertEquals(file2.getAbsolutePath(), file3.getAbsolutePath());
    }

常用方法速查

方法 返回类型 说明
exists() boolean 文件或目录是否存在
isFile() boolean 是否为文件
isDirectory() boolean 是否为目录
getName() String 获取文件名
getAbsolutePath() String 获取绝对路径
createNewFile() boolean 创建新文件(父目录必须已存在)
mkdir() boolean 创建单级目录
mkdirs() boolean 创建多级目录(推荐)
delete() boolean 删除文件或**空**目录
listFiles() File[] 列出目录下的所有文件和子目录

文件路径有哪些写法?

路径类型 示例 说明
绝对路径 D:/test/hello.txt 从盘符或根目录开始的完整路径
相对路径 test/hello.txt 相对于项目根目录(IDEA 中是 Project 根目录,不是 Module 根目录)
类路径 通过 ClassLoader 获取 相对于编译后的 classes 目录

获取类路径的方式:

获取类路径根目录
    /**
     * 获取类路径资源
     */
    @Test
    void testClasspathResource() {
        // 获取类路径根目录的绝对路径
        String classpath = Thread.currentThread()
                .getContextClassLoader()
                .getResource("")
                .getPath();

        System.out.println("类路径根目录: " + classpath);
        assertNotNull(classpath, "类路径不应为空");
    }

File 有什么不足?

File 在 JDK 1.0 就存在了,随着时间推移,它的设计缺陷越来越明显:

问题 说明
❌ 错误只返回 boolean delete() 失败时不知道原因——是权限不够?还是文件不存在?
❌ 无法原子操作 没有 move() 方法,重命名不保证原子性
❌ 性能差 listFiles() 一次性返回所有结果,大目录下内存占用高
❌ 不支持符号链接 无法区分真实文件和符号链接
❌ 路径操作笨拙 拼接路径靠字符串拼接,容易出错

→ 这些问题催生了 Java 7 的 NIO.2 文件 API。

⚡ java.nio.file.Path——现代路径表示

Java 7 引入的 Path 接口是 File 的现代替代品。它**只表示路径**(文件或目录的地址),不关心文件是否存在——就像一个门牌号,不管房子在不在。

如何创建 Path 对象?

创建 Path 对象的多种方式
    /**
     * 创建 Path 对象的多种方式
     */
    @Test
    void testPathCreation() {
        // 方式一:Path.of()(Java 11+,推荐)
        Path p1 = Path.of("docs", "java", "index.md");

        // 方式二:Paths.get()(Java 7+,等价于 Path.of)
        Path p2 = Paths.get("docs", "java", "index.md");

        // 两种方式结果相同
        assertEquals(p1, p2);

        System.out.println("路径: " + p1);
        System.out.println("文件名: " + p1.getFileName());
        System.out.println("父路径: " + p1.getParent());
        System.out.println("路径片段数: " + p1.getNameCount());
    }

Path.of() vs Paths.get()

两者完全等价。Path.of() 是 Java 11 新增的语法糖,更简洁。Java 7~10 只能用 Paths.get()

Path 的路径操作

Path 提供了丰富的路径操作方法,告别字符串拼接:

Path 的路径拼接与解析
    /**
     * Path 的路径拼接与解析
     */
    @Test
    void testPathOperations() {
        Path base = Path.of("project", "src");

        // resolve():拼接子路径
        Path full = base.resolve("main").resolve("java");
        assertEquals(Path.of("project", "src", "main", "java"), full);

        // resolveSibling():替换最后一个片段(取兄弟路径)
        Path sibling = full.resolveSibling("resources");
        assertEquals(Path.of("project", "src", "main", "resources"), sibling);

        // relativize():计算两个路径的相对关系
        Path from = Path.of("project", "src");
        Path to = Path.of("project", "docs", "guide");
        Path relative = from.relativize(to);
        System.out.println("从 src 到 docs/guide 的相对路径: " + relative);

        // normalize():消除 . 和 .. 冗余片段
        Path messy = Path.of("project", "src", "..", "docs", ".", "guide");
        Path clean = messy.normalize();
        assertEquals(Path.of("project", "docs", "guide"), clean);
    }

常用路径操作方法

方法 作用 示例
resolve(other) 拼接子路径 src.resolve("main")src/main
resolveSibling(other) 替换为兄弟路径 src/main.resolveSibling("test")src/test
relativize(other) 计算相对路径 从 A 到 B 要怎么走
normalize() 消除 ... a/../b/./cb/c
toAbsolutePath() 转为绝对路径 补全当前工作目录前缀
getFileName() 获取最后一段(文件名) a/b/c.txtc.txt
getParent() 获取父路径 a/b/c.txta/b
getNameCount() 路径片段数 a/b/c3

如何获取文件属性信息?

获取 Path 的各种属性信息
    /**
     * 获取 Path 的各种属性信息
     */
    @Test
    void testPathInfo(@TempDir Path tempDir) throws IOException {
        // 创建一个测试文件
        Path file = tempDir.resolve("info-test.txt");
        Files.writeString(file, "Hello NIO.2");

        // 基本路径信息
        System.out.println("文件名: " + file.getFileName());
        System.out.println("父路径: " + file.getParent());
        System.out.println("根路径: " + file.getRoot());
        System.out.println("是绝对路径: " + file.isAbsolute());

        // 通过 Files 工具类获取文件属性
        System.out.println("存在: " + Files.exists(file));
        System.out.println("是普通文件: " + Files.isRegularFile(file));
        System.out.println("是目录: " + Files.isDirectory(file));
        System.out.println("可读: " + Files.isReadable(file));
        System.out.println("可写: " + Files.isWritable(file));
        System.out.println("文件大小: " + Files.size(file) + " 字节");

        assertTrue(Files.exists(file));
        assertTrue(Files.isRegularFile(file));
    }

🔧 Files 工具类——一行代码搞定文件操作

java.nio.file.Files 是 NIO.2 的核心工具类,几乎所有文件操作都通过它的**静态方法**完成。与 File 类每个操作都返回 boolean 不同,Files 在失败时抛出明确的异常(如 NoSuchFileExceptionFileAlreadyExistsException),你能清楚知道出了什么问题。

读写文件——告别繁琐的流操作

对于「小文件」(能一次性放进内存的),Files 提供了极简的一行式读写方法:

Files 工具类的一行式读写
    /**
     * Files 工具类的一行式读写
     */
    @Test
    void testFilesReadWrite(@TempDir Path tempDir) throws IOException {
        Path file = tempDir.resolve("demo.txt");

        // ✅ 一行写入字符串(Java 11+)
        Files.writeString(file, "你好,NIO.2!\n这是第二行。");

        // ✅ 一行读取全部内容为字符串(Java 11+)
        String content = Files.readString(file);
        assertTrue(content.contains("你好,NIO.2!"));

        // ✅ 按行读取为 List<String>(Java 7+)
        List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
        assertEquals(2, lines.size());
        assertEquals("你好,NIO.2!", lines.get(0));
        assertEquals("这是第二行。", lines.get(1));

        // ✅ 按行写入 List<String>(Java 7+)
        Path file2 = tempDir.resolve("lines.txt");
        Files.write(file2, List.of("第一行", "第二行", "第三行"));
        assertEquals(3, Files.readAllLines(file2).size());

        // ✅ 写入字节数组(Java 7+)
        Path binFile = tempDir.resolve("data.bin");
        byte[] data = {0x48, 0x65, 0x6C, 0x6C, 0x6F}; // "Hello"
        Files.write(binFile, data);
        assertArrayEquals(data, Files.readAllBytes(binFile));
    }

读写方法速查

方法 版本 作用
Files.readString(path) Java 11+ 读取整个文件为 String
Files.writeString(path, str) Java 11+ String 写入文件
Files.readAllLines(path) Java 7+ 按行读取为 List<String>
Files.write(path, lines) Java 7+ Iterable<String> 按行写入
Files.readAllBytes(path) Java 7+ 读取全部字节
Files.write(path, bytes) Java 7+ 写入字节数组

大文件怎么办?——Files.lines() 流式读取

上面的方法会一次性把整个文件读进内存,大文件可能导致 OutOfMemoryErrorFiles.lines() 返回一个 Stream<String>,按需逐行读取,读多少加载多少:

Files.lines() 流式懒加载按行读取
    /**
     * Files.lines() —— 流式懒加载按行读取(适合大文件)
     */
    @Test
    void testFilesLinesStream(@TempDir Path tempDir) throws IOException {
        // 准备测试文件
        Path file = tempDir.resolve("large.txt");
        Files.write(file, List.of(
                "// 这是注释行",
                "name=Alice",
                "// 另一条注释",
                "age=30",
                "city=Shanghai"
        ));

        // Files.lines() 返回 Stream<String>,惰性读取,用完即关
        try (Stream<String> stream = Files.lines(file)) {
            List<String> configs = stream
                    .filter(line -> !line.startsWith("//")) // 跳过注释
                    .collect(Collectors.toList());

            assertEquals(3, configs.size());
            assertEquals("name=Alice", configs.get(0));
        }
        // Stream 关闭后底层文件句柄自动释放
    }

必须关闭 Stream

Files.lines() 返回的 Stream 持有底层文件句柄,必须用 try-with-resources 包裹,否则会泄漏文件描述符。

创建目录与临时文件

目录和临时文件的创建
    /**
     * 目录的创建
     */
    @Test
    void testDirectoryCreation(@TempDir Path tempDir) throws IOException {
        // createDirectory():创建单级目录(父目录必须存在)
        Path single = tempDir.resolve("level1");
        Files.createDirectory(single);
        assertTrue(Files.isDirectory(single));

        // createDirectories():创建多级目录(推荐,自动创建中间目录)
        Path multi = tempDir.resolve("a").resolve("b").resolve("c");
        Files.createDirectories(multi);
        assertTrue(Files.isDirectory(multi));

        // createTempDirectory():创建临时目录
        Path tmpDir = Files.createTempDirectory(tempDir, "test_");
        assertTrue(Files.isDirectory(tmpDir));
        assertTrue(tmpDir.getFileName().toString().startsWith("test_"));

        // createTempFile():创建临时文件
        Path tmpFile = Files.createTempFile(tempDir, "pre_", ".tmp");
        assertTrue(Files.exists(tmpFile));
        assertTrue(tmpFile.getFileName().toString().endsWith(".tmp"));
    }

复制、移动与删除

文件的复制、移动与删除
    /**
     * 文件的复制、移动与删除
     */
    @Test
    void testCopyMoveDelete(@TempDir Path tempDir) throws IOException {
        // 准备源文件
        Path src = tempDir.resolve("source.txt");
        Files.writeString(src, "原始内容");

        // 复制文件
        Path copied = tempDir.resolve("copied.txt");
        Files.copy(src, copied);
        assertEquals("原始内容", Files.readString(copied));

        // 复制并覆盖已存在的目标文件
        Files.writeString(copied, "旧内容");
        Files.copy(src, copied, StandardCopyOption.REPLACE_EXISTING);
        assertEquals("原始内容", Files.readString(copied));

        // 移动(重命名)文件
        Path moved = tempDir.resolve("moved.txt");
        Files.move(copied, moved);
        assertFalse(Files.exists(copied), "原文件应已不存在");
        assertTrue(Files.exists(moved), "新位置应存在");

        // 删除文件
        Files.delete(moved);
        assertFalse(Files.exists(moved));

        // deleteIfExists 不抛异常(文件不存在时返回 false)
        boolean deleted = Files.deleteIfExists(moved);
        assertFalse(deleted, "文件已不存在,应返回 false");
    }

🗂️ 目录遍历——如何查找文件?

Files.walk()——递归遍历目录树

Files.walk() 返回一个 Stream<Path>,深度优先遍历整个目录树。配合 filter()map() 等 Stream 操作,可以轻松实现各种查找需求:

Files.walk() 递归遍历目录树
    /**
     * Files.walk() —— 递归遍历目录树
     */
    @Test
    void testFilesWalk(@TempDir Path tempDir) throws IOException {
        // 构建目录结构:
        // tempDir/
        //   ├── a/
        //   │   ├── a1.txt
        //   │   └── a2.java
        //   ├── b/
        //   │   └── b1.txt
        //   └── root.txt
        Files.createDirectories(tempDir.resolve("a"));
        Files.createDirectories(tempDir.resolve("b"));
        Files.writeString(tempDir.resolve("root.txt"), "根文件");
        Files.writeString(tempDir.resolve("a/a1.txt"), "a1");
        Files.writeString(tempDir.resolve("a/a2.java"), "a2");
        Files.writeString(tempDir.resolve("b/b1.txt"), "b1");

        // walk() 递归遍历所有文件和目录
        try (Stream<Path> stream = Files.walk(tempDir)) {
            long totalCount = stream.count();
            // tempDir 本身 + a/ + b/ + 4个文件 = 7
            assertEquals(7, totalCount);
        }

        // 只查找 .txt 文件
        try (Stream<Path> stream = Files.walk(tempDir)) {
            List<String> txtFiles = stream
                    .filter(Files::isRegularFile)
                    .filter(p -> p.toString().endsWith(".txt"))
                    .map(p -> p.getFileName().toString())
                    .sorted()
                    .collect(Collectors.toList());

            assertEquals(List.of("a1.txt", "b1.txt", "root.txt"), txtFiles);
        }

        // walk(maxDepth):限制遍历深度
        try (Stream<Path> stream = Files.walk(tempDir, 1)) {
            long topLevelCount = stream
                    .filter(p -> !p.equals(tempDir)) // 排除根目录自身
                    .count();
            // 只有 a/, b/, root.txt 三项(不进入子目录)
            assertEquals(3, topLevelCount);
        }
    }

Files.walkFileTree()——访问者模式遍历

当你需要在遍历过程中做**复杂操作**(如递归删除整个目录树),Files.walkFileTree() 配合 SimpleFileVisitor 更合适:

walkFileTree 递归删除目录
    /**
     * Files.walkFileTree() —— 访问者模式遍历(可做删除等复杂操作)
     */
    @Test
    void testWalkFileTree(@TempDir Path tempDir) throws IOException {
        // 构建嵌套目录
        Path dir = tempDir.resolve("toDelete");
        Files.createDirectories(dir.resolve("sub1").resolve("sub2"));
        Files.writeString(dir.resolve("file.txt"), "内容");
        Files.writeString(dir.resolve("sub1/file.txt"), "内容");
        Files.writeString(dir.resolve("sub1/sub2/file.txt"), "内容");

        assertTrue(Files.exists(dir));

        // 使用 walkFileTree + SimpleFileVisitor 递归删除整个目录树
        Files.walkFileTree(dir, new SimpleFileVisitor<>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                    throws IOException {
                Files.delete(file); // 先删文件
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult postVisitDirectory(Path directory, IOException exc)
                    throws IOException {
                Files.delete(directory); // 再删空目录
                return FileVisitResult.CONTINUE;
            }
        });

        assertFalse(Files.exists(dir), "整个目录树应被删除");
    }

SimpleFileVisitor 提供了四个可重写的方法:

方法 调用时机
preVisitDirectory() 进入目录**前**
visitFile() 访问每个**文件**时
visitFileFailed() 文件**无法访问**时
postVisitDirectory() 离开目录**后**(子文件已全部访问)

💡 递归删除的诀窍:在 visitFile() 中删文件,在 postVisitDirectory() 中删(已空的)目录。

PathMatcher——用 glob 模式匹配文件

如果你只想找特定类型的文件,PathMatcher 比手动 endsWith() 更优雅:

PathMatcher 用 glob 模式查找文件
    /**
     * PathMatcher —— 用 glob 模式查找文件
     */
    @Test
    void testPathMatcher(@TempDir Path tempDir) throws IOException {
        // 准备测试文件
        Files.writeString(tempDir.resolve("readme.md"), "文档");
        Files.writeString(tempDir.resolve("App.java"), "代码");
        Files.writeString(tempDir.resolve("test.txt"), "文本");
        Files.writeString(tempDir.resolve("data.csv"), "数据");

        // 创建 glob 匹配器:匹配 .java 或 .md 文件
        PathMatcher matcher = FileSystems.getDefault()
                .getPathMatcher("glob:*.{java,md}");

        try (Stream<Path> stream = Files.list(tempDir)) {
            List<String> matched = stream
                    .filter(p -> matcher.matches(p.getFileName()))
                    .map(p -> p.getFileName().toString())
                    .sorted()
                    .collect(Collectors.toList());

            assertEquals(List.of("App.java", "readme.md"), matched);
        }
    }

常用 glob 语法

模式 含义 示例
* 匹配任意字符(不跨目录) *.java 匹配 App.java
** 匹配任意层级目录 **/*.txt 匹配所有 .txt 文件
? 匹配单个字符 ?.txt 匹配 a.txt,不匹配 ab.txt
{a,b} 匹配 a 或 b *.{java,md} 匹配 .java.md

🔄 File vs Path——对比与互转

功能对比

维度 java.io.File java.nio.file.Path + Files
引入版本 JDK 1.0 Java 7
错误处理 返回 boolean,失败原因未知 抛异常,错误原因明确
路径操作 字符串拼接 resolve()relativize()normalize()
读写文件 需配合 IO 流 Files.readString()Files.write() 一行搞定
目录遍历 listFiles() 一次性返回,大目录吃内存 Files.walk() 返回 Stream,惰性加载
Stream API ❌ 不支持 ✅ 无缝集成
符号链接 ❌ 不支持 ✅ 支持
文件属性 有限 丰富(权限、时间戳、所有者等)

📌 结论:新代码一律用 Path + Files。只在与旧 API 交互时才通过互转方法衔接。

相互转换

遗留代码中大量使用 File,好在它们可以轻松互转:

File 与 Path 的相互转换
    /**
     * File 与 Path 的相互转换
     */
    @Test
    void testFilePathConversion(@TempDir Path tempDir) throws IOException {
        // Path → File
        Path path = tempDir.resolve("convert.txt");
        Files.writeString(path, "转换测试");
        java.io.File file = path.toFile();
        assertTrue(file.exists());

        // File → Path
        Path backToPath = file.toPath();
        assertEquals(path, backToPath);
        assertEquals("转换测试", Files.readString(backToPath));
    }
转换方向 方法
PathFile path.toFile()
FilePath file.toPath()

👁️ WatchService——监控目录变化

需要监控某个目录下的文件创建、修改、删除事件?Java 7 提供了 WatchService API,底层利用操作系统的文件事件通知机制(Linux inotify、macOS kqueue、Windows ReadDirectoryChangesW),**无需轮询**即可感知文件变化。

使用流程

graph LR
    A["创建 WatchService"] --> B["注册目录 + 事件类型"]
    B --> C["循环 take() 等待事件"]
    C --> D["遍历 pollEvents()"]
    D --> E["处理事件"]
    E --> F["reset() 重置 Key"]
    F --> C

事件类型

事件 含义
ENTRY_CREATE 目录中新建了文件或子目录
ENTRY_MODIFY 文件被修改
ENTRY_DELETE 文件或子目录被删除
OVERFLOW 事件丢失(系统来不及处理时触发)

使用示例

WatchService 监控目录变化
    /**
     * WatchService:监控目录变化
     */
    @Test
    void testWatchService(@TempDir Path tempDir) throws IOException, InterruptedException {
        // 创建 WatchService 并注册要监控的目录
        try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
            tempDir.register(watcher,
                    StandardWatchEventKinds.ENTRY_CREATE,
                    StandardWatchEventKinds.ENTRY_MODIFY,
                    StandardWatchEventKinds.ENTRY_DELETE);

            // 在被监控的目录中创建一个文件
            Path newFile = tempDir.resolve("hello.txt");
            Files.writeString(newFile, "WatchService 演示");

            // 等待事件(poll 最多等 2 秒)
            WatchKey key = watcher.poll(2, TimeUnit.SECONDS);
            assertNotNull(key, "应该收到文件创建事件");

            boolean foundCreate = false;
            for (WatchEvent<?> event : key.pollEvents()) {
                WatchEvent.Kind<?> kind = event.kind();
                Path fileName = (Path) event.context();
                System.out.println("事件: " + kind.name() + " -> " + fileName);
                if (kind == StandardWatchEventKinds.ENTRY_CREATE
                        && fileName.toString().equals("hello.txt")) {
                    foundCreate = true;
                }
            }
            assertTrue(foundCreate, "应该检测到 hello.txt 的创建事件");

            // 重置 key 以继续监听(不重置则不会收到后续事件)
            key.reset();
        }
    }

⚠️ 注意事项

  • WatchService 只能监控**直接子项**(非递归),监控子目录需要对每个子目录分别注册
  • take() 是阻塞方法,会一直等到有事件发生;poll()poll(timeout) 是非阻塞/限时版本
  • 每次处理完事件后必须调用 key.reset(),否则不会再收到后续事件
  • reset() 返回 false 表示该 Key 已失效(如监控的目录被删除了),此时应退出监听循环