34.【Java】I/O流(下):NIO与文件操作

摘要: 本文全面介绍Java NIO.2文件操作API,涵盖从传统I/O到现代NIO.2的升级路径。详细讲解Path接口、Files工具类的核心用法,包括文件创建、读写、复制、移动、删除等基础操作,以及目录遍历、文件属性获取、与旧API互操作等高级功能。通过丰富的代码示例和综合工具类展示,帮助开发者掌握更简洁、高效的Java文件处理方式,提升日常开发效率。

关键词: Java NIO.2, Path接口, Files工具类, 文件操作, 目录遍历


在上一篇文章中,我们学习了 Java I/O 流的基础:字节流、字符流、缓冲流和转换流。你应该已经能用 FileInputStreamBufferedReader 等工具读写文件了。

但你可能也隐隐感觉到:那些 API 用起来有点“笨重”。

比如,要读取一个文本文件的所有行,你需要嵌套多层装饰器:new BufferedReader(new InputStreamReader(new FileInputStream("test.txt"), "UTF-8"))——这一串写起来既冗长又容易出错。而且,古老的 File 类在文件操作上也有很多不足:它只能检查文件是否存在、获取文件名,但复制、移动、遍历目录这些常见操作都需要你自己写循环和递归,非常不方便。

好在 Java 早在 2011 年(JDK 7)就引入了一套全新的文件 I/O API,叫做 NIO.2(New I/O 2.0)。它彻底改变了 Java 处理文件和目录的方式,提供了:

  • 更简洁、更直观的 API(Path 接口和 Files 工具类)
  • 支持符号链接、文件属性、目录流等高级特性
  • 更高效的异步 I/O 能力(本篇不涉及)

今天,我们就来全面学习这套现代化的文件操作 API。以后你写文件操作,几乎可以告别传统的 FileFileInputStream 了。


1. 从 FilePath —— 更现代的路径表示

在 NIO.2 中,文件和目录的路径不再用 File 类表示,而是用 Path 接口PathFile 更灵活,支持多种文件系统(比如 ZIP 文件系统),并且提供了丰富的方法来操作路径。

1.1 创建 Path 对象

使用 Paths.get() 静态方法:

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

public class PathDemo {
    public static void main(String[] args) {
        // 用字符串创建 Path
        Path path1 = Paths.get("/home/user/test.txt");
        Path path2 = Paths.get("docs", "readme.md");  // 多参数,自动拼接
        Path path3 = Paths.get(".");  // 当前目录
        
        System.out.println(path1);       // /home/user/test.txt
        System.out.println(path2);       // docs/readme.md
        System.out.println(path3.toAbsolutePath());  // 当前工作目录的绝对路径
    }
}

在 Windows 上,路径分隔符是 \,但你可以在代码里统一使用 /Paths.get() 会自动适配操作系统。

1.2 Path 的常用方法
Path p = Paths.get("/home/user/projects/demo/config.properties");

System.out.println("文件名:" + p.getFileName());           // config.properties
System.out.println("父路径:" + p.getParent());             // /home/user/projects/demo
System.out.println("根路径:" + p.getRoot());               // /
System.out.println("名称元素个数:" + p.getNameCount());    // 5
System.out.println("第2个元素:" + p.getName(1));           // user

// 拼接路径
Path newPath = p.resolveSibling("backup.properties");
System.out.println("同目录下其他文件:" + newPath);  // /home/user/projects/demo/backup.properties

// 规范化(去除 . 和 ..)
Path normalized = Paths.get("/home/../user/./test.txt").normalize();
System.out.println(normalized);  // /user/test.txt

Path 的 API 设计得很直观,方法名就能看出用途。


2. Files 工具类 —— 文件操作的“瑞士军刀”

Files 是 NIO.2 的核心工具类,提供了海量的静态方法,用于创建、复制、移动、删除、读取、写入文件,以及操作目录和文件属性。它比传统的 File 强大太多了。

重要Files 类所有方法在发生 I/O 错误时都会抛出 IOException(受检异常),所以调用时需要用 try-catchthrows

2.1 文件存在性检查和创建
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.io.IOException;

public class FilesCheckDemo {
    public static void main(String[] args) throws IOException {
        Path path = Paths.get("test.txt");

        // 检查文件是否存在
        if (Files.exists(path)) {
            System.out.println("文件存在");
        } else {
            System.out.println("文件不存在");
        }

        // 检查是否为普通文件(不是目录)
        boolean isRegular = Files.isRegularFile(path);
        // 检查是否为目录
        boolean isDir = Files.isDirectory(path);
        // 检查是否可读、可写、可执行
        boolean readable = Files.isReadable(path);
        boolean writable = Files.isWritable(path);
        boolean executable = Files.isExecutable(path);
    }
}
2.2 创建文件、目录和临时文件
// 创建空文件(如果不存在)
Path file = Paths.get("newfile.txt");
Files.createFile(file);   // 如果文件已存在会抛出 FileAlreadyExistsException

// 创建目录(单层)
Path dir = Paths.get("mydir");
Files.createDirectory(dir);  // 如果父目录不存在或目录已存在会抛异常

// 创建多级目录(父目录不存在时自动创建)
Path deepDir = Paths.get("a/b/c/d");
Files.createDirectories(deepDir);  // 推荐使用这个

// 创建临时文件/目录
Path tempFile = Files.createTempFile("prefix", ".tmp");
Path tempDir = Files.createTempDirectory("my-temp");
System.out.println("临时文件:" + tempFile);
System.out.println("临时目录:" + tempDir);

3. 读取和写入文件 —— 最常用的操作

NIO.2 让文件读写变得异常简单。你不再需要手动处理 InputStreamReader 和循环了。

3.1 读取所有字节/行(小文件)

对于小文件(比如配置文件、日志片段),你可以一次性读取所有内容:

import java.nio.file.Files;
import java.nio.file.Paths;
import java.io.IOException;
import java.util.List;

public class ReadWriteDemo {
    public static void main(String[] args) throws IOException {
        Path path = Paths.get("test.txt");

        // 1. 读取所有字节(返回 byte[])
        byte[] bytes = Files.readAllBytes(path);
        String content = new String(bytes, "UTF-8");
        System.out.println("内容:" + content);

        // 2. 读取所有行(返回 List<String>)
        List<String> lines = Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8);
        lines.forEach(System.out::println);

        // 3. 写入字节
        String newContent = "Hello, NIO!";
        Files.write(path, newContent.getBytes("UTF-8"));

        // 4. 写入行(覆盖)
        List<String> newLines = List.of("第一行", "第二行", "第三行");
        Files.write(path, newLines, java.nio.charset.StandardCharsets.UTF_8);

        // 5. 追加到文件
        Files.write(path, "追加的内容".getBytes("UTF-8"), 
                    java.nio.file.StandardOpenOption.APPEND);
    }
}

Files.write() 默认是覆盖模式。如果想追加,传入 StandardOpenOption.APPEND

3.2 流式读取(大文件)

对于大文件,一次性读取所有行可能会占用大量内存。这时可以用 Files.lines() 方法,它返回一个 Stream<String>,可以逐行处理,不会一次性加载全部内容到内存。

import java.nio.file.Files;
import java.nio.file.Paths;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class StreamLinesDemo {
    public static void main(String[] args) throws IOException {
        Path path = Paths.get("large_log.txt");

        // 使用 try-with-resources 确保 Stream 被关闭
        try (var lines = Files.lines(path, StandardCharsets.UTF_8)) {
            // 只处理前 10 行
            lines.limit(10)
                 .filter(line -> line.contains("ERROR"))
                 .forEach(System.out::println);
        }
    }
}

Files.lines() 返回的 Stream 是延迟加载的,并且必须用 try-with-resources 关闭,否则可能造成资源泄漏。

3.3 读写二进制文件

同样用 readAllBytes()write() 即可:

Path src = Paths.get("image.jpg");
Path dest = Paths.get("copy.jpg");

byte[] data = Files.readAllBytes(src);
Files.write(dest, data);  // 二进制复制

4. 复制、移动、删除文件

4.1 复制文件
Path source = Paths.get("source.txt");
Path target = Paths.get("target.txt");

// 复制文件(如果目标已存在会抛出 FileAlreadyExistsException)
Files.copy(source, target);

// 覆盖已存在的目标
Files.copy(source, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);

// 复制到目录(保持文件名)
Path destDir = Paths.get("backup/");
Files.copy(source, destDir.resolve(source.getFileName()));

Files.copy() 也支持复制目录(但不会递归复制内容,需要自己遍历)。

4.2 移动文件(或重命名)
Path source = Paths.get("data.txt");
Path target = Paths.get("archive/data.txt");

// 移动文件(如果目标已存在会抛异常)
Files.move(source, target);

// 覆盖已有文件,并原子地执行移动(如果文件系统支持)
Files.move(source, target, 
           java.nio.file.StandardCopyOption.REPLACE_EXISTING,
           java.nio.file.StandardCopyOption.ATOMIC_MOVE);

移动和复制都可以设置选项:REPLACE_EXISTING(覆盖)、ATOMIC_MOVE(原子移动)、COPY_ATTRIBUTES(复制文件属性)。

4.3 删除文件或目录
Path file = Paths.get("temp.txt");

// 删除文件(如果文件不存在抛出 NoSuchFileException)
Files.delete(file);

// 安全删除:如果文件不存在,不抛出异常,返回 boolean
boolean deleted = Files.deleteIfExists(file);
System.out.println("是否删除成功:" + deleted);

// 删除目录(必须为空)
Path emptyDir = Paths.get("mydir");
Files.deleteIfExists(emptyDir);

要删除非空目录,需要递归遍历删除所有子文件和子目录(后面会讲如何遍历)。


5. 目录遍历 —— 探索文件树

NIO.2 提供了强大的目录遍历能力,可以很方便地列出目录内容、搜索文件、递归处理整个目录树。

5.1 列出目录内容(浅层)
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.io.IOException;
import java.util.stream.Stream;

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

        // 方式一:Files.list() 返回 Stream<Path>(只包含一层)
        try (Stream<Path> entries = Files.list(dir)) {
            entries.forEach(System.out::println);
        }

        // 方式二:只遍历目录(过滤)
        try (Stream<Path> entries = Files.list(dir)) {
            entries.filter(Files::isDirectory)
                   .forEach(System.out::println);
        }
    }
}

Files.list() 不是递归的,只列出当前目录的直接子项。

5.2 递归遍历整个目录树

如果需要递归遍历所有子目录,使用 Files.walk()。它返回一个 Stream<Path>,深度优先遍历整个目录树。

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

public class WalkDemo {
    public static void main(String[] args) throws IOException {
        Path start = Paths.get("src");

        // 遍历 src 目录下的所有文件(包括子目录)
        try (var paths = Files.walk(start)) {
            paths.filter(Files::isRegularFile)
                 .forEach(System.out::println);
        }

        // 限制遍历深度(比如只遍历两层)
        try (var paths = Files.walk(start, 2)) {
            paths.forEach(System.out::println);
        }
    }
}
5.3 搜索文件(Find)

Files.find() 方法可以结合深度和谓词(条件)来搜索文件。

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

public class FindDemo {
    public static void main(String[] args) throws IOException {
        Path start = Paths.get(".");

        // 搜索所有扩展名为 .txt 或 .log 的文件
        try (var paths = Files.find(start, Integer.MAX_VALUE,
                (path, attrs) -> {
                    String name = path.getFileName().toString();
                    return name.endsWith(".txt") || name.endsWith(".log");
                })) {
            paths.forEach(System.out::println);
        }

        // 搜索最近 7 天内修改过的文件(需要 BasicFileAttributes)
        long sevenDaysAgo = System.currentTimeMillis() - 7 * 24 * 3600 * 1000;
        try (var paths = Files.find(start, Integer.MAX_VALUE,
                (path, attrs) -> attrs.lastModifiedTime().toMillis() > sevenDaysAgo)) {
            paths.forEach(System.out::println);
        }
    }
}

Files.find()walk() + filter() 更高效,因为它可以在遍历过程中就进行筛选,且能访问文件属性。

5.4 用 FileVisitor 进行更精细的控制

如果需要更复杂的遍历逻辑(比如在遍历中删除文件、处理符号链接等),可以使用 Files.walkFileTree() + SimpleFileVisitor。这是传统方式,适合需要精细控制的场景。

import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.io.IOException;

public class DeleteDirVisitor extends SimpleFileVisitor<Path> {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        Files.delete(file);  // 删除文件
        System.out.println("删除文件:" + file);
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
        Files.delete(dir);   // 删除目录(此时已空)
        System.out.println("删除目录:" + dir);
        return FileVisitResult.CONTINUE;
    }

    public static void main(String[] args) throws IOException {
        Path toDelete = Paths.get("temp");
        Files.walkFileTree(toDelete, new DeleteDirVisitor());
        System.out.println("删除完成!");
    }
}

这种方式的优势是你能精确控制访问每个文件/目录时的行为(继续、跳过、终止),还能处理异常。


6. 文件属性 —— 获取更多元数据

NIO.2 提供了统一的文件属性访问接口,支持不同操作系统的特定属性。

import java.nio.file.*;
import java.io.IOException;
import java.nio.file.attribute.BasicFileAttributes;

public class AttrDemo {
    public static void main(String[] args) throws IOException {
        Path path = Paths.get("test.txt");

        // 获取基础属性(所有文件系统都支持)
        BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
        System.out.println("创建时间:" + attrs.creationTime());
        System.out.println("最后修改时间:" + attrs.lastModifiedTime());
        System.out.println("文件大小:" + attrs.size());
        System.out.println("是否是目录:" + attrs.isDirectory());

        // 获取 Posix 属性(Linux/macOS 等 Unix 系统)
        if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
            java.nio.file.attribute.PosixFileAttributes posixAttrs = 
                Files.readAttributes(path, java.nio.file.attribute.PosixFileAttributes.class);
            System.out.println("权限:" + java.nio.file.attribute.PosixFilePermissions.toString(posixAttrs.permissions()));
            System.out.println("所有者:" + posixAttrs.owner());
        }

        // 设置文件权限(仅在 Posix 系统有效)
        if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
            java.nio.file.attribute.PosixFilePermission ownerRead = java.nio.file.attribute.PosixFilePermission.OWNER_READ;
            java.util.Set<java.nio.file.attribute.PosixFilePermission> perms = 
                java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--");
            Files.setPosixFilePermissions(path, perms);
        }
    }
}

在 Windows 下,你可以通过 DosFileAttributes 获取隐藏、只读等属性。


7. 与旧 I/O 的互操作

NIO.2 与传统的 File 和流可以互相转换:

  • PathFilepath.toFile()
  • FilePathfile.toPath()
  • PathInputStream/OutputStreamFiles.newInputStream(path)Files.newOutputStream(path)
  • PathReader/WriterFiles.newBufferedReader(path)Files.newBufferedWriter(path)
// 传统 File 转 Path
File oldFile = new File("test.txt");
Path path = oldFile.toPath();

// 使用 Files 工具类创建流
try (InputStream in = Files.newInputStream(path);
     BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    // 混用 NIO 和传统流 API
}

你可以逐步迁移代码,不用一次性重写所有旧的 I/O 代码。


8. 综合示例 —— 一个完整的文件工具类

我们把今天学到的常用操作封装成一个工具类,方便以后复用:

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;
import java.util.stream.Stream;

public class FileUtil {
    
    /**
     * 读取文件所有行(小文件)
     */
    public static List<String> readAllLines(String path) throws IOException {
        return Files.readAllLines(Paths.get(path), StandardCharsets.UTF_8);
    }
    
    /**
     * 写入文件(覆盖)
     */
    public static void writeAllLines(String path, List<String> lines) throws IOException {
        Files.write(Paths.get(path), lines, StandardCharsets.UTF_8);
    }
    
    /**
     * 追加一行到文件末尾
     */
    public static void appendLine(String path, String line) throws IOException {
        Path p = Paths.get(path);
        // 确保父目录存在
        if (p.getParent() != null) {
            Files.createDirectories(p.getParent());
        }
        String content = line + System.lineSeparator();
        Files.write(p, content.getBytes(StandardCharsets.UTF_8), 
                    StandardOpenOption.APPEND, StandardOpenOption.CREATE);
    }
    
    /**
     * 复制文件,自动创建目标目录
     */
    public static void copyFile(String source, String target) throws IOException {
        Path src = Paths.get(source);
        Path dest = Paths.get(target);
        // 确保目标目录存在
        if (dest.getParent() != null) {
            Files.createDirectories(dest.getParent());
        }
        Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING);
    }
    
    /**
     * 移动文件,自动创建目标目录
     */
    public static void moveFile(String source, String target) throws IOException {
        Path src = Paths.get(source);
        Path dest = Paths.get(target);
        if (dest.getParent() != null) {
            Files.createDirectories(dest.getParent());
        }
        Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
    }
    
    /**
     * 删除目录及所有内容(递归)
     */
    public static void deleteDirectory(String dirPath) throws IOException {
        Path root = Paths.get(dirPath);
        if (!Files.exists(root)) return;
        
        try (Stream<Path> walk = Files.walk(root)) {
            // 必须先删除子文件,再删除目录本身(逆序)
            walk.sorted((a, b) -> b.toString().compareTo(a.toString()))
                .forEach(p -> {
                    try {
                        Files.deleteIfExists(p);
                    } catch (IOException e) {
                        throw new RuntimeException("删除失败:" + p, e);
                    }
                });
        }
    }
    
    /**
     * 搜索目录下所有匹配扩展名的文件
     */
    public static List<Path> searchByExtension(String rootPath, String extension) throws IOException {
        Path start = Paths.get(rootPath);
        try (Stream<Path> paths = Files.find(start, Integer.MAX_VALUE,
                (p, attrs) -> p.getFileName().toString().endsWith(extension))) {
            return paths.toList();
        }
    }
    
    /**
     * 获取文件大小(人性化显示)
     */
    public static String getHumanReadableSize(String path) throws IOException {
        long size = Files.size(Paths.get(path));
        String[] units = {"B", "KB", "MB", "GB", "TB"};
        int idx = 0;
        double s = size;
        while (s >= 1024 && idx < units.length - 1) {
            s /= 1024;
            idx++;
        }
        return String.format("%.2f %s", s, units[idx]);
    }
    
    public static void main(String[] args) throws IOException {
        // 测试各个方法
        appendLine("logs/app.log", "程序启动于 " + java.time.LocalDateTime.now());
        copyFile("config.properties", "backup/config_backup.properties");
        System.out.println("文件大小:" + getHumanReadableSize("config.properties"));
        
        List<Path> txtFiles = searchByExtension(".", ".txt");
        System.out.println("找到 " + txtFiles.size() + " 个 txt 文件");
    }
}

9. 传统 I/O vs NIO.2 —— 何时用哪个?

场景推荐
新项目、新代码NIO.2 + Files 工具类,更简洁、更现代
需要处理大文件、高性能NIO.2(支持内存映射、异步 I/O)
需要与旧的 File 生态对接NIO.2 可互操作
需要手动控制缓冲、底层细节传统流可能更灵活,但通常不必要
简单读写小文件Files.readAllLines() / Files.write()

总结:从现在开始,优先使用 NIO.2 的 PathFiles,它们会让你的文件操作代码更短、更清晰、更不容易出错。


10. 今天的总结

今天我们全面学习了 NIO.2 的文件操作:

  • Path:代表文件或目录路径,提供了丰富的路径操作方法。
  • Files:包含了几乎所有文件操作的静态方法:existscreateFilecreateDirectoriescopymovedeletereadAllBytesreadAllLineswrite 等。
  • Files.lines():流式读取大文件,逐行处理。
  • 目录遍历Files.list()(一层)、Files.walk()(递归)、Files.find()(带条件搜索)、walkFileTree()(精细控制)。
  • 文件属性:通过 Files.readAttributes() 获取创建时间、修改时间、大小、权限等。
  • 与传统 I/O 互操作toFile() / toPath() / Files.newInputStream() 等。

NIO.2 让 Java 的文件操作从“繁琐”变成了“简洁”。现在你已经掌握了最现代、最推荐的文件处理方式。


动手试试

  1. 写一个程序,把当前目录下所有 .java 文件复制到 backup 目录下,保持原有的目录结构(提示:用 Files.walk()Files.copy())。

  2. 写一个 FileSizeCounter 方法,遍历一个目录,计算所有文件的总大小,并输出每个子目录的大小(类似 Linux 的 du 命令)。

  3. 写一个程序,从用户输入一个目录路径,然后定期(每隔 10 秒)检查该目录下是否有新增的 .tmp 文件,如果有则打印文件名(提示:用 WatchService,这是 NIO.2 的文件监听机制,可以选做)。

  4. 把你之前的学生管理系统中的数据存储功能改为用 Files.write()Files.readAllLines() 保存和加载数据(比如保存到 students.txt 中),体会用 NIO.2 做持久化是多么简单。

I/O 流和 NIO 的内容到这里就结束了。下一篇文章,我们将学习 日期与时间 API(java.time——这是 Java 8 引入的一套全新的、设计优秀的日期时间库,它将彻底取代旧的 DateCalendar

我们下一篇见。😃

📌 获取本系列示例代码请访问 GitCode

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值