《Java 网络编程基础》5.2 DirectBuffer 与堆外内存

allocateDirect 把字节放到堆外,不受 -Xmx 管,回收却依赖 Cleaner。本节在 32 MB MaxDirectMemorySize 下复现 Cannot reserve 报错,用 BufferPoolMXBean 观察 10000 个短命缓冲区的延迟释放,并说明 linkd 的 BufferPool 何时该用堆外、何时不该用。

本节目标:分清堆内与堆外缓冲区的成本,知道 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 与零拷贝 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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