NIO与Files 详解
场景:小崔被派去整理飞翔科技积累了三个月的服务器日志目录——里面有上千个日志文件,按日期命名但格式不统一,有的在子目录里,有的文件名带空格。小崔准备写个递归遍历脚本,大翔拦住了他:"用Java 7的NIO.2吧,
Files.walkFileTree几行就搞定了,比你自己写递归强多了。"白歌补充道:"还有Files.readAllLines读小文件、Files.copy拷贝文件,JDK 7把以前那些繁琐的IO操作全简化了。"Frank人在海外,远程连过来说:"And don't usejava.io.Fileanymore —java.nio.file.Pathis the modern way. Seriously, it's 2014." 朱璐好奇:"那原来的File类是不是可以扔了?"白歌摇头:"也不能完全扔,很多老代码和第三方库还在用,你得知道怎么转换。"
一、定义表
| 概念 | 定义 |
|---|---|
| NIO.2 | JDK 7 引入的新IO API(JSR 203),包含 java.nio.file 包,提供对文件系统的增强操作 |
| Path | NIO.2 中代表文件系统路径的不可变对象,替代 java.io.File |
| Paths | 工具类,提供静态方法 get() 来创建 Path 实例 |
| Files | 工具类,提供静态方法用于文件的创建、复制、移动、删除、读取属性等操作 |
| FileSystem | 表示文件系统的抽象,可通过 FileSystems.getDefault() 获取默认文件系统 |
| FileVisitor | 用于递归遍历文件树的访问者接口,包含 preVisitDirectory、visitFile、visitFileFailed、postVisitDirectory |
| SimpleFileVisitor | FileVisitor 的简易实现,所有方法默认行为是继续遍历,可按需覆盖 |
| FileVisitResult | 枚举,控制遍历行为:CONTINUE、TERMINATE、SKIP_SUBTREE、SKIP_SIBLINGS |
| WatchService | 文件系统监控服务,可监测目录中的文件创建、修改、删除事件 |
| StandardOpenOption | 枚举,文件操作选项:CREATE、CREATE_NEW、APPEND、TRUNCATE_EXISTING、READ、WRITE等 |
二、Mermaid 类关系图
三、Path vs File 对比
| 特性 | java.io.File (Legacy) | java.nio.file.Path (JDK 7+) |
|---|---|---|
| 不可变性 | 可变(如 renameTo 改变状态) | 不可变(所有操作返回新Path) |
| 错误处理 | 返回 boolean(失败只返回false) | 抛出具体异常(如 NoSuchFileException) |
| 路径操作 | 字符串拼接,容易出错 | resolve、relativize、normalize 等方法 |
| 符号链接 | 不支持(始终跟随链接) | 可选择是否跟随符号链接 |
| 文件属性 | 有限的属性支持 | 丰富的 BasicFileAttributes、DosFileAttributes、PosixFileAttributes |
| 遍历目录 | listFiles() 返回数组(非lazy) | Files.walk() 返回 Stream(lazy)、Files.list() 返回 Stream |
| 互操作 | file.toPath() 转换为Path | path.toFile() 转换为File |
四、Path API 核心操作详解
4.1 创建 Path
// 方式1: Paths.get()(推荐)
Path p1 = Paths.get("d:/feixiang/logs");
Path p2 = Paths.get("d:", "feixiang", "logs", "app.log"); // 可变参数
Path p3 = Paths.get("d:/feixiang", "data", "config.properties");
// 方式2: FileSystems.getDefault().getPath()
Path p4 = FileSystems.getDefault().getPath("d:/feixiang/logs");
// 方式3: 从 File 转换
File oldFile = new File("d:/feixiang/old.txt");
Path p5 = oldFile.toPath(); // File → Path
File backToFile = p5.toFile(); // Path → File
// 方式4: 相对路径
Path cwd = Paths.get("").toAbsolutePath(); // 当前工作目录
4.2 路径操作
Path path = Paths.get("d:/feixiang/logs/2024/app.log");
path.getFileName(); // app.log
path.getParent(); // d:\feixiang\logs\2024
path.getRoot(); // d:\
path.getName(0); // feixiang
path.getName(1); // logs
path.getNameCount(); // 5 (feixiang, logs, 2024, app.log —— 不含root)
// 路径拼接
Path base = Paths.get("d:/feixiang");
Path resolved = base.resolve("logs"); // d:\feixiang\logs
Path sibling = base.resolveSibling("data"); // d:\data (替换最后一段)
// 相对化
Path abs = Paths.get("d:/feixiang/logs/app.log");
Path base2 = Paths.get("d:/feixiang");
Path relative = base2.relativize(abs); // logs\app.log
// 规范化
Path messy = Paths.get("d:/feixiang/../feixiang/logs/./app.log");
Path clean = messy.normalize(); // d:\feixiang\logs\app.log
// 比较
path.startsWith(Paths.get("d:/feixiang")); // true
path.endsWith(Paths.get("app.log")); // true
五、Files API 核心操作详解
5.1 文件与目录操作
// 创建目录
Files.createDirectory(Paths.get("d:/feixiang/newDir")); // 只创建最后一级
Files.createDirectories(Paths.get("d:/feixiang/a/b/c")); // 创建所有中间目录
// 创建文件
Files.createFile(Paths.get("d:/feixiang/newFile.txt"));
// 复制
Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING);
Files.copy(src, dest, StandardCopyOption.COPY_ATTRIBUTES);
// 移动/重命名
Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
Files.move(src, dest, StandardCopyOption.ATOMIC_MOVE); // 原子移动
// 删除
Files.delete(path); // 文件不存在时抛异常
Files.deleteIfExists(path); // 文件不存在时返回false,不抛异常
5.2 文件属性
Path path = Paths.get("d:/feixiang/app.log");
// 基本属性
BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
attrs.size(); // 文件大小(字节)
attrs.creationTime(); // 创建时间
attrs.lastModifiedTime(); // 最后修改时间
attrs.isDirectory(); // 是否为目录
attrs.isRegularFile(); // 是否为常规文件
attrs.isSymbolicLink(); // 是否为符号链接
// 单个属性
long size = Files.size(path);
boolean exists = Files.exists(path);
boolean isDir = Files.isDirectory(path);
boolean readable = Files.isReadable(path);
boolean writable = Files.isWritable(path);
// 设置属性
Files.setLastModifiedTime(path, FileTime.fromMillis(System.currentTimeMillis()));
六、完整代码示例
示例1:飞翔科技日志文件递归遍历与分析
场景:运维部的服务器日志目录结构如下:
d:/feixiang/logs/YYYY-MM/DD/app.log。小崔需要遍历所有日志文件,统计总大小和每个月份的文件数量。大翔教他用Files.walkFileTree实现。
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* 飞翔科技 - 日志文件递归遍历与分析
* 场景:遍历服务器日志目录,统计各月日志文件数量和总大小
* 技术:Files.walkFileTree + SimpleFileVisitor
* 作者:大翔
*/
public class FeixiangLogFileTraversal {
// 统计容器
private static final Map<String, FileStat> monthStats = new TreeMap<>();
private static final AtomicLong totalSize = new AtomicLong(0);
private static final AtomicLong totalFiles = new AtomicLong(0);
public static void main(String[] args) throws IOException {
// 1. 准备测试数据——创建模拟的日志目录结构
prepareTestLogFiles();
// 2. 使用 FileVisitor 遍历
Path logRoot = Paths.get("d:/feixiang/logs");
traverseLogs(logRoot);
// 3. 打印统计报告
printReport(logRoot);
}
/**
* 准备模拟的测试日志文件
*/
private static void prepareTestLogFiles() throws IOException {
String[][] logs = {
// {路径, 内容}
{"2024-01/15/app.log", "INFO: 服务器启动 | 大翔登录"},
{"2024-01/15/error.log", "ERROR: 数据库连接超时"},
{"2024-01/20/app.log", "INFO: 备份完成"},
{"2024-02/05/app.log", "INFO: 系统升级 | 白歌操作"},
{"2024-02/05/debug.log", "DEBUG: 内存使用率 65%"},
{"2024-02/14/app.log", "INFO: 情人节特别维护"},
{"2024-02/14/access.log", "GET /api/users 200 15ms"},
{"2024-03/01/app.log", "INFO: 月度报表生成"},
{"2024-03/01/error.log", "WARN: 磁盘空间不足"},
{"2024-03/15/app.log", "INFO: 服务器启动成功"},
};
for (String[] entry : logs) {
Path filePath = Paths.get("d:/feixiang/logs", entry[0]);
Files.createDirectories(filePath.getParent());
Files.write(filePath, entry[1].getBytes("UTF-8"),
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
}
System.out.println("[大翔] 测试日志文件已准备完成,共 " + logs.length + " 个文件\n");
}
/**
* 核心:使用 Files.walkFileTree 递归遍历日志目录
*/
public static void traverseLogs(Path startPath) throws IOException {
System.out.println("[大翔] 开始遍历日志目录: " + startPath.toAbsolutePath());
Files.walkFileTree(startPath, new SimpleFileVisitor<Path>() {
/**
* 进入目录前调用
*/
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
System.out.println("[小崔] 进入目录: " + dir.getFileName());
return FileVisitResult.CONTINUE; // 继续遍历
}
/**
* 访问到文件时调用
*/
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
// 只处理 .log 文件
if (!file.toString().endsWith(".log")) {
return FileVisitResult.CONTINUE;
}
// 解析月份 (从路径中提取 "YYYY-MM")
String monthKey = extractMonth(file);
if (monthKey == null) {
return FileVisitResult.CONTINUE;
}
// 更新统计
long fileSize = attrs.size();
totalSize.addAndGet(fileSize);
totalFiles.incrementAndGet();
monthStats.computeIfAbsent(monthKey, k -> new FileStat())
.addFile(file.getFileName().toString(), fileSize);
System.out.printf(" [白歌] 发现日志: %s (%d bytes) [%s]%n",
file.getFileName(), fileSize, monthKey);
return FileVisitResult.CONTINUE;
}
/**
* 访问文件失败时调用(权限不足等)
*/
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc)
throws IOException {
System.err.println("[小崔] 无法访问文件: " + file + " - " + exc.getMessage());
return FileVisitResult.CONTINUE; // 继续遍历其他文件
}
/**
* 离开目录后调用
*/
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
throws IOException {
System.out.println("[小崔] 离开目录: " + dir.getFileName());
if (exc != null) {
System.err.println("[白歌] 目录遍历异常: " + exc.getMessage());
}
return FileVisitResult.CONTINUE;
}
});
System.out.println("[大翔] 遍历完成!\n");
}
/**
* 从文件路径中提取月份 (YYYY-MM)
*/
private static String extractMonth(Path file) {
// 路径格式: logs/2024-01/15/app.log
Path parent = file.getParent(); // logs/2024-01/15
if (parent != null) {
Path month = parent.getParent(); // logs/2024-01
if (month != null) {
String monthName = month.getFileName().toString();
if (monthName.matches("\\d{4}-\\d{2}")) {
return monthName;
}
}
}
return null;
}
/**
* 打印统计报告
*/
private static void printReport(Path logRoot) {
System.out.println("========================================");
System.out.println(" 飞翔科技 日志文件统计报告");
System.out.println(" 根目录: " + logRoot.toAbsolutePath());
System.out.println("========================================");
System.out.printf(" 总文件数: %d%n", totalFiles.get());
System.out.printf(" 总大小: %.2f KB (%d bytes)%n",
totalSize.get() / 1024.0, totalSize.get());
System.out.println("----------------------------------------");
for (Map.Entry<String, FileStat> entry : monthStats.entrySet()) {
FileStat stat = entry.getValue();
System.out.printf(" [%s] 文件数: %d, 大小: %d bytes%n",
entry.getKey(), stat.fileCount, stat.totalBytes);
}
System.out.println("========================================");
}
/**
* 内部统计类
*/
static class FileStat {
int fileCount = 0;
long totalBytes = 0;
List<String> fileNames = new ArrayList<>();
void addFile(String name, long bytes) {
fileCount++;
totalBytes += bytes;
fileNames.add(name);
}
}
}
控制台输出:
[大翔] 测试日志文件已准备完成,共 10 个文件
[大翔] 开始遍历日志目录: d:\feixiang\logs
[小崔] 进入目录: logs
[小崔] 进入目录: 2024-01
[小崔] 进入目录: 15
[白歌] 发现日志: app.log (42 bytes) [2024-01]
[白歌] 发现日志: error.log (27 bytes) [2024-01]
[小崔] 离开目录: 15
[小崔] 进入目录: 20
[白歌] 发现日志: app.log (20 bytes) [2024-01]
[小崔] 离开目录: 20
[小崔] 离开目录: 2024-01
[小崔] 进入目录: 2024-02
[小崔] 进入目录: 05
[白歌] 发现日志: app.log (27 bytes) [2024-02]
[白歌] 发现日志: debug.log (22 bytes) [2024-02]
[小崔] 离开目录: 05
[小崔] 进入目录: 14
[白歌] 发现日志: app.log (24 bytes) [2024-02]
[白歌] 发现日志: access.log (27 bytes) [2024-02]
[小崔] 离开目录: 14
[小崔] 离开目录: 2024-02
[小崔] 进入目录: 2024-03
[小崔] 进入目录: 01
[白歌] 发现日志: app.log (21 bytes) [2024-03]
[白歌] 发现日志: error.log (22 bytes) [2024-03]
[小崔] 离开目录: 01
[小崔] 进入目录: 15
[白歌] 发现日志: app.log (22 bytes) [2024-03]
[小崔] 离开目录: 15
[小崔] 离开目录: 2024-03
[小崔] 离开目录: logs
[大翔] 遍历完成!
========================================
飞翔科技 日志文件统计报告
根目录: d:\feixiang\logs
========================================
总文件数: 10
总大小: 0.25 KB (254 bytes)
----------------------------------------
[2024-01] 文件数: 3, 大小: 89 bytes
[2024-02] 文件数: 4, 大小: 100 bytes
[2024-03] 文件数: 3, 大小: 65 bytes
========================================
示例2:飞翔科技批量文件重命名与迁移工具
场景:白歌发现项目中的图片资源命名不规范——有的用空格,有的用中文,有的甚至没有扩展名。她写了一个工具,用 NIO.2 的
Files.move和Files.walk批量重命名和整理。
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 飞翔科技 - 批量文件重命名与迁移工具
* 场景:资源文件命名不规范,需要统一格式化
* 功能:
* 1. 递归遍历所有文件
* 2. 去除文件名中的空格和特殊字符
* 3. 统一扩展名为小写
* 4. 按修改时间归档到分类目录
* 作者:白歌
*/
public class FeixiangFileBatchRename {
private static final String SOURCE_DIR = "d:/feixiang/assets_in";
private static final String TARGET_DIR = "d:/feixiang/assets_out";
// 重命名统计
private static int renamedCount = 0;
private static int copiedCount = 0;
private static int skippedCount = 0;
public static void main(String[] args) throws IOException {
// 1. 准备测试文件
prepareMessyFiles();
// 2. 批量重命名(原地改名)
batchRename(Paths.get(SOURCE_DIR));
// 3. 按时间归档到目标目录
archiveByMonth(Paths.get(SOURCE_DIR), Paths.get(TARGET_DIR));
// 4. 对比两个目录
compareDirectories();
// 5. 打印报告
printSummary();
}
/**
* 准备命名混乱的测试文件
*/
private static void prepareMessyFiles() throws IOException {
// 清理并创建源目录
deleteIfExists(Paths.get(SOURCE_DIR));
deleteIfExists(Paths.get(TARGET_DIR));
Files.createDirectories(Paths.get(SOURCE_DIR));
Files.createDirectories(Paths.get(Paths.get(SOURCE_DIR), "subdir"));
// 各种不规范命名
Path[] messyFiles = {
Paths.get(SOURCE_DIR, "大翔 的 照片.JPG"), // 中文+空格
Paths.get(SOURCE_DIR, "logo final.png"), // 多余空格
Paths.get(SOURCE_DIR, "FEIXIANG-BANNER.PNG"), // 大写扩展名
Paths.get(SOURCE_DIR, "pic_2024_01_15"), // 无扩展名
Paths.get(SOURCE_DIR, "报表 -- 2024.xlsx"), // 特殊字符
Paths.get(SOURCE_DIR, "subdir", "Frank's Photo.jpg"), // 子目录 + 引号
Paths.get(SOURCE_DIR, "subdir", "zhulu_resume.PDF"), // 大写扩展名
Paths.get(SOURCE_DIR, "subdir", "README.txt"), // 正常命名
};
for (Path f : messyFiles) {
Files.createDirectories(f.getParent());
// 写入一些内容以创建文件
Files.write(f, ("file: " + f.getFileName()).getBytes("UTF-8"),
StandardOpenOption.CREATE);
}
System.out.println("[白歌] 测试文件准备完成,共 " + messyFiles.length + " 个文件\n");
}
/**
* 批量重命名:规范文件名
* 规则:
* - 中文空格替换为下划线
* - 多个空格合并为一个下划线
* - 去除特殊字符(保留 中文/英文/数字/下划线/连字符/点)
* - 扩展名统一为小写
*/
public static void batchRename(Path rootDir) throws IOException {
System.out.println("========== 阶段一:批量重命名 ==========");
try (Stream<Path> files = Files.walk(rootDir)) {
List<Path> allFiles = files
.filter(Files::isRegularFile)
.collect(Collectors.toList());
for (Path file : allFiles) {
String oldName = file.getFileName().toString();
String newName = normalizeFileName(oldName);
if (!newName.equals(oldName)) {
Path newPath = file.resolveSibling(newName);
Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING);
renamedCount++;
System.out.printf("[小崔] 重命名: '%s' => '%s'%n", oldName, newName);
} else {
skippedCount++;
}
}
}
System.out.println();
}
/**
* 规范化文件名
*/
private static String normalizeFileName(String fileName) {
// 分离文件名和扩展名
int dotIndex = fileName.lastIndexOf('.');
String namePart, extPart;
if (dotIndex > 0) {
namePart = fileName.substring(0, dotIndex);
extPart = "." + fileName.substring(dotIndex + 1).toLowerCase();
} else {
namePart = fileName;
extPart = "";
}
// 去除特殊字符(保留中文、字母、数字、下划线、连字符)
String cleaned = namePart.replaceAll("[^\\u4e00-\\u9fa5a-zA-Z0-9_\\-\\s]", "");
// 多余空格合并为单个下划线
cleaned = cleaned.replaceAll("\\s+", "_");
// 多个下划线合并
cleaned = cleaned.replaceAll("_+", "_");
// 去除首尾下划线
cleaned = cleaned.replaceAll("^_|_$", "");
// 如果清理后为空,使用默认名
if (cleaned.isEmpty()) {
cleaned = "unnamed";
}
return cleaned + extPart;
}
/**
* 按修改月份归档到目标目录
*/
public static void archiveByMonth(Path srcDir, Path destDir) throws IOException {
System.out.println("========== 阶段二:按时间归档 ==========");
Files.createDirectories(destDir);
try (Stream<Path> files = Files.walk(srcDir)) {
files.filter(Files::isRegularFile).forEach(file -> {
try {
BasicFileAttributes attrs =
Files.readAttributes(file, BasicFileAttributes.class);
// 获取修改时间的年月
FileTime modTime = attrs.lastModifiedTime();
String yearMonth = modTime.toString().substring(0, 7); // "2024-03"
// 目标子目录
Path monthDir = destDir.resolve(yearMonth);
Files.createDirectories(monthDir);
// 目标文件路径
Path destFile = monthDir.resolve(file.getFileName());
Files.copy(file, destFile, StandardCopyOption.REPLACE_EXISTING);
copiedCount++;
System.out.printf("[白歌] 归档: %s => %s%n",
file.getFileName(), destFile);
} catch (IOException e) {
System.err.println("[小崔] 归档失败: " + file + " - " + e.getMessage());
}
});
}
System.out.println();
}
/**
* 对比源目录和目标目录的内容
*/
private static void compareDirectories() throws IOException {
System.out.println("========== 阶段三:目录对比 ==========");
System.out.println("[Frank] 源目录 (整理后):");
try (Stream<Path> files = Files.walk(Paths.get(SOURCE_DIR))) {
files.filter(Files::isRegularFile)
.forEach(f -> System.out.println(" " + f.getFileName()));
}
System.out.println("\n[Frank] 目标目录 (归档后):");
try (Stream<Path> files = Files.walk(Paths.get(TARGET_DIR))) {
files.filter(Files::isRegularFile)
.forEach(f -> System.out.println(" " + f));
}
System.out.println();
}
private static void printSummary() {
System.out.println("========================================");
System.out.println(" 飞翔科技 批量文件处理报告");
System.out.println("========================================");
System.out.printf(" 重命名: %d 个文件%n", renamedCount);
System.out.printf(" 归档: %d 个文件%n", copiedCount);
System.out.printf(" 跳过: %d 个文件 (名称已规范)%n", skippedCount);
System.out.printf(" 源目录: %s%n", SOURCE_DIR);
System.out.printf(" 目标目录: %s%n", TARGET_DIR);
System.out.println("========================================");
}
private static void deleteIfExists(Path dir) throws IOException {
if (Files.exists(dir)) {
try (Stream<Path> files = Files.walk(dir)) {
files.sorted(Comparator.reverseOrder()) // 先删文件再删目录
.forEach(f -> {
try { Files.deleteIfExists(f); }
catch (IOException e) { /* ignore */ }
});
}
}
}
}
控制台输出:
[白歌] 测试文件准备完成,共 8 个文件
========== 阶段一:批量重命名 ==========
[小崔] 重命名: '大翔 的 照片.JPG' => '大翔_的照片.jpg'
[小崔] 重命名: 'logo final.png' => 'logo_final.png'
[小崔] 重命名: 'FEIXIANG-BANNER.PNG' => 'FEIXIANG-BANNER.png'
[小崔] 重命名: '报表 -- 2024.xlsx' => '报表_2024.xlsx'
[小崔] 重命名: 'Frank's Photo.jpg' => 'Franks_Photo.jpg'
[小崔] 重命名: 'zhulu_resume.PDF' => 'zhulu_resume.pdf'
========== 阶段二:按时间归档 ==========
[白歌] 归档: 大翔_的照片.jpg => d:\feixiang\assets_out\2024-03\大翔_的照片.jpg
[白歌] 归档: logo_final.png => d:\feixiang\assets_out\2024-03\logo_final.png
[白歌] 归档: FEIXIANG-BANNER.png => d:\feixiang\assets_out\2024-03\FEIXIANG-BANNER.png
[白歌] 归档: pic_2024_01_15 => d:\feixiang\assets_out\2024-03\pic_2024_01_15
[白歌] 归档: 报表_2024.xlsx => d:\feixiang\assets_out\2024-03\报表_2024.xlsx
[白歌] 归档: Franks_Photo.jpg => d:\feixiang\assets_out\2024-03\Franks_Photo.jpg
[白歌] 归档: zhulu_resume.pdf => d:\feixiang\assets_out\2024-03\zhulu_resume.pdf
[白歌] 归档: README.txt => d:\feixiang\assets_out\2024-03\README.txt
========== 阶段三:目录对比 ==========
[Frank] 源目录 (整理后):
大翔_的照片.jpg
logo_final.png
FEIXIANG-BANNER.png
pic_2024_01_15
报表_2024.xlsx
Franks_Photo.jpg
zhulu_resume.pdf
README.txt
[Frank] 目标目录 (归档后):
d:\feixiang\assets_out\2024-03\大翔_的照片.jpg
d:\feixiang\assets_out\2024-03\logo_final.png
... (省略)
========================================
飞翔科技 批量文件处理报告
========================================
重命名: 6 个文件
归档: 8 个文件
跳过: 2 个文件 (名称已规范)
源目录: d:/feixiang/assets_in
目标目录: d:/feixiang/assets_out
========================================
示例3:Files 便捷方法大集合 — Path vs File 对照
场景:朱璐还在用老式File API写代码,Frank建议她切换到Path。大翔写了对照示例展示Path的优雅之处。
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;
import java.util.stream.Stream;
/**
* 飞翔科技 - Path vs File 对比示例
* 场景:对比 JDK 7 NIO.2 与传统 IO 的文件操作方式
* 作者:大翔 & Frank
*/
public class PathVsFileComparison {
public static void main(String[] args) throws IOException {
System.out.println("========== 飞翔科技 Path vs File 对比 ==========\n");
compare_CreateAndDelete();
compare_ReadWrite();
compare_ListFiles();
compare_CopyAndMove();
compare_FileAttributes();
}
/**
* 对比1:创建和删除操作
*/
private static void compare_CreateAndDelete() throws IOException {
System.out.println("--- 创建与删除 ---");
// ❌ 老式 File API
File oldDir = new File("d:/feixiang/comparison/file_way");
boolean created = oldDir.mkdirs(); // 返回boolean,失败时不知道原因
System.out.println("[朱璐] File.mkdirs: " + created);
File oldFile = new File(oldDir, "test.txt");
oldFile.createNewFile(); // 返回boolean
// ✅ NIO.2 Path API
Path newDir = Paths.get("d:/feixiang/comparison/path_way");
Path result = Files.createDirectories(newDir); // 返回创建的路径
System.out.println("[Frank] Files.createDirectories: " + result);
Path newFile = newDir.resolve("test.txt");
Files.createFile(newFile); // 文件已存在抛 FileAlreadyExistsException
// 删除清理
Files.delete(newFile); // 文件不存在抛异常
Files.deleteIfExists(newFile); // 无异常版本
oldFile.delete();
}
/**
* 对比2:读写文件
*/
private static void compare_ReadWrite() throws IOException {
System.out.println("\n--- 文件读写 ---");
Path file = Paths.get("d:/feixiang/comparison/readwrite.txt");
Files.createDirectories(file.getParent());
// ✅ NIO.2: 一行读取所有行
Files.write(file,
java.util.Arrays.asList("飞翔科技", "Feixiang Technology", "2024"),
StandardCharsets.UTF_8);
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
System.out.println("[白歌] readAllLines: " + lines);
// ✅ NIO.2: 一次性读取所有字节
byte[] allBytes = Files.readAllBytes(file);
System.out.println("[大翔] readAllBytes length: " + allBytes.length);
// ✅ NIO.2: 使用 Stream 逐行处理(大文件友好)
try (Stream<String> lineStream = Files.lines(file, StandardCharsets.UTF_8)) {
long count = lineStream
.filter(l -> l.contains("飞翔"))
.count();
System.out.println("[Frank] lines().filter 包含'飞翔'的行数: " + count);
}
// ✅ NIO.2: newBufferedReader / newBufferedWriter 与传统方式对比
// 传统方式:
// BufferedReader br = new BufferedReader(new InputStreamReader(
// new FileInputStream("file"), "UTF-8"));
// NIO.2方式(更简洁):
try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String firstLine = br.readLine();
System.out.println("[白歌] newBufferedReader 第一行: " + firstLine);
}
// 清理
Files.deleteIfExists(file);
}
/**
* 对比3:列出目录
*/
private static void compare_ListFiles() throws IOException {
System.out.println("\n--- 目录列表 ---");
Path dir = Paths.get("d:/feixiang/comparison/list_test");
Files.createDirectories(dir);
Files.createFile(dir.resolve("a.txt"));
Files.createFile(dir.resolve("b.txt"));
Files.createFile(dir.resolve("c.log"));
Files.createDirectory(dir.resolve("subdir"));
// ❌ File: listFiles 返回 File[],需要写循环
File oldDir = dir.toFile();
File[] files = oldDir.listFiles();
if (files != null) {
for (File f : files) {
System.out.println("[朱璐] File.listFiles: " + f.getName());
}
}
// ✅ NIO.2: Files.list 返回 Stream<Path>(try-with-resources保证关闭)
System.out.println("--- 仅列出 .txt 文件 ---");
try (Stream<Path> stream = Files.list(dir)) {
stream.filter(p -> p.toString().endsWith(".txt"))
.forEach(p -> System.out.println(
"[Frank] Files.list.filter: " + p.getFileName()));
}
// ✅ NIO.2: Files.walk 返回 Stream(递归,限制深度)
System.out.println("--- 递归遍历(深度2) ---");
try (Stream<Path> stream = Files.walk(dir, 2)) {
stream.forEach(p -> System.out.println(
"[大翔] Files.walk: " + p.getFileName()));
}
// 清理
Files.walk(dir)
.sorted(java.util.Comparator.reverseOrder())
.forEach(p -> {
try { Files.deleteIfExists(p); }
catch (IOException e) { /* ignore */ }
});
}
/**
* 对比4:复制和移动
*/
private static void compare_CopyAndMove() throws IOException {
System.out.println("\n--- 复制与移动 ---");
Path workDir = Paths.get("d:/feixiang/comparison/cmtest");
Files.createDirectories(workDir);
Path src = workDir.resolve("source.txt");
Files.write(src, "飞翔科技源文件".getBytes(StandardCharsets.UTF_8));
// ✅ NIO.2: Files.copy
Path dest = workDir.resolve("copy_target.txt");
Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING);
System.out.println("[白歌] Files.copy: " + Files.exists(dest));
// ✅ NIO.2: Files.move (相当于重命名)
Path moved = workDir.resolve("moved_target.txt");
Files.move(dest, moved, StandardCopyOption.REPLACE_EXISTING);
System.out.println("[大翔] Files.move: copy_target.txt -> moved_target.txt, "
+ "原文件存在=" + Files.exists(dest));
// ❌ File: renameTo 不可靠(跨文件系统可能失败、没有异常信息)
File oldFile = new File(workDir.toFile(), "old.txt");
oldFile.createNewFile();
File newFile = new File(workDir.toFile(), "new.txt");
boolean renamed = oldFile.renameTo(newFile);
System.out.println("[朱璐] File.renameTo: " + renamed
+ " (无异常信息,失败也返回false)");
// 清理
Files.deleteIfExists(src);
Files.deleteIfExists(moved);
Files.deleteIfExists(newFile.toPath());
Files.deleteIfExists(workDir);
}
/**
* 对比5:文件属性
*/
private static void compare_FileAttributes() throws IOException {
System.out.println("\n--- 文件属性 ---");
Path file = Paths.get("d:/feixiang/comparison/attr_test.txt");
Files.createDirectories(file.getParent());
Files.write(file, "test".getBytes(StandardCharsets.UTF_8));
// ✅ NIO.2: 一次性读取所有基本属性
BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
System.out.println("[Frank] BasicFileAttributes:");
System.out.println(" size: " + attrs.size());
System.out.println(" isDirectory: " + attrs.isDirectory());
System.out.println(" creationTime: " + attrs.creationTime());
System.out.println(" lastModifiedTime: " + attrs.lastModifiedTime());
// ✅ NIO.2: Files 直接提供的便捷方法
long size = Files.size(file);
boolean exists = Files.exists(file);
System.out.println("[大翔] Files.size: " + size);
System.out.println("[大翔] Files.exists: " + exists);
// ❌ File: 每次属性查询是一个系统调用
File oldFile = file.toFile();
System.out.println("[朱璐] File.length: " + oldFile.length());
System.out.println("[朱璐] File.exists: " + oldFile.exists());
System.out.println("[朱璐] File.lastModified: " + oldFile.lastModified());
Files.deleteIfExists(file);
Files.deleteIfExists(file.getParent());
}
}
控制台输出(部分):
========== 飞翔科技 Path vs File 对比 ==========
--- 创建与删除 ---
[朱璐] File.mkdirs: true
[Frank] Files.createDirectories: d:\feixiang\comparison\path_way
--- 文件读写 ---
[白歌] readAllLines: [飞翔科技, Feixiang Technology, 2024]
[大翔] readAllBytes length: 38
[Frank] lines().filter 包含'飞翔'的行数: 1
[白歌] newBufferedReader 第一行: 飞翔科技
--- 目录列表 ---
[朱璐] File.listFiles: a.txt
[朱璐] File.listFiles: b.txt
[朱璐] File.listFiles: c.log
[朱璐] File.listFiles: subdir
--- 仅列出 .txt 文件 ---
[Frank] Files.list.filter: a.txt
[Frank] Files.list.filter: b.txt
--- 递归遍历(深度2) ---
[大翔] Files.walk: list_test
[大翔] Files.walk: a.txt
[大翔] Files.walk: b.txt
[大翔] Files.walk: c.log
[大翔] Files.walk: subdir
--- 复制与移动 ---
[白歌] Files.copy: true
[大翔] Files.move: copy_target.txt -> moved_target.txt, 原文件存在=false
[朱璐] File.renameTo: true (无异常信息,失败也返回false)
--- 文件属性 ---
[Frank] BasicFileAttributes:
size: 4
isDirectory: false
creationTime: 2024-03-15T10:30:45Z
lastModifiedTime: 2024-03-15T10:30:45Z
[大翔] Files.size: 4
[大翔] Files.exists: true
[朱璐] File.length: 4
[朱璐] File.exists: true
[朱璐] File.lastModified: 1710502245000
六、易错场景
易错1:Files.walk / Files.list 返回的 Stream 忘记关闭
// ❌ 错误:Stream 未关闭导致资源泄漏
Stream<Path> files = Files.walk(Paths.get("d:/feixiang"));
files.filter(Files::isRegularFile)
.forEach(System.out::println);
// Stream 打开的文件描述符/目录句柄没有释放!
// ✅ 正确:使用 try-with-resources
try (Stream<Path> files = Files.walk(Paths.get("d:/feixiang"))) {
files.filter(Files::isRegularFile)
.forEach(System.out::println);
} // 自动关闭 Stream,释放底层资源
原因:Files.walk() 和 Files.list() 返回的 Stream 包装了底层的 DirectoryStream,必须关闭才能释放目录句柄。在Windows上,未关闭的目录句柄会导致该目录无法被删除。
易错2:Files.delete() vs Files.deleteIfExists()
// ❌ 错误:盲目使用 Files.delete()
Files.delete(Paths.get("d:/feixiang/may_not_exist.txt"));
// 文件不存在 → NoSuchFileException!
// 目录非空 → DirectoryNotEmptyException!
// ✅ 正确:先检查或使用 deleteIfExists
Path path = Paths.get("d:/feixiang/may_not_exist.txt");
Files.deleteIfExists(path); // 文件不存在仅返回false,不抛异常
// ✅ 删除非空目录的正确方式
Path dir = Paths.get("d:/feixiang/non_empty_dir");
try (Stream<Path> files = Files.walk(dir)) {
files.sorted(Comparator.reverseOrder()) // 深度优先,先删文件后删目录
.forEach(p -> {
try {
Files.delete(p);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
易错3:Paths.get() 在不同OS上的路径分隔符差异
// ❌ 硬编码 Windows 路径分隔符
Path winPath = Paths.get("d:\\feixiang\\logs\\app.log"); // 仅Windows可用
// ❌ 硬编码 Unix 路径分隔符
Path unixPath = Paths.get("/opt/feixiang/logs/app.log"); // 仅Linux/Mac可用
// ✅ 使用可变参数或 File.separator
Path path1 = Paths.get("d:", "feixiang", "logs", "app.log"); // 跨平台
Path path2 = Paths.get(System.getProperty("user.dir"), "logs", "app.log");
Path path3 = FileSystems.getDefault().getPath("d:", "feixiang", "logs");
// 或者使用 resolve 拼接
Path base = Paths.get("d:/feixiang");
Path full = base.resolve("logs").resolve("app.log");
易错4:readAllLines 用于大文件导致 OOM
// ❌ 错误:2GB的日志文件用 readAllLines → OutOfMemoryError
List<String> lines = Files.readAllLines(Paths.get("d:/huge_server.log"));
for (String line : lines) {
processLine(line);
}
// ✅ 方案1:使用 Files.lines() 返回 Stream(惰性读取)
try (Stream<String> lines = Files.lines(Paths.get("d:/huge_server.log"))) {
lines.filter(l -> l.contains("ERROR"))
.forEach(System.out::println);
}
// ✅ 方案2:使用 newBufferedReader 逐行读取
try (BufferedReader br = Files.newBufferedReader(
Paths.get("d:/huge_server.log"), StandardCharsets.UTF_8)) {
String line;
while ((line = br.readLine()) != null) {
processLine(line);
}
}
易错5:混淆 resolve 和 resolveSibling
Path path = Paths.get("d:/feixiang/logs/app.log");
// resolve: 在路径末尾追加 → d:/feixiang/logs/app.log/data
Path r1 = path.resolve("data");
// resolveSibling: 替换路径最后一段 → d:/feixiang/logs/error.log
Path r2 = path.resolveSibling("error.log");
// 常见错误:想拼接到目录层级,却用了 resolve
Path dir = Paths.get("d:/feixiang/logs"); // 这是一个目录
Path file = dir.resolve("app.log"); // d:/feixiang/logs/app.log ✅
Path filePath = Paths.get("d:/feixiang/logs/app.log"); // 这是一个文件路径
Path wrong = filePath.resolve("error.log"); // d:/feixiang/logs/app.log/error.log ❌
Path correct = filePath.resolveSibling("error.log"); // d:/feixiang/logs/error.log ✅
七、面试考点
Q1: java.io.File 和 java.nio.file.Path 有什么区别?为什么要用Path?
答:
| 维度 | File | Path |
|---|---|---|
| 引入版本 | JDK 1.0 | JDK 7 |
| 错误处理 | 返回 boolean | 抛出具体异常 |
| 不可变性 | 可变 | 不可变 |
| 方法丰富度 | 有限的路径操作 | 丰富的路径拼接、解析、比较方法 |
| 符号链接 | 不支持 | 支持 |
| 文件属性 | 有限 | 丰富的属性视图 |
| 与Stream集成 | 无 | walk/list/find/lines 返回Stream |
| 跨文件系统 | renameTo 不可靠 | move/copy 跨文件系统可靠 |
追问:什么时候还需要用File?——与遗留代码交互、某些第三方库参数要求File类型时。可以通过 Path.toFile() 和 File.toPath() 互转。
Q2: Files.walk() 和 Files.walkFileTree() 有什么区别?
答:
- Files.walk():返回
Stream<Path>,惰性遍历。适合过滤、收集等Stream操作。最大深度可配(默认Integer.MAX_VALUE)。需在 try-with-resources 中使用。 - Files.walkFileTree():使用访问者模式(
FileVisitor)。在进入/离开目录、访问文件时有回调,可以在遍历过程中执行复杂逻辑,可以随时用FileVisitResult.TERMINATE终止,可以SKIP_SUBTREE跳过子树。
选择建议:
- 简单的过滤+收集 →
Files.walk() - 需要知道进入/离开目录事件、需要跳过子树、需要处理访问失败 →
Files.walkFileTree()
Q3: Files.copy 的三个 StandardCopyOption 有什么区别?
答:
- REPLACE_EXISTING:目标文件存在时替换。不指定时,目标存在会抛
FileAlreadyExistsException。 - COPY_ATTRIBUTES:复制文件的最后修改时间等元数据到目标文件(尽力而为,不是所有文件系统都支持)。
- ATOMIC_MOVE:
Files.move()专用,确保移动是原子操作——要么完全成功,要么完全不变。对于保证数据一致性重要(如防止进程崩溃时文件半移动)。不是所有文件系统都支持此选项,不支持时抛AtomicMoveNotSupportedException。
Q4: 如何用NIO.2高效读取一个超大日志文件?
答:
// ❌ 不推荐:readAllLines → OOM
// ❌ 不推荐:readAllBytes → OOM
// ✅ 最佳实践:Files.lines() 惰性读取 + Stream处理
try (Stream<String> lines = Files.lines(
Paths.get("d:/huge.log"), StandardCharsets.UTF_8)) {
// 统计ERROR行数而不加载全部到内存
long errorCount = lines.filter(l -> l.contains("ERROR")).count();
System.out.println("ERROR count: " + errorCount);
}
// ✅ 备选:Files.newBufferedReader 传统方式
try (BufferedReader br = Files.newBufferedReader(
Paths.get("d:/huge.log"), StandardCharsets.UTF_8)) {
String line;
int lineNum = 0;
while ((line = br.readLine()) != null) {
if (lineNum++ % 100000 == 0) {
System.out.println("Processing line " + lineNum);
}
}
}
进阶:还可以使用 FileChannel(NIO核心通道)配合 MappedByteBuffer 进行内存映射文件访问,实现零拷贝读取。
Q5: NIO.2 中的 Path 为什么设计成不可变的?
答:
- 线程安全:不可变对象天然线程安全,无需同步。
- 避免副作用:类似 String,
resolve()、normalize()等操作返回新实例,不改变原对象,避免在共享引用时产生意外的副作用。 - 可缓存/Hash友好:不可变对象可以作为Map的Key使用,hashCode不会变化。
- 设计一致性:JDK 7 同时引入的
java.time包也采用了不可变设计,这是现代Java API的设计趋势。
八、JDK 8 NIO 补充:Stream 集成
JDK 8 为 Files 类新增了返回 Stream 的方法,与 Lambda/Stream API 无缝集成:
// Files.list: 列出当前目录(非递归)
try (Stream<Path> entries = Files.list(Paths.get("."))) {
entries.filter(Files::isRegularFile)
.sorted(Comparator.comparingLong(p -> {
try { return Files.size(p); }
catch (IOException e) { return 0L; }
}))
.forEach(System.out::println);
}
// Files.find: 按条件搜索(递归)
try (Stream<Path> matches = Files.find(
Paths.get("d:/feixiang"),
5, // 最大深度
(path, attrs) -> attrs.isRegularFile()
&& path.toString().endsWith(".log")
&& attrs.size() > 1024)) {
matches.forEach(System.out::println);
}
// Files.lines: 读取文件所有行作为 Stream
try (Stream<String> lines = Files.lines(
Paths.get("d:/feixiang/data.csv"), StandardCharsets.UTF_8)) {
List<String> headers = lines.skip(0).limit(1)
.flatMap(line -> Stream.of(line.split(",")))
.collect(Collectors.toList());
System.out.println("CSV Headers: " + headers);
}
九、最佳实践总结
| 实践 | 说明 |
|---|---|
| 新代码优先使用 Path,而非 File | Path 不可变、方法丰富、错误处理优秀 |
| 使用 try-with-resources 包裹 Files.walk/list/lines | 避免资源泄漏(目录句柄/文件句柄) |
| 大文件用 Files.lines() 而非 readAllLines | 避免 OOM,惰性读取 |
| 使用 StandardCharsets 常量 | 编译期安全,避免 UnsupportedEncodingException |
| 跨平台时用可变参数 Paths.get() | Paths.get("a", "b", "c") 而非字符串拼接 |
| 显式指定删除/复制选项 | REPLACE_EXISTING 等选项使意图明确 |
| 文件遍历注意符号链接循环 | FOLLOW_LINKS 选项可能导致无限循环 |
| 批量操作使用 Stream 而非循环 | 代码简洁,支持并行处理 |
大翔合上电脑:"JDK 7 的 NIO.2 是 Java IO 史上最大的革新之一。Path、Files、FileVisitor 这套API,把以前各种繁琐的文件操作变得优雅简洁。再配合 JDK 8 的 Stream,文件处理代码可以写得像诗一样。"白歌点头:"不过面试的时候,至少要能说清楚 File 和 Path 的区别、walk 和 walkFileTree 的区别、以及 readAllLines 为什么不适合大文件。"Frank 远程语音里传来笑声:"My rule of thumb — if you're still typing 'new File()' in a new project, you're doing it wrong. Long live java.nio.file!" 小崔看着笔记:"原来文件操作还能这么写!之前用File的递归遍历简直是受罪……"朱璐已经开始重构她的代码了。