
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 的绝大多数业务系统。方案 CFiles.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 SimpleFileVisitorPath() { 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 SimpleFileVisitorPath() { 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 SetString 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 SimpleFileVisitorPath() { 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()) { SetPosixFilePermission 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.encodingUTF-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、SetCopyOptionKind、PredicatePath 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。希望这篇内容能帮你在下次处理复制任务时少踩几个坑。如果里面有方案不适配你的特殊场景欢迎在评论区把具体的目录结构、文件类型和目标环境发出来我们继续讨论。