Java 复制文件有哪些方式?它们的性能差别有多大、该怎么选?
简化版
**Java 复制文件有好几种方式,性能差别很大,核心区别在「每次搬多少数据、经过多少次拷贝」:**① 逐字节复制(FileInputStream.read() 一个字节一个字节读写)——最慢,每字节一次系统调用;② 缓冲区复制(read(byte[]) + write(byte[]),用一个字节数组批量搬)——快很多,一次搬一个数组(如 8KB),是最经典的手写方式;③ 缓冲流复制(BufferedInputStream + BufferedOutputStream)——加缓冲,也是批量搬;④ FileChannel.transferTo/transferFrom——NIO 的零拷贝复制,数据不经过用户空间、直接在内核里搬,大文件最快;⑤ Files.copy(source, target)(NIO.2)——JDK 封装好的一行搞定,内部已优化,代码最简洁、日常首选。性能排序(大文件):逐字节 ≪ 缓冲区/缓冲流 < Files.copy ≈ transferTo(零拷贝)。选择:日常复制用 Files.copy(简洁 + 内部优化);超大文件追求极致性能用 FileChannel.transferTo(零拷贝);绝对别用逐字节复制。
详细版
几种复制方式对比:
| 方式 | 原理 | 性能 | 代码 |
|---|---|---|---|
| 逐字节 read()/write() | 每字节一次系统调用 | 极慢 | 繁琐 |
| 缓冲区 read(byte[]) | 一次搬一个数组 | 快 | 中等 |
| 缓冲流 Buffered | 加缓冲批量搬 | 快 | 中等 |
| FileChannel.transferTo | 零拷贝(内核内搬) | 最快(大文件) | 中等 |
| Files.copy(NIO.2) | JDK 封装+优化 | 快(首选) | 一行 |
// ① 逐字节:极慢,别用
int b;
while ((b = in.read()) != -1) out.write(b); // 每字节一次系统调用
// ② 缓冲区复制:经典手写方式
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len); // 一次搬 8KB
}
// ④ FileChannel 零拷贝:大文件最快
try (FileChannel src = new FileInputStream(from).getChannel();
FileChannel dst = new FileOutputStream(to).getChannel()) {
src.transferTo(0, src.size(), dst); // 数据不经用户空间,内核直接搬
}
// ⑤ Files.copy:一行搞定,日常首选
Files.copy(Paths.get("from.txt"), Paths.get("to.txt"),
StandardCopyOption.REPLACE_EXISTING);
⚠️ 性能差别的本质,是「拷贝次数」和「系统调用次数」——逐字节复制慢,是因为每个字节都触发一次 read + 一次 write 系统调用(几百万字节 = 几百万次系统调用,见 buffered-stream 题)。缓冲区/缓冲流把它降到「总量/缓冲区大小」次,快了几个数量级。而
FileChannel.transferTo更进一步——它是「零拷贝」(见 zero-copy 题):普通复制的数据流是「磁盘→内核缓冲区→用户空间 buffer→内核缓冲区→磁盘」(数据在内核和用户空间之间来回拷贝),而transferTo底层用sendfile,数据直接在内核里从源搬到目标、不经过用户空间,减少了 2 次拷贝和用户态/内核态切换,所以大文件复制最快。日常开发的答案很简单:用Files.copy一行搞定(它内部已经用了合适的优化),只有在明确要复制超大文件、追求极致性能时才手动上FileChannel.transferTo。
完整版教学
一、逐字节复制:最慢的反面教材
先看最慢的方式,理解「为什么慢」:
逐字节复制:
int b;
while ((b = in.read()) != -1) {
out.write(b);
}
为什么极慢:
in.read():读一个字节 = 一次系统调用
out.write(b):写一个字节 = 一次系统调用
复制 1000 万字节 = 1000 万次 read + 1000 万次 write
= 2000 万次系统调用!
→ 系统调用的固定开销(用户态/内核态切换)巨大 → 慢到无法接受
这是"反面教材"——面试问"怎么复制文件",
逐字节是错误答案(除非加缓冲流)
改进方向:减少系统调用次数 → 批量搬(缓冲区)
逐字节复制(while((b=in.read())!=-1) out.write(b))极慢——每字节一次 read + 一次 write 系统调用,复制 1000 万字节 = 2000 万次系统调用,系统调用的固定开销(用户态/内核态切换)巨大。这是反面教材,改进方向是「减少系统调用次数 → 批量搬」。理解「逐字节复制极慢(每字节一次 read+write 系统调用,海量系统调用)、是反面教材、改进方向批量搬」,就理解了最慢方式的问题。
二、缓冲区复制:批量搬
第一个改进是「用字节数组批量搬」:
缓冲区复制(经典手写方式):
byte[] buffer = new byte[8192]; // 8KB 缓冲数组
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
为什么快:
in.read(buffer):一次读最多 8192 字节(一次系统调用搬一大块)
out.write(buffer, 0, len):一次写 len 字节
复制 1000 万字节 ≈ 1000万/8192 ≈ 1220 次读写
→ 从 2000 万次系统调用降到约 2440 次!快几千倍
关键:write(buffer, 0, len) 的 len 很重要
最后一次读可能不满 8192,len 是实际读到的字节数
写的时候要写 len 字节,不能写整个 buffer(否则多写了垃圾字节)
这是"手写复制"的标准写法,性能已经很好
第一个改进是「用字节数组批量搬」——byte[] buffer = new byte[8192]; while((len=in.read(buffer))!=-1) out.write(buffer,0,len)。一次读写搬一大块(8KB),复制 1000 万字节从 2000 万次系统调用降到约 2440 次、快几千倍。关键:write(buffer, 0, len) 的 len 很重要(最后一次读可能不满 8192,要写实际读到的 len 字节、不能写整个 buffer 否则多写垃圾)。这是手写复制的标准写法。理解「缓冲区复制用 byte[8192]批量搬、一次读写搬一大块系统调用降几千倍、write(buffer,0,len)的 len 是实际字节数很重要」,就掌握了缓冲区复制。
三、缓冲流复制
另一种批量方式是「用缓冲流」:
缓冲流复制:
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(from));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(to))) {
int b;
while ((b = bis.read()) != -1) { // 即使逐字节读,缓冲流也快
bos.write(b);
}
}
原理:
BufferedInputStream 内部有 8KB 缓冲区
即使你逐字节 read(),它也是"一次从磁盘读 8KB 到缓冲区、之后从内存取"
→ 缓冲流把逐字节的系统调用也优化成批量(见 buffered-stream 题)
对比缓冲区复制:
缓冲区复制:你自己用 byte[] 批量读写
缓冲流复制:缓冲流帮你缓冲(你可以逐字节,它内部批量)
→ 两者性能相近(都是批量搬)
缓冲流 + 大数组读 = 双重批量(但收益递减)
所以:缓冲流让"逐字节代码"也能快(内部缓冲)
另一种批量方式是「缓冲流」——BufferedInputStream+BufferedOutputStream,即使逐字节 read(),缓冲流内部也批量(一次从磁盘读 8KB 到缓冲区、之后从内存取,见 buffered-stream 题)。对比缓冲区复制:缓冲区复制是你自己用 byte[] 批量、缓冲流是缓冲流帮你缓冲,两者性能相近(都批量搬)。理解「缓冲流复制:Buffered 流即使逐字节读内部也批量、和缓冲区复制性能相近(都批量搬)、缓冲流让逐字节代码也快」,就掌握了缓冲流复制。
四、FileChannel.transferTo:零拷贝
最快的方式是「FileChannel 的零拷贝传输」:
FileChannel.transferTo / transferFrom:
src.transferTo(position, count, dst);
→ 把 src channel 的数据直接传输到 dst channel
零拷贝的意义(见 zero-copy 题):
普通复制的数据流:
磁盘 → 内核缓冲区 → 用户空间 buffer → 内核缓冲区 → 磁盘
(数据在内核和用户空间之间来回拷贝 + 用户态/内核态切换)
transferTo(零拷贝):
磁盘 → 内核缓冲区 → 磁盘(底层用 sendfile)
数据"不经过用户空间",直接在内核里从源搬到目标
→ 少了 2 次拷贝 + 少了用户态/内核态切换
所以 transferTo 大文件复制最快:
数据量大时,"少来回拷贝"的收益明显
尤其网络传输文件(如下载服务、文件服务器)用它性能好
注意:
transferTo 单次传输量有上限(历史上约 2GB),
大文件要循环传输(transferTo 返回实际传输的字节数)
最快的方式是 FileChannel.transferTo/transferFrom(零拷贝)——src.transferTo(pos, count, dst) 把数据直接从 src channel 传到 dst channel。零拷贝:普通复制数据流是「磁盘→内核缓冲区→用户空间 buffer→内核缓冲区→磁盘」(来回拷贝+用户态内核态切换),transferTo 底层用 sendfile、数据不经用户空间、直接在内核里搬(少 2 次拷贝+少切换,见 zero-copy 题)。所以大文件复制最快(尤其网络传输文件)。注意单次传输有上限(约 2GB),大文件要循环。理解「FileChannel.transferTo 零拷贝复制:数据不经用户空间直接内核里搬(底层 sendfile)、少 2 次拷贝和切换、大文件最快、单次有上限要循环」,就掌握了零拷贝复制。
五、Files.copy:日常首选
最简洁的方式是「Files.copy(NIO.2)」,日常首选:
Files.copy(一行搞定):
Files.copy(source, target, options...);
Files.copy(Paths.get("from"), Paths.get("to"),
StandardCopyOption.REPLACE_EXISTING, // 覆盖已存在
StandardCopyOption.COPY_ATTRIBUTES); // 复制文件属性
优点:
① 代码最简洁(一行)
② JDK 内部已优化(用合适的缓冲/传输方式)
③ 支持选项:覆盖、复制属性、跟随符号链接
④ 还有其他重载:
Files.copy(InputStream, target) 从流复制到文件
Files.copy(source, OutputStream) 从文件复制到流
为什么是日常首选:
简洁 + 内部优化 → 大多数场景直接用它,不用手写循环
(不用纠结缓冲区大小、不用管 len、不用 try-with-resources 一堆流)
局限:
对"极致性能的超大文件",可能不如手动 transferTo 精细控制
但日常复制文件,Files.copy 足够好
所以:日常复制 → Files.copy(首选)
最简洁的方式是 Files.copy(NIO.2)——一行搞定(Files.copy(source, target, options),支持 REPLACE_EXISTING 覆盖、COPY_ATTRIBUTES 复制属性)。优点:代码最简洁、JDK 内部已优化、支持选项、有多种重载(流↔文件)。是日常首选(简洁+内部优化,不用手写循环/管缓冲区/管 len)。局限是极致性能的超大文件可能不如手动 transferTo 精细。理解「Files.copy 一行搞定(支持覆盖/复制属性选项)、代码最简洁+内部优化、日常首选、极致性能超大文件用 transferTo」,就掌握了这个首选方式。
六、性能对比与选择
综合性能对比,给出选择建议:
性能排序(复制大文件,从慢到快):
逐字节 read()/write()
≪(差几千倍)
缓冲区复制 ≈ 缓冲流复制
<
Files.copy ≈ FileChannel.transferTo(零拷贝)
(对小文件,差别不明显;文件越大,零拷贝优势越明显)
选择建议:
① 日常复制文件 → Files.copy(简洁 + 内部优化,首选)
② 超大文件、追求极致性能 → FileChannel.transferTo(零拷贝)
③ 需要在复制过程中处理数据(如边复制边转换)→ 缓冲区复制
(因为数据要经过用户空间,你能拿到 buffer 处理)
④ 绝对别用逐字节复制(反面教材)
一个重要区别:
零拷贝(transferTo/Files.copy)数据不经用户空间 → 快,
但你"看不到/碰不到"数据(不能边复制边处理)
缓冲区复制数据经过用户空间 → 慢一点,
但你能拿到每一块数据处理(加密、压缩、转换)
→ 要"纯复制"用零拷贝,要"复制+处理"用缓冲区
结论:纯复制用 Files.copy(或 transferTo),要处理数据用缓冲区
性能排序(大文件):逐字节 ≪ 缓冲区/缓冲流 < Files.copy ≈ transferTo(零拷贝)。选择:日常复制用 Files.copy(首选)、超大文件追求极致用 transferTo、需要边复制边处理数据用缓冲区复制、绝对别用逐字节。一个重要区别:零拷贝快但数据不经用户空间(碰不到数据、不能边复制边处理),缓冲区复制慢一点但能拿到每块数据处理(加密/压缩/转换)——纯复制用零拷贝、复制+处理用缓冲区。理解「性能:逐字节≪缓冲<Files.copy≈transferTo;选择:日常 Files.copy、超大用 transferTo、要处理数据用缓冲区、别用逐字节;零拷贝快但碰不到数据、缓冲区能处理数据」,就掌握了完整的选择逻辑。
记忆钩子:「Java 复制文件几种方式(性能差别在拷贝次数和系统调用次数):①逐字节 read()/write()极慢(每字节一次系统调用,反面教材)②缓冲区复制 byte[8192]批量搬(快几千倍,write(buffer,0,len)的 len 很重要)③缓冲流 Buffered(内部批量,和缓冲区相近)④FileChannel.transferTo 零拷贝(数据不经用户空间、内核直接搬、底层 sendfile、大文件最快、单次有上限要循环)⑤Files.copy 一行搞定(内部优化、日常首选);性能:逐字节≪缓冲<Files.copy≈transferTo;选择:日常 Files.copy、超大 transferTo、要边复制边处理用缓冲区(零拷贝碰不到数据)、别用逐字节」。
七、常见误区与追问
- 误区:复制文件用逐字节 read/write 就行。 极慢——每个字节一次 read + 一次 write 系统调用,复制大文件是几千万次系统调用;至少要用缓冲区(byte[8192] 批量搬)或缓冲流,日常直接用 Files.copy。
- 误区:缓冲区复制时 write(buffer) 写整个数组就行。 要写 write(buffer, 0, len)——len 是本次实际读到的字节数;最后一次读可能不满 8192,如果写整个 buffer 会把上次遗留的垃圾字节也写进去、导致复制的文件多出错误内容。
- 误区:Files.copy 性能不如手写循环。 Files.copy 内部已经用了合适的优化(缓冲/传输),性能很好、且代码最简洁,是日常首选;只有复制超大文件、追求极致性能时,手动 FileChannel.transferTo(零拷贝)才可能更快。
- 误区:transferTo 零拷贝在任何情况下都最快最好。 零拷贝快,但数据不经过用户空间——你「碰不到」数据,不能在复制过程中处理(加密、压缩、转换);如果需要边复制边处理数据,得用缓冲区复制(数据经过用户空间、你能拿到每块处理),此时不能用零拷贝。
- 追问:FileChannel.transferTo 为什么比缓冲区复制快? 它是零拷贝——普通复制数据要在「内核缓冲区↔用户空间 buffer」之间来回拷贝(还有用户态/内核态切换),transferTo 底层用 sendfile,数据直接在内核里从源文件搬到目标(不经过用户空间),减少了 2 次拷贝和上下文切换,大文件时这个节省很明显。
- 追问:日常复制文件应该用哪种方式? Files.copy(NIO.2)——一行搞定、内部已优化、支持覆盖/复制属性等选项,是大多数场景的首选;不用纠结缓冲区大小、不用管 len、不用手写 try-with-resources 一堆流;只有明确要复制超大文件追求极致性能时才手动上 FileChannel.transferTo。
- 追问:什么时候必须用缓冲区复制而不能用零拷贝? 需要在复制过程中处理数据时——比如边复制边加密、边压缩、边做格式转换、边计算校验和;这些都需要拿到数据本身处理,而零拷贝的数据不经过用户空间、你碰不到;缓冲区复制的数据经过用户空间的 byte[],你可以在 read 之后、write 之前对每一块数据做处理。
八、加强记忆
Java 复制文件有几种方式,性能差别在「拷贝次数和系统调用次数」:① 逐字节 read()/write()——极慢(每字节一次系统调用,复制大文件几千万次系统调用,反面教材);② 缓冲区复制 read(byte[8192]) + write(buffer, 0, len)——批量搬,快几千倍(len 是实际读到的字节数,很重要,不能写整个 buffer);③ 缓冲流复制(Buffered 流内部批量,和缓冲区复制相近);④ FileChannel.transferTo(零拷贝)——数据不经用户空间、直接在内核里搬(底层 sendfile,少 2 次拷贝和切换),大文件最快(单次传输有上限约 2GB,要循环);⑤ Files.copy(NIO.2)——一行搞定、内部已优化、支持选项(覆盖/复制属性),日常首选。性能排序(大文件):逐字节 ≪ 缓冲区/缓冲流 < Files.copy ≈ transferTo。选择:日常复制用 Files.copy(首选)、超大文件追求极致用 transferTo、绝对别用逐字节;一个关键区别——零拷贝快但数据不经用户空间(碰不到数据、不能边复制边处理),缓冲区复制慢一点但能拿到每块数据处理(加密/压缩/转换),所以纯复制用零拷贝、复制+处理用缓冲区。一句话「复制文件:逐字节极慢(反面教材)、缓冲区 byte[8192]批量(快几千倍,len 重要)、transferTo 零拷贝(不经用户空间内核直接搬,大文件最快)、Files.copy 一行搞定(日常首选);纯复制用 Files.copy/transferTo、要边复制边处理数据用缓冲区(零拷贝碰不到数据)」。