本节目标:分清 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:MaxDirectMemorySize | JDK 的 ByteBuffer.allocateDirect 计数 | OutOfMemoryError: Cannot reserve ... |
| Netty 计数器 | -Dio.netty.maxDirectMemory | Netty 分配器自己的累计量 | 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) | 20 | 20 |
-XX:MaxDirectMemorySize=256m | 10 | 20 |
-XX:MaxDirectMemorySize=128m | 5 | 20 |
-XX:MaxDirectMemorySize=64m | 2 | 20 |
-Dio.netty.maxDirectMemory=33554432 | 1 | 20 |
(本机单次运行,每组各跑一次,数值可复现。)
堆侧 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.90 | ERROR | 限流或拒绝新连接;准备重启,不要在临近上限时扩容 |
低峰期 used 不回落且台阶式上涨 | WARNING | 记录两次快照的 arena 差值,按 5.2 的所有权链排查 |
numDirectArenas ≤ 2 | WARNING | 检查 -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 与连接诊断 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。