ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JVM 内存与 GC 实战:OOM 分五种,加内存只对一种有用

JVM 内存与 GC 实战:OOM 分五种,加内存只对一种有用 JVM 内存与 GC 实战OOM 分五种加内存只对一种有用前面第 7 篇讲 CPU 飙升排查时内存只讲到系统层free -h看 available。但应用层的内存问题——堆、GC、OOM——一次都没讲。这篇补上。核心是一个反直觉的判断看到 OOM 就加-Xmx这个动作只对五种 OOM 里的一种有用。文章目录JVM 内存与 GC 实战OOM 分五种加内存只对一种有用零、五种 OOM报错信息不同解法完全不同一、JVM 内存分几块谁管哪块二、对象的一生以及正常和泄漏的分界线2.1 对象的流转路径2.2 判断泄漏的唯一标准三、四个必须加上的启动参数⚠️ 3.1 GC 日志参数的写法变了3.2 一个次要的取舍OOM 后要不要让进程退出四、怎么读 GC 日志不用背只看三个数4.1 一个反直觉的建议别急着调 GC 参数五、堆 dumpOOM 之后到底怎么查5.1 手动 dump进程还活着的时候5.2 用什么工具看一定要用支配树5.3 五种最常见的泄漏模式六、容器里跑 JVM两个必须知道的坑6.1 容器内存限制和 JVM 的自我认知6.2 OOMKilled ≠ Java OOM第 6 篇讲过的 137七、一张排查决策树八、坑清单九、上线检查清单写在最后零、五种 OOM报错信息不同解法完全不同这是全文最重要的一张表。很多人一辈子只见过第一种然后把它当成全部。报错信息哪里满了加-Xmx有用吗该看什么OutOfMemoryError: Java heap space堆放对象的地方✅有用但如果根因是泄漏只是拖时间堆 dump、GC 日志OutOfMemoryError: Metaspace元空间放类元数据❌完全没用有没有动态生成类的框架、类加载器泄漏OutOfMemoryError: Direct buffer memory直接内存NIO 堆外内存❌完全没用-XX:MaxDirectMemorySize、Netty 等 NIO 框架OutOfMemoryError: unable to create native thread系统线程数 / 原生内存❌可能更糟系统ulimit -u、线程是否泄漏OutOfMemoryError: GC overhead limit exceeded堆但 GC 已经救不回来⚠️只缓解不解决根因几乎总是内存泄漏这张表的实用价值在于拿到 OOM 的第一件事不是加内存而是看清报错是哪一行。加错方向的结果是——报错从第一种变成第二种服务照样挂而你多花了钱。为什么-Xmx只管第一种这要从 JVM 的内存布局说起。一、JVM 内存分几块谁管哪块最容易被忽略的事实-Xmx只控制堆其他几块都要各自的参数。┌─────────────────────────── JVM 进程占用的物理内存 ───────────────────────────┐ │ │ │ ┌── 堆Heap───────────────┐ ← -Xmx / -Xms 管这里 │ │ │ 新生代Eden S0 S1 │ │ │ │ 老年代Old Gen │ │ │ └────────────────────────────┘ │ │ │ │ 元空间 Metaspace ← -XX:MaxMetaspaceSize默认无上限 │ │ 直接内存 Direct Memory ← -XX:MaxDirectMemorySize默认约等于 -Xmx │ │ 线程栈 线程数 × -Xss ← 每个线程约 1MB │ │ JVM 自身代码缓存、GC 结构等 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘这张图解释了生产上最常见的一个误判设了-Xmx1024m以为进程最多占 1G。结果docker stats看到它用了 1.6G然后被容器杀了。多出来的 600M 就是上表下半部分元空间、直接内存、线程栈、JVM 自身。所以-Xmx应该设成物理内存的 50%~70%“而不是就是物理内存大小”。三个默认值的坑值得单独记参数默认值风险-XX:MaxMetaspaceSize无上限只受原生内存限制动态生成类多的框架会一直吃直到整个进程被 OOM Killer 干掉-XX:MaxDirectMemorySize约等于-Xmx堆 1G 直接内存 1G 至少 2G很容易超容器限制-Xss线程栈约 512KB ~ 1MB线程数一多这块也很可观。用虚拟线程可以绕开它第 17 篇二、对象的一生以及正常和泄漏的分界线2.1 对象的流转路径新对象 → Eden │ Minor GC很快 ▼ 存活对象进 SurvivorS0/S1 交替 │ 每熬过一次 Minor GC年龄 1 ▼ 年龄到阈值默认 15→ 晋升老年代 │ 老年代满了 ▼ Full GC慢会 STW两个关键判断现象是否正常Minor GC 频繁几秒一次但每次很快几毫秒✅正常。说明短命对象多这正是分代设计的预期Full GC 偶发一天几次之后老年代占用明显下降✅正常Full GC 之后老年代占用几乎不降❌这就是泄漏或者真的容量不够Full GC 频繁一分钟好几次❌ 有问题堆严重不足或泄漏2.2 判断泄漏的唯一标准看 Full GC 之后老年代占用能不能降下来。能降下来比如从 900M 降到 200M→ 是容量问题。对象确实该回收的都回收了只是流量大。这时候加堆或者优化对象创建量是对的。降不下来从 900M 只降到 850M→ 是泄漏。有对象被不该持有的地方引用着GC 想收也收不掉。加内存只会让它晚一点挂挂得更贵。这就是为什么必须打开 GC 日志——没有它你无法做这个判断。三、四个必须加上的启动参数java\-Xms1024m-Xmx1024m\-XX:MaxMetaspaceSize256m\-XX:MaxDirectMemorySize256m\-XX:HeapDumpOnOutOfMemoryError\-XX:HeapDumpPath/opt/app/dump\-Xlog:gc*:file/opt/app/logs/gc.log:time,uptime,level,tags:filecount5,filesize50M\-jarapp.jar--spring.profiles.activeprod逐个说明为什么要加参数作用不加的后果-Xms设成和-Xmx一样启动就分配满避免运行中反复扩容堆扩缩容本身有开销且抖动-XX:MaxMetaspaceSize给元空间封顶默认无上限动态生成类多时会吃掉全部原生内存-XX:MaxDirectMemorySize给直接内存封顶默认接近-Xmx堆外再吃一份-XX:HeapDumpOnOutOfMemoryErrorOOM 时自动 dump 堆现场没了——重启后你再也复现不出那一刻的堆-XX:HeapDumpPath指定 dump 存哪默认写在启动目录可能写不下-Xlog:gc*打开 GC 日志无法判断是容量问题还是泄漏⚠️ 3.1 GC 日志参数的写法变了这是一个很容易踩的兼容性问题# ❌ JDK 8 的写法JDK 9 之后已失效不会报错但也不生效-XX:PrintGCDetails-XX:PrintGCDateStamps-Xloggc:/path/gc.log# ✅ JDK 9 的统一日志Unified Logging-Xlog:gc*:file/opt/app/logs/gc.log:time,uptime,level,tags:filecount5,filesize50M危险之处在于老的参数在 JDK 9 上是被忽略而不是报错。所以你可能一直以为开着 GC 日志实际上文件根本不存在。检查方法启动后去那个路径下看文件在不在、有没有内容。上面那串参数的含义拆开看片段含义gc*打开所有 GC 相关的日志标签file...输出到文件不写就打到 stdouttime,uptime带上绝对时间和运行时长排查时两个都要filecount5,filesize50M自带日志轮转最多 5 个文件、每个 50Mfilecountfilesize这个组合很重要GC 日志在频繁 GC 时会涨得非常快一天几个 G 都有。不做轮转它会成为第 6 篇讲过的磁盘写满的头号元凶。3.2 一个次要的取舍OOM 后要不要让进程退出# 选项 A不退出默认# 进程继续跑但状态不可信——某些线程已经失败了数据可能处于不一致状态# 选项 BOOM 直接退出-XX:ExitOnOutOfMemoryError我的建议是选 B。理由是OOM 之后这个进程的内部状态已经不可靠了——带着不确定状态继续服务比干脆重启更危险。让它干净地退出交给 systemd 拉起来第 9 篇的Restartalways用户感知只是几秒的不可用。这个判断有个前提你的服务要能被安全重启无状态或状态在外部的 MySQL/Redis。如果进程里存着不可恢复的状态那就要慎重。四、怎么读 GC 日志不用背只看三个数GC 日志很长但排查只需要看三个数。[2026-09-30T03:12:41.3120800][12345.678s][info][gc] GC(1024) Pause Young (Normal) (G1 Evacuation Pause) 768M-128M(1024M) 23.456ms [2026-09-30T03:15:02.1110800][12486.477s][info][gc] GC(1025) Pause Full (G1 Compaction Pause) 1002M-890M(1024M) 2941.203ms① Minor GC 的耗时第 1 行末尾的23.456ms几毫秒到几十毫秒正常超过 100ms要关注了说明新生代要么太大要么对象存活率太高。② Full GC 的频率数一下Pause Full出现的间隔一天几次正常一小时几次有问题一分钟几次严重问题服务基本不可用。③ Full GC 之后老年代占用第 2 行的1002M-890M——这个数字最重要这次 Full GC只回收了 112M回收完还剩 890M。这是典型的泄漏特征回收率不到 15%。对比一种正常情况1002M-210M—— 回收了 79%说明对象该回收的都回收了只是流量大。用这三条就能做出第一篇那张表里的判断加内存到底有没有用。4.1 一个反直觉的建议别急着调 GC 参数网上有大量JVM 调优参数大全但现代 JDK 的实际经验是绝大多数性能问题不是 GC 造成的。JDK 9 之后默认 GC 是G1它的自适应能力已经不错大多数服务不需要调。在动手改 GC 参数之前先回答两个问题GC 到底占了多少时间如果 GC 总时间只占总运行时间的 1%那它不是瓶颈延迟抖动真的是 GC 引起的吗用第 17 篇讲的压测对比或者看 GC 暂停的时间点能不能和接口变慢的时间点对上。如果这两条都不成立花在调 GC 参数上的时间基本是浪费。真正该优化的是为什么产生这么多对象——比如循环里反复new、大量字符串拼接、一次查十万行数据。五、堆 dumpOOM 之后到底怎么查-XX:HeapDumpOnOutOfMemoryError给了你现场接下来是怎么看。5.1 手动 dump进程还活着的时候# 先看一下堆的概况各区域用了多少jcmdpidGC.heap_info jstat-gcutilpid1000# 每秒打印一次各区使用率# 生成堆 dumpjmap -dump:live,formatb,file/tmp/heap.hprofpid两个注意点live参数会先触发一次 Full GC只 dump 存活对象文件更小、更聚焦。但如果怀疑是该回收的没回收就别加live——你要看的就是那些本该死却没死的对象dump 大堆会很慢可能 STW 几秒到几十秒。生产上做这件事要先评估影响最好在流量低谷或者先把这台从负载均衡上摘掉。5.2 用什么工具看一定要用支配树打开 hprof 的常用工具是Eclipse Memory AnalyzerMAT免费。用它的时候只用两个功能就够功能作用为什么Histogram直方图按类统计对象数量和总大小⚠️只用来发现异常不能用来定位。它只告诉你有 800 万个byte[]不告诉你谁持有它们Dominator Tree支配树✅按谁占的内存最多排列这才是入口。看哪个对象持有的内存最多Path to GC Roots✅从可疑对象反查谁引用着它定位泄漏的关键一步。排除弱引用/软引用后剩下的就是真凶完整的排查动线① 打开 hprof → 看 Dominator Tree找出占用最大的几个对象 ② 对着可疑对象 → 右键 Path to GC Roots → 排除 weak/soft 引用 ③ 看引用链的根是什么 → 通常是一个静态字段或长生命周期对象 ④ 找到代码里那个字段看它为什么只加不减第 ③ 步是最关键的。泄漏的本质永远是有一个生命周期很长的对象持有了本该短命的对象的引用。5.3 五种最常见的泄漏模式模式典型代码症状静态集合只加不减static MapString, Object CACHE new HashMap();当缓存用却没有淘汰和上限最经典。堆缓慢上涨Full GC 回收率越来越低ThreadLocal没清理第 17 篇讲过。Web 容器里线程复用ThreadLocalMap一直挂着堆里出现大量同一类型的对象监听器 / 回调注册未注销事件总线、观察者模式注册了但没有对应的反注册对象数只增不减且来源集中连接 / 流未关闭忘了 try-with-resources同时表现为连接池耗尽第 9 篇缓存无上限无过期自己写了个本地缓存只put不evict和第一种同源。参考第 13 篇 Redis 的maxmemory一个实用建议任何当缓存用的本地集合都必须有上限和淘汰。如果嫌写起来麻烦直接用 Caffeine 这类成熟库别自己用HashMap手搓缓存——你一定会忘了淘汰。六、容器里跑 JVM两个必须知道的坑现在的 Java 应用基本都跑在容器里第 4 篇的 Compose、第 8 篇的镜像这里有两个经典问题。6.1 容器内存限制和 JVM 的自我认知JDK 8u191 之前JVM 不认 cgroup 限制——它会按宿主机的物理内存来决定默认堆大小。后果很具体容器--memory1g宿主机有 64GJVM 以为有 64G于是默认堆设成 16G物理内存的 1/4——启动就直接被 OOMKilled连日志都看不到。# 检查 JVM 实际识别到多少内存java-XX:PrintFlagsFinal-version|grep-imaxheapsize jcmdpidVM.info|grep-imemoryJDK 10 默认开启-XX:UseContainerSupport8u191 也回移了这个修复。所以用 JDK 11我们是 21→ 不用管默认是对的维护老项目用 JDK 8 的 →先确认小版本号必要时显式加-XX:UseContainerSupport。6.2 OOMKilled ≠ Java OOM第 6 篇讲过的 137这是一个很容易混淆的点两者的排查方向完全不同Java OOMOOMKilled容器被杀现象日志里有OutOfMemoryError堆栈日志可能什么都没有进程直接没了谁干的JVM 自己Linux 内核cgroup 限制或系统内存不足怎么确认看应用日志docker inspect 容器 | grep -i oomkilled、docker inspect -f {{.State.ExitCode}}137解法查泄漏 / 调堆降-Xmx、查直接内存、查线程数关键判断如果进程无声无息地消失优先怀疑 OOMKilled 而不是 Java OOM。# 确认是不是被内核杀的dockerinspectcontainer--format{{.State.OOMKilled}} {{.State.ExitCode}}dmesg-T|grep-ikilled process# 内核日志里会有记录算一笔内存账这个账值得写在你的部署文档里容器限制 1.5G ├─ 堆 -Xmx 1024M ├─ 元空间封顶 256M ├─ 直接内存封顶 128M ├─ 线程栈 200 × 1M 200M ← 容易被忽略 └─ JVM 自身 其他 ~100M ──────────────────────────────── 合计约 1708M ← 超出了必然被 OOMKilled结论给容器设的内存限制要比-Xmx多出至少 30%~50%。反过来说如果容器限制是死的那就得按上面的账倒推-Xmx该设多少。七、一张排查决策树把前面几篇和这篇串起来服务变慢 / 挂了 │ ├─ CPU 高 → 走第 7 篇top → top -Hp → jstack 找线程栈 │ ├─ 内存高 │ │ │ ├─ 进程不见了、日志没报错 → 疑似 OOMKilled │ │ → docker inspect 看 OOMKilled / ExitCode 137 │ │ → 按第六节算内存账降 -Xmx │ │ │ └─ 日志里有 OutOfMemoryError → 看是哪一种第零节的表 │ ├─ heap space → 看 GC 日志Full GC 后能降吗 │ │ ├─ 能降 → 容量问题加堆 / 减少对象创建 │ │ └─ 不能降 → 泄漏堆 dump 支配树 引用链 │ ├─ Metaspace → 查动态生成类反射/代理/CGLIB、类加载器泄漏 │ ├─ Direct buffer → 查 NIO / Netty 的堆外分配是否释放 │ └─ unable to create native thread → 查 ulimit -u、线程是否泄漏 │ └─ GC 频繁 → 看 GC 日志的三个数第四节 → 先确认 GC 是不是真的瓶颈再考虑调参这张树的价值是分流——在动手之前先确定方向。方向错了后面所有动作都是白费的。八、坑清单#坑后果与解法1看到 OOM 就加-Xmx只对heap space有用其他四种完全无效2忘了设-XX:MaxMetaspaceSize默认无上限动态生成类多的应用会吃光原生内存3忘了设-XX:MaxDirectMemorySize默认接近-Xmx堆外再吃一份4-Xmx就等于容器内存限制必然 OOMKilled。要给元空间/直接内存/线程栈留 30%~50%5没开HeapDumpOnOutOfMemoryErrorOOM 现场丢了重启后无法复现只能靠猜6用了 JDK 8 的 GC 日志参数JDK 9静默忽略你以为开着其实没有7GC 日志不做轮转频繁 GC 时一天涨几个 G导致磁盘写满第 6 篇8用 Histogram 定位泄漏只能看类型总量必须用 Dominator Tree Path to GC Roots9分析该回收没回收却加了-dump:livelive会先 Full GC把你要找的证据清掉了10在流量高峰直接 dump大堆 dump 会 STW 几十秒等于一次小故障11用HashMap手搓缓存一定会忘了淘汰。用 Caffeine 之类的库12把 OOMKilled 当 Java OOM 查方向完全错。先看docker inspect的 ExitCode13一上来就调 GC 参数大多数性能问题不是 GC 引起的。先量化再动手九、上线检查清单-Xms与-Xmx设为相同值-XX:MaxMetaspaceSize已显式设置不是默认无上限-XX:MaxDirectMemorySize已显式设置-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath已开GC 日志已开JDK 9 的-Xlog:gc*且验证过文件真的有内容GC 日志配了filecountfilesize轮转算过内存账-Xmx 元空间 直接内存 线程栈 JVM 自身 容器限制容器限制比总占用多出 30% 以上余量确认过 JVM 识别到的内存jcmd VM.info尤其是老版本 JDKdump 文件的存放目录有足够空间堆多大dump 就差不多多大dump 目录不在容器内容器一重启就没了或已挂载出来项目里没有无上限的本地缓存静态 Map、HashMap当缓存用ThreadLocal的用法检查过配合第 17 篇监控里有堆使用率和Full GC 次数两个指标第 11 篇演练过一次本地或测试环境真的读一次 heap dump确认 MAT 会用写在最后JVM 这块知识最容易被学偏网上讲的大多是参数怎么调但排查真正的难点不在参数而在方向。这篇所有内容其实都在回答一个问题我现在看到的这个症状到底该往哪个方向查报错是哪一种 OOM决定了要不要加内存Full GC 后老年代能不能降决定了是容量问题还是泄漏进程是报错退出还是无声消失决定了是 Java OOM 还是 OOMKilled。这三个判断做对了剩下的都是执行做错了就是在错误的方向上花时间。最后提醒一句加内存几乎永远不是答案它只是把问题推迟。真正该问的是那句老话——“这些对象为什么还活着”回答清楚它你才算真正解决了问题。互动时间你遇到过哪一种 OOM是堆、元空间还是直接被容器杀了如果是后两种加-Xmx是没用的——评论区说说你的情况这类问题的排查思路其实很固定。觉得有用点个收藏调 JVM 参数之前先对着第九节过一遍。系列回顾《运维到底是干什么的》 · 《运维工程师该学哪些技能》 · 《Docker 到底是什么》 · 《Docker Compose 一键部署》 · 《上线之后必须做的四件事》 · 《线上故障排查实录》 · 《Linux 常用命令速查手册》 · 《用 GitHub Actions 自动部署》 · 《一个人把二手图书交易网站做到上线》 · 《MySQL 索引与 EXPLAIN 从入门到会用》 · 《从零搭一套监控告警体系》 · 《HTTPS 与安全加固》 · 《Redis 从入门到能运维》 · 《Shell 脚本实战》 · 《Nginx 配置从入门到能配》 · 《MySQL 备份与时间点恢复》 · 《JDK 21 虚拟线程实战》
返回列表