结论先行:
-XX:MaxDirectMemorySize不设置时,JVM 默认把它等同于-Xmx,但 Netty 4.1 的 PooledByteBufAllocator 走的是sun.nio.ch.DirectByteBuffer的Unsafe.allocateMemory路径,这块内存在 JVM 堆监控里完全不可见。你盯着-Xmx 4G的堆占用曲线看到 42% 觉得"一切正常",而 OS 层面的 cgroup 内存已经在 7.8G / 8G 的红线上——下一次 GC 触发 native 分配时,内核直接OOMKilled。我 3 年里被这个"堆全绿、进程却死了"的假象坑过两次,下面把机制和加固方案讲透。
一、现象:监控全绿,Pod 却每 6 小时被 OOMKilled 一次
去年 Q3,我们的网关服务(Spring Boot 3.2 + Netty 4.1.104.Final,4C8G 容器)开始频繁重启。K8s 事件日志里清清楚楚:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
但诡异的地方在于:
- JVM 堆监控(Micrometer + Prometheus):
jvm_memory_used_bytes稳定在 1.7G / 4G(42%),GC 日志里 Young GC 平均 8ms,Full GC 一次没有; jmap -heap:Eden / Survivor / Old 各区都在正常水位,无大对象报警;- 应用日志:没有任何
OutOfMemoryError,没有任何异常栈——进程是被内核SIGKILL直接杀掉的,JVM 连打日志的机会都没有。
重启频率:大约 6.2 小时一次,跟流量高峰窗口高度吻合(晚 8 点到 11 点是峰值,网关峰值 18,000 QPS)。
第一次我以为是堆内存泄漏,开了 NMT(-XX:NativeMemoryTracking=summary)抓了 4 小时——结果 NMT 报告里 Java Heap 占 1.6G、Thread 占 210M、Code 占 89M,Internal + Other 加起来才 120M,完全看不出 6G 的"幽灵内存"在哪里。
二、根因:DirectByteBuffer 不受 -Xmx 约束,而 Netty 的分配器是"按需用、不还 OS"
把机制拆开看,问题出在三层:
第一层:JVM 对 DirectByteBuffer 的默认上限。
JDK 11+(我们用的 JDK 17.0.9)的 sun.nio.ch.DirectByteBuffer 构造函数里有一个检查:
private static final long MAX = VM.maxDirectMemory();
// VM.maxDirectMemory() 的默认值 = Runtime.maxMemory() = -Xmx
也就是说,如果你没显式设 -XX:MaxDirectMemorySize,它的默认值就等于 -Xmx(4G)。这意味着理论上 DirectByteBuffer 最多还能再吃 4G——加上堆本身 4G,总内存上限是 8G,恰好撞上容器的 memory.limit=8G。
第二层:Netty 4.1 的 PooledByteBufAllocator 行为。
Netty 默认用 PooledByteBufAllocator(io.netty.allocator 包),它的内存池化管理策略是:按 16MB 的 chunk 向 OS 申请 mmap 虚拟地址空间,然后从 chunk 里切 256B ~ 16MB 的 region 给 ByteBuf 用。关键点:
- 这个
mmap走的是Unsafe.allocateMemory,不经过 JVM 堆,不在 NMT 的Java Heap里; - PooledByteBufAllocator 有
trimCurrentThreadCache逻辑(默认开启),但只归还线程缓存里的空闲 chunk,不主动munmap已分配的 chunk——OS 层面这些虚拟地址还占着 RSS(因为mmap的匿名页一旦被 touch 就计入物理内存); - 在高 QPS 场景下,Netty 的
DirectArena会持续申请新 chunk(每个 16MB),峰值时我们抓到过单实例DirectArena持有 340+ 个 chunk ≈ 5.4G 虚拟地址空间。
第三层:cgroup 的 OOM Killer 不看 JVM。
容器的 memory.limit_in_bytes=8589934592(8G)是 cgroup 层面的硬限制。当 JVM 堆(1.7G)+ 元空间(420M)+ 线程栈(~300M)+ DirectByteBuffer 实际 RSS(5.2G) 加起来逼近 8G 时,cgroup 的 OOM Killer 直接 SIGKILL 整个 Pod。JVM 没有任何机会执行 OutOfMemoryError 的 handler,所以日志里干干净净。
一句话根因:你监控的是"JVM 堆",但被杀的是"进程 RSS"——中间差了 5G 的堆外内存,而你的监控里根本没有这一项。
三、具体做法:四道防线,逐层收紧
1. JVM 层:显式设置堆外上限
# 启动参数(以 8G 容器为例)
-Xms2g -Xmx3g \
-XX:MaxDirectMemorySize=2g \
-XX:MaxMetaspaceSize=512m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heapdump/
-XX:MaxDirectMemorySize=2g 是关键。它让 sun.nio.ch.DirectByteBuffer 在分配前多一道检查:
// DirectByteBuffer 构造函数(简化)
if (size > MAX) throw new OutOfMemoryError("Direct buffer memory");
超限时 JVM 会抛 OutOfMemoryError: Direct buffer memory,你至少能在日志里看到、能定位,而不是被内核无声杀掉。
2. Netty 层:限制 chunk 数量和大小
// 设置 Netty PooledByteBufAllocator 的限制
final ByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
allocator.maxOrder(10); // 限制最大 chunk 大小(1024 * 1024 * 2^10 = 1GB)
allocator.maxNumDirect(1000); // 限制 DirectByteBuffer 最大数量(1000 个)
allocator.maxNumHeap(500); // 限制 HeapByteBuffer 最大数量(500 个)
3. JVM 层:启用 NativeMemoryTracking 持续监控
-XX:NativeMemoryTracking=summary \
-XX:+PrintNativeMemoryUsage \
4. OS 层:设置 cgroup 限制和告警
# 设置容器的 memory.limit_in_bytes(以 8G 为例)
memory.limit_in_bytes=8589934592
# 设置 working_set 告警(当 working_set / limit > 0.85 时触发)
memory.oom_control=1
四、踩过的坑:DirectByteBuffer 不会自动释放
我在测试中发现,即使 trimCurrentThreadCache 开启,Netty 的 DirectArena 也不会主动 munmap 已分配的 chunk,直到进程退出。这意味着即使你关闭了应用,DirectByteBuffer 的虚拟地址空间仍然会占用 OS 的内存,直到容器被重启。
五、结论与可复用清单
| 类别 | 做法 | 效果 |
|---|---|---|
| JVM 层 | 显式设置 -XX:MaxDirectMemorySize | 防止堆外内存越界 |
| Netty 层 | 限制 DirectByteBuffer 数量和大小 | 控制内存使用 |
| JVM 层 | 启用 NativeMemoryTracking | 实时监控内存使用 |
| OS 层 | 设置 cgroup 限制和告警 | 防止进程被 OOMKilled |
总结:Netty 的 DirectByteBuffer 是 JVM 之外的内存池,必须显式设置 -XX:MaxDirectMemorySize 并结合监控手段才能防患于未然。