本节目标:分清
MappedByteBuffer的建立、刷盘、取消映射三个边界,能判断静态文件该用 mmap 还是transferTo,并用MappedFileServer观察两种方式在BufferPoolMXBean上的差别。
适用版本:JDK 25(Temurin 25.0.5+8);FileChannel.map、MappedByteBuffer.force、Arena(JDK 22+)。
承接的问题
13.2 的直接内存由 Java 显式申请、显式归还;mmap 换了一种归属:地址空间由 FileChannel.map 建立,物理页由内核按缺页加载,取消映射的时机不再由 release() 决定。linkd-http 的静态资源曾用 Files.readAllBytes 加 write,一个 8 MiB 的 JS 包每次请求都分配 8 MiB 堆内存;改成 transferTo 后堆稳了,但每次仍要读文件。mmap 看起来能把这两步都省掉,可它有自己的边界:映射什么时候消失、force() 到底刷什么、关闭通道算不算解除映射。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
映射建立时发生了什么
FileChannel.map(MapMode, long position, long size) 返回一个 MappedByteBuffer。它做的是三件事:检查参数、向内核要一段虚拟地址、把文件区间挂到这段地址上。此时并没有把整个文件读进内存;第一次访问某一页才触发缺页,内核从页缓存或磁盘取页。所以映射建立的耗时不等于文件大小,RSS 也是逐步上涨的。
| 模式 | 写行为 | 典型用途 |
|---|---|---|
READ_ONLY | 写抛 ReadOnlyBufferException | 发送静态文件、只读索引 |
READ_WRITE | 写回页缓存,force 后落盘 | 可修改的文件、日志索引 |
PRIVATE | 写时复制,不落盘 | 只读解析但要改内容的场景 |
MappedByteBuffer 的 isDirect() 是 true,但它在 BufferPoolMXBean 的 mapped 池里,不在 direct 池里。这一点在 13.2 的三本账里容易看漏:直接内存涨了,先确认是哪本账。
映射建立后,FileChannel 可以关闭,映射仍然有效。本机 MapProbe 的输出:
mapped capacity=1048576 isDirect=true firstInt=0x1020304
readOnly force ok firstInt=0x1020304
after channel close firstInt=0x1020304
前两行说明 1 MiB 文件映射成功、默认大端读出 0x01020304;第三行说明 FileChannel.close() 只关闭通道,不解除映射,关闭后仍能读到内容。映射的生命周期挂在 MappedByteBuffer 对象上,不挂在通道上。
还有一个容易忽略的上限:单个 MappedByteBuffer 的容量是 int,size 超过 Integer.MAX_VALUE 时 map 直接抛 IllegalArgumentException。大文件要分段映射,或者改用 transferTo。
force、close 与取消映射
force() 把脏页刷到存储设备。对 READ_ONLY 映射,它什么都不做,也不抛异常;上面的 readOnly force ok 就是本机输出。force(int index, int length) 只刷指定区间,适合只改了一小段的索引文件。
force() 的保证边界要说清:它保证数据到达内核和存储设备的队列,不保证物理介质完成写;需要连同元数据刷盘时用 FileChannel.force(true),MappedByteBuffer 本身没有 boolean 重载。它也不负责解除映射。
经典 MappedByteBuffer 没有公开的取消映射方法。FileChannel.close() 不解除,System.gc() 只是建议,真正的解除依赖 Cleaner 或 Unsafe.invokeCleaner 这类非标准手段。生产代码不应该为了及时取消映射去反射调用 Unsafe。
JDK 22+ 的 FFM 提供了确定性方案:
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = channel.map(
FileChannel.MapMode.READ_ONLY, 0, size, arena);
// 使用 segment
} // close() 时解除映射
Arena.close() 返回时映射已经解除。之后再访问,抛 IllegalStateException: Already closed,不是段错误。本机输出:
arena segment bytes=4096 firstInt=0x4030201
arena closed
after close: java.lang.IllegalStateException: Already closed
注意第二行的 0x4030201:MappedByteBuffer 默认大端,MemorySegment.get(JAVA_INT, 0) 用本机字节序,Apple Silicon 是小端,所以同一个文件读出相反的字节序。混用两种 API 时,字节序要显式指定。
MapProbe:把三个边界跑一遍
下面这段只保留关键路径,写 4 KiB、刷盘、关闭通道后重开只读映射:
Path file = Path.of("/tmp/map-probe.bin");
try (FileChannel ch = FileChannel.open(file, CREATE, READ, WRITE)) {
MappedByteBuffer m = ch.map(READ_WRITE, 0, 4096);
m.putInt(0, 0x01020304);
m.force();
}
try (FileChannel ch = FileChannel.open(file, READ)) {
MappedByteBuffer m = ch.map(READ_ONLY, 0, 4096);
m.force(); // 只读映射:空操作
System.out.printf("%x%n", m.getInt(0));
}
编译运行后得到上面的 firstInt 与 after channel close 两行。把第二段换成 Arena 版本,就得到 arena closed 与 Already closed。这四个现象合起来回答三个问题:映射何时建立、force 刷什么、谁负责解除。
transferTo 还是 mmap
两者不是替代关系,选择取决于「要不要在用户态碰这些字节」:
| 场景 | 选择 | 原因 |
|---|---|---|
| 只发送、不解析,文件较大 | transferTo | 内存占用有界,不占地址空间,不依赖取消映射 |
| 只发送、不解析,文件很小且频繁 | mmap 或 transferTo | mmap 省一次内核到用户态拷贝,但映射有建立成本 |
| 要在用户态解析、改字段 | mmap | 直接按偏移读,避免整文件读入堆 |
| 只改少量字节 | READ_WRITE + force(range) | 不需要重写整个文件 |
| 并发打开大量文件 | transferTo | 映射数量和地址空间不会随并发线性增长 |
| 需要确定性释放 | Arena 映射或 transferTo | 经典映射要等 GC/Cleaner |
transferTo 的退路是 8 KB 堆内拷贝(13.1 已拆过),所以它不保证零拷贝,但内存上界稳定。mmap 的代价是缺页、地址空间和取消映射延迟;文件被截断时,访问已映射页还可能触发 SIGBUS。
阈值没有通用答案。下面的服务器用 1 MiB 作为演示阈值,生产值要用真实文件分布压测,而不是照抄。
MappedFileServer:把两种方式放在一个进程里
服务器只做三件事:按大小选模式、发文件、暴露 mapped 池的计数。/stats 用来观察经典映射是否累积。
import java.io.IOException;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.atomic.AtomicLong;
public final class MappedFileServer {
private final Path root;
private final long mmapThreshold;
private final boolean arena;
private final AtomicLong mmapRequests = new AtomicLong();
public MappedFileServer(Path root, long mmapThreshold, boolean arena) {
this.root = root.toAbsolutePath().normalize();
this.mmapThreshold = mmapThreshold;
this.arena = arena;
}
public static void main(String[] args) throws Exception {
int port = 9090;
Path root = Path.of("/tmp/mapped-server-root");
boolean arena = false;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case "--port" -> port = Integer.parseInt(args[++i]);
case "--root" -> root = Path.of(args[++i]);
case "--arena" -> arena = true;
default -> throw new IllegalArgumentException(args[i]);
}
}
Files.createDirectories(root);
new MappedFileServer(root, 1024 * 1024, arena).serve(port);
}
public void serve(int port) throws IOException {
try (ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), 128);
System.out.println("listening on 127.0.0.1:" + port
+ " root=" + root + " arena=" + arena);
while (true) {
try (SocketChannel ch = server.accept()) {
handle(ch);
} catch (IOException e) {
System.out.println("connection error: " + e);
}
}
}
}
private void handle(SocketChannel ch) throws IOException {
String line = readRequestLine(ch);
if (line == null) {
return;
}
String[] parts = line.split(" ");
if (parts.length < 2 || !parts[0].equals("GET")) {
respond(ch, 405, "text/plain", "method not allowed", "none");
} else if (parts[1].equals("/healthz")) {
respond(ch, 200, "text/plain", "ok", "none");
} else if (parts[1].equals("/stats")) {
respond(ch, 200, "text/plain", stats(), "none");
} else if (parts[1].startsWith("/files/")) {
serveFile(ch, parts[1].substring("/files/".length()));
} else {
respond(ch, 404, "text/plain", "not found", "none");
}
}
private void serveFile(SocketChannel ch, String name) throws IOException {
if (name.isEmpty() || name.contains("/") || name.contains("\\")) {
respond(ch, 400, "text/plain", "bad name", "none");
return;
}
Path file = root.resolve(name).normalize();
if (!file.startsWith(root) || !Files.isRegularFile(file)) {
respond(ch, 404, "text/plain", "not found", "none");
return;
}
long size = Files.size(file);
String mode = size <= mmapThreshold ? "mmap" : "transfer";
String head = "HTTP/1.1 200 OK\r\n"
+ "Content-Length: " + size + "\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Connection: close\r\n"
+ "X-Send-Mode: " + mode + "\r\n\r\n";
writeFully(ch, ByteBuffer.wrap(head.getBytes(StandardCharsets.US_ASCII)));
if (mode.equals("mmap")) {
try (FileChannel fc = FileChannel.open(file, StandardOpenOption.READ)) {
sendMapped(ch, fc, size);
}
mmapRequests.incrementAndGet();
} else {
try (FileChannel fc = FileChannel.open(file, StandardOpenOption.READ)) {
long position = 0;
while (position < size) {
long n = fc.transferTo(position, size - position, ch);
if (n <= 0) {
throw new IOException("transferTo stalled at " + position);
}
position += n;
}
}
}
}
private void sendMapped(SocketChannel ch, FileChannel fc, long size) throws IOException {
if (arena) {
try (Arena a = Arena.ofConfined()) {
MemorySegment segment = fc.map(FileChannel.MapMode.READ_ONLY, 0, size, a);
writeFully(ch, segment.asByteBuffer());
}
} else {
MappedByteBuffer map = fc.map(FileChannel.MapMode.READ_ONLY, 0, size);
writeFully(ch, map);
}
}
private void respond(SocketChannel ch, int status, String type, String body, String mode)
throws IOException {
String reason = status == 200 ? "OK" : status == 404 ? "Not Found" : "Error";
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
String head = "HTTP/1.1 " + status + " " + reason + "\r\n"
+ "Content-Length: " + bytes.length + "\r\n"
+ "Content-Type: " + type + "\r\n"
+ "Connection: close\r\n"
+ "X-Send-Mode: " + mode + "\r\n\r\n";
writeFully(ch, ByteBuffer.wrap(head.getBytes(StandardCharsets.US_ASCII)));
writeFully(ch, ByteBuffer.wrap(bytes));
}
private static String readRequestLine(SocketChannel ch) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(4096);
while (ch.read(buffer) != -1) {
buffer.flip();
String text = StandardCharsets.US_ASCII.decode(buffer).toString();
int end = text.indexOf("\r\n");
if (end >= 0) {
return text.substring(0, end);
}
buffer.compact();
if (!buffer.hasRemaining()) {
break;
}
}
return null;
}
private static void writeFully(SocketChannel ch, ByteBuffer buffer) throws IOException {
while (buffer.hasRemaining()) {
ch.write(buffer);
}
}
private String stats() {
long count = -1;
long used = -1;
for (BufferPoolMXBean p : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
if (p.getName().equals("mapped")) {
count = p.getCount();
used = p.getMemoryUsed();
}
}
return "arena=" + arena
+ "\nmapped.count=" + count
+ "\nmapped.used=" + used
+ "\nmmap.requests=" + mmapRequests.get() + "\n";
}
}
准备 51 个 256 KiB 文件和一个 2 MiB 文件,经典模式启动:
javac --release 25 -d out MappedFileServer.java
mkdir -p /tmp/mapped-server-root
for i in $(seq 1 51); do
python3 -c "open('/tmp/mapped-server-root/f$i.bin','wb').write(b'x'*262144)"
done
python3 -c "open('/tmp/mapped-server-root/large.bin','wb').write(b'y'*2097152)"
java -cp out MappedFileServer --port 19090 --root /tmp/mapped-server-root
逐个请求 f1.bin 到 f51.bin,再请求一次 large.bin,响应头分别带 X-Send-Mode: mmap 和 X-Send-Mode: transfer。随后访问 /stats,本机输出:
arena=false
mapped.count=51
mapped.used=13369344
mmap.requests=51
51 × 262144 = 13369344。经典模式下,51 次 mmap 请求留下 51 个映射,FileChannel.close() 没有释放它们;请求返回后 MappedByteBuffer 对象已经不可达,但 Cleaner 还没执行,所以映射仍在。请求结束后 mapped.count 不会自动归零。
换成 --arena 重启,同样请求 51 次,本机输出:
arena=true
mapped.count=0
mapped.used=0
mmap.requests=51
mmap.requests=51 说明确实走了映射分支,但每个请求结束 Arena.close() 已解除映射,所以 mapped 池归零。这就是确定性取消映射的价值:映射数量不随请求累积,/stats 可以直接作为告警指标。
在 linkd 里落地
linkd-http 的静态资源处理器按文件大小分两路:大于阈值走 transferTo,小于等于阈值走 Arena 映射。映射分支用 Arena.ofConfined(),在同一个请求线程里创建、使用、关闭,不把 MemorySegment 交给异步写队列。
- 路径先
normalize()再startsWith(root),拒绝..、绝对路径和反斜杠;只允许单段文件名。 - mmap 只用于只读静态资源,不用于上传临时文件或动态响应;后两者生命周期不受请求控制,容易在文件截断时触发
SIGBUS。 - 指标分开记:
mapped.count、mapped.used、mmap.requests、transfer.requests;mapped.count持续上涨说明有映射没释放,mmap.requests高但mapped.count为 0 说明 Arena 路径正常。
常见错误与边界
错误一:误解 force() 的作用。 它只刷脏页,不解除映射;只读映射上它是空操作,不抛异常,不要用异常判断是否只读。
错误二:以为 FileChannel.close() 会解除映射。 本机 after channel close 仍是 0x1020304;通道和映射的生命周期独立。
错误三:映射超过 2 GiB 的单个文件。 MappedByteBuffer 容量是 int,map 会抛 IllegalArgumentException;分段映射或改用 transferTo。
错误四:映射文件被其他进程截断后继续读。 已映射页可能触发 SIGBUS,JVM 不一定能把它转成异常;静态资源要先确认文件大小稳定。
错误五:混用 MappedByteBuffer 与 MemorySegment 的字节序。 前者默认大端,后者用本机序;同一个 4 字节读出 0x1020304 和 0x4030201,不是数据坏了,是解释方式不同。
边界:Arena.ofConfined() 的线程约束。 它只能在创建线程使用和关闭;跨线程传递会抛 WrongThreadException。异步发送场景用 ofShared(),或者干脆走 transferTo。
小结
map建立虚拟地址映射,页按需加载;FileChannel.close()不解除映射;force()只刷脏页,只读映射上是空操作,force(range)只刷区间。- 经典
MappedByteBuffer依赖 Cleaner 取消映射;JDK 22+ 的Arena在close()时确定性解除,关闭后访问抛IllegalStateException。 - 只发送不解析优先
transferTo;要在用户态解析或改少量字节才用 mmap;确定性释放用 Arena。 - 本机 51 次 256 KiB 请求:经典模式
mapped.count=51、used=13369344;Arena 模式count=0、used=0,mmap.requests仍为 51。 - linkd 只对只读静态资源做 mmap,路径校验和映射指标一起上线。
13.3 把文件传输的两条路走完了。下一节回到连接本身:当发送速度超过对端消费速度,写队列如何用高水位和背压把压力传回上游。
阅读导航:上一节:13.2 DirectByteBuffer 分配与回收 · 下一节:14.1 高水位与背压 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。