本节目标:分清
transferTo的原生直传、mmap 分块与 8 KB 拷贝三条路径,解释返回值 0 的三种来源,并能用 JFR 和通道形态判断是否退化。
适用版本:JDK 25(Temurin 25.0.5+8);FileChannel.transferTo/transferFrom、SocketChannel、JFR。
承接的问题
5.3 给过 StaticFileSender:先写响应头,再用 transferTo 循环发送文件。8.3 把它接进 HTTP 路由。那段代码能工作,但留下三个没有回答的问题:
transferTo一次返回 8 MiB,是不是说明内核真的没有拷贝?- 非阻塞 socket 上返回 0,是文件读完,还是发送缓冲区满?
- 什么情况下 JDK 会悄悄退回用户态拷贝,代码却完全看不出来?
linkd v1 的静态文件接口就踩过第二条。事件循环里 transferTo 返回 0,代码把它当成「文件结束」,连接被提前关闭,客户端收到半截响应。抓包只能看到服务端先发了 FIN,看不出 0 的来源。
这一节只讨论系统调用边界,不重复 5.3 的 FileChannel 读写,也不做吞吐排名。本机没有执行 dtruss:macOS 的 SIP 和权限不一定允许,所以不写「本机确认走了 sendfile」。判断路径的办法是看 JDK 25 的分派代码,再用返回值形态和 JFR 事件做行为验证。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
transferTo 在 JDK 25 里的三级分派
sun.nio.ch.FileChannelImpl 没有导出,用 javap 的模块参数读取:
javap -p --module java.base sun.nio.ch.FileChannelImpl \
| grep -E 'transferTo|transferFrom|TRANSFER'
本机输出(节选):
private long transferToDirect(long, int, java.nio.channels.WritableByteChannel);
private long transferToTrustedChannel(long, long, java.nio.channels.WritableByteChannel);
private long transferToArbitraryChannel(long, long, java.nio.channels.WritableByteChannel);
private long transferFromArbitraryChannel(java.nio.channels.ReadableByteChannel, long, long);
transferTo 先尝试 transferToDirect,失败再试 transferToTrustedChannel,最后落到 transferToArbitraryChannel。三层不是「零拷贝 / 非零拷贝」的二分,而是三种不同的数据路径:
| 顺序 | 方法 | 进入条件 | 数据路径 | 可观察特征 |
|---|---|---|---|---|
| 1 | transferToDirect | 目标是 FileChannelImpl 或 SocketChannelImpl,平台原生实现支持 | 内核直传,具体 syscall 由平台决定 | JFR 没有 Java 层 socket 写 |
| 2 | transferToTrustedChannel | count >= 16384,目标是 FileChannelImpl 或 SelChImpl | 按 8 MiB 分块 map(READ_ONLY),写目标,再解除映射 | 单次最多 8 MiB,JFR 有 Java 层写 |
| 3 | transferToArbitraryChannel | 其他目标,或前两层返回不支持 | 8 KB 堆内 ByteBuffer,读一次写一次 | 目标每次看到不超过 8192 字节 |
第 2 层的 8 MiB 和 16 KB 不是猜测。javap -c 里 transferToTrustedChannel 先比较 count 与 16384,再用 Math.min(remaining, 8388608) 决定映射长度;第 3 层调用 ByteBuffer.allocate(Math.min(count, 8192))。这两个常量在 JDK 25.0.5 的这个构建里可以直接从字节码读到。
transferTo 还会把 count 截到文件剩余长度:position >= size 时直接返回 0,根本不进入分派。这一点很重要,0 不一定是系统调用返回的。
macOS 的边界要说清楚:这里没有 Linux 的 epoll,Selector 走的是 kqueue;macOS 的 sendfile(2) 在参数和返回值语义上也与 Linux 不同,JDK 在 native 层做了适配,不能把 Linux 的结论原样搬过来。本节没有用 dtruss 证明 macOS 上具体是哪个 syscall,只说「这次运行有没有经过 Java 层写」。
transferFrom 的分派更窄。字节码显示只有源是 FileChannelImpl 时才尝试 transferFromDirect;SocketChannel 这类源直接走 transferFromArbitraryChannel。所以 socket 到文件没有「反过来的 sendfile」,至少 JDK 25 的这条 API 路径没有。
返回值 0 的三种来源
transferTo 返回 0 至少有三种完全不同的含义:
position >= size或count == 0:没有数据可传,方法在进入 native 前就返回。- 非阻塞目标不可写:发送缓冲区满,内核返回
EAGAIN,JDK 归一化成 0。 - 目标只写入了 0 字节:短写的一种极端情况,调用方必须保留
position。
下面的 nonblock 场景把客户端设成非阻塞,发送缓冲区和接收缓冲区都设成 16 KB,服务端 accept 后不读:
transferTo non-blocking calls=2 position=65328 zeroAt=65328 eof=false
本机输出(单次运行,未做统计)。65328 不是常量,它取决于 SO_SNDBUF、SO_RCVBUF、loopback 和对端是否读取;换一台机器、换一个内核版本都会变。能复现的是形态:第一次调用搬走一部分,第二次返回 0,而 position < size,所以 0 不是 EOF。
阻塞 socket 上,0 通常意味着 position >= size 或 count == 0,因为内核会等待可写。但不要把这句话当成保证:短写仍然可能发生,循环条件必须是 position < size,不能用「上次返回值大于 0」判断结束。
文件读完的 0 可以直接构造:position 等于文件大小时,transferTo(size, 1024, sink) 返回 0,不碰 sink。本机输出是 position=8388608 returned=0。
返回值大小不能证明零拷贝
最容易被误读的现象是:一次 transferTo 返回了完整的 8 MiB。它可能是一次内核直传,也可能是 1024 次 8 KB 拷贝,返回值都长一样。
wrapped 场景把 socket 的输出流包成 WritableByteChannel。这个目标既不是 FileChannelImpl,也不是 SelChImpl,第 1、2 层都会拒绝,只能走第 3 层。探针在 OutputStream 外面套一个计数装饰器,记录 write(byte[], int, int) 被调了多少次、每次最多多少字节:
transferTo -> wrapped OutputStream calls=1 total=8388608 streamWrites=1024 maxStreamWrite=8192
本机输出(单次运行,未做统计)。transferTo 调用一次,返回 8 MiB;内部是 1024 次 8 KB 写。只看返回值,会把它误判成零拷贝。
transferFrom 也一样。用一个每次只返回 4 KB 的 ReadableByteChannel 作为源,FileChannel.transferFrom 会读到 2048 次,且它请求的缓冲区最大是 8192 字节:
transferFrom <- 4KB source total=8388608 reads=2048 maxReadBuffer=8192
本机输出(单次运行,未做统计)。JDK 没有因为源是 4 KB 就更聪明,它仍然用 8 KB 的固定缓冲区,只是每次读到的字节数被源限制在 4 KB。
JFR 可以从另一个方向给证据。用 profile 配置启动探针,然后看 jdk.SocketWrite 和 jdk.FileRead:
java -XX:StartFlightRecording=filename=/tmp/zc-socket.jfr,settings=profile,dumponexit=true \
-cp out ZeroCopyProbe socket
jfr summary /tmp/zc-socket.jfr | grep -E 'jdk.SocketWrite|jdk.FileRead'
本机输出:
jdk.FileRead 0 0
jdk.SocketWrite 0 0
同一个探针换成 wrapped 场景,jdk.SocketWrite 至少出现一次(本机一次运行是 1 条,另一次是 7 条)。JFR 对 socket 事件有节流,条数不能当作系统调用次数;这里只用「有没有」:直传场景没有 Java 层 socket 写,包裹流场景有。它仍不能证明具体 syscall,只能证明 JDK 走了哪一层。
在 linkd 里落地:ZeroCopyProbe
把上面的观察收进一个类。四个场景共用同一个 8 MiB 文件和同一套循环:
import java.io.*;
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.concurrent.CountDownLatch;
public final class ZeroCopyProbe {
static final int SIZE = 8 * 1024 * 1024;
static final InetAddress LOOPBACK = InetAddress.getLoopbackAddress();
static final Path FILE = Path.of("/tmp/zero-copy-probe.bin");
public static void main(String[] args) throws Exception {
byte[] data = new byte[SIZE];
for (int i = 0; i < data.length; i++) {
data[i] = (byte) i;
}
Files.write(FILE, data);
switch (args.length == 0 ? "all" : args[0]) {
case "socket" -> socket();
case "nonblock" -> nonblock();
case "wrapped" -> wrapped();
case "from" -> fromFallback();
default -> {
socket();
nonblock();
wrapped();
fromFallback();
}
}
}
static void socket() throws Exception {
try (ServerSocket server = new ServerSocket(0, 1, LOOPBACK)) {
CountDownLatch done = new CountDownLatch(1);
discard(server, done).start();
try (SocketChannel out = SocketChannel.open(
new InetSocketAddress(LOOPBACK, server.getLocalPort()));
FileChannel in = FileChannel.open(FILE, StandardOpenOption.READ)) {
long[] s = transferAll(in, out);
out.shutdownOutput();
done.await();
System.out.printf("transferTo -> SocketChannel calls=%d total=%d%n", s[0], s[1]);
}
}
}
static void nonblock() throws Exception {
try (ServerSocket server = new ServerSocket(0, 1, LOOPBACK)) {
CountDownLatch accepted = new CountDownLatch(1);
Thread peer = new Thread(() -> {
try (Socket s = server.accept()) {
s.setReceiveBufferSize(16384);
accepted.countDown();
Thread.sleep(3000);
} catch (Exception e) {
e.printStackTrace();
}
});
peer.setDaemon(true);
peer.start();
try (SocketChannel out = SocketChannel.open();
FileChannel in = FileChannel.open(FILE, StandardOpenOption.READ)) {
out.configureBlocking(false);
out.setOption(StandardSocketOptions.SO_SNDBUF, 16384);
out.connect(new InetSocketAddress(LOOPBACK, server.getLocalPort()));
while (!out.finishConnect()) {
Thread.onSpinWait();
}
accepted.await();
long position = 0;
long calls = 0;
long zeroAt = -1;
while (position < SIZE) {
long n = in.transferTo(position, SIZE - position, out);
calls++;
if (n == 0) {
zeroAt = position;
break;
}
position += n;
}
System.out.printf("transferTo non-blocking calls=%d position=%d zeroAt=%d eof=%b%n",
calls, position, zeroAt, position == SIZE);
}
}
}
static void wrapped() throws Exception {
try (ServerSocket server = new ServerSocket(0, 1, LOOPBACK)) {
CountDownLatch done = new CountDownLatch(1);
discard(server, done).start();
try (Socket socket = new Socket(LOOPBACK, server.getLocalPort());
FileChannel in = FileChannel.open(FILE, StandardOpenOption.READ)) {
CountingOutputStream counting = new CountingOutputStream(socket.getOutputStream());
long[] s = transferAll(in, Channels.newChannel(counting));
socket.shutdownOutput();
done.await();
System.out.printf("transferTo -> wrapped OutputStream calls=%d total=%d streamWrites=%d maxStreamWrite=%d%n",
s[0], s[1], counting.calls, counting.maxWrite);
}
}
}
static void fromFallback() throws Exception {
Path dst = Path.of("/tmp/zero-copy-from.bin");
try (FileChannel out = FileChannel.open(dst, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
ChunkedReadable src = new ChunkedReadable(ByteBuffer.wrap(Files.readAllBytes(FILE)), 4096);
long n = out.transferFrom(src, 0, SIZE);
System.out.printf("transferFrom <- 4KB source total=%d reads=%d maxReadBuffer=%d%n",
n, src.reads, src.maxReadBuffer);
}
}
static Thread discard(ServerSocket server, CountDownLatch done) {
Thread t = new Thread(() -> {
try (Socket s = server.accept()) {
s.getInputStream().transferTo(OutputStream.nullOutputStream());
} catch (IOException e) {
e.printStackTrace();
} finally {
done.countDown();
}
});
t.setDaemon(true);
return t;
}
static long[] transferAll(FileChannel in, WritableByteChannel out) throws IOException {
long calls = 0;
long total = 0;
while (total < SIZE) {
long n = in.transferTo(total, SIZE - total, out);
if (n <= 0) {
break;
}
calls++;
total += n;
}
return new long[]{calls, total};
}
static final class CountingOutputStream extends OutputStream {
private final OutputStream delegate;
long calls;
int maxWrite;
CountingOutputStream(OutputStream delegate) {
this.delegate = delegate;
}
@Override public void write(int b) { throw new UnsupportedOperationException(); }
@Override
public void write(byte[] b, int off, int len) throws IOException {
calls++;
maxWrite = Math.max(maxWrite, len);
delegate.write(b, off, len);
}
}
static final class ChunkedReadable implements ReadableByteChannel {
private final ByteBuffer data;
private final int chunk;
long reads;
int maxReadBuffer;
ChunkedReadable(ByteBuffer data, int chunk) {
this.data = data;
this.chunk = chunk;
}
@Override
public int read(ByteBuffer dst) {
reads++;
maxReadBuffer = Math.max(maxReadBuffer, dst.remaining());
int n = Math.min(chunk, Math.min(dst.remaining(), data.remaining()));
if (n == 0) {
return data.hasRemaining() ? 0 : -1;
}
ByteBuffer slice = data.slice();
slice.limit(n);
dst.put(slice);
data.position(data.position() + n);
return n;
}
@Override
public boolean isOpen() {
return true;
}
@Override
public void close() {
}
}
}
编译并运行:
javac --release 25 -d out ZeroCopyProbe.java
java -cp out ZeroCopyProbe all
本机输出:
transferTo -> SocketChannel calls=1 total=8388608
transferTo non-blocking calls=2 position=65328 zeroAt=65328 eof=false
transferTo -> wrapped OutputStream calls=1 total=8388608 streamWrites=1024 maxStreamWrite=8192
transferFrom <- 4KB source total=8388608 reads=2048 maxReadBuffer=8192
linkd 里有两个直接相关的落点。第一是 linkd-http 的 StaticFileSender:它接收的是 SocketChannel 和 FileChannel,能享受第 1 层;如果把响应体先读成 byte[] 再包 ByteBuffer,就退回第 3 层,transferTo 的优势消失。第二是 linkd-core 的 Connection.enqueue(ByteBuffer out):接口只接受 ByteBuffer,没有「文件区间」的概念,所以静态文件发送不能简单塞进写队列。要么在连接状态里增加 filePosition 和 FileChannel 字段,要么把文件发送交给独立的阻塞线程;前者要保持非阻塞,后者要接受线程池上限。
TLS 是第三个落点。10.2 的 SSLEngine 必须先加密再写 socket,文件字节一定要经过用户缓冲区;transferTo 到 TLS 连接不成立,静态文件在 TLS 下不要假设零拷贝。压缩、内容改写同理。
常见错误与边界
错误一:只调用一次 transferTo。 返回值可能小于 count,非阻塞时可能为 0。循环条件用 position < size,不要用「返回值大于 0」。
错误二:在事件循环里忙等 0。 transferTo 返回 0 表示当前不可写,不是文件结束;立即重试会空转。保存 position,注册 OP_WRITE,下一次可写再从 position 继续。
错误三:把「一次返回 8 MiB」当成零拷贝的证据。 本机包裹流场景同样一次返回 8 MiB,内部是 1024 次 8 KB 写。要看目标类型、JFR 事件或系统调用,不能只看返回值。
错误四:认为 transferFrom 是 transferTo 的反方向零拷贝。 JDK 25 只对 FileChannelImpl 源尝试直传;SocketChannel 源直接走 8 KB 拷贝。socket 到文件本来就不是 Linux sendfile 的输入方向。
错误五:把 Linux 的 sendfile/epoll 结论搬到 macOS。 macOS 的 Selector 基于 kqueue,sendfile(2) 的参数和语义也不同;没有 dtruss 证据时,只描述 JDK 的分派层,不写死 syscall。
边界:零拷贝不等于零 CPU。 即使命中内核直传,协议栈封装、校验和、页缓存查找仍然要做;TLS 下还要加解密。它省掉的是用户态复制和部分上下文切换。
小结
- JDK 25 的
transferTo分三层:原生直传、16 KB 以上可信目标的 8 MiB mmap 分块、其他目标的 8 KB 堆内拷贝。 transferFrom只对FileChannel源尝试直传;socket 源直接走 8 KB 拷贝,不存在反方向的 sendfile。- 返回值 0 可能是 EOF、
count == 0、非阻塞不可写,三者处理方式不同;循环必须自己维护position。 - 一次返回 8 MiB 不能证明零拷贝;本机包裹流场景是 1024 次 8 KB 写。
- JFR 的
jdk.SocketWrite只能看「有没有 Java 层写」,不能当系统调用计数;macOS 上不要声称与 Linux 同一实现。 linkd的Connection.enqueue(ByteBuffer)装不下文件区间;静态文件发送要么加连接状态,要么交给阻塞线程。
13.1 讲的是「字节怎么从文件到 socket」。下一节换到另一端:这些直接缓冲区本身怎么分配、怎么记账、什么时候才算真正释放。
阅读导航:上一节:12.3 上下文传播与线程安全 · 下一节:13.2 DirectByteBuffer 分配与回收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。