《Java 网络编程基础》13.2 DirectByteBuffer 分配与回收

DirectByteBuffer 的分配与回收不在一条时间线上。本节拆开 Cleaner、MaxDirectMemorySize 与 NMT 三本账,用 DirectMemoryBudget 复现预算拒绝、切片共享、可达泄漏与不可达回收,并给出 linkd 中 BufferPool 的所有权规则与泄漏判定顺序。

本节目标:看清 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:MaxDirectMemorySizeBits.reserveMemory 的总额进程级上限、什么时候抛 OOME谁在用、什么时候释放
BufferPoolMXBeandirect / mapped 池的 count 与 usedByteBuffer 这条线预留了多少RSS、JNI、线程栈、代码缓存
NMTOther 等分类的 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 有效:

  1. BufferPoolMXBean 的 direct.used 是否持续上涨;不涨就转去查 mapped、JNI、线程栈。
  2. jcmd GC.class_histogram | grep DirectByteBuffer 看壳对象数量;数量少但 used 高,说明单个缓冲区很大;数量多,说明分配频率高。
  3. 在测试环境触发一次受控 GC,看 used 是否回落。回落是 Cleaner 延迟;不回落才是可达泄漏。
  4. 仍然不回落,抓 heap dump 找 DirectByteBuffer 的引用链;dump 里没有那几 MB native 字节,但能看到谁持有壳对象。
  5. 需要进程级证据时,用 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 内存映射与文件传输 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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