堆只用了 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,堆使用率看不出堆外泄漏。
查看「Java 与 JVM」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。