本节目标:看清
DirectByteBuffer的对象结构、Cleaner 时序和三本内存账,能用DirectMemoryBudget在分配前拦截超限,并按顺序判断「泄漏还是延迟回收」。
适用版本:JDK 25(Temurin 25.0.5+8);ByteBuffer.allocateDirect、java.lang.ref.Cleaner、BufferPoolMXBean、NMT。
承接的问题
5.2 讲过 allocateDirect 比堆内分配贵,回收依赖 Cleaner;13.1 又看到 transferTo 的退路是 8 KB 堆内缓冲区。两节合起来说明一件事:直接内存不是「更接近操作系统」的同义词,它是一套独立的资源账。
linkd 上过一次这样的告警:容器 RSS 6 GB,-Xmx2g,BufferPoolMXBean 的 direct.used 1.8 GB,但 jcmd GC.class_histogram 只看到 12 个 DirectByteBuffer 对象。运维第一反应是「12 个对象怎么可能占 1.8 GB」,第二反应是「是不是堆外泄漏」。这两个判断都可能错:12 个对象可以各自持有 150 MB 的 native 内存;1.8 GB 也可能是对象已经不可达、Cleaner 还没跑。
这一节把「分配」「记账」「回收」拆成三条时间线,先看对象结构,再看三本账,最后用一个预算类把「分配前拦截」和「泄漏判定」落到代码里。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
DirectByteBuffer 的对象结构
先看 JDK 25 的字段:
javap -p --module java.base java.nio.DirectByteBuffer
本机输出(节选):
class java.nio.DirectByteBuffer extends java.nio.MappedByteBuffer
implements sun.nio.ch.DirectBuffer {
private final java.lang.Object att;
private final jdk.internal.ref.Cleaner cleaner;
}
堆里只有这个壳对象:一个 att 引用、一个 Cleaner 引用、几个 Buffer 字段。真正的字节在 native 内存里,地址不在 Java 堆中。att 是附件,切片和副本通过它保持原缓冲区的可达性;cleaner 是 JDK 内部的 jdk.internal.ref.Cleaner,不是公开的 java.lang.ref.Cleaner,但两者都是幻象引用机制。
清理动作是一个 record:
javap -p -c --module java.base 'java.nio.DirectByteBuffer$Deallocator'
本机输出(run() 节选):
0: getstatic #27 // Field java/nio/Buffer.UNSAFE:Ljdk/internal/misc/Unsafe;
7: invokevirtual #33 // Method jdk/internal/misc/Unsafe.freeMemory:(J)V
19: invokestatic #39 // Method java/nio/Bits.unreserveMemory:(JJ)V
Deallocator.run() 先 Unsafe.freeMemory(address) 把 native 内存还给操作系统,再 Bits.unreserveMemory(size, capacity) 把这条账从 MaxDirectMemorySize 的总额里减掉。两步都在 Cleaner 线程里发生,不在 release() 或 close() 里。
切片不重新预留。后文预算实验的 after slice 一行会看到 count=1、used=16777216,尽管切片本身只有 1 MiB:切片和原缓冲区共享同一条 native 分配,释放责任在原缓冲区。把切片单独归还池子,池里会出现一块「容量 1 MiB、实际归属 16 MiB」的内存。
Cleaner 的时序在 Thread.print 里能看到:JDK 自己的共享清理线程叫 Common-Cleaner,Cleaner.create() 创建的是 Cleaner-0。每个 Cleaner.create() 都创建一个线程;按请求创建 Cleaner 等于按请求创建线程。预算类应该共用一个 Cleaner,而不是每个缓冲区一个。
三本账:MaxDirectMemorySize、MXBean 与 NMT
| 账本 | 看什么 | 能回答 | 不能回答 |
|---|---|---|---|
-XX:MaxDirectMemorySize | Bits.reserveMemory 的总额 | 进程级上限、什么时候抛 OOME | 谁在用、什么时候释放 |
BufferPoolMXBean | direct / mapped 池的 count 与 used | ByteBuffer 这条线预留了多少 | RSS、JNI、线程栈、代码缓存 |
| NMT | Other 等分类的 malloc | 进程 malloc 总量和调用点 | 单个 ByteBuffer 的归属 |
第一本账的默认值容易误解。-XX:MaxDirectMemorySize=0 不是「禁止直接内存」,而是「上限等于最大堆」。容器里只设 -Xmx2g 时,直接内存默认也可以涨到 2 GB 量级,再加上元空间、线程栈、代码缓存,很容易超过容器限额。
第二本账的 used 是「已预留的缓冲区容量」,不是进程 RSS。它不包含线程栈、代码缓存、JNI 自己 malloc 的内存;MappedByteBuffer 在另一个叫 mapped 的池里,不在 direct。看到 direct.used 高,只能确认 ByteBuffer 这条线;看到它不高,也不能排除其他堆外内存。
第三本账用 NMT。启动时加 -XX:NativeMemoryTracking=summary,运行中用 jcmd 取快照:
java -XX:NativeMemoryTracking=summary -XX:MaxDirectMemorySize=128m -cp out DirectMemoryBudget hold 64
jcmd <pid> VM.native_memory summary scale=MB
本机输出(节选):
- Other (reserved=64MB, committed=64MB)
(malloc=64MB tag=Other #64) (at peak)
64 个 1 MiB 的直接缓冲区落在 Other 的 malloc 下,#64 是调用次数。NMT 没有单独的「Direct Buffer」分类,直接把 Other 当成直接内存会多算,因为 Other 还包含其他未分类的 malloc;但在「申请 64 MiB 前后做 diff」这个用法里,它足够定位。
类直方图回答「有多少个壳对象」:jcmd <pid> GC.class_histogram | grep DirectByteBuffer。本机 64 个 1 MiB 缓冲区对应 64 个 DirectByteBuffer 壳,每个浅堆 4096 字节,和 64 MiB native 内存没有换算关系。直方图能告诉你「有多少个」,不能告诉你「占了多少堆外」。
DirectMemoryBudget:把预算放在分配之前
-XX:MaxDirectMemorySize 是进程级最后一道闸,触发时已经是 OutOfMemoryError。应用层还需要一道更早的闸:在 allocateDirect 之前 reserve,超过预算直接拒绝;正常归还进池,池满或裁剪后由 Cleaner 减账。
import java.lang.management.*;
import java.lang.ref.Cleaner;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
public final class DirectMemoryBudget {
private final long limit;
private final int maxPerClass;
private final AtomicLong reserved = new AtomicLong();
private final AtomicLong rejected = new AtomicLong();
private final AtomicLong highWater = new AtomicLong();
private final Cleaner cleaner = Cleaner.create();
private final Map<Integer, Deque<ByteBuffer>> free = new HashMap<>();
public DirectMemoryBudget(long limit, int maxPerClass) {
this.limit = limit;
this.maxPerClass = maxPerClass;
}
public ByteBuffer acquire(int bytes) {
ByteBuffer pooled = poll(bytes);
if (pooled != null) {
pooled.clear();
return pooled;
}
if (!tryReserve(bytes)) {
rejected.incrementAndGet();
throw new OutOfMemoryError("direct budget exhausted: requested=" + bytes
+ " reserved=" + reserved.get() + " limit=" + limit);
}
ByteBuffer buffer;
try {
buffer = ByteBuffer.allocateDirect(bytes);
} catch (OutOfMemoryError e) {
reserved.addAndGet(-bytes);
throw e;
}
cleaner.register(buffer, new Release(bytes));
highWater.accumulateAndGet(reserved.get(), Math::max);
return buffer;
}
// 只接受 acquire 返回的原缓冲区;slice/duplicate 不能归还池。
public void release(ByteBuffer buffer) {
if (buffer == null || !buffer.isDirect() || buffer.isReadOnly()) {
return;
}
synchronized (this) {
Deque<ByteBuffer> queue =
free.computeIfAbsent(buffer.capacity(), k -> new ArrayDeque<>());
if (queue.size() >= maxPerClass) {
return;
}
buffer.clear();
queue.addFirst(buffer);
}
}
public void trim() {
synchronized (this) {
free.clear();
}
}
public long reserved() {
return reserved.get();
}
public long highWater() {
return highWater.get();
}
public long rejected() {
return rejected.get();
}
public int pooled() {
synchronized (this) {
int n = 0;
for (Deque<ByteBuffer> q : free.values()) {
n += q.size();
}
return n;
}
}
private ByteBuffer poll(int bytes) {
synchronized (this) {
Deque<ByteBuffer> queue = free.get(bytes);
return queue == null ? null : queue.pollFirst();
}
}
private boolean tryReserve(int bytes) {
while (true) {
long current = reserved.get();
if (current + bytes > limit) {
return false;
}
if (reserved.compareAndSet(current, current + bytes)) {
return true;
}
}
}
private final class Release implements Runnable {
private final int bytes;
Release(int bytes) {
this.bytes = bytes;
}
@Override
public void run() {
reserved.addAndGet(-bytes);
}
}
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";
}
public static void main(String[] args) throws Exception {
if (args.length > 0 && args[0].equals("hold")) {
hold(Integer.parseInt(args[1]));
return;
}
DirectMemoryBudget budget = new DirectMemoryBudget(32L * 1024 * 1024, 8);
List<ByteBuffer> held = new ArrayList<>();
int acquired = 0;
for (int i = 1; i <= 9; i++) {
try {
held.add(budget.acquire(4 * 1024 * 1024));
acquired++;
} catch (OutOfMemoryError e) {
System.out.println("acquire#" + i + " " + e.getMessage());
}
}
System.out.printf("acquired=%d reserved=%d highWater=%d rejected=%d pooled=%d %s%n",
acquired, budget.reserved(), budget.highWater(), budget.rejected(),
budget.pooled(), pool());
for (ByteBuffer b : held) {
budget.release(b);
}
held.clear();
System.out.printf("after release reserved=%d pooled=%d %s%n",
budget.reserved(), budget.pooled(), pool());
budget.trim();
System.gc();
Thread.sleep(300);
System.out.printf("after trim+gc reserved=%d pooled=%d %s%n",
budget.reserved(), budget.pooled(), pool());
ByteBuffer original = budget.acquire(16 * 1024 * 1024);
ByteBuffer slice = original.slice(0, 1024 * 1024);
System.out.printf("after slice reserved=%d sliceDirect=%b %s%n",
budget.reserved(), slice.isDirect(), pool());
budget.release(original);
budget.trim();
original = null;
slice = null;
System.gc();
Thread.sleep(300);
List<ByteBuffer> leak = new ArrayList<>();
for (int i = 0; i < 2; i++) {
leak.add(budget.acquire(2 * 1024 * 1024));
}
budget.trim();
System.gc();
Thread.sleep(300);
System.out.printf("leak reachable held=%d reserved=%d %s%n",
leak.size(), budget.reserved(), pool());
leak.clear();
System.gc();
Thread.sleep(300);
System.out.printf("leak unreachable reserved=%d %s%n",
budget.reserved(), pool());
}
static void hold(int mib) throws Exception {
DirectMemoryBudget budget = new DirectMemoryBudget(256L * 1024 * 1024, 8);
List<ByteBuffer> held = new ArrayList<>();
for (int i = 0; i < mib; i++) {
ByteBuffer b = budget.acquire(1024 * 1024);
b.putLong(0, i);
held.add(b);
}
System.out.println("pid=" + ProcessHandle.current().pid()
+ " heldMib=" + mib + " reserved=" + budget.reserved() + " " + pool());
System.out.flush();
Thread.sleep(120_000);
}
}
编译并运行:
javac --release 25 -d out DirectMemoryBudget.java
java -XX:MaxDirectMemorySize=64m -cp out DirectMemoryBudget
本机输出:
acquire#9 direct budget exhausted: requested=4194304 reserved=33554432 limit=33554432
acquired=8 reserved=33554432 highWater=33554432 rejected=1 pooled=0 count=8 used=33554432
after release reserved=33554432 pooled=8 count=8 used=33554432
after trim+gc reserved=0 pooled=0 count=0 used=0
after slice reserved=16777216 sliceDirect=true count=1 used=16777216
leak reachable held=2 reserved=4194304 count=2 used=4194304
leak unreachable reserved=0 count=0 used=0
- 第 9 个 4 MiB 被预算拒绝,报错里
reserved=33554432、limit=33554432。-XX:MaxDirectMemorySize=64m给了 JDK 64 MiB 的余量,所以先撞到的是应用预算,不是 VM 上限。 after release的reserved没降,因为 8 个缓冲区进了池,内存还在进程里;held.clear()只是丢掉调用方引用,池还持有它们。trim()清空池,System.gc()后 Cleaner 才执行;reserved=0、count=0、used=0三本账同时归零。- 16 MiB 原缓冲区加 1 MiB 切片,
reserved还是 16 MiB,count=1,used=16777216。切片没有新增预留。 - 两个 2 MiB 缓冲区被
leak列表强引用,reserved=4 MiB、used=4 MiB;leak.clear()后对象不可达,Cleaner 回收,账归零。
release 的契约是只接受 acquire 返回的原缓冲区;代码里的 isReadOnly() 只能挡住只读切片,可写切片仍要靠调用方遵守契约。切片和副本的 capacity 可能和原缓冲区不同,底层内存却属于原缓冲区;把它们放进池,池里会出现一块「借出去 1 MiB、实际占着 16 MiB」的内存,而且原缓冲区被切片通过 att 保持可达,Cleaner 不会释放。
泄漏判定按这个顺序做,比一上来抓 heap dump 有效:
BufferPoolMXBean的direct.used是否持续上涨;不涨就转去查mapped、JNI、线程栈。jcmd GC.class_histogram | grep DirectByteBuffer看壳对象数量;数量少但 used 高,说明单个缓冲区很大;数量多,说明分配频率高。- 在测试环境触发一次受控 GC,看
used是否回落。回落是 Cleaner 延迟;不回落才是可达泄漏。 - 仍然不回落,抓 heap dump 找
DirectByteBuffer的引用链;dump 里没有那几 MB native 字节,但能看到谁持有壳对象。 - 需要进程级证据时,用 NMT 的
summary或detail.diff对照Other的增长。
在 linkd 里落地
linkd-core 的 BufferPool 在 5.2 的基础上接一个全局 DirectMemoryBudget:每个事件循环仍有自己的空闲栈,但 acquire 先向预算 reserve,release 只把原缓冲区放回本线程的栈。预算对象由 LinkdMain 创建,所有事件循环共享。
BufferPool.acquire(int minCapacity):超过池容量时不再直接allocateDirect,而是budget.acquire(minCapacity);预算拒绝时抛OutOfMemoryError,连接层捕获后返回 503 或关闭连接,避免把整个进程拖到 VM 级 OOME。Connection.close()和写队列排空:缓冲区在enqueue(ByteBuffer)时转移所有权,写完成或连接关闭时由队列调用release。连接半关闭、RST、超时三条路径都要归还在途缓冲区,否则预算只涨不降。
启动参数要把两条线一起设:
java -Xmx2g -XX:MaxDirectMemorySize=1g -cp linkd-app.jar com.plume.linkd.app.LinkdMain
1g 不是拍脑袋:预算上限设 768 MiB,给 JDK 内部直接缓冲区、NIO 临时缓冲区和 native 库留 256 MiB。/stats 暴露 reserved、highWater、rejected;rejected 一旦大于 0,说明预算比实际流量紧,或者有连接没有归还缓冲区。
常见错误与边界
错误一:用 System.gc() 做资源管理。 它只是建议,-XX:+DisableExplicitGC 下完全无效;即使执行,也要等 GC 选中对象、Cleaner 线程调度。直接内存要靠池化复用和预算,不能靠 GC 兜底。
错误二:只设 -Xmx,不设 -XX:MaxDirectMemorySize。 默认上限等于最大堆,直接内存可能和堆一样大;容器里两者相加会超过限额。
错误三:把 BufferPoolMXBean.used 当成 RSS。 它只统计直接缓冲区,不含 mapped、线程栈、代码缓存和 JNI 分配;进程内存上涨时,它只能排除或确认 ByteBuffer 这一条线。
错误四:把类直方图的 count 当成字节数。 64 个 DirectByteBuffer 的浅堆是 4096 字节,背后的 native 内存是 64 MiB;两个数字没有换算关系。
错误五:把 slice/duplicate 归还池。 切片共享原缓冲区的 native 内存和 Cleaner;归还切片会让池子借出一块归属别处的内存。池只接受 acquire 返回的原对象。
错误六:Cleaner 动作捕获了缓冲区本身。 cleaner.register(buffer, () -> use(buffer)) 会让 action 强引用 referent,缓冲区永远不可达,Cleaner 永远不跑。清理动作只能捕获地址、容量、预算计数器这类不反向引用缓冲区的东西。
边界:Cleaner.create() 的线程成本。 每个 Cleaner 一个线程;按请求创建会线程爆炸。一个预算或一个池共用一个 Cleaner。
边界:-XX:MaxDirectMemorySize 不是所有堆外内存的上限。 它约束 Bits.reserveMemory 这条账;JNI 里 malloc 的内存、native 库自己的分配不受它管。NMT 的 Other 比它大是正常的。
小结
DirectByteBuffer是堆内壳对象,native 字节在堆外;Deallocator.run()先Unsafe.freeMemory,再Bits.unreserveMemory。- 切片和副本不新增预留,
BufferPoolMXBean的count和used不变;释放责任在原缓冲区。 - 三本账各管一段:
MaxDirectMemorySize管进程上限,MXBean 管 ByteBuffer 预留量,NMT 的Other管 malloc 总量。 DirectMemoryBudget在分配前 CAS reserve,超限抛 OOME;release进池不减账,trim加 GC 后由 Cleaner 减账。- 本机 32 MiB 预算下第 9 个 4 MiB 缓冲区被拒绝;16 MiB 加 1 MiB 切片后
count=1、used=16 MiB。 - 泄漏判定先看
used趋势,再看壳对象数量,再做受控 GC,最后才抓 heap dump 和 NMT diff。
13.2 把直接内存的账算清了。13.3 换一种映射方式:不再由 Java 手动管理一块 native 内存,而是让内核把文件页映射进地址空间,边界从 Cleaner 变成 force 和取消映射。
阅读导航:上一节:13.1 零拷贝的系统调用边界 · 下一节:13.3 内存映射与文件传输 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。