本节目标:分清堆内与堆外缓冲区的成本,知道
allocateDirect何时该用、何时不该用,并能用MaxDirectMemorySize和BufferPoolMXBean定位直接内存问题。
适用版本:JDK 25(Temurin 25.0.5+8);ByteBuffer.allocateDirect、java.lang.ref.Cleaner、BufferPoolMXBean。
承接的问题
5.1 的 BufferPool 用 ByteBuffer.allocate 分配堆内缓冲区。堆内缓冲区背后是一个 byte[],分配快、能被 GC 直接管理、调试时能看到内容。但它和 Channel 之间隔着一层:SocketChannel.read(heapBuffer) 在进入 native 读之前,通常要准备一块临时直接内存,把内核读到的字节先放进临时缓冲区,再拷回堆内的 byte[]。写方向也可能发生一次反向拷贝。
linkd 的 Echo 路径每个连接每轮读写都要经过这块缓冲区。单次拷贝是微秒级,但连接数上千、每连接 64 KB、每秒多轮读写时,这次 memcpy 会稳定出现在 profiler 里。把缓冲区换成 ByteBuffer.allocateDirect,字节直接落在堆外,native 读写可以省掉这次中转。
代价有两个:分配更贵,回收不由 close() 控制。直接内存不受 -Xmx 限制,出问题时不是 OutOfMemoryError: Java heap space,而是另一条报错;缓冲区对象被 GC 回收之前,堆外那块内存也不会还回去。这一节先把这两条边界跑出来,再决定 linkd 的池子怎么改。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
allocateDirect 与 allocate 的差别
两个方法都返回 ByteBuffer,但实现类不同:
import java.nio.ByteBuffer;
public class HeapProbe {
public static void main(String[] args) {
ByteBuffer heap = ByteBuffer.allocate(8);
ByteBuffer direct = ByteBuffer.allocateDirect(8);
System.out.println("heap isDirect=" + heap.isDirect()
+ " hasArray=" + heap.hasArray()
+ " class=" + heap.getClass().getName());
System.out.println("direct isDirect=" + direct.isDirect()
+ " hasArray=" + direct.hasArray()
+ " class=" + direct.getClass().getName());
}
}
本机输出:
heap isDirect=false hasArray=true class=java.nio.HeapByteBuffer
direct isDirect=true hasArray=false class=java.nio.DirectByteBuffer
HeapByteBuffer 有可访问的 byte[],array() 直接返回底层数组;DirectByteBuffer 的可访问数组是 null,hasArray() 返回 false,array() 抛 UnsupportedOperationException。堆外缓冲区的数据在 JVM 堆之外,通过一个本地地址访问;堆内只有一个很小的 DirectByteBuffer 壳对象。
“省一次拷贝”不等于“读写一定更快”。直接缓冲区仍然要跨 JNI 边界,仍然要经过内核;它省掉的是堆内数组和临时直接缓冲区之间的那次复制。数据量越小、越一次性,分配和注册 Cleaner 的固定成本占比越高,直接缓冲区可能更慢。判断要不要换,应该在目标机器上测量,而不是看「direct 更接近操作系统」这句话。
分配成本也值得单独看。allocate 主要是在 TLAB 里挪指针;allocateDirect 要向 JVM 的直接内存账本预留容量、向操作系统申请内存、注册清理动作。两者不是同一量级的操作。把 allocateDirect 放进每个请求的路径,等于把一次廉价的堆分配换成一次更贵的本地内存分配。
Cleaner:释放发生在 GC 之后
直接缓冲区没有 close()。它的释放依赖 java.lang.ref.Cleaner:DirectByteBuffer 构造时注册一个清理动作,当这个对象变得不可达,GC 把清理动作交给 Cleaner 线程执行,堆外内存才被释放。
用一个循环分配 10000 个 1 KB 的直接缓冲区,然后观察 BufferPoolMXBean 的 direct 池:
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
public class DirectChurnDemo {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 10_000; i++) {
ByteBuffer b = ByteBuffer.allocateDirect(1024);
b.putLong(0, i);
}
System.out.println("after churn " + pool());
System.gc();
Thread.sleep(200);
System.out.println("after gc " + pool());
}
static String pool() {
for (BufferPoolMXBean p : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
if (p.getName().equals("direct")) {
return "count=" + p.getCount() + " used=" + p.getMemoryUsed();
}
}
return "no direct pool";
}
}
本机输出(-Xmx256m -XX:MaxDirectMemorySize=512m):
after churn count=10000 used=10240000
after gc count=0 used=0
循环结束时,10000 个缓冲区全部不可达,但 count 仍然是 10000,used 是 10240000 字节(约 9.8 MB)。这说明对象已经没人引用,堆外内存却还没还。System.gc() 之后,Cleaner 线程执行清理,count 和 used 才归零。
生产代码不能靠 System.gc() 管理直接内存。默认的显式 GC 只是建议,-XX:+DisableExplicitGC 会把它变成空操作;即使执行了,也要等 GC 选中这些对象、Cleaner 线程调度到清理任务。高并发下,分配速度超过 Cleaner 回收速度,used 会持续上涨,最后撞上直接内存上限。
BufferPoolMXBean 的 used 是「已预留的直接缓冲区容量」,不是进程 RSS。它不包含线程栈、代码缓存、JNI 自己分配的内存,也不包含 MappedByteBuffer(那部分在名为 mapped 的池里)。排查「进程内存比堆大」时,这个值只能回答「ByteBuffer 这条线用了多少」,不能回答全部。
JDK 22 起,FFM 的 Arena 提供了确定性释放:Arena.ofConfined() 在 close() 时立即释放它管理的 MemorySegment,不需要等 GC。新代码如果本来就要用 FFM,可以用它替代「分配大块直接缓冲区再等 Cleaner」的写法。这里仍以 ByteBuffer 为主,因为 linkd 的 Channel API 接收的是 ByteBuffer;Arena 与 MemorySegment 的细节放到 13.2。
MaxDirectMemorySize 与 OOM
先看默认值:
java -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'MaxDirectMemorySize|MaxHeapSize'
本机输出:
uint64_t MaxDirectMemorySize = 0 {product} {default}
size_t MaxHeapSize = 8589934592 {ergonomic}
MaxDirectMemorySize=0 不是「禁止直接内存」,而是「上限等于最大堆」。本机堆上限是 8 GB,所以直接内存默认上限也在 8 GB 量级。容器里如果只设了 -Xmx2g,没有设 -XX:MaxDirectMemorySize,直接缓冲区仍然可以涨到 2 GB,再加上元空间、线程栈、代码缓存,很容易超过容器限额。
把上限设成 32 MB,每次分配 4 MB,观察第几次失败:
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class DirectLimitDemo {
public static void main(String[] args) {
List<ByteBuffer> kept = new ArrayList<>();
try {
for (int i = 1; i <= 12; i++) {
kept.add(ByteBuffer.allocateDirect(4 * 1024 * 1024));
System.out.println("allocated=" + i + " " + directPool());
}
} catch (OutOfMemoryError e) {
System.out.println("caught=" + e.getMessage());
System.out.println("kept=" + kept.size() + " " + directPool());
}
}
static String directPool() {
for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
if (pool.getName().equals("direct")) {
return "pool{count=" + pool.getCount() + " used=" + pool.getMemoryUsed() + "}";
}
}
return "no direct pool";
}
}
本机输出(-XX:MaxDirectMemorySize=32m):
allocated=1 pool{count=1 used=4194304}
allocated=2 pool{count=2 used=8388608}
allocated=3 pool{count=3 used=12582912}
allocated=4 pool{count=4 used=16777216}
allocated=5 pool{count=5 used=20971520}
allocated=6 pool{count=6 used=25165824}
allocated=7 pool{count=7 used=29360128}
allocated=8 pool{count=8 used=33554432}
caught=Cannot reserve 4194304 bytes of direct buffer memory (allocated: 33554432, limit: 33554432)
kept=8 pool{count=8 used=33554432}
第 9 次分配失败,报错原文是 Cannot reserve 4194304 bytes of direct buffer memory (allocated: 33554432, limit: 33554432)。前 8 个缓冲区仍然被 kept 强引用,Cleaner 不会释放它们,所以 allocated 和 limit 都停在 32 MB。
这个检查由 Bits.reserveMemory 完成:它把本次要预留的容量加到总账上,超过 VM.maxDirectMemory() 时先尝试一次 System.gc(),给 Cleaner 一个释放机会,再重试;仍然不够就抛 OutOfMemoryError。-XX:+DisableExplicitGC 会让这次重试变成空操作,直接内存不足时更快失败。
直接内存 OOM 的堆 dump 帮助有限。-XX:+HeapDumpOnOutOfMemoryError 能抓到 DirectByteBuffer 壳对象和引用链,但堆外那几 MB 字节不在 dump 里。看到「dump 不大,进程却被杀」,要把 BufferPoolMXBean、-XX:MaxDirectMemorySize 和容器内存限制一起看。
在 linkd 里落地
5.1 的 BufferPool 改成直接内存版本。acquire(int) 和 release(ByteBuffer) 签名不变,构造参数多一个池上限;变化在分配方式、线程归属和容量控制:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Deque;
public final class BufferPool {
private final int bufferSize;
private final int maxPerThread;
private final ThreadLocal<Deque<ByteBuffer>> free =
ThreadLocal.withInitial(ArrayDeque::new);
public BufferPool(int bufferSize, int maxPerThread) {
this.bufferSize = bufferSize;
this.maxPerThread = maxPerThread;
}
public ByteBuffer acquire(int minCapacity) {
if (minCapacity > bufferSize) {
return ByteBuffer.allocateDirect(minCapacity);
}
Deque<ByteBuffer> stack = free.get();
ByteBuffer buffer = stack.pollFirst();
if (buffer == null) {
buffer = ByteBuffer.allocateDirect(bufferSize);
}
buffer.clear();
return buffer;
}
public void release(ByteBuffer buffer) {
if (buffer == null || !buffer.isDirect() || buffer.isReadOnly()) {
return;
}
if (buffer.capacity() != bufferSize) {
return;
}
Deque<ByteBuffer> stack = free.get();
if (stack.size() >= maxPerThread) {
return;
}
buffer.clear();
stack.addFirst(buffer);
}
}
几个设计点:
bufferSize固定成 64 KB 之类的常用值,池的命中率才高。请求超过这个容量时单独分配,不放进池,避免池里出现大小不一的块。ThreadLocal让每个事件循环线程有自己的空闲栈,acquire和release不需要锁。代价是跨线程归还会落到另一个线程的池里;linkd约定缓冲区在哪个事件循环借出,就在哪个事件循环归还。maxPerThread给池设上限。连接数从 1000 掉到 10 时,空闲栈最多留maxPerThread个缓冲区,其余交给 Cleaner,不会把直接内存长期占住。release拒绝readOnly和非 direct 的缓冲区,避免把 slice 或只读视图放进池里;clear()把状态重置成写模式。
缓冲区一旦 enqueue 给 Connection,所有权就转移给写队列。调用方不能再读、不能再 release;写完成或连接关闭时,由队列负责归还。重复 release 同一个缓冲区,会让两个连接拿到同一块内存,表现为「A 的响应出现在 B 的连接上」这种很难查的数据串线。池子本身无法识别重复归还,所有权规则必须由调用方遵守。
何时该用、何时不该用
| 场景 | 选择 | 原因 |
|---|---|---|
| 长连接读写缓冲,固定大小,反复复用 | 直接内存池 | 省掉堆内与临时直接缓冲区之间的拷贝,分配成本被复用摊薄 |
| 一次性小消息,几十到几百字节 | 堆内 | 分配便宜,GC 能直接回收,不值得为一次读写付 Cleaner 成本 |
需要 array() / arrayOffset() | 堆内 | 直接缓冲区没有可访问数组 |
| 大文件、大块内存传给 native/JNI | 直接内存 | 避免每次调用前复制到临时直接缓冲区 |
| 无界缓存「先存着」 | 都不用 | 直接内存同样会 OOM,缓存必须有上限和淘汰 |
| 大小混杂、命中率很低 | 堆内或分桶池 | 池里全是不同容量的块,复用率低,反而增加管理成本 |
直接内存适合「大块、长期、反复用」;堆内适合「小块、短命、需要数组」。这条分界比「direct 更快」有用。
常见错误与边界
错误一:每个请求 allocateDirect。 请求结束只把引用丢掉,Cleaner 还没跑,下一批请求继续分配。本机 10000 个 1 KB 缓冲区在 GC 前保持 count=10000 used=10240000,这就是「短命对象把直接内存占满」的形态。
错误二:用 System.gc() 做资源管理。 它只是建议;-XX:+DisableExplicitGC 下完全无效。即使生效,释放时机也不确定。直接内存要靠池化复用,不能靠 GC 兜底。
错误三:只设 -Xmx,不设 -XX:MaxDirectMemorySize。 默认上限等于最大堆,直接内存可能和堆一样大;容器里两者相加会超过限额。启动参数要同时给两条线设上限,并留出元空间、线程栈、代码缓存的空间。
错误四:重复 release 或 release 后继续用。 池子会把同一块缓冲区借给两个连接,数据互相覆盖。所有权转移后,原持有者不能再碰这个缓冲区。
错误五:把 BufferPoolMXBean.used 当成进程 RSS。 它只统计直接缓冲区,不含 mapped、线程栈、代码缓存和 JNI 分配。进程内存上涨时,它只能排除或确认 ByteBuffer 这一条线。
边界:MaxDirectMemorySize 不是所有堆外内存的上限。 它约束的是 Bits.reserveMemory 这条账,也就是 ByteBuffer.allocateDirect 和 JDK 内部使用直接缓冲区的路径;JNI 里 malloc 的内存、native 库自己的分配不受它管。排查堆外泄漏时,不要只盯着这个参数。
边界:直接内存不一定更快。 小消息、一次性操作、分配频率高的场景,直接内存的固定成本可能超过省下的拷贝。是否切换要用目标机器上的吞吐和延迟数据判断。
小结
allocate返回HeapByteBuffer,有byte[];allocateDirect返回DirectByteBuffer,hasArray=false,数据在堆外。- 直接内存没有
close(),释放依赖 Cleaner;本机 10000 个 1 KB 缓冲区在 GC 前保持count=10000 used=10240000。 MaxDirectMemorySize=0表示上限等于最大堆;本机默认堆 8 GB,直接内存上限也在 8 GB 量级。- 32 MB 上限下,第 9 个 4 MB 缓冲区触发
Cannot reserve 4194304 bytes of direct buffer memory。 linkd的BufferPool用固定大小、按线程缓存、设上限的直接缓冲区;所有权随enqueue转移,写完成或关闭时归还。BufferPoolMXBean.used只覆盖直接缓冲区,不等于 RSS,也不覆盖 JNI 自己分配的内存。
缓冲区的位置定了,接下来要把它接到文件上。FileChannel 能把文件字节直接送到 socket,也能用一组缓冲区做 Scatter/Gather;下一节看这些 API 的返回值为什么不能当成「全部完成」。
阅读导航:上一节:5.1 Buffer 的 position、limit 与 flip · 下一节:5.3 FileChannel、Scatter/Gather 与零拷贝 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。