现象:堆才用了一半,容器就被 OOMKilled
10 月 28 号晚上,网关服务的一个 Pod 被 K8s 杀了。
$ kubectl describe pod api-gateway-5f8b7c9d4-xt2n9
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
奇怪的是 JVM 里的堆非常空闲:
$ jcmd 1 GC.heap_info
garbage-first heap total 4194304K, used 1823412K [0x00000006c0000000, ...)
region size 4096K, 1 young region, 452 used regions
4 GB 堆只用了 1.78 GB,而容器 limit 是 7 GB。看容器的实际内存:
$ cat /sys/fs/cgroup/memory/memory.usage_in_bytes
7131242496 # 6.64 GB,逼近 7 GB 上限
差值 6.64 - 1.78 = 4.86 GB,这些内存不在堆里。典型的堆外内存泄漏。再看应用日志,在被杀之前确实有异常:
java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(java.base@11.0.12/Bits.java:178)
at java.nio.DirectByteBuffer.<init>(java.base@11.0.12/DirectByteBuffer.java:119)
at java.nio.ByteBuffer.allocateDirect(java.base@11.0.12/ByteBuffer.java:317)
at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:699)
at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:675)
at io.netty.buffer.PoolArena.allocateNormal(PoolArena.java:237)
at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:362)
at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
at com.xxx.gateway.codec.BodyDecoder.decode(BodyDecoder.java:73)
栈顶在 BodyDecoder.java:73。但先别急着下结论,得先确认堆外的构成。
用 NMT 把堆外内存摊开
Native Memory Tracking 是 JDK 自带的堆外内存分析工具,需要在启动时打开(有 5% 到 10% 的性能开销,我们只在预发和灰度实例上开):
-XX:NativeMemoryTracking=detail
-XX:+UnlockDiagnosticVMOptions
$ jcmd 1 VM.native_memory summary scale=MB
Native Memory Tracking:
Total: reserved=7248MB, committed=6812MB
- Java Heap (reserved=4096MB, committed=4096MB)
(mmap: reserved=4096MB, committed=4096MB)
- Class (reserved=1124MB, committed=142MB)
(classes #28412)
(malloc=12MB #48211)
(mmap: reserved=1112MB, committed=130MB)
- Thread (reserved=298MB, committed=298MB)
(thread #287)
(stack: reserved=296MB, committed=296MB)
- Code (reserved=261MB, committed=78MB)
- GC (reserved=312MB, committed=312MB)
- Internal (reserved=218MB, committed=218MB)
(malloc=218MB #102482)
- Direct (reserved=4218MB, committed=4218MB) ← 就是这个
(malloc=4218MB #1241)
- Other (reserved=41MB, committed=41MB)
Direct 占了 4218 MB,占了整个堆外的绝大部分。剩下的 Class(元空间)、Thread(287 个线程的栈)、Code(CodeCache)、GC 都在正常范围。
NMT 还有个增量对比功能,适合确认"内存在持续涨"这个事实:
$ jcmd 1 VM.native_memory baseline
Baseline succeeded
# 等 10 分钟
$ jcmd 1 VM.native_memory summary.diff scale=MB
- Direct (reserved=4218MB, committed=4218MB)
+1184MB
10 分钟涨了 1.18 GB,确认是持续泄漏,不是一次性分配。
Direct 内存的上限是多少
$ jcmd 1 VM.system_properties | grep -i direct
io.netty.maxDirectMemory=4294967296
sun.nio.MaxDirectMemorySize=4294967296
默认情况下 MaxDirectMemorySize 等于 -Xmx(如果不显式指定的话)。我们堆设了 4 GB,所以直接内存也有 4 GB 额度,加上堆的 4 GB、元空间、线程栈、CodeCache,总需求轻松超过 7 GB 的容器 limit。
这是个很常见的配置错误:只按堆大小去定容器 limit。经验公式是 limit ≈ 堆 * 1.5 + 1 GB,我们这次是 4 GB 堆配 7 GB limit,看起来够,但直接内存能吃满 4 GB,加起来就爆了。
另外 Netty 自己也会读这个值。io.netty.maxDirectMemory 显示 4 GB,说明 Netty 认为它有 4 GB 额度,会放心地分配(Netty 的 PlatformDependent 会统计已用直接内存,超了就抛 OutOfDirectMemoryError)。
谁在分配:BufferPoolMXBean
NMT 只能看到总量,看不到是谁分配的。用 JMX 的 BufferPoolMXBean 能看到 direct buffer 的对象数和占用:
$ jcmd 1 ManagementAgent.start_local
$ jconsole # 或者用下面的代码
List<BufferPoolMXBean> pools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
for (BufferPoolMXBean pool : pools) {
System.out.printf("%s: count=%d, used=%d MB, capacity=%d MB%n",
pool.getName(), pool.getCount(),
pool.getMemoryUsed() >> 20, pool.getTotalCapacity() >> 20);
}
输出:
direct: count=1241, used=4218 MB, capacity=4218 MB
mapped: count=18, used=124 MB, capacity=124 MB
注意 count=1241:只有 1241 个 direct buffer 对象,却占了 4218 MB,平均每个 3.4 MB。这说明是少量大块内存没被释放,而不是大量小块泄漏。这个信息很关键——它指向"业务代码里的大块 ByteBuf 没 release",而不是 Netty 内部的小对象池问题。
顺便说一个机制:ByteBuffer.allocateDirect() 分配时如果额度不够,JDK 会先触发一次 System.gc(),等一会儿再重试,还是不够才抛 OOM。所以直接内存泄漏的现场往往伴随大量 Full GC。我们那台机器在被杀之前的 20 分钟里 Full GC 了 340 次,但堆回收不了多少东西(堆才用了 43%),这个现象本身就是重要线索。
根因:ByteBuf 转 InputStream 之后忘了释放
看 BodyDecoder 第 73 行附近的代码:
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < 4) {
return;
}
in.markReaderIndex();
int length = in.readInt();
if (in.readableBytes() < length) {
in.resetReaderIndex();
return;
}
// 第 73 行:分配一块直接内存,把 body 拷进去
ByteBuf body = ctx.alloc().directBuffer(length);
in.readBytes(body);
// 转成 InputStream 交给 Jackson 解析
try (InputStream is = new ByteBufInputStream(body)) {
out.add(objectMapper.readValue(is, GatewayRequest.class));
} catch (Exception e) {
throw new CodecException(e);
}
// ← 没有 body.release()
}
Netty 的 ByteBuf 用的是引用计数(ReferenceCounted 接口),分配时 refCnt = 1,必须显式 release() 才会归还给池。这个 body 是从 ctx.alloc() 分配出来的,Netty 不会自动管它的生命周期。
为什么不一定每次都泄漏?因为 Netty 有个兜底:如果 ByteBuf 变得不可达,它内部持有的 ByteBuffer 会被 GC 回收,GC 时 Cleaner 会释放堆外内存。但这条路有两个前提:
PooledByteBufAllocator的池化内存块在被 GC 之前不会归还给池。我们用的是池化分配器(Netty 4.1 默认),分配出来的 4 MB chunk 会一直挂着,直到整个 chunk 里的所有 buffer 都被释放。- GC 什么时候发生不确定。我们堆很空闲,Young GC 频繁但 Full GC 要等老年代涨上来,堆外就这么一直攒着。
这正好解释了 count=1241, used=4218MB 的现象:1241 个 3.4 MB 的 buffer 挂在池里,等着被 GC 或者被显式释放。
修复:三处改动
1. 显式 release,用 try-finally 保证
ByteBuf body = null;
try {
body = ctx.alloc().directBuffer(length);
in.readBytes(body);
try (InputStream is = new ByteBufInputStream(body)) {
out.add(objectMapper.readValue(is, GatewayRequest.class));
}
} catch (Exception e) {
throw new CodecException(e);
} finally {
if (body != null) {
body.release(); // refCnt 减到 0 就归还给池
}
}
更省事的写法是用 Netty 提供的工具类,它会判空并且吞掉异常:
ReferenceCountUtil.safeRelease(body);
顺带说一下哪些情况 Netty 会自动释放,避免矫枉过正:
- 继承
SimpleChannelInboundHandler并重写channelRead0,入站的ByteBuf在方法返回后会被自动释放。 - 调用
ctx.fireChannelRead(msg)把消息往后传,责任转给下一个 handler,最后一个 handler 负责释放。ByteToMessageDecoder就是这么做的。 - 出站写(
ctx.write())的 buffer 由 Netty 写完自动释放。
凡是自己 alloc() 出来的,或者从入站 buffer 里 retainedSlice() / retainedDuplicate() 出来的,都必须自己释放。这是唯一需要记的规则。
2. 开启 Netty 的内存泄漏检测
-Dio.netty.leakDetection.level=advanced
四个级别:DISABLED(关)、SIMPLE(默认,采样 1% 并报告泄漏)、ADVANCED(采样 1%,报告泄漏并给出分配位置)、PARANOID(全量检测,性能损失大,只能调试时用)。
开着 ADVANCED 跑了一轮预发压测,日志里立刻抓到了泄漏点:
LEAK: ByteBuf.release() was not called before it's garbage-collected.
See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records:
Created at:
io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:362)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
com.xxx.gateway.codec.BodyDecoder.decode(BodyDecoder.java:73)
io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:502)
直接把行号都报出来了。这个功能应该常开在预发环境,采样开销很小。
3. 限制直接内存上限 + 加上监控
-Xms4g -Xmx4g
-XX:MaxDirectMemorySize=1g # 显式限制,不要让它等于 Xmx
-XX:NativeMemoryTracking=summary # 预发开 detail,生产开 summary
-Dio.netty.leakDetection.level=simple
-Dio.netty.allocator.numDirectArenas=4
把 MaxDirectMemorySize 显式设成 1 GB。这样万一再泄漏,会先抛 OOM 而不是等容器杀进程——抛 OOM 至少能被监控捕获、能拿到堆栈,被 OOMKilled 就什么都没有了。
监控上加了这几个指标到 Prometheus:
// 直接内存使用量
BufferPoolMXBean direct = ManagementFactory
.getPlatformMXBeans(BufferPoolMXBean.class).stream()
.filter(b -> "direct".equals(b.getName()))
.findFirst().orElseThrow();
Gauge.builder("jvm.buffer.direct.used", direct, BufferPoolMXBean::getMemoryUsed)
.baseUnit("bytes").register(registry);
Gauge.builder("jvm.buffer.direct.count", direct, BufferPoolMXBean::getCount)
.register(registry);
- alert: DirectMemoryTooHigh
expr: jvm_buffer_direct_used_bytes / 1073741824 > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "{{ $labels.application }} 直接内存使用超过 0.8 GB,检查是否有 ByteBuf 未释放"
效果
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 直接内存稳态占用 | 4.2 GB 且持续增长 | 96 MB 稳定 |
| 容器 RSS | 6.64 GB(limit 7 GB) | 2.41 GB |
| Full GC 频率(20 分钟内) | 340 次 | 0 次 |
| 网关 P99 | 412 ms | 38 ms |
| OOMKilled 次数(一周) | 7 次 | 0 |
P99 从 412 ms 降到 38 ms 是个意外收获。原因就是前面说的:直接内存不够时 JDK 会触发 System.gc(),那些 Full GC 把整个应用拖慢了。泄漏修掉之后,Full GC 也跟着消失了。
另一个案例:MappedByteBuffer 不释放
网关修完之后,我在另一个服务(一个做本地索引加载的服务)上又遇到一次类似的堆外增长。这次 NMT 的 Distribution 不一样:
$ jcmd 1 VM.native_memory summary scale=MB
- Internal (reserved=318MB, committed=318MB)
- Direct (reserved=124MB, committed=124MB)
- Other (reserved=2841MB, committed=2841MB) ← 这次在 Other 里
堆外的主体在 Other 而不是 Direct。Other 里放的是 NMT 无法归类的区域,最常见的大户就是 MappedByteBuffer(mmap 出来的内存映射文件)。
看代码,这个服务在启动和每小时的热更新时都会重新加载索引文件:
private IndexBlock loadBlock(File file) throws IOException {
try (FileChannel channel = FileChannel.open(file.toPath(), StandardOpenOption.READ)) {
MappedByteBuffer buffer =
channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
return IndexBlock.parse(buffer); // 返回的对象持有 buffer 的引用
}
}
MappedByteBuffer 的释放依赖 GC:它也是通过 Cleaner 在对象不可达时 munmap。问题是我们的老索引对象被一个静态的 Map 缓存着,热更新时虽然替换了引用,但旧对象在 Young GC 里活过来了(因为进了老年代),老年代的回收要等 Full GC 或者并发标记,而堆很空闲,一直没触发。
结果是每次热更新泄漏约 300 MB,8 次之后堆外就到了 2.8 GB。
处理办法是主动 unmap。JDK 没有公开 API,只能通过反射调 Cleaner:
public static void unmap(MappedByteBuffer buffer) {
try {
Method cleanerMethod = buffer.getClass()
.getMethod("cleaner"); // JDK 9+ 在 Unsafe 里,要 add-opens
cleanerMethod.setAccessible(true);
Object cleaner = cleanerMethod.invoke(buffer);
if (cleaner != null) {
cleaner.getClass().getMethod("clean").invoke(cleaner);
}
} catch (Exception e) {
log.warn("unmap failed", e);
}
}
在 JDK 11 上这需要 --add-opens java.base/java.nio=ALL-UNNAMED。因为这个服务和索引文件的生命周期是完全可控的(热更新时明确知道旧块不用了),主动释放比等 GC 靠谱得多。改完之后堆外稳定在 340 MB。
判断是不是 mmap 泄漏,最直接的是看进程的 maps:
$ pmap -x 1 | sort -k3 -rn | head -10
Address Kbytes RSS Dirty Mode Mapping
00007f3a1c000000 307200 307200 307200 rw-s- index_20211028_1400.bin ← 已删除但还映射着
00007f3a2d000000 307200 307200 307200 rw-s- index_20211028_1300.bin
$ ls -la /proc/1/fd | grep deleted | wc -l
9
看到一堆 deleted 状态的映射文件,就是典型的 mmap 没释放。
另一个常见坑:DisableExplicitGC
顺带提一个可能会让问题更严重的参数:
-XX:+DisableExplicitGC
有些团队为了禁止代码里乱调 System.gc() 会加这个。但它同时禁止了 JDK 内部回收 DirectByteBuffer 的 Cleaner(Cleaner 就是靠 System.gc() 触发的),直接内存会更容易堆积。如果加了,建议改用:
-XX:+ExplicitGCInvokesConcurrent # 让 System.gc() 走并发 GC,而不是 Full GC
我们没加 DisableExplicitGC,但这台机器上的确出现过 System.gc() 风暴,最后靠修掉泄漏解决。
一个固定的排查顺序
事后我把整个流程整理成一份 checklist,贴在 wiki 上,后来两次堆外问题都是照着它十分钟定位到的:
1. 确认是不是堆外问题
jcmd <pid> GC.heap_info # 堆用了多少
cat /sys/fs/cgroup/memory/memory.usage_in_bytes # 容器总共用了多少
差值大 → 堆外
2. 看堆外构成
jcmd <pid> VM.native_memory summary scale=MB # 需要启动参数 NativeMemoryTracking
jcmd <pid> VM.native_memory baseline
# 等几分钟
jcmd <pid> VM.native_memory summary.diff # 看哪一块在涨
3. 盯住 Direct 还是 Other
Direct 大 → ByteBuffer / Netty ByteBuf,走第 4 步
Other 大 → mmap,用 pmap -x <pid> | sort -k3 -rn 看映射文件
Thread 大 → 线程泄漏,jstack 数一下同名线程
4. Direct 细化
BufferPoolMXBean 看 count 和 memoryUsed
count 少、单块大 → 业务代码没 release
count 多、单块小 → 池化配置问题或者小块泄漏
5. 定位分配点
-Dio.netty.leakDetection.level=advanced # Netty 会直接打印泄漏栈
或者 jmap -dump 后 MAT 看 DirectByteBuffer 的 GC Root
第 5 步里 leakDetection 是最省力的,可惜它只在预发开。生产环境出问题就只能靠堆转储,MAT 里搜 java.nio.DirectByteBuffer,看它的 att 字段(Netty 会在这里挂上 PooledByteBuf 的信息)。
小结
- 判断堆外泄漏:堆用得少、容器 OOMKilled、日志里有
OutOfMemoryError: Direct buffer memory。用jcmd GC.heap_info对比/sys/fs/cgroup/memory/memory.usage_in_bytes。 jcmd VM.native_memory summary把堆外摊开看,baseline+summary.diff确认是否在持续增长。需要启动时加-XX:NativeMemoryTracking。- 默认
MaxDirectMemorySize等于-Xmx,一定要显式设小。容器 limit 按堆 * 1.5 + 1 GB估算。 BufferPoolMXBean的count和memoryUsed能判断是"少量大块"(业务代码没 release)还是"大量小块"(对象池问题)。- Netty 的
ByteBuf靠引用计数,自己alloc()的必须自己release(),用 try-finally 或ReferenceCountUtil.safeRelease()。SimpleChannelInboundHandler的入站 buffer 和出站 write 不用管。 - 预发环境常开
-Dio.netty.leakDetection.level=advanced,它会在日志里直接给出泄漏点的堆栈。 - 别忘了别配
-XX:+DisableExplicitGC,它会禁掉 DirectByteBuffer 的 Cleaner 回收路径。 - 把直接内存使用量做成指标并告警,比等 OOM 强。