《Java 网络编程基础》13.1 零拷贝的系统调用边界

零拷贝不是一个系统调用的别名。本节用 8 MiB 文件在 loopback 上拆开 JDK 25 的 transferTo 三级分派,解释返回值 0 的三种来源,并用包裹通道与 JFR 证明:一次返回 8 MiB 也可能只是 1024 次 8 KB 拷贝,macOS 上不要照搬 Linux sendfile/epoll 的结论。

本节目标:分清 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 路由。那段代码能工作,但留下三个没有回答的问题:

  1. transferTo 一次返回 8 MiB,是不是说明内核真的没有拷贝?
  2. 非阻塞 socket 上返回 0,是文件读完,还是发送缓冲区满?
  3. 什么情况下 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。三层不是「零拷贝 / 非零拷贝」的二分,而是三种不同的数据路径:

顺序方法进入条件数据路径可观察特征
1transferToDirect目标是 FileChannelImpl 或 SocketChannelImpl,平台原生实现支持内核直传,具体 syscall 由平台决定JFR 没有 Java 层 socket 写
2transferToTrustedChannelcount >= 16384,目标是 FileChannelImpl 或 SelChImpl按 8 MiB 分块 map(READ_ONLY),写目标,再解除映射单次最多 8 MiB,JFR 有 Java 层写
3transferToArbitraryChannel其他目标,或前两层返回不支持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 分配与回收 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发