本节目标:掌握
FileChannel的读写与transferTo语义,能用 Scatter/Gather 避免拼接,并判断零拷贝在什么条件下成立。
适用版本:JDK 25(Temurin 25.0.5+8);FileChannel、SocketChannel、MappedByteBuffer。
承接的问题
5.2 把缓冲区搬到了堆外。现在考虑一个具体场景:linkd 要返回一个 8 MB 的静态文件。最直白的写法是 Files.readAllBytes(path) 读成 byte[],包成 ByteBuffer,再 socket.write(buffer)。这条路径至少有三份字节:文件页缓存里的、Java 堆里的 byte[]、以及写进 socket 发送队列的。文件越大,堆压力越大,Full GC 之后还要重新读。
FileChannel.transferTo(position, count, target) 提供了另一条路径:把文件通道的字节直接送到目标通道,JDK 在支持的平台上可以让内核完成传输,不需要在 Java 堆里准备一整份文件内容。它常被叫作「零拷贝」,但这个名字容易让人误以为「任何情况下都不拷贝、一定更快」。
这一节先把 FileChannel 的返回值语义讲清楚,再看 Scatter/Gather 怎么把「响应头 + 响应体」写给同一个 socket,然后分别跑 transferTo 和 mmap。最后给出 linkd 的静态文件发送方法,以及它在非阻塞事件循环里的边界。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
FileChannel 的读写语义
FileChannel 是阻塞的、面向字节的通道。它没有 readLine,也不理解 HTTP;它只负责在文件位置和 ByteBuffer 之间搬运字节。假设 path 是一个已存在的文件:
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024);
long total = 0;
int n;
while ((n = ch.read(buf)) != -1) {
buf.flip();
total += buf.remaining();
buf.clear();
}
System.out.println("read=" + total + " size=" + ch.size());
}
这段代码只统计字节数,没有消费内容。几个返回值要分清:
read(ByteBuffer)返回本次读到的字节数,读到文件末尾返回-1。返回0不是 EOF;在remaining() == 0时会返回 0,调用方要先clear()或compact()。write(ByteBuffer)返回本次写出的字节数,可能小于remaining()。磁盘满、管道缓冲区满、信号中断都可能让一次写只完成一部分,必须循环到hasRemaining()为 false。position()是下一次读写的文件位置;position(long)可以跳转;size()是当前文件长度;truncate(long)截断文件;force(boolean)把脏页刷到磁盘,true连元数据一起刷。read(ByteBuffer, long position)和write(ByteBuffer, long position)是不改变通道当前位置的定位读写,适合多线程并发读同一个文件。多个线程共享一个FileChannel时,用定位读写比「先position再read」安全,后者两步之间可能被别的线程插进来。
FileChannel 的读写是阻塞的。文件在本地磁盘上时,read 会等页缓存或磁盘;这一点和 SocketChannel 的非阻塞模式不同。把 FileChannel 放进事件循环线程会阻塞整个循环,静态文件发送要么放在独立线程,要么改成基于 transferTo 的状态机。
Scatter/Gather:不拼接的读写
HTTP 响应通常是「头部 + 正文」。头部是几十到几百字节的 ASCII,正文可能是一个 byte[]、一个文件,或者另一个缓冲区。最直接的写法是把头部和正文拼成一个 byte[],但这样要多一次分配和复制。
ScatteringByteChannel.read(ByteBuffer[]) 和 GatheringByteChannel.write(ByteBuffer[]) 允许一次系统调用读写多个缓冲区。SocketChannel 和 FileChannel 都实现了这两个接口。
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.GatheringByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class ScatterGatherDemo {
public static void main(String[] args) throws Exception {
Path path = Path.of("/tmp/linkd-sg.bin");
ByteBuffer header = ByteBuffer.wrap("HDR1".getBytes(StandardCharsets.US_ASCII));
ByteBuffer body = ByteBuffer.wrap("payload".getBytes(StandardCharsets.US_ASCII));
try (FileChannel ch = FileChannel.open(path,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.READ, StandardOpenOption.WRITE)) {
long written = writeFully(ch, new ByteBuffer[]{header, body});
ch.position(0);
ByteBuffer h = ByteBuffer.allocate(4);
ByteBuffer b = ByteBuffer.allocate(7);
long read = ch.read(new ByteBuffer[]{h, b});
h.flip();
b.flip();
System.out.println("gather written=" + written
+ " scatter read=" + read
+ " header=" + StandardCharsets.US_ASCII.decode(h)
+ " body=" + StandardCharsets.US_ASCII.decode(b));
}
}
static long writeFully(GatheringByteChannel ch, ByteBuffer[] bufs) throws IOException {
long total = 0;
boolean remaining = true;
while (remaining) {
long n = ch.write(bufs);
total += n;
remaining = false;
for (ByteBuffer b : bufs) {
if (b.hasRemaining()) {
remaining = true;
break;
}
}
if (n == 0 && remaining) {
break;
}
}
return total;
}
}
本机输出:
gather written=11 scatter read=11 header=HDR1 body=payload
writeFully 的关键不是「一次写完」,而是「按缓冲区顺序写,直到所有缓冲区都没有剩余」。write(ByteBuffer[]) 返回所有缓冲区合计写出的字节数,可能只写了第一个缓冲区的一部分,也可能只写了第一个。缓冲区数组里 remaining() == 0 的项会被跳过;如果所有项都是 0,write 返回 0。阻塞 FileChannel 在还有剩余时正常不会返回 0;helper 里的 0 检查是为了避免非阻塞通道上空转。
read(ByteBuffer[]) 按数组顺序填充缓冲区,填满一个再填下一个。它返回本次读到的总字节数;如果所有缓冲区都没有剩余空间,返回 0;文件末尾返回 -1。这里也有一个容易误判的边界:read 返回 0 不等于 EOF,先检查缓冲区是否还有空间。
Gather 写适合「头部和正文已经在两个缓冲区里」的场景。它省掉的是用户态的拼接复制,不是内核里的复制:字节最终仍然要从用户缓冲区写进 socket 发送队列。真正的零拷贝要在文件到 socket 这条链路上做,下一节看 transferTo。
transferTo、transferFrom 与 sendfile
FileChannel.transferTo(long position, long count, WritableByteChannel target) 把文件从 position 开始的 count 字节写到目标通道。返回值是实际传输的字节数,可能小于 count,也可能在文件末尾返回 0。
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class TransferDemo {
public static void main(String[] args) throws Exception {
Path file = Path.of("/tmp/linkd-transfer.bin");
long size;
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
size = ch.size();
}
try (SocketChannel out = SocketChannel.open(
new InetSocketAddress(InetAddress.getLoopbackAddress(), 9090));
FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
long position = 0;
while (position < size) {
long n = ch.transferTo(position, size - position, out);
if (n <= 0) {
break;
}
position += n;
}
out.shutdownOutput();
System.out.println("transferred=" + position + " of " + size);
}
}
}
这段代码要连一个正在 9090 端口收数据的服务端,单独跑会得到 Connection refused。它的重点是循环:transferTo 不承诺一次传完。单次调用可能只传几十 KB 到几 MB,取决于内核发送缓冲区、文件系统和目标通道的状态。把返回值当成「全部完成」,响应就会少一截。
transferFrom(ReadableByteChannel src, long position, long count) 是反方向:从源通道读到文件,调用形态是 file.transferFrom(socket, position, count)。它同样可能只传一部分。transferFrom 的零拷贝条件比 transferTo 更窄:从 socket 到文件通常需要把数据从内核网络缓冲区搬到文件页缓存,未必能省掉拷贝。
JDK 在支持的平台上可能把 transferTo 映射到 sendfile 一类的系统调用:文件页缓存直接送到 socket,不需要经过用户缓冲区。这里不能写死「一定走 sendfile」:JDK 会根据通道类型、平台和传输长度选择实现,传输失败时还可能退回普通读写。本节没有用 dtruss 验证具体 syscall,只从 API 语义和下面的吞吐差异讨论。
零拷贝有几个明确的前提:
- 目标必须是 socket 一类的通道。
transferTo到另一个FileChannel走的是文件到文件的复制路径,不享受 sendfile 的零拷贝。 - 中间不能有必须经过用户态的处理。TLS 加密、gzip 压缩、内容改写都要先把字节读进用户缓冲区,零拷贝链断掉。
linkd的 TLS 章用SSLEngine,静态文件走 TLS 时只能读文件、加密、再写 socket。 - 非阻塞 socket 上,
transferTo可能返回 0。发送缓冲区满时,内核没有空间可写,调用不会阻塞等待;事件循环里不能忙等,要记录当前位置,等OP_WRITE可写再继续,或者把文件发送放到阻塞线程。
最后一条在实际代码里最容易出问题。linkd 的事件循环用非阻塞 SocketChannel,如果把上面的 while 直接放进 onWritable,transferTo 返回 0 时会立即再次调用,CPU 空转,连接却没有任何进展。非阻塞版本要保存 position 和剩余长度,返回 0 就注册写事件退出。
mmap:把文件映射进地址空间
FileChannel.map(MapMode, position, size) 把文件的一段映射到进程地址空间,返回 MappedByteBuffer。读写这个缓冲区就像读写内存,页错误时由内核把文件页装进来。
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class MmapDemo {
public static void main(String[] args) throws Exception {
Path path = Path.of("/tmp/linkd-mmap.bin");
try (FileChannel ch = FileChannel.open(path,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.READ, StandardOpenOption.WRITE)) {
ch.write(ByteBuffer.allocate(4096));
MappedByteBuffer map = ch.map(FileChannel.MapMode.READ_WRITE, 0, 4096);
map.putInt(0, 0x01020304);
map.force();
}
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer b = ByteBuffer.allocate(4);
ch.read(b);
b.flip();
System.out.printf("mmap firstInt=0x%08X%n", b.getInt());
}
}
}
本机输出:
mmap firstInt=0x01020304
MapMode.READ_WRITE 的修改会写回文件;READ_ONLY 只读;PRIVATE 是写时复制,修改不落盘。force() 把脏页刷回磁盘,类似 FileChannel.force。映射是惰性的:map 返回时并没有把 4096 字节全部读进物理内存,第一次访问某一页才触发页错误。
mmap 的边界比 transferTo 多:
MappedByteBuffer的容量是int,单个映射最多 2 GB 左右。大文件要分段映射,分段处理。- 映射的解除不由
close()控制。FileChannel.close()不会立即解除映射;映射要等缓冲区不可达、Cleaner 执行,或者由平台在进程退出时回收。长期持有映射会让文件句柄和地址空间占用持续存在。 - 文件被截断后继续访问已经映射的页,在 Linux 上可能触发
SIGBUS,进程直接崩溃,不是 Java 异常。不要在映射还活着的时候truncate同一个文件。 - 在 Windows 上,映射期间文件通常不能删除或重命名;macOS 和 Linux 可以 unlink,但已映射的页仍然有效。跨平台代码不要依赖「先删文件再解除映射」的顺序。
- mmap 不一定比
read快。小文件、一次性顺序读、页缓存本来就热的数据,映射的建立和页错误成本可能超过收益。它更适合大文件的随机访问、多次读同一段、或者需要把文件当内存结构解析的场景。
MappedByteBuffer 也是直接缓冲区,isDirect() 返回 true,但它在 BufferPoolMXBean 里归到 mapped 池,不是 direct 池。排查内存时两个池要分开看。
在 linkd 里落地
linkd 的静态文件发送放在 linkd-http 里。下面是一个阻塞版本,假设调用方给的是阻塞 SocketChannel;事件循环版本在 8.3 再改。
package com.plume.linkd.http;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
public final class StaticFileSender {
public static long send(SocketChannel out, FileChannel file, long length)
throws IOException {
String head = "HTTP/1.1 200 OK\r\n"
+ "Content-Length: " + length + "\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Connection: close\r\n\r\n";
ByteBuffer header = ByteBuffer.wrap(head.getBytes(StandardCharsets.US_ASCII));
while (header.hasRemaining()) {
out.write(header);
}
long position = 0;
while (position < length) {
long n = file.transferTo(position, length - position, out);
if (n <= 0) {
throw new IOException("transferTo stalled at " + position);
}
position += n;
}
return position;
}
}
这个方法先写响应头,再用 transferTo 循环写正文。头部只有几百字节,单独写一次系统调用可以接受;正文可能是几 MB 到几 GB,走 transferTo 避免在堆里准备整份内容。
Connection: close 是为了让这个最小版本不处理 keep-alive。8.2 会讲 keep-alive 和 chunked;如果连接要复用,响应头里必须有准确的 Content-Length,并且发送完后不能关闭 socket。Content-Length 用文件大小,不要用 transferTo 的返回值去填,因为返回值是实际传输量,不是文件长度。
非阻塞场景不能直接复用这个方法。事件循环里 out 配置了 configureBlocking(false),transferTo 返回 0 时上面的代码会抛 IOException,但 0 不代表文件有问题,只代表 socket 发送缓冲区暂时满了。正确做法是保存 position,把连接状态标成「正在发送文件」,注册 OP_WRITE;下一次可写时从 position 继续。或者更简单:静态文件发送交给一个小的阻塞线程池,事件循环只负责把任务投递过去,线程池大小按磁盘和网络带宽设上限。
小文件还有另一条路径:文件小于几十 KB 时,read 进一个直接缓冲区,和响应头一起用 GatheringByteChannel.write(ByteBuffer[]) 写出去。这样只有一次写系统调用,比分两次写头部和正文更省。阈值要在目标机器上测,不要拍一个「64 KB 最优」的结论。
测量方法与平台差异
要验证 transferTo 是否真的带来差异,需要一个可复现的对比。测量程序由三部分组成:一个读到 EOF 的 loopback 服务端、上面的 transferTo 循环、以及下面的 64 KB 拷贝循环。拷贝循环的核心是:
static long copyLoop(FileChannel file, SocketChannel out) throws IOException {
ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024);
long total = 0;
int n;
while ((n = file.read(buf)) != -1) {
buf.flip();
while (buf.hasRemaining()) {
total += out.write(buf);
}
buf.clear();
}
return total;
}
把两个发送方法和一个计数服务端放进同一个 TransferBenchmark 类,创建 64 MiB 文件后分别调用,记录 System.nanoTime() 差值:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
javac --release 25 -d out TransferBenchmark.java
java -Xmx512m -cp out TransferBenchmark
本机输出(连续四次运行,单次计时,未做统计):
transferTo received=67108864 elapsed=179ms throughput=358 MiB/s
copy-loop received=67108864 elapsed=210ms throughput=305 MiB/s
transferTo received=67108864 elapsed=161ms throughput=398 MiB/s
copy-loop received=67108864 elapsed=196ms throughput=327 MiB/s
transferTo received=67108864 elapsed=151ms throughput=424 MiB/s
copy-loop received=67108864 elapsed=195ms throughput=328 MiB/s
transferTo received=67108864 elapsed=144ms throughput=444 MiB/s
copy-loop received=67108864 elapsed=172ms throughput=372 MiB/s
这组数字只说明「在这台机器、这个 loopback 路径、页缓存已预热的情况下,transferTo 比 64 KB 拷贝循环快一些」。它包含连接建立、服务端读取和缓冲区分配,不是纯粹的 sendfile 基准;loopback 也不经过真实网卡。不要把它当成生产吞吐,更不要拿它证明「零拷贝一定快一倍」。
要确认底层系统调用,macOS 可以用 dtruss,Linux 可以用 strace:
# macOS,需要 sudo;本节没有执行,只给命令
sudo dtruss -f -t sendfile java -cp out TransferBenchmark 2>&1 | grep sendfile
# Linux
strace -f -e trace=sendfile,sendfile64,write,writev java -cp out TransferBenchmark
dtruss 受 SIP 和权限影响,不一定能在所有机器上跑;没有实际输出时,不要写「本机确认走了 sendfile」。平台差异也要留出余地:Linux 的 sendfile 支持文件到 socket,macOS 的 sendfile 参数和语义不同,Windows 用 TransmitFile;JDK 在这些平台上可能选择不同实现,甚至在条件不满足时退回普通读写。代码只依赖 transferTo 的返回值语义,不依赖具体 syscall,才能跨平台。
TLS 是另一个断点。SSLEngine 必须先加密再写 socket,文件字节一定要经过用户缓冲区;transferTo 到 TLS 通道要么不成立,要么退化成内部拷贝。linkd 的 TLS 章会单独处理这条路径,静态文件在 TLS 下不要假设零拷贝。
常见错误与边界
错误一:只调用一次 transferTo。 返回值可能小于 count,甚至为 0。必须循环到 position == length,并且处理 0 的情况。
错误二:在非阻塞 socket 上忙等。 transferTo 返回 0 表示发送缓冲区满,不是文件结束;在事件循环里反复调用会空转。保存位置,等 OP_WRITE。
错误三:把 transferTo 用到 TLS 或压缩路径上。 加密和压缩都要在用户态处理字节,零拷贝链断掉。先用普通缓冲区读文件、处理、再写 socket。
错误四:认为 transferTo 到 FileChannel 也是零拷贝。 文件到文件走的是复制路径;只有文件到 socket 一类目标才可能命中 sendfile。
错误五:映射文件后截断同一个文件。 Linux 上访问已映射但超出新文件长度的页可能触发 SIGBUS,进程直接退出。先解除映射(或至少停止访问),再截断。
错误六:把 MappedByteBuffer 当成可确定释放的资源。 close() 不解除映射,映射要等 Cleaner 或进程退出。长期持有大映射会让地址空间和文件句柄占用持续存在。
错误七:Scatter 读返回 0 就当 EOF。 所有缓冲区都没有剩余空间时 read 返回 0;文件末尾才返回 -1。先检查 remaining()。
边界:transferTo 的 count 是 long,但单次调用实际传输量可能远小于它。 大文件必须循环;不要用 int 保存剩余长度。
边界:零拷贝不等于零 CPU。 即使走 sendfile,内核仍然要做页缓存查找、协议栈封装和校验和计算;TLS 下还要加解密。它省掉的是用户态复制和部分上下文切换,不是全部工作。
小结
FileChannel.read到末尾返回-1,返回 0 不是 EOF;write可能只写一部分,必须循环。- Scatter/Gather 用
read(ByteBuffer[])/write(ByteBuffer[])按顺序处理多个缓冲区;本机示例一次 gather 写入 11 字节,scatter 读回HDR1+payload。 transferTo返回实际传输量,可能小于请求量;本机 64 MiB 文件需要循环发送,不能用单次返回值判断完成。- 非阻塞 socket 上
transferTo返回 0 表示发送缓冲区满,事件循环要保存位置并等OP_WRITE。 transferTo只在文件到 socket 一类目标上可能走 sendfile;TLS、压缩、文件到文件都会退回拷贝路径。mmap适合大文件随机访问;单次映射受int容量限制,解除映射不确定,截断已映射文件有SIGBUS风险。
Buffer 和 Channel 的基础到这里就齐了。下一章把这些通道注册到 Selector 上,先看就绪事件和 interestOps 怎么表达「我现在关心读还是写」。
阅读导航:上一节:5.2 DirectBuffer 与堆外内存 · 下一节:6.1 Selector 就绪事件与 interestOps 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。