堆只用了 900 MB 却被 OOMKilled:Metaspace 与堆外内存
Xmx 2g,堆使用率 46%,容器却以 Exit Code 137 被杀。差出来的 1.5 GB 在 Metaspace 和 Netty 的池化堆外内存里。NMT 与 pmap 怎么对账、每个区域该设什么上限,以及告警口径要换成 RSS。
周三凌晨,网关后面的商品服务重启了两次。kubectl describe pod 里写着 Last State: Terminated, Reason: OOMKilled, Exit Code: 137。
137 = 128 + 9,SIGKILL。这通常是容器内存超限被内核干掉,可我把堆参数翻出来看了三遍:-Xmx2g,容器 limit 2.5 GiB,堆最大才 2G,怎么会超?
jstat -gcutil 打出来的结果是老年代 46%,Full GC 一次都没频繁触发。堆根本没满。但 docker stats 里这个进程的 RSS 已经顶到 2.5 GiB,top 的 RES 也是 2.4g 上下。
堆用 900 MB,进程吃 2.4 GB。差出来的 1.5 GB 在哪,这才是那次故障真正的内容。
一、现象:堆很干净,进程却在涨
先把重启前的几个数据摆在一起:
| 指标 | 数值 | 说明 |
|---|---|---|
-Xmx |
2g | 堆上限 |
| 堆实际使用 | 约 900 MB | jstat -gcutil 老年代 46% |
| Non-Heap(Metaspace+CodeCache) | 约 420 MB | jcmd VM.native_memory |
| 进程 RSS | 2.4 GB | ps -o rss |
| 容器内存 limit | 2.5 GiB | cgroup 限制 |
900 + 420 约等于 1.3 GB,离 2.4 GB 还差一个多 G。而它是在流量平稳的凌晨被杀掉的,不是尖峰时刻——这一点很重要,说明是「持续泄漏」而不是「瞬时打满」。
如果是瞬时打满,通常会伴随一次大批量查询或者一个导出任务;平稳期的缓慢上涨,指向的是「分配了不释放」的东西。
二、Xmx 从来不是进程内存
这是最容易混淆的一点。JVM 进程的内存分布大致是这样:
| 区域 | 受什么控制 | 常见大小 |
|---|---|---|
| Java 堆 | -Xmx / -Xms |
按配置 |
| Metaspace | -XX:MaxMetaspaceSize(默认无上限) |
100 MB – 1 GB+ |
| Code Cache | -XX:ReservedCodeCacheSize |
默认 240 MB |
| 线程栈 | -Xss × 线程数 |
1 MB × 线程数 |
| Direct ByteBuffer | -XX:MaxDirectMemorySize(默认等于 Xmx) |
可达数 GB |
| JNI / 本地库 | 不可配 | 视依赖而定 |
| glibc malloc arena | MALLOC_ARENA_MAX |
每核数十 MB |
| JVM 自身结构 | 不可配 | 100–200 MB |
-Xmx2g 只约束第一行。其余部分加起来经常能再吃掉 1 GB 以上——而 cgroup 的 memory.limit_in_bytes 看的是整进程 RSS,不是堆。
更麻烦的是 -XX:MaxMetaspaceSize 默认是「无限制」(只受本机内存约束)。容器里如果某个动态代理框架一直生成类,Metaspace 会一路涨到把 limit 撞穿,而堆监控永远是绿的。
三、定位:先开 NMT,再对账
JVM 自带原生内存跟踪,需要启动参数打开,重跑一次就能拿到分项账本:
java -XX:NativeMemoryTracking=summary \
-Xms2g -Xmx2g \
-XX:MaxMetaspaceSize=384m \
-XX:MaxDirectMemorySize=256m \
-jar app.jar
然后在运行中查:
jcmd 1 VM.native_memory summary scale=MB
输出里 Total: reserved=2800MB, committed=2350MB,往下看明细,Java Heap 900、Metaspace 386、Class 380、Thread 210、Code 120,剩下 Other 里有个 Internal 项异常地大。
Internal 涨到 900 MB 很反常,它通常跟 Direct ByteBuffer、Unsafe.allocateMemory、以及 JNI 的本地分配有关。
第二步用 pmap 看真实映射,确认是哪一类:
# 按映射大小排序,看有没有大量 64MB 的匿名映射
pmap -x 1 | sort -k3 -rn | head -20
# 统计 anon 映射总和
pmap -x 1 | awk '/\[ anon \]/{s+=$3} END{print s/1024" MB anon"}'
pmap 里出现了六十多个 64 MB 的匿名块,总和接近 1 GB。64 MB 是 Netty 默认的 PooledByteBufAllocator chunk 大小——到这里基本可以确定方向了:堆外缓冲区在涨,而且没还。
四、两个来源:堆外缓冲与动态类
来源一:Netty 的 DirectByteBuffer 记了账但没释放。
这个服务用 Netty 做内部 RPC,代码里有这么一段:
// 错误:ReferenceCountUtil 没释放,ByteBuf 的引用计数一直是 1
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
byte[] body = new byte[buf.readableBytes()];
buf.readBytes(body); // 拷贝到堆里
handle(body);
// 少了 buf.release()
}
看起来「数据已经拷到堆里了,buf 无所谓」,但 ByteBuf 是引用计数的,不 release() 就不会归还到池子,chunk 一直被标记为占用。请求量不大时看不出来,一天下来池子把所有 chunk 都占满,然后继续向操作系统要新的——于是 pmap 里的匿名映射越来越多,RSS 一路涨。
修法有两种,看场景选:
// 方案 A:处理完立刻释放(零拷贝场景必须这样)
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
try {
byte[] body = new byte[buf.readableBytes()];
buf.readBytes(body);
handle(body);
} finally {
ReferenceCountUtil.release(buf);
}
}
// 方案 B:交给 pipeline 末尾的处理器统一释放(推荐)
pipeline.addLast(new SimpleChannelInboundHandler<ByteBuf>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) {
byte[] body = new byte[buf.readableBytes()];
buf.readBytes(body);
handle(body);
}
});
SimpleChannelInboundHandler 会在 channelRead0 返回后自动 release,这是最容易不犯错的方式。为了能发现漏释放,我把 Netty 的泄漏检测级别调到 PARANOID(只在预发开,生产用 SIMPLE):
-Dio.netty.leakDetection.level=paranoid
它会在漏掉的 ByteBuf 被 GC 时打印分配堆栈,直接指到那一行。
来源二:Metaspace 被动态类撑大。
同一时期 Metaspace 也在涨,从 120 MB 涨到 386 MB。原因是每次请求都会用反射生成一个动态代理:
// 每次调用都新建一个 Proxy 类,类元数据进 Metaspace,且不会被卸载
public Object invokeRemote(Class<?> iface, String url) {
return Proxy.newProxyInstance(
iface.getClassLoader(), new Class<?>[]{iface},
(proxy, method, args) -> doHttp(url, method, args));
}
Proxy.newProxyInstance 对同一组接口本应复用缓存,但这里 iface.getClassLoader() 每次拿到的是不同的类加载器(热部署插件里各自独立),于是同一个接口被生成了 N 份代理类。这些类的元数据只有在类加载器被回收时才释放,而类加载器被一个静态 Map 强引用着,永远不回收。
改成按「接口 + 类加载器」缓存,并把静态 Map 换成 WeakHashMap:
private static final Map<String, Object> PROXY_CACHE =
Collections.synchronizedMap(new WeakHashMap<>());
public Object invokeRemote(Class<?> iface, String url) {
String key = iface.getName() + "@" + System.identityHashCode(iface.getClassLoader());
return PROXY_CACHE.computeIfAbsent(key, k ->
Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface},
(proxy, method, args) -> doHttp(url, method, args)));
}
改完之后 Metaspace 稳定在 180 MB,不再单调上涨。
五、修完代码,还要把边界画清楚
代码修好只解决「不泄漏」,不解决「万一再漏一次就被杀」。容器里必须给每个区域设上限,让越界变成可观测的异常而不是 SIGKILL。
| 区域 | 参数 | 配置值 | 理由 |
|---|---|---|---|
| 堆 | -Xms / -Xmx |
2g / 2g | 固定大小,避免动态扩容抖动 |
| Metaspace | -XX:MaxMetaspaceSize |
384m | 越界抛 OutOfMemoryError: Metaspace 而不是吃光内存 |
| 直接内存 | -XX:MaxDirectMemorySize |
256m | 越界抛 OutOfMemoryError: Direct buffer memory |
| Code Cache | -XX:ReservedCodeCacheSize |
128m | 防止 JIT 编译产物无限增长 |
| 线程栈 | -Xss |
512k | 千级线程时省下几百 MB |
| 容器 limit | resources.limits.memory |
3Gi | 留出约 20% 给堆外与系统开销 |
这里有个反直觉的点:MaxDirectMemorySize 设得比实际需要小,确实会抛 OOM,但那是一个「能看到堆栈、能告警、进程能自己活下来」的 OOM,比被内核 SIGKILL 强太多。被 kill 的容器没有任何遗言,只有 137。
Kubernetes 侧把 limit 从 2.5Gi 调到 3Gi,同时加上启动参数:
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-Xms2g -Xmx2g
-XX:MaxMetaspaceSize=384m
-XX:MaxDirectMemorySize=256m
-XX:ReservedCodeCacheSize=128m
-Xss512k
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dump
HeapDumpOnOutOfMemoryError 只在堆 OOM 时有用,堆外 OOM 不会生成 dump。所以另外加了 -XX:+ExitOnOutOfMemoryError:宁可让进程立刻退出被重启,也不要留一个半死不活的实例继续接流量。
六、监控要盯「RSS / limit」,不是「堆使用率」
这次最深的教训是监控口径。原来只有一条告警:堆使用率 > 85%。堆永远在 45% 左右,告警从来没响过,直到容器被杀。
| 指标 | 采集方式 | 告警阈值 |
|---|---|---|
| 堆使用率 | jvm_memory_used_bytes |
> 85% 持续 5m |
| Metaspace 使用 | jvm_memory_used_bytes{area="nonheap",id="Metaspace"} |
> 85% 上限 |
| 进程 RSS / limit | container_memory_working_set_bytes / limit |
> 85% 持续 10m |
| 容器重启次数 | kube_pod_container_status_restarts_total |
10 分钟内 > 1 |
| Netty 池化内存 | netty_allocator_pooled_used_bytes |
单调上涨 1h |
其中 container_memory_working_set_bytes 是 K8s 真正用来判定 OOM 的口径(不是 container_memory_usage_bytes,后者含可回收的 page cache,会虚高)。用它除以 limit,就是「离被杀还有多远」。
补上这条之后,同类问题在 RSS 到 80% 时就会告警,提前量有二十分钟,足够在杀之前抓到现场。
小结
- 137 是内核的
SIGKILL,-Xmx只管堆;堆绿着被杀,说明问题在堆外。 - 先开
-XX:NativeMemoryTracking=summary,用jcmd VM.native_memory summary分项对账,再用pmap验证真实映射。 pmap里成片的 64 MB 匿名块是 Netty 池化 chunk 的特征,顺着查ByteBuf.release()。- Metaspace 无上限是容器里的隐藏炸弹,
Proxy.newProxyInstance这类动态类要按「接口 + 类加载器」缓存。 - 每个区域都要设上限,让越界变成带堆栈的 OOM,而不是没有遗言的 137。
- 告警口径要用
container_memory_working_set_bytes / limit,堆使用率看不出堆外泄漏。
正在加载评论…