Java 应用明明配置了 -Xmx4g,容器限制也是 4 GiB,为什么还是会被 Kubernetes 以 OOM Killed 结束?老年代使用率一直很高,要不要立刻扩大堆?Full GC 多了,是垃圾回收器选错了,还是应用确实留住了太多对象?
这些问题有一个共同的误区:把 JVM 内存等同于 Java 堆。堆很重要,但它只是进程内存的一部分。生产配置真正要解决的是三件事:弄清内存花在哪里,给各部分留出合理预算,再用监控和现场数据验证预算。
一、一个 Java 进程的内存花在哪里?
从 Java 虚拟机规范看,运行时数据区包括堆、方法区、虚拟机栈、程序计数器和本地方法栈。到了 HotSpot 和操作系统这一层,还要算上直接内存、代码缓存、GC 数据结构、线程本地存储以及 JNI 或第三方本地库申请的内存。咱们可以把进程内存粗略理解为:
进程内存(RSS)≈ Java 堆
元空间与压缩类空间
线程数 × 单线程栈及本地开销
直接内存
JIT 代码缓存
GC/JVM 内部结构
JNI、glibc 等本地分配
1、Java 堆:对象主要生活的地方
绝大多数 Java 对象和数组分配在堆中,堆由所有线程共享,也是 GC 的主要工作区域。从对象生命周期看,传统分代模型把堆分为年轻代和老年代。新对象通常先进入 Eden,经历若干次年轻代回收后仍存活的对象,会进入 Survivor 区,最后晋升到老年代。长期缓存、单例持有的数据和大对象更容易留在老年代。常用参数:
-Xms 初始堆大小
-Xmx 最大堆大小
2、元空间:类信息不在 Java 堆里
方法区是 JVM 规范中的逻辑概念。HotSpot 从 JDK 8 开始用本地内存中的 Metaspace 实现它,存放类元数据、运行时常量池等内容。JIT 编译后的机器码则主要进入 Code Cache,二者不要混为一谈。元空间常见问题不是普通业务对象太多,而是类或类加载器卸载不掉。例如频繁生成动态代理类、重复创建类加载器,或者热部署后旧类加载器仍被线程、静态变量持有。常用参数:
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=512m
注意:MetaspaceSize 不是元空间的初始占用或上限,它主要影响首次触发元数据 GC 的阈值。MaxMetaspaceSize 才是上限。
3、虚拟机栈:线程多,内存自然就上去了
每个 Java 线程都有自己的栈,栈帧保存局部变量、操作数栈、返回地址等数据。栈的上限通常由 -Xss 控制:
-Xss512k
如果服务有 800 个线程,按每个线程 1 MiB 的栈上限估算,仅线程栈的虚拟内存预算就接近 800 MiB,尚未计算线程相关的本地结构。线上 RSS 不一定一次性达到这个数,但容器预算不能假装它不存在。减小 -Xss 能支持更多线程,也会降低可容纳的调用深度。比如:递归、复杂表达式解析可能会出现StackOverflowError。
4、直接内存:堆外也会 OOM
NIO、Netty、压缩库和零拷贝场景会使用直接内存。它不计入 Java 堆使用量,却计入进程 RSS 和容器内存。常用限制参数是:
-XX:MaxDirectMemorySize=512m
如果堆曲线平稳、GC 也正常,但容器 RSS 持续上涨,要重点排查直接内存和其他本地内存。
5、程序计数器、本地方法栈与 JVM 自身开销
程序计数器记录当前线程执行到哪条字节码,体积小,却是线程切换后继续执行的基础。本地方法栈服务于 JNI 等本地调用。除此之外,JIT 编译后的代码、GC 的记忆集和卡表、符号表、线程本地分配缓冲区等都要占内存。
特别注意:当Xmx = 【容器内存限制】产生危险的原因是,Java 堆一旦吃满容器额度,其他部分没有任何余地,进程很可能先被操作系统杀掉,来不及抛出 Java OOM,更来不及生成堆转储。
二、GC 到底在回收什么
JVM 判断对象是否存活,核心方法是可达性分析。它从一组 GC Roots 出发沿引用关系查找;找不到路径的对象,才有资格被回收。常见 GC Roots 包括线程栈中的局部变量、已加载类的静态字段、JNI 引用和 JVM 内部引用。
有个容易被忽略的事实:内存泄漏不等于对象"没人用了但没释放" 。在 Java 中,更常见的是业务已经不用某批对象,程序却仍保留着一条到它们的强引用路径 。无限增长的本地缓存、没有移除的监听器、ThreadLocal 使用不当,都是典型例子。GC 看不懂业务意图,只知道这些对象仍然可达。
年轻代回收、混合回收与 Full GC:
- Young GC 主要处理年轻对象,通常频繁但停顿较短。
- G1 的 Mixed GC 会在处理年轻代的同时回收部分收益较高的老年代 Region。
- Full GC 往往意味着回收压力已经很大、并发周期跟不上,或发生了显式 GC、分配失败等情况。它通常停顿更久,但"出现一次 Full GC"不等于一定有泄漏,启动、运维操作和特殊分配失败都可能触发它。
判断GC是否健康,不能只数次数。还应该关注:暂停时间、GC后堆占用、对象分配速率、晋升速率、GC消耗的CPU、请求延迟是否同步恶化等
三、收集器怎么选
注:可通过命令查询JDK所使用的收集器:java -XX:+PrintCommandLineFlags -version
1、Serial GC:简单、省资源,但停顿明显
Serial GC 使用单个 GC 线程执行回收。年轻代通常采用复制算法,老年代执行标记、整理,回收期间应用线程全部暂停。使用场景:生命周期较短的任务、单核小容器堆只有几十到几百 MiB 的简单应用。不建议把Serial GC 用于大堆、高并发接口服务。
2、CMS:JDK1.8时代的低停顿方案
CMS 主要负责老年代,年轻代通常由 ParNew 回收。它把老年代的大部分标记和清理工作放到应用运行期间并发执行,只在初始标记、重新标记等阶段暂停应用。在 JDK1.9被标记为废弃,JDK14已经移除。
3、Parallel GC:批处理更关心单位时间产出
Parallel GC是JDK1.8默认的收集器,目标偏向吞吐量。批处理、离线计算等任务如果能够接受较长的 Stop-The-World 停顿,反而是个不错的选择。
4、G1:大多数服务的稳妥起点
在 JDK 17/21 的常见服务器配置中,G1 是默认收集器。它兼顾吞吐与可预测停顿,通常只需确定最大堆和暂停目标,再让 JVM 自适应调整年轻代等细节。适用场景:常规 Web 服务、微服务、中等到较大堆、既关心吞吐也关心尾延迟的应用。
5、ZGC:低延迟服务的候选项
JDK 21 引入了分代 ZGC,ZGC 把大部分工作与应用线程并发执行,适合大堆或对停顿极其敏感的服务。具体原理,使用高版本JDK的同学可以详细了解一下。
四、生产配置先做内存预算
假设一个容器的内存限制为 4 GiB,不要先拍脑袋写 -Xmx4g。可以从下面这张预算表开始:
| 项目 | 示例预算 | 估算依据 |
|---|---|---|
| Java 堆 | 2.5 GiB | 业务对象量、分配速率、GC 后存活集 |
| 元空间与代码缓存 | 350 MiB | 类数量、动态生成类、JIT 情况 |
| 直接内存 | 512 MiB | Netty/NIO 缓冲池和并发连接数 |
| 线程相关内存 | 300 MiB | 线程上限、-Xss、本地线程结构 |
| JVM、GC、JNI 及安全余量 | 434 MiB | GC 结构、系统库、流量尖峰 |
真正的预算应满足:
Xmx + 可观测的非堆内存峰值 + 未完全可观测的本地内存 + 安全余量 < 容器Memory限制
安全余量一般先留 10%~20%,再根据压测和生产高峰收紧。线程很多、使用 Netty、加载大量类或依赖 JNI 的服务,需要留得更多。如何拿到自己的预算数据?可以根据下面的命令去获取JVM的内存情况(由于命令执行后可能涉及到的比较多的参数信息,本章就不做详细分析。后续有时间了,会出一期JVM命令参数详解)
查看 JVM 实际识别到的容器资源(Linux 容器内执行)
java -XshowSettings:system -version
查看运行进程实际参数
jcmd -l
jcmd <pid> VM.flags
jcmd <pid> VM.command_line
查看堆概况与对象直方图
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram
五、上线后要监控什么
常见的JVM 内存看板至少应包含以下数据:
| 观察项 | 需要关注的现象 | 常见解释 |
|---|---|---|
| 堆使用量 | GC 后基线持续抬升 | 缓存增长、真实负载增长或堆泄漏 |
| 老年代/长期存活集 | 长时间逼近上限 | 堆太小、并发标记启动太晚或对象留存过多 |
| GC 暂停 | 与接口尾延迟同步抬升 | 回收压力、系统调度或安全点问题 |
| 分配速率 | 短时间陡增 | 大量临时对象、序列化或批量查询 |
| 晋升速率 | 大量对象过早进入老年代 | 对象生命周期变长或回收跟不上 |
| 元空间与已加载类数 | 两者持续单调增长 | 动态类或类加载器泄漏 |
| 线程数 | 持续上涨且不回落 | 线程池失控、任务阻塞或线程泄漏 |
| 直接内存/进程 RSS | 堆稳定但 RSS 上涨 | DirectBuffer、JNI 或本地库分配 |
六、结语
JVM 内存管理是没有一组通用的适配任何应用的参数配置,但可复用的是参数配置方法:
- 先把 Java 堆和进程总内存分开,按工作负载做预算;
- 再选一个符合延迟目标的收集器,用尽量少的参数起步;
- 最后靠 GC 日志、JFR文件(应用运行期间,JFR 持续记录 JVM、Java 应用和操作系统发生的事件)和业务指标验证。