《Netty 高性能网络编程》13.2 堆外内存与 PoolArena 指标

拆开 JVM 的 MaxDirectMemorySize 与 Netty 的 io.netty.maxDirectMemory 两层限制,用 metric() 读直接内存、arena 与线程缓存,实测上限如何压缩 arena 数量,并给出 linkd-v2 的阈值告警。

本节目标:分清 JVM 与 Netty 两套直接内存上限,会用 metric() 读出可判断趋势的数字,知道这些数字为什么不能当实时精确值,并能给 linkd-v2 配一组可执行的阈值。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-buffer 里的 PooledByteBufAllocatorMetric 与 PoolArenaMetric)。

承接的问题

13.1 抓的是「忘了 release()」。但生产上更常见的现象是另一种:代码里每一处 release() 都写了,直接内存还是一直涨,最后 OutOfDirectMemoryError。这时候泄漏检测器帮不上忙,因为它只看单个对象的引用计数,不关心池子里囤了多少 chunk。

5.3 已经介绍过 metric() 能读出哪些字段。这一节补上当时没展开的两件事:一是上限到底由谁决定,二是这些数字在读的时候有哪些天然误差。先记住一个前提:usedDirectMemory 统计的是池子从系统申请到、还没还回去的 chunk 总量,不是活跃缓冲区字节数,也不是进程 RSS。

核心机制一:两层限制,各记各的账

直接内存有两道闸门:

闸门设置方式作用范围超出时的报错
JVM 账本-XX:MaxDirectMemorySizeJDK 的 ByteBuffer.allocateDirect 计数OutOfMemoryError: Cannot reserve ...
Netty 计数器-Dio.netty.maxDirectMemoryNetty 分配器自己的累计量OutOfDirectMemoryError: failed to allocate ...

-Dio.netty.maxDirectMemory 有三个区间:不设置(或负数)时,Netty 取 JVM 报告的上限作为自己的上限;正数时,用它覆盖 JVM 的值;等于 0 时,关掉 Netty 自己的计数器,名义上把限制交给 JVM,实际效果见下面的实验。

用一段固定申请 1 MiB 直接缓冲区、不释放的程序,把三种组合跑一遍:

import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import java.util.ArrayList;
import java.util.List;

public class DirectLimitDemo {
    public static void main(String[] args) {
        List<ByteBuf> keep = new ArrayList<>();
        try {
            for (int i = 0; i < 256; i++) {
                keep.add(PooledByteBufAllocator.DEFAULT.directBuffer(1024 * 1024));
            }
            System.out.println("allocated=" + keep.size() + " MiB");
        } catch (Throwable t) {
            System.out.println("held=" + keep.size() + " MiB");
            System.out.println("thrown=" + t.getClass().getSimpleName());
            System.out.println("message=" + t.getMessage());
        }
    }
}

本机输出,单次运行:

=== -XX:MaxDirectMemorySize=64m(Netty 默认)===
held=64 MiB
thrown=OutOfDirectMemoryError
message=failed to allocate 4194304 byte(s) of direct memory (used: 67108864, max: 67108864)

=== -Dio.netty.maxDirectMemory=33554432(不设 -XX)===
held=32 MiB
thrown=OutOfDirectMemoryError
message=failed to allocate 4194304 byte(s) of direct memory (used: 33554432, max: 33554432)

=== -XX:MaxDirectMemorySize=64m -Dio.netty.maxDirectMemory=0 ===
allocated=256 MiB

三组结果的读法:

  • 第一组说明不设 Netty 参数时,它跟着 JVM 的上限走,报错文本里的 max 就是 -XX 的值。
  • 第二组说明 Netty 参数可以单独把上限压到比 JVM 更小,这时 JVM 的 8 GiB 默认值完全不起作用。
  • 第三组最值得注意:把 Netty 计数器关掉之后,-XX:MaxDirectMemorySize=64m 并没有拦住池化直接内存,计划里的 256 MiB 全部拿到,没有任何报错。原因是这两套计数本来就不在同一个账本上,关掉 Netty 的计数器等于放弃唯一一个盯着池化 chunk 的看守。重复两次结果一致。

结论不是「某个参数更好」,而是:默认让它跟随 JVM,需要更细的控制时用正数显式指定,不要用 0。用 0 之后,堆外增长不再有任何一方报错,只剩 metric() 这一条证据链。

核心机制二:JVM 的账本看不见 Netty 的缓冲区

jcmd、GC 日志、BufferPoolMXBean 读到的直接内存,都是 JVM 自己那份计数。写一段程序对比:

import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class BufferPoolDemo {
    public static void main(String[] args) {
        report("start");
        List<ByteBuf> netty = new ArrayList<>();
        for (int i = 0; i < 64; i++) {
            netty.add(PooledByteBufAllocator.DEFAULT.directBuffer(1024 * 1024));
        }
        report("after netty 64 MiB");
        List<ByteBuffer> nio = new ArrayList<>();
        for (int i = 0; i < 8; i++) {
            nio.add(ByteBuffer.allocateDirect(1024 * 1024));
        }
        report("after nio 8 MiB");
    }

    static void report(String tag) {
        for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
            if (pool.getName().equals("direct")) {
                System.out.printf("%-20s count=%d used=%d%n", tag, pool.getCount(), pool.getMemoryUsed());
            }
        }
    }
}

本机输出:

start                count=0 used=0
after netty 64 MiB   count=1 used=1
after nio 8 MiB      count=9 used=8388609

Netty 拿了 64 MiB,JVM 的 direct 池只多出 1 个字节的对象;再补 8 个 ByteBuffer.allocateDirect,计数立刻变成 9 个、8 MiB 加 1 字节。count=1 used=1 是 Netty 初始化时留下的小对象,和那 64 MiB 无关。

这条差异决定了排查顺序:如果只看 BufferPoolMXBean、只看 jcmd GC.heap_info,Netty 的池化直接内存是隐形的。13.1 报告的 Created at: PooledByteBufAllocator.newDirectBuffer 和这一节的 metric() 是同一套账本,两者能对上;JVM 的账本对不上。

还有一个默认值陷阱。用 -XX:+PrintFlagsFinal 看:

java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "MaxDirectMemorySize|MaxHeapSize"

本机输出:

 uint64_t MaxDirectMemorySize   = 0            {product} {default}
   size_t MaxHeapSize           = 8589934592   {ergonomic}

MaxDirectMemorySize=0 不是「上限为零」,而是「没有显式设置」。按 JDK 的规则,此时直接内存上限取最大堆,也就是这里的 8 GiB;用 -XX:MaxDirectMemorySize=64m 再跑同一条命令,才会显示 67108864 {command line}。看到 0 就以为没限制,会得出错误结论。

核心机制三:metric() 六个读数与它们的语义

PooledByteBufAllocator.DEFAULT.metric() 返回一个快照式的对象,常读的六个值如下:

import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocatorMetric;

public class MetricBasics {
    public static void main(String[] args) {
        PooledByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;
        PooledByteBufAllocatorMetric m = alloc.metric();
        System.out.printf("arenas direct=%d heap=%d caches=%d chunk=%d%n",
                m.numDirectArenas(), m.numHeapArenas(), m.numThreadLocalCaches(), m.chunkSize());
        System.out.printf("used direct=%d heap=%d%n", m.usedDirectMemory(), m.usedHeapMemory());
        for (int i = 0; i < 512; i++) {
            alloc.directBuffer(1024).release();
        }
        System.out.printf("after 512x1KiB direct=%d heap=%d%n",
                m.usedDirectMemory(), m.usedHeapMemory());
    }
}

本机输出:

arenas direct=20 heap=20 caches=0 chunk=4194304
used direct=0 heap=0
after 512x1KiB direct=4194304 heap=0

三行输出里的四点含义各不相同:

  • numDirectArenas=20、numHeapArenas=20 是两侧的 arena 数,默认值跟核数与上限都有关,不能按核数硬算,只能读。
  • numThreadLocalCaches=0 是因为主线程还没走过分配路径;等 EventLoop 线程开始分配,这个值会变成实际建立的缓存数,5.3 的三轮实验里就是 0 → 1。
  • chunkSize=4194304 是 4 MiB,池化的最小记账单位。申请 512 个 1 KiB 的缓冲区,usedDirectMemory 直接跳到 4 MiB,因为拿到的是一个 chunk。
  • usedHeapMemory=0 是因为全程只用了 directBuffer。linkd-v2 的 pipeline 走 ctx.alloc(),默认也是直接内存。

usedDirectMemory 在释放之后不回落是正常的:chunk 留在池子里等下次复用。要判断「是不是漏了」,不能看它是否归零,而是看它在低峰期是否长期停在高位、并且各 arena 的 numActiveAllocations 跟着涨。

核心机制四:遍历 arena 读累计与活跃

总量之外,每个 arena 的明细更有诊断价值。directArenas() 返回 List<PoolArenaMetric>,逐个读 numAllocations()、numActiveAllocations()、numActiveBytes():

import io.netty.buffer.ByteBuf;
import io.netty.buffer.PoolArenaMetric;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocatorMetric;

public class ArenaDetail {
    public static void main(String[] args) {
        ByteBuf[] bufs = new ByteBuf[512];
        for (int i = 0; i < bufs.length; i++) {
            bufs[i] = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
        }
        for (ByteBuf b : bufs) {
            b.release();
        }
        PooledByteBufAllocatorMetric m = PooledByteBufAllocator.DEFAULT.metric();
        long alloc0 = 0;
        long active0 = 0;
        int idx = 0;
        for (PoolArenaMetric arena : m.directArenas()) {
            if (arena.numAllocations() > 0) {
                System.out.printf("arena[%d] alloc=%d activeAlloc=%d activeBytes=%d%n",
                        idx, arena.numAllocations(),
                        arena.numActiveAllocations(), arena.numActiveBytes());
                alloc0 += arena.numAllocations();
                active0 += arena.numActiveAllocations();
            }
            idx++;
        }
        System.out.printf("sum alloc=%d active=%d%n", alloc0, active0);
    }
}

本机输出:

arena[0] alloc=512 activeAlloc=0 activeBytes=4194304
sum alloc=512 active=0

三个数字要一起看:

  • alloc=512 是自进程启动以来的累计分配次数,只增不减,重启才归零。它适合算分配速率,不适合判断当前占用。
  • activeAlloc=0 说明 512 个缓冲区全部归还了,没有滞留。
  • activeBytes=4194304 在活跃分配为 0 时仍然是 4 MiB,因为它按 chunk 记账,不代表业务还持有 4 MiB。

所以「按 arena 找泄漏」的正确读法是:在固定时间窗口内取两次快照,看 activeAlloc 的差值是否持续为正,同时看 usedDirectMemory 有没有台阶式上涨。单点数值没有意义。

核心机制五:直接内存上限会压缩 arena 数量

这一条在 5.3 里没有展开,但它是「为了省内存把上限调小」之后最常见的副作用。同一台 10 核机器,改变直接内存上限(多数行只改 -XX),读 metric() 的前两个字段:

启动参数numDirectArenas()numHeapArenas()
不设置(堆上限 8 GiB)2020
-XX:MaxDirectMemorySize=256m1020
-XX:MaxDirectMemorySize=128m520
-XX:MaxDirectMemorySize=64m220
-Dio.netty.maxDirectMemory=33554432120

(本机单次运行,每组各跑一次,数值可复现。)

堆侧 arena 数量不受影响,直接侧会随上限收缩,最小到 1。arena 是分配时争锁的单位:arena 从 20 降到 2,意味着 20 个 EventLoop 线程抢两把锁,压测时表现为分配路径的竞争上升;从 20 降到 1 更极端,所有线程排同一个 arena。映射关系由实现决定,不要按「上限除以 chunk」自己推,用 numDirectArenas() 读实际值。反过来说,arena 少了空闲 chunk 更集中,读数更好解释;取舍按压测结果定,不能凭感觉。

核心机制六:这些数字不是实时精确值

metric() 的返回值适合看趋势和量级,不适合做精确对账,原因有五条:

  • 不是同一瞬间的快照。 各 arena 的计数分别读取,多线程同时分配时求和会有少量偏差,差几次分配是正常的。
  • 累计量只增不减。 numAllocations、numTinyAllocations 这类字段永远上涨,重启才清零;把它们直接画成曲线会得到一条单调上升的线,看起来永远像泄漏。
  • 记账单位是 chunk。 usedDirectMemory、numActiveBytes 都按 chunk 算,不等于业务持有的字节数;要算业务量,得在 pipeline 里自己统计 readableBytes()。
  • 只覆盖一个分配器实例。 读的是 PooledByteBufAllocator.DEFAULT 的账;如果某处代码自建了 new PooledByteBufAllocator(...),那部分内存不在这个 metric() 里,需要单独持有引用再读。
  • GC 不管直接内存。 堆很健康、GC 日志干净,直接内存照样能 OOM;反过来,直接内存吃紧时通常先看到 OutOfDirectMemoryError,而不是 GC 抖动。

在 linkd-v2 里落地

linkd-v2 把指标采集收进一个组件,30 秒一次,按配置的上限算比例并分级告警:

package com.plume.linkd.v2.app;

import io.netty.buffer.PoolArenaMetric;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocatorMetric;
import io.netty.util.concurrent.GlobalEventExecutor;
import java.util.concurrent.TimeUnit;

public final class DirectMemoryGuard {

    private static final System.Logger LOG =
            System.getLogger(DirectMemoryGuard.class.getName());
    private static final double WARN = 0.75;
    private static final double CRITICAL = 0.90;

    private DirectMemoryGuard() {
    }

    public static void start(long limitBytes) {
        GlobalEventExecutor.INSTANCE.scheduleAtFixedRate(
                () -> sample(limitBytes), 0, 30, TimeUnit.SECONDS);
    }

    static void sample(long limitBytes) {
        PooledByteBufAllocatorMetric m = PooledByteBufAllocator.DEFAULT.metric();
        long allocations = 0;
        long active = 0;
        for (PoolArenaMetric arena : m.directArenas()) {
            allocations += arena.numAllocations();
            active += arena.numActiveAllocations();
        }
        long used = m.usedDirectMemory();
        double ratio = limitBytes <= 0 ? 0 : (double) used / limitBytes;
        System.Logger.Level level = ratio >= CRITICAL ? System.Logger.Level.ERROR
                : ratio >= WARN ? System.Logger.Level.WARNING
                : System.Logger.Level.DEBUG;
        LOG.log(level, "direct used={0} limit={1} ratio={2} arenas={3} caches={4} "
                        + "alloc={5} active={6}",
                used, limitBytes, String.format("%.2f", ratio),
                m.numDirectArenas(), m.numThreadLocalCaches(), allocations, active);
    }
}

limitBytes 来自配置 linkd.direct.limit,必须和启动参数里的 -XX:MaxDirectMemorySize 保持一致:

"$JAVA_HOME/bin/java" \
  -Xms2g -Xmx2g -XX:MaxDirectMemorySize=2g \
  -Dlinkd.direct.limit=2147483648 \
  -cp "linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar:linkd-app/target/lib/*" \
  com.plume.linkd.v2.app.LinkdV2Main

两个值靠人工同步,就会漂移。启动时加一次自检:没设 -XX 而 limit 大于最大堆时打 WARNING,因为此时真实上限等于堆,比例告警永远到不了 0.75;limit 小于 256 MiB 也打 WARNING,提醒 arena 会被压缩到个位数。

阈值和动作建议按这张表落地,数值只是起点,用 14.2 的压测口径校准;告警文本必须带 limit,只报 used=1.9 GB 看不出严重程度。

条件级别建议动作
used / limit ≥ 0.75 持续 5 分钟WARNING看 active 是否同步上涨;抓一次 13.1 的 ADVANCED 报告
used / limit ≥ 0.90ERROR限流或拒绝新连接;准备重启,不要在临近上限时扩容
低峰期 used 不回落且台阶式上涨WARNING记录两次快照的 arena 差值,按 5.2 的所有权链排查
numDirectArenas ≤ 2WARNING检查 -XX 是否设得过小,评估分配竞争

常见错误与边界

错误一:用 -Dio.netty.maxDirectMemory=0 关掉计数器,以为 -XX 还在兜底。 本机实测里,64 MiB 的 JVM 上限没有拦住池化直接内存,程序分配了 256 MiB 才结束。这个参数的实际效果是「放弃 Netty 侧的唯一看守」,不是「交给 JVM 管」。

错误二:只配一边。 只配 -XX 时 Netty 会跟随;只配 -Dio.netty.maxDirectMemory 时 JVM 的默认 8 GiB 不再有意义。两边都配且不相等时,以更早触发的那道为准,报错文本里的 max 会告诉你实际生效的是哪个数。

错误三:把 usedDirectMemory 当进程堆外总量。 它只统计这个分配器持有的 chunk,不含 JVM 内部、JNI、线程栈和 mmap 文件。用它解释 RSS 会差出一大截;反过来,RSS 涨而它不涨,说明内存在 Netty 之外。

错误四:把 numActiveBytes 当业务字节数。 本机输出里 activeAlloc=0 而 activeBytes=4194304,因为它按 chunk 记账。业务量要在 pipeline 里统计 readableBytes(),或者用 activeAlloc × 典型缓冲区大小 粗估。

边界一:两种 OOM 指向两套账本。 OutOfDirectMemoryError 来自 Netty 计数器,OutOfMemoryError: Cannot reserve ... (allocated: ..., limit: ...) 来自 JDK 的 Bits。看到前者查 -Dio.netty.maxDirectMemory,看到后者查 -XX:MaxDirectMemorySize;两者的处理方式不同,不要一起调。

边界二:metric() 不覆盖堆外映射文件。 mmap、FileChannel.map、本地库自己 malloc 的内存都不在统计里。堆外排查要把 /proc/<pid>/smaps(Linux)或 vmmap <pid>(macOS)放在最后一步,用来看「Netty 之外还有谁」。

小结

  • 直接内存有两层限制:-XX:MaxDirectMemorySize 管 JVM 账本,-Dio.netty.maxDirectMemory 管 Netty 计数器;不设置时后者跟随前者,设成 0 会关掉后者。
  • PrintFlagsFinal 里 MaxDirectMemorySize=0 表示「未显式设置」,实际上限取最大堆,不是零。
  • JVM 的 BufferPoolMXBean 看不到 Netty 的池化直接内存;64 MiB 的 Netty 分配只让 direct 池增加 1 个字节,两套账本必须分开读。
  • metric() 的六个读数里,usedDirectMemory 按 chunk 记账,numAllocations 是累计值;判断泄漏看两次快照的差值与低峰期是否回落。
  • 直接内存上限会压缩 arena 数量(本机 64m 只有 2 个),改上限后要重读 numDirectArenas() 并重跑压测。
  • 告警要带 limit 和 ratio,阈值从 0.75/0.90 起步,按压测校准,不要照搬。

账面数字到这里能读了。但「服务变慢」不一定是内存问题,下一节按连接、线程、内存、JFR 的顺序把诊断流程固定下来。

阅读导航:上一节:13.1 ResourceLeakDetector · 下一节:13.3 线程栈、JFR 与连接诊断 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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