FileChannel.transferTo 没传完:短传输、零进度和回退复制
把文件复制改成 source.transferTo(0, source.size(), target),代码少了,文件却偶尔不完整。这个调用请求传输一段数据,但返回值才是实际完成的字节数。目标通道能接收多少、底层实现采用什么路径,都可能让一次调用少于请求数量。
正确的复制器需要管理显式位置、剩余长度与零进度。只写一个while却不检查进展,还可能把不完整文件问题改成忙循环。下面用Java 17实现一个范围明确的复制函数:输入是普通文件,目标是本例采用的阻塞写通道,不支持把非阻塞网络通道塞进来无限等待。
复制完成的标准先定下来
函数接收要复制的字节数,从源文件偏移0开始。每次根据实际返回量推进位置;请求范围以外的后续增长不纳入本次结果。如果源在复制期间提前结束,整次操作失败。目标发生错误时允许留下部分输出,由调用者负责清理,函数不声称能回滚通道已经接收的字节。
本例成功标准是完整复制指定长度,目标字节与固定输入一致。它不承诺持久落盘,也不承诺文件属性、权限和时间戳一并复制。把这些要求混进“复制成功”三个字,容易让调用者产生错误保证。
完整可运行实验
保存为 ChannelCopy.java,执行 java ChannelCopy.java。只需要JDK 17。程序创建受控临时目录并在结束时删除,输入是七万个确定字节,不读取其他文件。
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.Comparator;
import java.util.stream.Stream;
public class ChannelCopy {
record Result(long bytes, int fallbacks) { }
static Result copy(FileChannel source, WritableByteChannel target, long count) throws IOException {
if (count < 0) throw new IllegalArgumentException("negative count");
long position = 0; int fallbacks = 0; ByteBuffer buffer = ByteBuffer.allocate(8192);
while (position < count) {
long n = source.transferTo(position, count - position, target);
if (n > 0) { position += n; continue; }
fallbacks++;
buffer.clear(); buffer.limit((int)Math.min(buffer.capacity(), count - position));
int read = source.read(buffer, position);
if (read < 0) throw new EOFException("source shorter than requested range");
if (read == 0) throw new IOException("source made no progress");
buffer.flip();
while (buffer.hasRemaining()) {
if (target.write(buffer) <= 0) throw new IOException("blocking target made no progress");
}
position += read;
}
return new Result(position, fallbacks);
}
static final class Chunked implements WritableByteChannel {
final FileChannel delegate; boolean first = true;
Chunked(FileChannel delegate) { this.delegate = delegate; }
public int write(ByteBuffer src) throws IOException {
if (first) { first = false; return 0; }
ByteBuffer part = src.duplicate(); part.limit(part.position() + Math.min(3, part.remaining()));
int n = delegate.write(part); src.position(src.position() + n); return n;
}
public boolean isOpen() { return delegate.isOpen(); }
public void close() throws IOException { delegate.close(); }
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("channel-copy-");
Path source = dir.resolve("source.bin"), target = dir.resolve("target.bin");
byte[] bytes = new byte[70000]; for (int i = 0; i < bytes.length; i++) bytes[i] = (byte)i;
Files.write(source, bytes);
try {
try (FileChannel in = FileChannel.open(source);
FileChannel out = FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
Result result = copy(in, new Chunked(out), bytes.length);
System.out.println("bytes=" + result.bytes());
System.out.println("fallback_used=" + (result.fallbacks() > 0));
System.out.println("source_position=" + in.position());
}
System.out.println("equal=" + (Files.mismatch(source, target) == -1));
WritableByteChannel stalled = new WritableByteChannel() {
public int write(ByteBuffer src) { return 0; }
public boolean isOpen() { return true; }
public void close() { }
};
try (FileChannel in = FileChannel.open(source)) {
try { copy(in, stalled, 1); throw new AssertionError(); }
catch (IOException expected) { System.out.println("stalled=REJECTED"); }
}
try (FileChannel in = FileChannel.open(source);
WritableByteChannel out = Channels.newChannel(OutputStream.nullOutputStream())) {
try { copy(in, out, bytes.length + 1L); throw new AssertionError(); }
catch (EOFException expected) { System.out.println("short_source=EOF"); }
}
} finally {
try (Stream<Path> paths = Files.walk(dir)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(path);
}
}
}
}
输出:
bytes=70000
fallback_used=true
source_position=0
equal=true
stalled=REJECTED
short_source=EOF
Chunked目标故意第一次不接收字节,随后每次最多接收三个字节,让正常磁盘上不容易稳定出现的短写和零返回变成确定测试。它只用于故障注入,不代表文件系统实际每次会写三个字节。
fallback_used=true说明回退分支确实跑到了。equal=true核对最终字节,而不只是文件长度;两个文件同样大仍可能在中间错位或重复。source_position=0则帮助确认本函数沿显式位置推进,不靠修改源通道的当前位置记进度。
少传了就继续,不能把请求数量当实际数量
最容易写错的是每轮都加count,或每轮都从0开始。前者会提前宣布成功,后者会重复传前缀。这里唯一用于推进的值是transferTo的实际返回数。
当它返回正数时,即使只有很小一段,仍是有效进度;从新位置继续请求剩余范围即可。不要因为短传输就判断源文件已经结束,源文件EOF是另一项需要确认的状态。
这也解释了为什么不能依赖一次调用复制超大文件。具体实现和平台可能有单次限制,Java接口没有把“整个count一次完成”列为承诺。复制循环的正确性不应建立在某台机器上恰好返回了完整长度。
返回零时,要有结束等待的办法
本例选择回退到普通ByteBuffer读写。先从显式源位置读一块,再把这块完整写给目标,之后才推进逻辑位置。读到EOF就报告源过短;读取或写入没有进展则失败,避免无限循环。
这里的零写失败策略只适用于本文限定的目标。若目标是非阻塞SocketChannel,暂时不能写返回零本来就是正常状态,此时应等待可写事件或把剩余缓冲交还事件循环,而不是调用这个阻塞复制器。接口只有WritableByteChannel类型,编译器不会替我们保证运行模式,调用约定必须写明。
即便是阻塞目标,写操作本身仍可能长时间停住。零进度检查不是超时设施;磁盘故障、远端挂载或特定驱动的阻塞需要额外的故障与截止时间设计。不能把这个函数包进Future等待超时,就宣称底层I/O已经被可靠停止。
测试为什么还要造一个永远不接收数据的目标
stalled通道每次返回零。第一次transferTo未推进,回退写入又没有进展,程序会明确失败。这个测试的意义不是证明所有平台都有相同零返回行为,而是证明本地循环不会在这项输入下自己耗尽CPU。
short_source测试则请求比源文件多一个字节。前面内容可能已经写出,但最后仍要失败。测试不把留下的前缀当成成功文件,也不因读到EOF就无条件break。对规定长度的复制,EOF是否正常取决于已完成字节数。
还应根据用途增加空文件、恰好一块、超过一块、输出中途抛异常和关闭失败的测试。文件大小不是随机越大越好,每份样本都应触发一个有意义的边界。
源文件变化与结果发布属于调用者协议
size()不是一致性快照。另一个进程可以在复制期间改写已有字节,即使长度不变,结果也可能混合多个时点。需要稳定内容时,应让生产者先完成文件再交付,使用受控暂存目录或其他明确快照机制。文件锁是否被所有写入者遵守,也要核查,不能只在读取端加一行锁就假设外部程序配合。
目标应使用新的临时路径,成功校验以后再对业务发布。若直接覆盖正式文件,第二个故障样本就可能把旧可用内容换成半截新文件。发布动作是否支持原子替换、跨文件系统时如何处理,都要按存储边界验证,不由transferTo代劳。
需要持久性时还要决定是否调用force以及如何理解存储设备保证。进程写完、通道关闭和故障后数据仍可恢复是不同层次。权限、所有者、稀疏属性等也不在本函数范围内。
最后,不要因为使用transferTo就宣称所有平台都实现同样的零复制路径。接口允许底层优化,实际路径受通道类型与环境影响。先让返回值、失败和结果校验正确,再用明确负载测量性能,才能避免用一个好听的方法名替代真正的复制协议。
别忽略目标文件原来的位置和尾部
复制器沿目标通道当前写入位置输出,它不会擅自把任意目标重置为零,也不会删除目标原来更长的尾部。本例用CREATE_NEW保证目标此前不存在。如果改成打开已有文件,必须由调用者明确位置与截断策略:只覆盖前七万个字节,旧文件后面的内容可能仍然存在,结果长度甚至大于本次返回字节数。
源与目标也不能随意指向同一个文件。两个路径字符串不同,可能通过符号链接或硬链接指向同一内容;如果打开目标时先截断,再去发现两者相同,源已经被破坏。受控临时目标能避开这类覆盖风险,需要覆盖已有对象时则要在打开和发布协议中专门处理。
若需求只是普通文件复制,不需要传指定范围或接自定义通道,Files.copy可能比手写这份循环更适合。本文选FileChannel是为了暴露可核对的进度和故障分支,而不是建议所有复制都替换成自定义工具。保留标准接口已经提供的语义,通常比维护更多分支划算。
并发共享源通道时,本函数不修改源position仍不代表可以忽略协调:另一个调用仍能截断或改写文件。共享目标position更可能让多份输出交错。给每次复制独立的目标作用域,并让输入内容稳定,才是结果校验成立的前提。