结论先行:
-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 数量和大小
// 启动时通过系统属性或代码设置
// 方式一:系统属性(推荐,全局生效)
-Dio.netty.allocator.chunkSize=8m // 默认 16m,缩小一半
-Dio.netty.allocator.numDirectArenas=4 // 默认 = 2 * CPU 核数,8 核时是 16,太多
-Dio.netty.allocator.maxOrder=6 // 默认 11(即 2048KB),6 即 64KB 上限
-Dio.netty.maxDirectMemory=2147483648 // 2G,与 JVM 参数对齐
方式二(代码):
PooledByteBufAllocator allocator = new PooledByteBufAllocator(
true, // useDirectMemory
4, // numDirectArenas
8 * 1024 * 1024, // chunkSize = 8MB
6, // maxOrder = 64KB
0, // tinyCacheSize
0, // smallCacheSize
0, // normalCacheSize
true // useCache
);
3. 容器层:cgroup 预留安全边际
# K8s Pod spec
resources:
requests:
memory: "6Gi"
limits:
memory: "8Gi" # 必须 > JVM 堆 + 元空间 + 线程栈 + MaxDirectMemorySize
经验公式:memory.limit ≥ Xmx + MaxDirectMemorySize + MaxMetaspaceSize + (线程数 × 1MB) + 512MB 余量。
以我们为例:3G + 2G + 512M + 400M + 512M ≈ 6.9G,8G 刚好够。如果当初堆设了 4G,4G + 2G + 512M + 400M + 512M ≈ 8.1G > 8G——必炸。
4. 监控层:把堆外内存拉进可观测体系
// Micrometer 自定义指标(Spring Boot Actuator 暴露)
@Scheduled(fixedRate = 10_000)
void exportDirectMemory() {
long used = ManagementFactory.getMemoryPoolMXBean(
MemoryUsage.getUsed()).getUsed(); // 这个不对,DirectByteBuffer 没有 MXBean
// 正确方式:读 /proc/self/smaps 或 jcmd
}
更实用的方式是 读 /proc/<pid>/smaps 里的 Rss 和 Private_Dirty,或者用 jcmd <pid> VM.native_memory summary(需 -XX:NativeMemoryTracking=summary,生产只建议定期开 10 分钟抓一次,不要常开——NMT 有 5%~10% 的性能开销)。
Prometheus 侧加一条告警规则:
- alert: ContainerMemoryPressure
expr: (container_memory_working_set_bytes / container_spec_memory_limit_bytes) > 0.85
for: 2m
labels:
severity: critical
这条规则才是真正救命的——它监控的是进程 RSS 占容器 limit 的比例,而不是 JVM 堆。
四、踩过的坑与反直觉之处
坑 1:NMT 报告里的 Internal 不包含 Netty 的 DirectByteBuffer。
NMT 把 Unsafe.allocateMemory 的内存归入 Other 类别,而且只有 JDK 自己通过 Internal 分配的才计入 Internal。Netty 的 PooledByteBufAllocator 走的是 PlatformDependent.allocateMemoryNoCleaner,在 NMT summary 里表现为 Other 的一个大数字——我第一次看 NMT 报告时完全没意识到 Other 里藏着 5G。
坑 2:-XX:MaxDirectMemorySize 只限制 DirectByteBuffer,不限制所有 native 内存。
Netty 的 mmap 文件 I/O(比如读大文件)、JNI 库(如 RocksDB、LZ4 native 库)的内存都不受这个参数约束。所以设了 MaxDirectMemorySize 不代表你就安全了,它只是把"不可见的内存"变成"可见且受限的内存"。
坑 3:K8s 重启后 DirectByteBuffer 不会"自动释放"。
Pod 被 OOMKilled 后,内核回收所有内存,新 Pod 从零开始。但如果你用的是 StatefulSet + 本地盘,Netty 的 Arena 状态是内存态,重启后归零——这意味着重启本身会掩盖问题,让你误以为"重启后就好了"。真正的问题是:每次重启后,同样的流量模式会在 6 小时内再次触发 OOM。
坑 4:io.netty.leakDetection.level=PARANOID 的性能代价。
开发环境建议开 SIMPLE(默认)或 PARANOID,但 PARANOID 模式下每个 ByteBuf 分配 / 释放都会记录调用栈,CPU 开销增加 15%~25%。生产环境千万别开 PARANOID,用 SIMPLE(默认,约 2% 开销)+ 定期抓 ResourceLeakDetector 日志就够了。
五、结论与可复用清单
一句话总结:在 Netty 4.1 + JDK 17 的微服务里,"堆内存监控"和"进程内存"是两本账。不显式设置 -XX:MaxDirectMemorySize,你就是用一本账去管两本账的开销。
可复用清单(按优先级):
| # | 动作 | 关键参数 | 验证方式 |
|---|------|---------|---------|
| 1 | JVM 显式设堆外上限 | -XX:MaxDirectMemorySize=2g | 日志出现 OutOfMemoryError: Direct buffer memory 而非被 SIGKILL |
| 2 | Netty 限 chunk 大小 | -Dio.netty.allocator.chunkSize=8m | jcmd <pid> VM.native_memory summary 中 Other 不超 2G |
| 3 | 容器 limit 留余量 | limit ≥ Xmx + MaxDirect + Meta + 栈 + 512M | kubectl describe pod 无 OOMKilled |
| 4 | Prometheus 告警 RSS/limit | > 0.85 持续 2min | Alertmanager 能收到 |
| 5 | 开发环境开泄漏检测 | -Dio.netty.leakDetection.level=PARANOID | 日志出现 LEAK: ByteBuf.release() was not called |
最后说一句:这个问题的本质不是"配置少了个参数",而是 JVM 的内存模型(堆内 / 堆外 / native)和 OS 的内存模型(RSS / cgroup)之间有一个巨大的语义鸿沟。你监控的粒度如果停在"JVM 堆",你就永远在"全绿"和"被杀"之间反复横跳。把监控拉到进程 RSS 和 cgroup 层面,才算是把这本账真正记全了。