1. 项目概述:先搞清楚文件复制的真实需求
我平时帮同事排错时发现,很多人对“文件夹复制”的理解就是“把A目录的文件搬到B目录”。但实际业务里,这个操作往往要复杂得多:目标目录可能已经存在同名文件,源目录下还有嵌套子目录,文件里可能有大视频、小配置,还可能混着隐藏文件和临时文件。
这期内容我打算用一个最典型的场景开刀:用 Java 实现把一个文件夹下的所有文件复制到另一个文件夹。我会从最朴素的双重循环开始,讲到 Java 7 之后的Files和walkFileTree,再补充权限、属性、空目录这些容易被忽略的角落。无论你是准备面试时遇到“手写一个递归复制目录”,还是负责写一个数据迁移工具,这篇文章里提到的方案应该都能直接改改拿去用。
先说清楚需求边界。我默认的输入是:源目录存在、目标目录可以不存在(自动创建)、复制行为包含所有子文件和子目录、覆盖同名的目标文件、不复制源目录自身这一层外壳。如果你的场景更简单(比如只搬当前一层文件),可以直接跳到对应小节删改代码。
2. 核心思路拆解:为什么“复制文件夹”不是“读文件再写文件”那么简单
2.1 复制操作背后隐藏的三个维度
很多初学者闭着眼睛就写出InputStream.read加OutputStream.write的循环,结果踩坑后才意识到,文件复制至少有三个维度要同时兼顾:
第一是文件本身的数据,这是最直观的维度。但光搬数据不够,第二个维度是目录结构。源目录里可能有a/b/c.txt这种三层嵌套,如果只是把c.txt搬到目标根目录,整个项目的相对路径就乱了,后续引用资源的代码全会报错。第三个维度是文件元数据,包括最后修改时间、权限位、隐藏属性、符号链接目标等。日常开发中修改时间很重要,一批配置文件复制过去后全变成当前时间,打包时增量比对就会失效。
所以“复制文件夹”本质上是一次带结构的递归遍历,外加一次元数据尽量保留的流式拷贝。想清楚这个,后面所有设计都围绕它展开。
2.2 适合不同场景的三个方案选型
我在实际项目里一般只考虑三种方案:
- 方案 A:手工递归 +
FileInputStream/FileOutputStream字节复制。优点是零依赖、任何 JDK 版本都能运行,适合嵌入式环境或老旧项目。 - 方案 B:手工递归 +
Files.copy。代码短很多,性能也好,适合 JDK 7+ 的绝大多数业务系统。 - 方案 C:
Files.walkFileTree+ 覆盖visitFile和preVisitDirectory。这是最“专业”的做法,能细粒度控制过滤逻辑,适合做备份工具、资源同步器和面试手写题。
后面我会把 B 和 C 都完整写出来,A 会简讲原理。因为实际工作中方案 B 最常用,而方案 C 最能体现对 Java NIO 的理解深度。
3. 实操过程与核心环节实现
3.1 基础版本:递归遍历目录并逐文件复制
先看一个最容易理解的版本。核心逻辑分三步:目标目录不存在则创建;遍历源目录下的所有条目;如果条目是子目录就递归,如果是文件就直接复制。
import java.io.*; public class FolderCopyBasic { public static void main(String[] args) throws IOException { File source = new File("/data/input"); File target = new File("/backup/output"); copyFolderByRecursion(source, target); System.out.println("复制完成"); } public static void copyFolderByRecursion(File source, File target) throws IOException { if (source == null || !source.exists() || !source.isDirectory()) { throw new IllegalArgumentException("源目录无效:" + source); } if (!target.exists()) { if (!target.mkdirs()) { throw new IOException("无法创建目标目录:" + target); } } File[] files = source.listFiles(); if (files == null) { return; } for (File file : files) { if (file.isDirectory()) { copyFolderByRecursion(file, new File(target, file.getName())); } else { copyFileByStream(file, new File(target, file.getName())); } } } private static void copyFileByStream(File sourceFile, File targetFile) throws IOException { try (InputStream in = new FileInputStream(sourceFile); OutputStream out = new FileOutputStream(targetFile)) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } } }这里有个我特别想强调的细节:递归调用时,目标子目录必须用new File(target, file.getName())拼接,而不是直接传target.getPath() + "/" + file.getName()。因为 Windows 系统下如果target路径末尾有分隔符,手工拼接可能产生C:\backup\\sub这种双反斜杠,虽然通常能正常工作,但遇到某些第三方文件系统 API 时会出现怪异问题。用构造函数拼接是跨平台最稳妥的写法。
缓冲区大小 8192 不是随便写的,这是BufferedInputStream的默认缓冲区大小。我实测过 4096、8192、16384 三种规格,在小文件为主的数据集上差别不大,但 8192 在 JDK 内部有最优的 AIO 对齐优化,属于“性价比最高”的取值。
3.2 用 Files.copy 简化单文件复制逻辑
基础版代码能跑,但写起来太啰嗦。JDK 7 提供的Files.copy可以把copyFileByStream整个方法替换掉:
public static void copyFileByNio(File sourceFile, File targetFile) throws IOException { Files.copy(sourceFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); }Files.copy底层会检测文件是否在同一文件系统,如果是,它可能直接走操作系统的快速复制通道(比如 Linux 上的sendfile或 Windows 上的内建复制),速度比从 Java 层读字节再写字节快很多。
注意COPY_ATTRIBUTES这个选项,它能复制基础属性(修改时间、最后访问时间),但它不复制 ACL 权限和所有者。如果业务上要求复制后权限也要保持一致,需要额外做一步,这个我在第 4 节演示。
然后完整的递归版本可以改写成:
import java.io.IOException; import java.nio.file.*; public class FolderCopyNio { public static void main(String[] args) throws IOException { Path source = Paths.get("/data/input"); Path target = Paths.get("/backup/output"); copyDirectoryWithFiles(source, target); System.out.println("复制完成"); } public static void copyDirectoryWithFiles(Path source, Path target) throws IOException { if (Files.exists(target)) { if (!Files.isDirectory(target)) { throw new IOException("目标路径已存在且不是目录:" + target); } Files.walkFileTree(source, new SimpleFileVisitor<Path>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { Path targetDir = target.resolve(source.relativize(dir)); Files.createDirectories(targetDir); return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { Path targetFile = target.resolve(source.relativize(file)); Files.copy(file, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); return FileVisitResult.CONTINUE; } }); } else { Files.walkFileTree(source, new SimpleFileVisitor<Path>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { Path targetDir = target.resolve(source.relativize(dir)); Files.createDirectories(targetDir); return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { Path targetFile = target.resolve(source.relativize(file)); Files.copy(file, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); return FileVisitResult.CONTINUE; } }); } } }看到没,两个分支的 visitor 完全一样,这个if-else其实就是判断目标目录是否已经存在。如果不存在,preVisitDirectory里的Files.createDirectories会在第一次访问源根目录时自动创建整个目标路径;如果已经存在,我们直接复用空目录就好。其实不判断也能跑,因为createDirectories在目录存在时不会报错,但提前判断能让代码意图更清晰,面试时这也是加分点。
3.3 使用 FileVisitor 的进阶写法:自动过滤和异常处理
walkFileTree的另一个好处是能借助FileVisitResult控制遍历路径。比如我只想复制.java和.xml文件,不想复制target目录、.git目录或者*.tmp临时文件。
public class SelectiveFileCopy { public static void main(String[] args) throws IOException { Path source = Paths.get("/workspace/project"); Path target = Paths.get("/backup/project"); copyWithFilter(source, target); } private static final Set<String> SKIP_DIRS = Set.of("target", ".git", "node_modules"); public static void copyWithFilter(Path source, Path target) throws IOException { if (!Files.isDirectory(source)) { throw new IllegalArgumentException("源路径不是目录:" + source); } Files.walkFileTree(source, new SimpleFileVisitor<Path>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { String dirName = dir.getFileName() == null ? "" : dir.getFileName().toString(); if (SKIP_DIRS.contains(dirName)) { return FileVisitResult.SKIP_SUBTREE; } Path targetDir = target.resolve(source.relativize(dir)); Files.createDirectories(targetDir); return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { String fileName = file.getFileName().toString(); if (fileName.endsWith(".tmp") || fileName.endsWith(".bak")) { return FileVisitResult.CONTINUE; } Path targetFile = target.resolve(source.relativize(file)); Files.copy(file, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); System.out.println("已复制: " + source.relativize(file)); return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { System.err.println("访问文件失败: " + file + ",原因: " + exc.getMessage()); return FileVisitResult.CONTINUE; } }); } }SKIP_SUBTREE是这里的关键,它表示“这整个目录都不要了,连里面的文件都别遍历”。过滤target目录尤其重要,否则如果备份目录刚好建在源目录内部(比如/workspace/project/backup),递归遍历时会把已经复制过去的文件又复制一遍,陷入无限增长的灾难。
visitFileFailed是默认实现里最容易踩坑的钩子。官方SimpleFileVisitor的默认实现会抛出IOException,也就是遇到一个无权限访问的文件,整个复制就中断了。我接手过一个老系统,就是因为某个目录下有个权限为 000 的文件,整个备份任务一跑就失败,后来重写了这个方法把错误记录下来、继续下一个文件,任务才算稳定下来。
4. 核心细节解析:元数据、覆盖策略与性能优化
4.1 复制文件时间属性和权限
Files.copy用COPY_ATTRIBUTES只能复制基础时间属性。很多系统对目录的修改时间也很敏感,比如做增量发布时,“目录时间”是判断配置是否变更的重要依据。下面的代码展示了如何手动补齐目录和文件的全部基础属性:
public static void copyAttributesPreservingAll(Path sourceFile, Path targetFile, Path sourceDir, Path targetDir) throws IOException { BasicFileAttributes attrs = Files.readAttributes(sourceFile, BasicFileAttributes.class); FileTime lastModified = attrs.lastModifiedTime(); FileTime lastAccess = attrs.lastAccessTime(); Files.setLastModifiedTime(targetFile, lastModified); if (supportsPosix()) { Set<PosixFilePermission> perms = Files.getPosixFilePermissions(sourceFile); Files.setPosixFilePermissions(targetFile, perms); } if (supportsDos()) { DosFileAttributes dosAttrs = Files.readAttributes(sourceFile, DosFileAttributes.class); if (dosAttrs.isHidden()) { Files.setAttribute(targetFile, "dos:hidden", true); } else { Files.setAttribute(targetFile, "dos:hidden", false); } } // 目录的时间属性 Files.setLastModifiedTime(targetDir, Files.getLastModifiedTime(sourceDir)); } private static boolean supportsPosix() { return FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); } private static boolean supportsDos() { return FileSystems.getDefault().supportedFileAttributeViews().contains("dos"); }注意Files.getPosixFilePermissions在 Windows 上会抛UnsupportedOperationException,所以必须先用supportedFileAttributeViews()判断。这是最容易被忽略的跨平台问题。Windows 上对应的dos:hidden属性同样要判断后才会真正生效。
4.2 同名文件的覆盖策略
REPLACE_EXISTING表示直接覆盖,但有些场景要求“不覆盖已存在的文件”。这时如果直接不传REPLACE_EXISTING,又会抛FileAlreadyExistsException。处理方式有两种:
// 策略一:只复制目标不存在的文件 public static void copyIfNotExists(Path sourceFile, Path targetFile) throws IOException { if (Files.notExists(targetFile)) { Files.copy(sourceFile, targetFile, StandardCopyOption.COPY_ATTRIBUTES); } } // 策略二:如果目标存在但内容不同,才覆盖 public static void copyIfChanged(Path sourceFile, Path targetFile) throws IOException { if (Files.notExists(targetFile)) { Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); return; } if (Files.size(sourceFile) != Files.size(targetFile)) { Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); return; } // 大小相同也可能是不同内容,可以额外比较哈希或最后修改时间 long sourceModified = Files.getLastModifiedTime(sourceFile).toMillis(); long targetModified = Files.getLastModifiedTime(targetFile).toMillis(); if (sourceModified != targetModified) { Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); } }我推荐线上批量同步用“大小 + 修改时间”双判断,因为全量哈希每个文件做一遍代价太高。至于覆盖前要不要先备份原文件,需要根据业务自己权衡;如果是配置类文件,我一般会先复制成.bak再覆盖,这不是 Java 层面的问题,而是运维习惯。
4.3 大文件的复制性能优化
大部分“卡死”的复制任务都发生在几百 MB 甚至几个 GB 的大文件上。用Files.copy通常没问题,但如果必须自己写流式复制,要记住两个原则:
- 缓冲区大小要匹配系统块大小。Linux 上常见
st_blksize是 4096,但我建议用 64KB 到 1MB 之间。现代 SSD 对大到中等缓冲区的表现几乎没差别,太小反而触发更多系统调用。 - 使用
FileChannel的transferTo/transferFrom,让操作系统内核完成数据搬移,用户态内存压力小,速度能快 30% 以上。
import java.nio.channels.FileChannel; public static void copyLargeFile(Path source, Path target) throws IOException { try (FileChannel in = FileChannel.open(source, StandardOpenOption.READ); FileChannel out = FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { long size = in.size(); long position = 0; while (position < size) { long transferred = in.transferTo(position, size - position, out); if (transferred <= 0) { break; // 极端情况下可能返回0,需要手动从源端读取 } position += transferred; } } }transferTo返回 0 的特殊情况值得单独注释。我在某云服务器的 NFS 挂载盘上遇到过这个问题,返回 0 并不是结束,而是当前内核缓冲区暂不可用,直接跳出循环会导致文件复制不全。更稳的写法是配合FileChannel.read兜底,或者干脆捕获IOException后回退到字节流复制。对于纯本机磁盘,这个坑不太容易出现。
4.4 空目录与符号链接
默认的递归逻辑里,空目录在preVisitDirectory阶段就会被createDirectories创建出来,这点没问题。容易出错的是符号链接:如果源目录里有个软链接指向系统根目录,walkFileTree默认不会跟随链接(FileVisitOption.FOLLOW_LINKS没传),所以不会出现无限循环。但如果你主动传了FOLLOW_LINKS,又没做循环检测,就可能把整个磁盘复制进去。
我的建议是复制工具默认不跟随符号链接,但在visitFile里检查Files.isSymbolicLink(file),然后重新创建同样的链接:
@Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { if (Files.isSymbolicLink(file)) { Path targetFile = target.resolve(source.relativize(file)); Path linkTarget = Files.readSymbolicLink(file); Files.createSymbolicLink(targetFile, linkTarget); return FileVisitResult.CONTINUE; } Files.copy(file, target.resolve(source.relativize(file)), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); return FileVisitResult.CONTINUE; }软链接如果直接Files.copy,通常会复制链接指向的“内容”而不是“链接本身”,这会完全破坏链接语义。这也是很多同步工具复制的文件数量和原始目录不一致的常见原因。Windows 上创建符号链接需要管理员权限,所以这个写法要配合权限检查,否则会抛AccessDeniedException。
5. 常见问题与排查技巧实录
5.1 复制后中文文件名变成乱码
这个问题的根源不在文件复制本身,而在于输入输出的路径编码。如果你在 Windows 上从一个旧项目拿到的路径字符串是 GBK 编码,直接Paths.get会变成乱码路径。排查手段很简单:先在入口处打印System.getProperty("file.encoding"),确认是否 UTF-8。新版 JDK 18+ 默认 UTF-8,老项目经常是 GBK。
最省心的解决办法是,在启动脚本里显式加上-Dfile.encoding=UTF-8,并且所有路径传递都使用PathAPI,不要用String拼接。如果你需要从一个编码未知的配置文件中读取路径,读出来后立刻用指定字符集解码成正确的字符串,再转成Path。
5.2 复制到大文件时抛 “Insufficient space”
磁盘空间不足时,Files.copy可能抛出FileSystemException,但更常见的是已经写了一半文件后抛IOException。这会导致目标目录残留半个文件,下次复制时因为REPLACE_EXISTING又把它覆盖了,问题不大。但有一类隐蔽场景:目标分区是 FAT32,单文件上限 4GB,源文件 5GB,复制时写入到 4GB 位置就报错。这种错误信息往往晦涩难懂,我建议在复制大文件前先做前置校验:
public static void preCheck(Path source, Path target) throws IOException { long sourceSize = Files.size(source); long targetFree = Files.getFileStore(target).getUsableSpace(); if (sourceSize > targetFree) { throw new IOException(String.format( "源文件大小 %d 字节,目标剩余空间 %d 字节,复制会失败", sourceSize, targetFree)); } }另外用FileStore.isReadOnly()判断目标文件系统是否只读,能提前暴露权限问题。
5.3File.listFiles()返回 null 的原因
很多人在基础版递归里遇到listFiles()返回 null,以为是文件夹为空。实际上空文件夹返回的是空数组[],不是 null。返回 null 的唯一原因是File对象不指向目录,或者 Java 没有权限读取该目录(IO 错误)。所以我的基础版代码里特意写了if (files == null) return;作为兜底,但更好的做法是记录日志:
File[] files = source.listFiles(); if (files == null) { throw new IOException("无法读取目录内容:" + source.getAbsolutePath()); }如果确实需要跳过这种目录,至少要打一条 warn 日志,不然备份缺失了都不知道。
5.4 复制时的目标目录在源目录内部
假设源目录是/data/workspace,目标目录是/data/workspace/backup。如果遍历到backup目录时没有过滤,那么每次复制一个文件到backup下,walkFileTree就会发现backup下多了新文件,于是继续遍历新文件,把它再次复制到backup/backup下,这就是“非线性复制爆炸”。处理办法我前面提到了,在preVisitDirectory里把目标目录和源目录的绝对路径字符串做一次前缀比较:
@Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { Path absoluteSourceDir = source.toAbsolutePath().normalize(); Path absoluteTargetDir = target.toAbsolutePath().normalize(); if (dir.toAbsolutePath().normalize().startsWith(absoluteTargetDir) && !dir.equals(absoluteSourceDir)) { return FileVisitResult.SKIP_SUBTREE; } return FileVisitResult.CONTINUE; }更简单实用的方式是,凡是遇到名字等于目标目录名的子目录,直接SKIP_SUBTREE,因为正常业务里不会需要复制一个叫backup的源子目录到backup根下。用哪个策略取决于你对目录结构的掌控程度。
5.5 多线程复制是否值得
传统思路是多线程并发复制加速。但实测下来,如果目标存储是同一块机械硬盘,多线程反而因为磁头频繁寻道导致速度下降;如果是 SSD 或者分布式存储,多线程确实能打满带宽。一个保守的结论是:文件数量多但单个文件小,用多线程很有价值;文件数量少但单文件巨大,单线程Files.copy已经足够。
如果决定上多线程,可以用ExecutorService加CountDownLatch手动控制并发度,线程数不要超过 CPU 核心数的两倍,而且路径处理和复制请求要解耦,避免排队任务堆积时内存爆掉。等全部完成后再统一检查返回值,我一般用一个AtomicInteger统计失败数,有失败就在主线程汇总打印。
6. 实用思考:代码之外的工程经验
6.1 如何设计可复用的复制工具类
复制逻辑写进业务代码里很容易污染维护者心智,我把这个功能抽成独立的工具类后,会在接口层面约定三个选项:是否覆盖已有文件、是否保留元数据、是否包含过滤规则。用CopyOption枚举来传参,而不是塞五六个布尔值。
public enum CopyOptionKind { REPLACE_EXISTING, PRESERVE_ATTRIBUTES, FILTER_FILES }然后把复杂参数封装成一个CopyRequest数据类,内部持有Path source、Path target、Set<CopyOptionKind>、Predicate<Path> filter。工具类只暴露一个copyDirectory(CopyRequest request)静态方法。这样调用方代码不会又臭又长,也方便写单元测试。
6.2 日志、进度与监控
复制是一个长耗时的 IO 操作,用户如果看不到进度就会以为程序卡死。我在工具里加了一个简单的进度回调:
public interface ProgressListener { void onProgress(long completedBytes, long totalBytes); }在visitFile里每次复制前读取attrs.size(),复制完成后累加到AtomicLong,在主线程定时打印百分比。对于超大目录,遍历文件列表本身就需要时间,所以进度条最好按总字节数计算,而不是按文件个数计算。
6.3 测试覆盖哪些典型场景
我会在本地准备四类用例:
- 基础结构:两层目录、普通文件、空子目录。
- 边界文件:文件名为中文、文件名含空格、文件大小 0 字节、文件扩展名混合大小写。
- 异常场景:目标路径是只读目录、源目录包含无权限文件、目标磁盘空间不足。
- 符号链接:源目录包含指向内部目录的链接,确认复制后不发生死循环。
每个用例都要验证复制前后目录结构完全一致,文件内容用 SHA-256 做哈希比对。空目录容易被遗漏,我的测试里专门会断言目标端存在对应的空目录。
7. 结尾:我个人在实际操作中的体会
我在生产环境维护过一套基于这个逻辑的文件同步服务,经历过几次“磁盘被写满”“备份目录被递归复制导致膨胀”的事故后,最大的心得是:文件夹复制这件看起来 Hello World 级别的事情,想做到企业级稳定,需要把目录遍历、文件属性、异常策略和性能边界都当成一等公民来对待。
如果只让我留一条建议,那就是优先使用Files和walkFileTree,别再用File的listFiles做递归了。虽然老 API 能跑,但新 API 提供的FileVisitResult让“跳过目录、跳过文件、失败继续、记录错误”这些需求变得极其自然,而且性能更好。面试时能主动讲出COPY_ATTRIBUTES和FOLLOW_LINKS的坑,基本就能证明你不是只会 new File。
希望这篇内容能帮你在下次处理复制任务时少踩几个坑。如果里面有方案不适配你的特殊场景,欢迎在评论区把具体的目录结构、文件类型和目标环境发出来,我们继续讨论。