2023 年我维护一个数据处理服务,核心功能是从 Kafka 消费消息、做一些计算、再把结果写回另一个 Topic。上线之后表现一直平稳,直到某天我们的业务量翻了一倍——服务开始出问题:消费延迟越来越大,几分钟后开始疯狂报错 OutOfMemoryError,JVM 直接挂掉,容器被 Kubernetes 重启。重启后又能跑一段时间,然后再次 OOM。我盯着监控看了半天,堆内存曲线像锯齿一样起起落落,最后冲到顶。第一版我做得很顺手:既然是 OOM,那就加内存呗——把 -Xmx 从 2G 调到 4G,部署上去看看效果。果不其然,服务多撑了二十分钟,然后又 OOM 了。我又加到 8G,撑了一小时,又 OOM。再加到 16G,撑了几小时,还是 OOM。我心里很笃定:JVM 内存问题嘛,不就是堆不够大——把 -Xmx 一调,内存够用了,OOM 自然就没了;堆 dump 文件我也下不来(几十 G 谁分析得动),反正业务能跑就行,内存设大点又不要钱。可这个 "加内存就完事"的思路,把我拖进了一个更深的坑。第一种最先把我打懵:加到 16G 之后,GC 时间从原来的几百毫秒,变成了动辄五六秒——服务卡顿严重,业务延迟雪崩。第二种最难缠:同样的代码,在内存 4G 的容器里跑得勉强稳,搬到 16G 的容器里,看似空间大了,GC 反而更频繁了,堆使用率曲线比之前还吓人。第三种最离谱:我以为加内存能"治本",可某天我看了一眼 JVM 监控才发现,堆内存只占了 10G 不到——OOM 报的不是堆溢出,而是 Metaspace 满了,跟堆大小一点关系都没有,我加 -Xmx 完全是在治错病。第四种最莫名其妙:同样的服务在测试环境怎么压都不 OOM,在生产环境跑两天就挂——后来才知道是生产环境的某个用户行为,会反复创建大量临时对象,触发了一种我从来没听过的"大对象分配失败"。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为JVM 内存就是一个堆,服务跑得不够稳就是堆不够大,-Xmx 设大一点就能解决一切;GC 是 JVM 自己的事,我不用管它什么时候跑、跑多久;Metaspace、栈、堆外内存这些概念听起来高深,反正应用层不需要关心,堆才是大头。可这个认知是错的。JVM 内存远不只是"堆"这一块,它至少分成五大区域——堆、Metaspace、栈、堆外、代码缓存——每一块的溢出表现都不同,治法也完全不同;加大堆能缓解的,只有"对象分配不够"这一种情形,其它所有溢出场景,加 -Xmx 都是治错病。更关键的是,堆这一块本身,也不是"越大越好":堆越大,Full GC 一次扫描的范围就越大,停顿时间会指数级增长,16G 的堆一次 Full GC 卡个三五秒是家常便饭——你为了不 OOM 而加内存,反过来又因为 GC 时间过长把服务搞卡顿,这是一个把自己绕进去的死循环。再深一层,GC 不是后台默默运行的、和你无关的家政,它的每一次执行都在抢你的 CPU,它的停顿直接转化成你接口的延迟,它的频率和耗时是可以也必须被你调优的——选错 GC 算法、堆比例配错、留太多老年代,后果都很具体。所以做 JVM 调优,根上不是"加 -Xmx"这一个动作,而是一整套排查与设计:要先看清 OOM 报的到底是哪一类溢出;要知道堆内部还分新生代和老年代,各自的 GC 行为完全不同;要理解为什么"加大堆"在某种规模之后反而是负优化;要会选 GC 算法,以及 G1、ZGC 在不同场景下的取舍;要会读 GC 日志,而不是猜;还要把内存监控、GC 监控接上告警,而不是出了事故才下 dump。本文从头梳理:为什么"OOM 就加 -Xmx"会出事、JVM 内存到底分几块、堆为什么不是越大越好、GC 算法该怎么选、GC 日志怎么读,以及一些把 JVM 调优做扎实要避开的工程坑。
问题背景
先把 JVM 内存这件事说清楚。JVM 进程的内存,远不止你 -Xmx 配的那块"堆"。一个运行中的 JVM,内存大致分为:堆(Heap,存对象实例,GC 管理)、Metaspace(存类元数据,JDK 8+ 在堆外)、线程栈(每条线程一份,存调用栈)、堆外内存(NIO 直接内存、Native 库的内存)、代码缓存(JIT 编译后的本地代码)。这五块加起来,才是 JVM 真正占用的物理内存。OOM 这个错误,可以从任何一块发生,但报出来的异常名是不同的——java.lang.OutOfMemoryError: Java heap space 是堆溢出、Metaspace 是 Metaspace 溢出、unable to create new native thread 是线程栈无法分配、Direct buffer memory 是堆外溢出。第一版的错,不在于"加了 -Xmx",而在于它把全部 JVM 内存都当成了"堆"——它看到 OOM 就条件反射地加堆,根本没看 OOM 报的具体是哪一类,这相当于不看病情就上同一副药。
错误认知是:JVM 内存就是堆,-Xmx 越大越稳;GC 不用管。真相是:JVM 内存分五块,各类 OOM 治法完全不同;堆越大 Full GC 停顿越长;GC 算法和分代比例都要选。把这一点摊开,第一版的几类问题就都能解释了:
- 加完内存 GC 反而更慢:堆越大 Full GC 扫描越久,停顿从几百毫秒涨到几秒。
- 容器变大反而更频繁 GC:容器内存大了但分代比例没调,新生代撑不住高分配率。
- 加 -Xmx 完全无效:OOM 报的是 Metaspace 或堆外,跟堆大小毫无关系。
- 测试不复现生产挂:生产有触发大对象分配的特定路径,本地压不到。
所以让 JVM 服务真正稳,核心不是"-Xmx 一调",而是一整套排查与设计:看清 OOM 类型、理解分代 GC、选对 GC 算法、读懂 GC 日志、配齐内存与停顿监控。下面六节,就从第一版"OOM 就加 -Xmx"的想当然讲起。
一、为什么"OOM 就加 -Xmx"会出事
第一版我处理 OOM 的方式,核心就是一行启动参数,反复调大。
# 反面教材:第一版 —— 出了 OOM 就加 -Xmx,加完不行再加
# 第一次部署
java -Xmx2g -jar app.jar
# OOM 了,加到 4g
java -Xmx4g -jar app.jar
# 还 OOM,加到 8g
java -Xmx8g -jar app.jar
# 还不行,加到 16g
java -Xmx16g -jar app.jar
# 结果:
# 1) OOM 没了,但 GC 停顿从 500ms 涨到 6 秒,业务延迟雪崩
# 2) 没几天又开始报 OOM,但这次错误信息变了 —— Metaspace
# 3) 我从来没确认过 OOM 报的到底是哪一类,以为都是"堆不够"
问题就藏在这个"加 -Xmx"看似有效的假象之下。它隐含了三个极其乐观的假设:OOM 一定是堆不够大;加大堆只有好处,没有副作用;堆是 JVM 内存的全部,加 -Xmx 等于加 JVM 内存。这三个假设,在你刚接触 JVM 时听着都很自然,可一接触真实的生产场景,它们一个接一个地碎掉。
这一节要建立的认知是:第一版最深的想当然,是把"JVM OOM"看成了一个单一的、只有一种成因、只有一种治法的故障——以为它就等于"堆不够装",所以"加堆"就是万能答案;可 OOM 实际上是一个"症状名",它背后藏着至少五种完全不同的成因,每种成因对应一块不同的内存区域,治法也完全不一样;不看具体错误信息就条件反射地加 -Xmx,就像不看病人具体哪里疼就一律开止痛片,运气好掩盖症状,运气不好把人吃出新的病。这件事的关键,是要彻底破除一个根深蒂固的混淆:"JVM 内存"和"堆"不是同一个东西。"堆"只是 JVM 内存里的一块,虽然通常是最大的一块,但绝不是唯一一块。一个 JVM 进程跑起来,操作系统看到的总内存占用,大致是这样几块加起来:堆(你 -Xmx 配的那块,存所有对象实例,GC 在这里干活)、Metaspace(存所有加载进来的类的元信息——类名、方法签名、字节码等,默认大小有限制)、线程栈(每开一条线程都要分一份,默认 1MB,几百条线程就是几百兆)、堆外内存(NIO 的 DirectBuffer、JNI 调用的 native 库占用的内存,完全不归 GC 管)、代码缓存(JIT 把热点字节码编译成本地代码后存放的地方)。这五块是相互独立的——你 -Xmx 加得再大,Metaspace 不会跟着变大,线程栈不会跟着变大,堆外不会跟着变大。这意味着:任何一块溢出,都会报 OOM,但只有"堆"那一块溢出,加 -Xmx 才有用。第一版那个"加到 16g 还报 OOM,但错误信息变了"的场景,就是堆够了之后,Metaspace 顶不住了——这种 OOM,你 -Xmx 加到 1T 都没用,得调的是 -XX:MaxMetaspaceSize。更糟糕的是第二个假设:"加大堆只有好处"。这个假设忽略了 GC 的成本。GC 不是免费的,它要扫描堆里所有(或一部分)对象,判断哪些还活着、哪些可以回收、把活着的对象搬到新位置。这个工作的代价,粗略地说,正比于堆里对象的总量。堆越大,一次 Full GC 要扫的对象越多、要搬的对象越多、要更新的引用越多,停顿时间就越长——而 Full GC 停顿,是 STW(Stop The World)的,整个 JVM 在这段时间内停止响应所有请求。在 4G 堆上,Full GC 可能只停 200ms,你感觉不到;到 16G 堆上,同样的 GC 算法可能要停 3-5 秒,所有正在处理的请求全卡住,接口超时一片。第一版"加 -Xmx 治 OOM"的副作用,就是把"偶发 OOM"换成了"经常性卡顿",代价更隐蔽、更难排查。第三个假设的错误更直接:OOM 不一定是堆。我反复加 -Xmx 一直无效,真相是 Metaspace 满了——某个老业务在动态生成大量 Class(常见于反复加载的代理类、动态编译的脚本),Metaspace 一点点被这些类元信息撑爆,这是堆的事吗?完全不是。我加 -Xmx 是在治错病,真正该做的是 -XX:MaxMetaspaceSize 调大,或者(更根本地)去找出谁在疯狂生成类。看清这三个错误假设,第一版的所有麻烦就都有了根源:不是某次内存没加够,而是它从一开始就用错了"问题是什么、答案应该往哪找"的框。要改对它,得先认清 JVM 内存到底分几块、各种 OOM 长什么样,下一节讲。
二、JVM 内存到底分几块:别把 OOM 全当堆溢出
JVM 进程占的物理内存,大致是下面这几块的总和。把它们记清楚,看 OOM 异常时一眼就能定位是哪一块。
# JVM 进程内存大致结构(以 64 位 JDK 8+ 为例)
+-------------------------+
| Heap 堆(-Xmx 控制) | 存对象实例,GC 在这里干活
+-------------------------+
| Metaspace 元空间 | 存类元数据,默认无上限 危险
| (-XX:MaxMetaspaceSize) |
+-------------------------+
| Thread Stacks 线程栈 | 每条线程一份,默认 1MB
| (-Xss) | 几百条线程就是几百兆
+-------------------------+
| Direct Memory 堆外 | NIO DirectBuffer 等,GC 管不到
| (-XX:MaxDirectMemorySize)|
+-------------------------+
| Code Cache 代码缓存 | JIT 编译后的本地代码
| (-XX:ReservedCodeCacheSize)|
+-------------------------+
# 总占用 = 上面这几块的总和 + JVM 自身一些 native 开销
# 容器内存上限要够大,否则被 K8s OOMKilled 时连 dump 都来不及
# 关键事实:
# 1) -Xmx 只控制堆这一块,加它对其它四块毫无影响
# 2) Metaspace 在 JDK 8+ 默认无上限 —— 类生成失控时会吃光物理内存
# 3) 几百条线程 * 1MB 栈 = 几百 MB,容易被忽略
# 4) NIO Netty 这类用 DirectBuffer 的库,堆外内存能涨得很猛
不同的 OOM 错误信息,对应不同的内存区域,治法也完全不同。下面这张对照表是排查时的第一参考。
OOM 类型对照表:看错误信息定位故障区 OOM 异常信息 原因 治法 -------------------------------------------------------------- Java heap space 堆里活对象超过 -Xmx 先 dump 看泄漏 再考虑加堆 GC overhead limit exceeded GC 占比超 98% 回收 < 2% 通常仍是堆泄漏 同上 Metaspace 加载的类元数据撑爆 查动态类生成 再调 MaxMetaspaceSize unable to create new native thread 线程数撑爆 OS 限制 查线程泄漏 限制线程池上限 Direct buffer memory 堆外 DirectBuffer 撑爆 查 Netty NIO 用法 调 MaxDirectMemorySize Requested array size exceeds VM limit 单次分配数组超 VM 上限 优化代码 别一次分这么大 原则 先看错误信息后两个词 别看到 OOM 就反射性加 -Xmx
这一节的认知是:OOM 不是一个故障,它是一个故障类别的统称——五种内存区域对应五种 OOM 错误信息,每一种的成因、排查路径、解决方案都完全不一样;能不能一眼从错误信息后两个词("heap space"、"Metaspace"、"native thread"……)定位到具体是哪一区出了问题,就是排查 OOM 的第一道分水岭,跨过去是有方向地查,跨不过去就是瞎加内存。第一版反复加 -Xmx 这件事,最荒诞的地方,不是某一次加得多了少了,而是从头到尾它根本没看错误信息——它只看到了"OOM"这三个字母,然后就跳进了"加堆"这个唯一的反射动作。可你只要稍微仔细地看一眼异常栈,那条 java.lang.OutOfMemoryError 后面,通常会跟一行非常清楚的描述,告诉你这次溢出的是哪一区。"Java heap space"——这是最常见的一种,堆里活的对象总量超过了 -Xmx,要么是堆本来就小、要么是有对象泄漏一直堆积。这一种,先 dump 一份堆出来分析(jmap -dump 或开 -XX:+HeapDumpOnOutOfMemoryError 让 JVM 自己 dump),用 MAT/JProfiler 看哪一类对象异常地多,大概率能定位到具体哪一行代码在泄漏;只有在排除了泄漏、确认是业务量本就大到这个程度之后,才轮到考虑加 -Xmx。"GC overhead limit exceeded"——这个看着像新东西,其实本质上还是堆问题:JVM 发现自己花了 98% 以上的时间在 GC,却只回收了不到 2% 的内存,认为再这么搞下去也没意义,主动报这个错。治法和上面一样,八九不离十是泄漏。"Metaspace"——这一类我专门吃过亏。JDK 8 之后,类元数据从堆里搬到了堆外的 Metaspace,默认是没有上限的(理论上可以一直涨到吃光物理内存)。哪些场景容易出问题?动态 class 生成的场景——CGLIB 动态代理、JDK 动态代理、Groovy/JavaScript 脚本反复编译、热部署不规范——每生成一个 Class,Metaspace 就涨一点,如果生成完不释放(比如 ClassLoader 没回收),就会一直涨,涨到爆。这一类的治法,首先是定位是谁在疯狂生成类(通过 jcmd VM.class_hierarchy 或 jvisualvm 看),根治是改代码;短期缓解是 -XX:MaxMetaspaceSize 设一个上限,至少让它早爆早暴露,而不是无声地涨到吃光机器内存把整个容器搞挂。"unable to create new native thread"——这个一看就和线程有关,通常是某个线程池没设上限、或者每来一个请求就 new 一条线程,把进程的线程数撑到了操作系统的限制(ulimit -u);治法是查谁在疯狂建线程,把线程池的最大数限制住。"Direct buffer memory"——这个是堆外的 DirectBuffer 用完了。常见于用 Netty、NIO 做网络 IO 的服务,DirectBuffer 不归 GC 管,要么靠引用对象被回收时一起被释放,要么手动 release,如果 release 漏了就会涨;治法是查代码里 ByteBuffer.allocateDirect 的调用、Netty 的 ByteBuf 是否正确 release,再用 -XX:MaxDirectMemorySize 限一下上限。"Requested array size exceeds VM limit"——这个是单次想分配一个超大数组,超过了 JVM 的限制,改代码别一次分这么大。这五类对应五种治法,中间没有一种是"加 -Xmx 就能解决"的,除了最纯粹的堆 space 这一种,还得先排除泄漏。所以排查 OOM 的标准动作,永远是先把异常栈的后两个词找出来,定位到具体区域,然后按这一区的标准流程查——而不是看到 OOM 就抓 -Xmx。看清了内存怎么分,接下来要看的是堆这一块内部又怎么分——这决定了 GC 的行为,以及为什么"堆不是越大越好",下一节讲。
三、为什么"堆不是越大越好":Full GC 与分代结构
第一版以为加 -Xmx 是稳赚不赔的,因为它压根没意识到 GC 的代价。要明白这一点,得先知道堆内部并不是一片连续的对象池——它是分代的。
# JVM 堆的分代结构(经典 -XX:+UseG1GC 之前的模型)
+---------------- 堆 Heap ----------------+
| |
| 新生代 Young (-Xmn 控制大小) |
| +---- Eden ----+ +-S0-+ +-S1-+ | 新对象在 Eden 分配
| | Minor GC 在新生代发生,很快
| -------------------------------- |
| |
| 老年代 Old (堆 - 新生代) | 长期活着的对象会"晋升"到这里
| | Full GC 扫整个堆,很慢
| |
+------------------------------------------+
# 为什么要分代:基于"大多数对象很快就死"这条经验规律
# 新生代用 Minor GC,扫的是 Eden+1 个 Survivor,通常几十毫秒
# 老年代里都是"经过多轮 Minor GC 还活着"的长寿对象,
# 一旦老年代满了触发 Full GC,要扫整个堆 —— 这才是停顿的大头
# 关键事实:
# 1) 堆越大 = 老年代越大 = Full GC 一次扫得越久
# 2) 16G 堆的 Full GC 停顿 3-5 秒是家常便饭
# 3) 高分配率场景下,新生代太小会导致频繁 Minor GC
# 新生代太大又会让晋升到老年代的对象判定不准
所以堆大小不是简单地"越大越稳",它是一组在多个目标之间的权衡。
# 堆大小的权衡:防 OOM vs 控停顿 vs 控 GC 频率
# 堆设太小 堆设太大 合理范围
# --------------------------------------------------
# Full GC 快但频繁 Full GC 慢但少 视业务而定
# 容易 OOM GC 停顿可能秒级 一般 4-8G 较稳
# 内存利用率高 浪费 单实例承载更多请求 ZGC 下可以更大
# 经验法则:
# 1) 不是"内存有多少就给多大堆",而是"业务对停顿能容忍多久反推堆"
# 2) 老年代占堆的 2/3,新生代占 1/3,是经典默认 也可按业务调
# 3) 容器化部署务必加 -XX:+UseContainerSupport(JDK 8u191+ 默认开),
# 否则 JVM 看到的是宿主机内存,容器一限就被 OOMKilled
这一节的认知是:堆大小这个参数,从来不是"防 OOM"这个单一维度上的事——它同时是"GC 频率"和"GC 停顿时长"这两个维度上的事,而这三个维度的关系还不是简单的"堆越大越好"——堆越大,确实越不容易 OOM、Full GC 越少,但每一次 Full GC 的停顿时长会指数级上涨;堆越小,GC 停顿短但频率高、还可能直接 OOM;真正合理的堆大小,是这三个目标之间的一个平衡点,而不是"机器内存多大就给多大"。第一版犯的错,是把"堆大小"当成一个一维的旋钮——向"大"的方向拧,就能解决 OOM,代价是"机器内存多花一些",但这点机器钱不算什么。可它彻底忽略了堆大小其实是一个多维权衡的旋钮:你向"大"的方向每拧一格,确实买到了"更少 OOM、更少 Full GC",但同时也付出了"每次 Full GC 停顿更长"的代价——而这后一个代价,是用户能直接感知到的延迟。问题在于,大多数线上服务对延迟非常敏感,接口超时 3 秒和接口超时 200 毫秒,是用户体验上完全不同的两件事,而 16G 堆下一次 Full GC 的 3-5 秒停顿,意味着这 3-5 秒里所有请求要么超时、要么积压,等 GC 一结束,积压的请求一起涌进来,可能还触发限流、雪崩。第一版从"偶发 OOM"换到"经常性几秒停顿",在用户体验和系统稳定性上,反而更糟。要理解为什么"堆越大停顿越长",得回到 JVM 堆的分代结构上。堆不是一块连续的、平均的对象池,它被分成新生代和老年代,这个划分基于一条无数次被实证的经验:绝大多数对象生命非常短,刚 new 出来用一下就不再被引用了。基于这一条,JVM 把新对象统一分配在新生代的 Eden 区,新生代满了就触发 Minor GC——Minor GC 只扫新生代,扫到的活对象(就是那少数没死的)从 Eden 搬到 Survivor 区,经过几轮 Minor GC 还活着的"老顽固"再"晋升"到老年代。这个分代设计的好处是:Minor GC 扫的范围很小,通常几十毫秒就完成,频率高也不要紧;真正昂贵的 Full GC,只在老年代也快满的时候才触发。可一旦触发 Full GC,它要扫的就是整个堆——新生代加老年代,堆越大,这个总扫描范围越大,停顿就越长。这就是"堆越大,Full GC 越慢"的根本原因。所以堆大小的选择,本质上是回答这两个问题:"业务能容忍多长的停顿"和"业务的对象分配率有多高"。能容忍秒级停顿的离线分析,可以把堆开得很大,反正没人在等它;接口要 P99 < 200ms 的在线服务,堆就不能开太大,即便加了再先进的 GC 算法也得权衡。容器化部署的服务,还有一个隐藏的坑:JDK 8 早期版本里,JVM 看到的是宿主机内存而不是容器内存限制,你 -Xmx 没设好,JVM 自己估的堆大小可能就超过容器限制,K8s 一上来就 OOMKilled 你,连一个体面的 OOM 异常都没有,只有一个冷冰冰的容器重启。这一类问题,JDK 8u191+ 开了 -XX:+UseContainerSupport 之后基本能自动识别容器内存,但老版本上务必显式设 -Xmx 不要超过容器内存的 70-75%。理解了堆为什么不是越大越好、Full GC 为什么是大头,接下来要决定的是选哪种 GC 算法——这是真正能在大堆下控住停顿的关键,下一节讲。
四、GC 算法选型:Parallel、CMS、G1、ZGC 的取舍
第一版除了乱加堆,还有一个更隐蔽的错——它跑的是 JDK 8 默认的 Parallel GC,堆 16G 跑 Parallel GC 是个非常糟糕的组合。GC 算法不是一个可以"不管"的东西,它直接决定了你的服务在高内存下能不能保持低延迟。
# 主流 GC 算法对照:不同场景该怎么选
GC 设计目标 Full GC 停顿 适用场景
--------------------------------------------------------------
Parallel(吞吐) 最大化吞吐 堆越大停顿越久 离线批处理 不在乎停顿
CMS(已弃) 降低停顿 会"碎片化" 历史项目 已不推荐
G1(JDK 9+ 默认) 可控停顿目标 分区回收 较稳 大多数在线服务 4-32G
ZGC(JDK 15+ 生产) 亚毫秒级停顿 < 10ms 大堆 32G+ 极致低延迟
Shenandoah(RedHat) 低停顿 与 ZGC 类似 OpenJDK 部分发行版
# 启动参数对照(JDK 11+ 推荐写法)
# Parallel: -XX:+UseParallelGC
# G1: -XX:+UseG1GC -XX:MaxGCPauseMillis=200
# ZGC: -XX:+UseZGC (JDK 15+ 生产可用)
# 选型最常见的错误,是在堆 8G 以上还用 Parallel GC ——
# Full GC 一次几秒起步,服务被 STW 卡得死死的。
选定算法之后,有几个关键参数得跟着调,而不是用默认值。
# G1 的几个关键参数(在线服务最常用的组合)
# -XX:+UseG1GC 启用 G1
# -Xmx8g -Xms8g 堆固定大小 避免运行时扩容引发抖动
# -XX:MaxGCPauseMillis=200 告诉 G1 你想要的最大停顿目标
# -XX:InitiatingHeapOccupancyPercent=45 老年代占 45% 就启动并发标记
# -XX:+HeapDumpOnOutOfMemoryError OOM 时自动 dump,事后能分析
# -XX:HeapDumpPath=/var/log/dump dump 文件存哪
# -Xlog:gc*:file=/var/log/gc.log:time,level,tags:filecount=10,filesize=50M
# GC 日志写到文件 滚动保留
# 关键认识:
# 1) -Xmx 和 -Xms 设成一样,防止运行时堆扩容/收缩带来抖动
# 2) MaxGCPauseMillis 是"目标"不是"保证",G1 会尽量但不一定做到
# 3) GC 日志默认是关的,出事时没日志可看 —— 务必上线就开
这一节的认知是:GC 算法这一选,不只是"换个回收器"那么简单,它本质上是在不同 GC 设计哲学(最大吞吐 vs 可控停顿 vs 极致低停顿)之间做选择,而不同哲学适配的是完全不同的业务场景——离线跑批的场景,你可能更在乎吞吐(每秒处理多少数据),停顿几秒钟没人关心;在线接口的场景,你更在乎尾部延迟(P99 不能超 500ms),停顿几秒就是事故。GC 算法选错,等于把一台跑车给了拉货司机,或者把一台货车交给赛车手,工具和场景错配,再多调参也调不出来。第一版还在用 Parallel GC,这是 JDK 8 的默认值,设计初衷是"最大化吞吐"——它的回收策略基本上是"攒一波,STW 暂停整个 JVM,用多线程并行回收,完事继续跑"。在堆小、对停顿不敏感的场景下,这个策略效率确实高;可一旦堆涨到 8G、16G,它"STW 整个堆"的代价就成了灾难——一次 Full GC 几秒钟起步,在线服务根本扛不住。这就是 G1 设计出来的初衷:把堆划分成若干个 Region,每次 GC 不再扫整个堆,而是挑"回收性价比最高的几个 Region"来扫,把单次停顿控制在一个目标范围内(通过 MaxGCPauseMillis 告诉它)。G1 在 4-32G 堆的在线服务里,几乎是默认选择——它的停顿稳定性比 Parallel 好一个数量级,虽然吞吐略有损失,但对在线服务来说,这点吞吐损失换回的稳定停顿,完全值。再往上,堆超过 32G 还要求极致低延迟,就要看 ZGC 或 Shenandoah——它们用了更激进的并发回收技术,绝大多数 GC 工作都和应用并发执行,STW 部分能压到 10ms 以内,即便几百 G 的堆也是如此。代价是它们对 CPU 的占用更高,而且对 JDK 版本有要求(ZGC 生产可用得 JDK 15+)。至于 CMS,JDK 9 已经标记 deprecated、JDK 14 移除,新项目不要再用,老项目能迁就迁。选好算法之后,几个关键参数千万不要用默认值。-Xmx 和 -Xms 务必设成一样大——如果不一样,JVM 会按需扩容堆,扩容这个动作本身就要触发 GC,而且物理内存的申请和释放也会带来系统抖动,生产环境就是要让堆从启动起就保持恒定大小。MaxGCPauseMillis 是 G1 的核心参数,它告诉 G1"我希望每次 GC 停顿不超过这个时间",G1 会动态调整 Region 的回收数量去尽量满足这个目标;注意是"尽量"不是"保证",对象分配速率太快、老年代回收太密时仍可能超目标,但有这个目标在,G1 的行为就有方向。还有 -XX:+HeapDumpOnOutOfMemoryError——这条参数务必加,OOM 发生时 JVM 会自动 dump 一份堆快照到指定目录,事后用 MAT 一看,泄漏的对象类型、引用链全都在里面;没有这个,OOM 完容器一重启,你手上什么证据都没有,只能猜。最后,GC 日志默认是不打印的,你必须显式用 -Xlog 开起来——这是下一节要讲的事,GC 日志是 JVM 调优最重要的输入,你不开它、不看它,就等于闭着眼睛开车。把分代结构、堆大小权衡、GC 算法选型这几条线连起来,JVM 内存调优的决策树,可以画成下面这张图:
[mermaid]
flowchart TD
A[服务出现 OOM 或卡顿] --> B{看 OOM 错误信息后两个词}
B -->|heap space| C[先 dump 堆 查泄漏]
B -->|Metaspace| D[查动态类生成 调 MaxMetaspaceSize]
B -->|native thread| E[查线程泄漏 限线程池上限]
B -->|Direct buffer| F[查 Netty NIO 用法 调 MaxDirectMemorySize]
C --> G{是泄漏吗}
G -->|是| H[改代码修泄漏]
G -->|不是 业务量真涨了| I{堆已经多大}
I -->|小于 8G| J[加堆 同时保持 Parallel 或换 G1]
I -->|8G 以上| K[换 G1 设停顿目标]
I -->|32G 以上| L[考虑 ZGC]
K --> M[开 GC 日志 持续观察]
L --> M
五、GC 日志怎么读:从日志里看出真相
第一版从头到尾没看过一行 GC 日志——因为它根本没开。这是 JVM 调优里最普遍、最致命的盲点。开 GC 日志几乎零成本,但能给你的信息是无可替代的。
# 开 GC 日志(JDK 11+ 用 -Xlog,JDK 8 用旧的 -XX:+PrintGCDetails)
# JDK 11+ 推荐写法
java -Xlog:gc*:file=/var/log/gc.log:time,level,tags:filecount=10,filesize=50M \
-XX:+UseG1GC -Xmx8g -Xms8g -jar app.jar
# 日志里一条典型的 G1 Minor GC(Pause Young)长这样:
# [2023-08-14T10:23:01.123+0800] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
# Eden regions: 100->0(80) Eden 用了 100 个 Region 回收后 0 个
# Survivor regions: 10->15(15) Survivor 区
# Old regions: 200->205 老年代 略涨 一点对象晋升进去
# Heap: 5120M->2048M(8192M) 堆使用 5G 收到 2G 总 8G
# Pause Time: 45ms 这次停顿 45 毫秒
# 一条 Full GC 长这样,要警觉:
# [2023-08-14T10:23:05.000+0800] GC(43) Pause Full (Allocation Failure)
# Heap: 8000M->7800M(8192M) 8G 堆几乎满 回收完还剩 7.8G
# Pause Time: 3200ms 停顿 3.2 秒 —— 这就是事故
光开日志还不够,要会用工具去汇总分析——一行行肉眼看是看不出趋势的。
# GC 日志分析,推荐用工具,不要肉眼翻
# 1) GCViewer(开源,经典):上传 gc.log,出图表
# 看吞吐率(应用时间占比) Full GC 频率 平均停顿 最大停顿
# 2) GCEasy.io(在线):同样是上传 + 出报告
# 3) Prometheus + JMX Exporter:把 GC 指标接进监控,设告警
# 应该重点盯的几个数字:
# 1) Full GC 频率:理想是几小时一次甚至更少;每分钟都来就有问题
# 2) Full GC 平均停顿:在线服务希望 < 200ms,> 1s 就要重新选算法
# 3) 吞吐率(application time / total):应该 > 95%,< 90% 是 GC 抢 CPU
# 4) 老年代每次 Full GC 后的占用:如果一直涨,就是泄漏的铁证
这一节的认知是:GC 日志,是 JVM 内部"自我汇报"的唯一窗口——它告诉你每一次 GC 发生的时间、原因、回收前后的堆大小、停顿时长;不开 GC 日志做 JVM 调优,就像不看监控做服务运维,所有的判断都只能靠"我觉得"和"它好像变好了",而 JVM 是一个内部行为极其复杂的运行时,凭感觉调它的成功率,跟扔骰子差不多。第一版从头到尾没看过一行 GC 日志,这件事比"加错 -Xmx"更糟糕——加错参数是方向不对,而不开日志是连方向都看不见。GC 日志开起来几乎是免费的:启动参数加一条 -Xlog:gc*:file=...,JVM 就会把每一次 GC 的详细信息写到指定文件,滚动保留;对性能的影响微乎其微,绝大多数线上服务都应该默认开着。开起来之后,你就有了一份"JVM 在过去几小时/几天里都干了些什么"的完整记录,出问题时第一时间就能拉出来分析,而不是事后无凭无据地猜。具体看什么?最关键的是几个汇总指标。第一,Full GC 的频率。理想状态下,在线服务的 Full GC 应该是"几小时一次"甚至"几天一次"——这意味着 G1 把绝大多数回收都通过 Minor GC 在新生代内部消化掉了,老年代基本平静。如果你看到 Full GC 每分钟、每几分钟来一次,那意味着新生代根本扛不住对象的分配速度,大量对象被强行晋升到老年代,老年代很快又满,陷入 Full GC 循环——这种情况要么是内存泄漏(对象本不该活那么久),要么是新生代设得太小(短期对象没等死就被赶到老年代去了)。第二,Full GC 的平均停顿。在线服务希望这个数控制在 200ms 以内,500ms 就已经会让用户感知到偶尔的卡顿,超过 1 秒就是事故级别——这时候要么是堆太大(考虑减小或换 ZGC),要么是 GC 算法选错(还在用 Parallel GC 跑大堆)。第三,吞吐率,就是"应用真正运行的时间"除以"总时间"。健康的服务这个值应该在 95% 以上,意思是 GC 只占了不到 5% 的 CPU 时间;如果掉到 90% 以下,说明 GC 已经在抢业务的 CPU,服务的 QPS 上限会被拖下来;掉到 80% 以下,基本上就是"GC 在搞主业、业务在打杂"的灾难状态。第四,也是定位泄漏的关键——每次 Full GC 之后,老年代的占用量。如果这个值一直在涨(比如第一次 Full GC 后老年代占 3G,第十次 Full GC 后还有 6G、7G、8G),那就是铁证如山的内存泄漏:对象正在以一种你没意识到的方式被某条引用链长期持有,Full GC 都回收不掉。这种情况,加多少堆都是治标,得 dump 堆,用 MAT 看"Histogram"找哪类对象异常地多,然后看"Path to GC Roots"找谁在引用它,顺着找到代码层面就能修。这四个指标,GCViewer 或 GCEasy 这类工具能从一份 gc.log 里自动算出来,你只要传文件,几秒钟就出报告——比肉眼翻日志高效几个数量级。生产环境更进一步,把这些指标接进 Prometheus(通过 JMX Exporter),做成监控面板,关键阈值设告警(比如 Full GC 超过 500ms 告警、吞吐率掉到 90% 以下告警),JVM 一有异常你就能第一时间收到,而不是等到接口大面积超时才被人通报。把 GC 日志当成"JVM 的体检报告"——定期看、出事第一时间看、把关键指标接进监控,你才算真正在调 JVM,而不是闭着眼睛拧旋钮。剩下的是把整套调优做扎实的工程细节,下一节讲。
六、把 JVM 调优做扎实,要避开的工程坑
前面五节讲清了 JVM 调优的核心:看清 OOM 类型、理解分代、选对算法、读懂日志。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,是 OOM 一旦发生,要能拿到证据——而不是容器一重启什么都没了。
# 坑一:OOM 发生时,要有"现场证据"留下来
# 务必加这两条启动参数:
# -XX:+HeapDumpOnOutOfMemoryError OOM 时自动 dump 堆
# -XX:HeapDumpPath=/data/dump/ dump 文件存到哪
# -XX:+ExitOnOutOfMemoryError OOM 后直接退出 让 K8s 重启
# (或者用 -XX:OnOutOfMemoryError="kill -9 %p" 配合外部重启策略)
# 容器化部署的关键:
# 1) /data/dump/ 必须挂在容器外的持久卷,否则容器重启 dump 就没了
# 2) dump 文件可能有几 G,目录要预留足够空间
# 3) 如果用 K8s,Pod 重启策略要保留 dump 卷,不要每次都清空
# 没有 dump,事后排查 OOM 基本只能猜 —— 这条参数是事故应急的命根
第二个坑,容器化部署下,JVM 内存设置必须"留出余量"给非堆部分。
# 坑二:容器化部署,容器内存不能等于 -Xmx
# 一个 JVM 进程的总内存 = 堆 + Metaspace + 栈*线程数 + 堆外 + Code Cache
# 如果你容器限制 4G、-Xmx 也设 4G,
# 那 Metaspace 等其它块一申请,就会超容器限制,被 K8s OOMKilled
# 经验:容器内存 = -Xmx + 至少 1G(或 25%-30%),给非堆部分留余地
# 比如容器限制 4G,-Xmx 设 2.5G-3G,留 1-1.5G 给非堆
# 另外:JDK 8u191+ 默认开了 -XX:+UseContainerSupport,
# 不显式设 -Xmx 时,JVM 会自动按容器内存的 1/4 选堆大小 —— 这个比例
# 通常太保守,生产还是建议显式 -Xmx,自己控制留多少给非堆
第三个坑,JVM 启动参数要版本化管理,而不是散落在各处。
# 坑三:JVM 参数要版本化,不要散落在脚本和 Dockerfile 里
# 推荐做法:把所有 JVM 参数集中到一个文件 jvm.options
# (Elasticsearch 用的就是这种模式)
# jvm.options
-Xmx4g
-Xms4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-Xlog:gc*:file=/var/log/gc.log:time,level,tags:filecount=10,filesize=50M
# 启动时用 @ 引入
# java @jvm.options -jar app.jar
# 这样:
# 1) JVM 参数纳入 Git,每次改动有 commit 记录,能回滚
# 2) 不同环境的差异(开发/测试/生产)用不同 jvm.options 文件,清楚
# 3) Code Review 时 JVM 参数变更能被看见,不会被偷偷改
还有几个坑值得点一下。其一,不要在生产环境随手用 jmap -dump:live——这条命令会触发一次 Full GC,堆几 G 时会卡住进程几秒,业务直接受影响;非紧急情况用 -dump(不带 live),或者去 K8s 里 exec 进容器再 dump。其二,GC 日志要按大小滚动,不要无限增长——上面 filecount=10,filesize=50M 就是滚 10 个 50M,共 500M。其三,JVM 参数不要照搬网上的"最佳实践",每个业务的对象分配特点不一样,合不合适得自己跑评测、看日志才知道。下面把"JVM 调优"的整体心法对照一下:
JVM 出问题?别条件反射加 -Xmx
症状 错误的反应 正确的第一步
--------------------------------------------------------------
报 OOM 直接加 -Xmx 看异常信息后两个词定位区
服务卡顿 重启了事 看 GC 日志 Full GC 多久一次
GC 占用 CPU 高 加 CPU 换 G1 或 ZGC 改并发回收
容器频繁 OOMKilled 加容器内存 算清 -Xmx + 非堆 < 容器限制
Metaspace 持续涨 默默观察 找谁在动态生成类
原则 不开 GC 日志 = 闭着眼睛调 JVM
不看异常信息后两词 = 不看病情就开药
不留 dump 自动配置 = 出事故无法复盘
这一节这几个坑,串起来是同一个意思:JVM 调优不是一次性的"开机配置",它是一个需要"应急能力 + 长期监控 + 版本管理"配套的工程对象——你必须像对待一个真正的生产系统那样,给它准备好出事时能拿到证据的机制(自动 dump)、留好容器内存的合理余量(避免被 OOMKilled)、把参数纳入版本管理(避免被偷偷改)、把指标接进监控(出事第一时间发现),而不是把 JVM 参数当成一行启动脚本里偶尔改一改的字符串。第一版对 JVM 调优的心态,是"出事再改"——OOM 了就加 -Xmx,卡顿了就重启,日志默认关着、dump 没配置、参数散落在启动脚本里。这种"消防员"式的心态导致的最大问题,不是某一次故障没处理好,而是每一次故障都没留下证据,所以每一次都从头猜——上次猜对了是运气,下次猜错就是事故。HeapDump 自动化那个坑,点破的是 JVM 故障排查的根本:OOM 发生的那一瞬间,堆里的所有对象、所有引用链,就是故障现场最完整的证据;一旦 JVM 退出、容器重启,这份证据就永远消失了。-XX:+HeapDumpOnOutOfMemoryError 这一条参数,就是 JVM 帮你自动"拍下现场照片"的开关——不开,你就等于事故后到了案发现场发现尸体已经被抬走了,只能听人转述;开了,你手上就有一份几 G 的 dump 文件,用 MAT 一打开,泄漏对象、引用链、threadlocal 死循环、缓存爆炸,清清楚楚。容器内存那个坑,点破的是"JVM 占用 ≠ -Xmx"这个反直觉的事实:Metaspace、线程栈、堆外内存,加起来动辄几百兆到几 G,这些都不归 -Xmx 管,但都计入容器的内存使用——你 -Xmx 顶着容器上限设,等于完全没给非堆部分留余量,Metaspace 稍微一涨,K8s 立刻 OOMKilled 你,而且这种 Kill 是"硬 Kill",JVM 连写一行 OOM 异常的机会都没有,日志里干干净净,你都不知道发生了什么。参数版本化那个坑,点破的是 JVM 参数和代码同等重要的事实:一个 -Xmx 改错、一个 GC 算法选错,引发的事故不亚于一行代码 bug,但传统做法是把这些参数写在启动脚本、Dockerfile、K8s YAML 的某个角落,谁都能改、没 review、没记录;把它们集中到 jvm.options 这种文件,纳入 Git,改动就要走 PR、要被 review、能回滚、能溯源——和代码同等的工程标准。把 JVM 理解成一个需要应急配置、容器配套、参数版本化、指标监控的真正生产系统,而不是一个"出事改改"的启动参数集合,你才算真正把 JVM 调优做扎实了。
关键概念速查
| 概念 | 说明 |
|---|---|
| JVM 内存区域 | 分堆 Metaspace 线程栈 堆外 代码缓存五块,各自独立,-Xmx 只控堆 |
| OOM 类型 | 错误信息后两个词标明溢出区,heap space 才是堆 Metaspace 等都不是 |
| 分代结构 | 堆分新生代和老年代,基于"大多数对象短命"的经验规律 |
| Minor GC | 只回收新生代,通常几十毫秒,频繁但不致命 |
| Full GC | 回收整个堆,堆越大停顿越长,在线服务要尽量少触发 |
| STW Stop The World | GC 期间整个 JVM 暂停响应,直接转化成业务延迟 |
| Parallel GC | JDK 8 默认,追求吞吐,大堆下 Full GC 停顿过长 |
| G1 GC | JDK 9+ 默认,分区回收 可设停顿目标,4-32G 在线服务首选 |
| ZGC | JDK 15+ 生产可用,亚毫秒级停顿,32G+ 大堆低延迟首选 |
| HeapDump | 堆内存快照,排查 OOM 和泄漏的金标准,务必配置 OOM 时自动 dump |
避坑清单
- 不要看到 OOM 就加 -Xmx:先看错误信息后两个词,定位是哪一类 OOM。
- 不要以为 -Xmx 就是 JVM 总内存:还有 Metaspace 线程栈堆外代码缓存,加起来才是真实占用。
- 不要让 Metaspace 无上限:JDK 8+ 默认无上限,动态类生成失控会吃光物理内存,务必设 MaxMetaspaceSize。
- 不要以为堆越大越稳:堆越大 Full GC 停顿越长,16G 堆停 3-5 秒是家常便饭。
- 不要在 8G 以上堆还用 Parallel GC:换 G1 设 MaxGCPauseMillis,大堆低延迟换 ZGC。
- 不要不开 GC 日志:开启几乎零成本,不开等于闭着眼睛调,务必上线就开。
- 不要不配 HeapDumpOnOutOfMemoryError:OOM 没现场证据,事后只能猜,这条是命根。
- 不要让容器内存等于 -Xmx:留至少 25%-30% 余量给非堆部分,否则被 K8s 硬 OOMKilled。
- 不要在生产用 jmap -dump:live:会触发 Full GC,卡几秒业务直接受影响。
- 不要让 JVM 参数散落在各处:集中到 jvm.options,纳入 Git 版本化管理。
总结
回头看第一版那个"OOM 就加 -Xmx"的方案,它的失控很典型。它不在某次内存没加够,而在一个对 JVM 内存的根本误解:以为 JVM 内存就是堆、OOM 就等于堆不够、加堆只有好处没有副作用、GC 是 JVM 自己的事不用管。真相是,JVM 内存远不只是堆——还有 Metaspace、线程栈、堆外、代码缓存,任何一块溢出都报 OOM,但治法完全不同;堆这一块本身,也绝不是"越大越稳"——堆越大,Full GC 一次扫描的范围越大,停顿时长指数级增长,16G 的堆 Full GC 卡 3-5 秒,业务延迟直接雪崩;GC 算法也绝不是"不用管"——Parallel、G1、ZGC 三种,各自适配不同的堆大小和延迟需求,选错了再调参也没用;GC 日志更不是"可有可无"——不开它,你做 JVM 调优就是闭着眼睛拧旋钮。第一版把所有这些都当成了细节,只剩下"加 -Xmx"这一个万能解,于是 GC 卡顿、Metaspace 溢出、容器 OOMKilled,问题全都顺理成章。
而把 JVM 调优做对,工程量并不小。它不是"-Xmx 一调"那么简单,而是要看清 OOM 错误信息后两个词定位是哪一类溢出;要理解堆内部新生代老年代的分代结构、Minor GC 和 Full GC 的代价差异;要按业务的延迟需求选 GC 算法,在线服务用 G1、大堆低延迟用 ZGC;要开 GC 日志、用 GCViewer 这类工具看 Full GC 频率、停顿时长、吞吐率、老年代占用趋势;要配 HeapDumpOnOutOfMemoryError 留好出事现场;要在容器化部署时给非堆留足余量;还要把 JVM 参数集中到 jvm.options 纳入版本管理。一套真正稳定的 JVM 服务,是这些环节一个不少地拼起来的。
这件事其实很像一辆要长途运营的卡车出了油耗问题。第一版的做法,是"油不够烧?换个更大的油箱呗"——油箱从 200 升换到 400 升、800 升,以为这样就能跑更远。可实际呢?油箱大了,卡车更重,油耗反而更高;开了几趟还是抛锚,这次不是油不够,是机油快烧干了,跟燃油大小一点关系都没有;再过几天,刹车油也不够了,你还在想"那我再加大点油箱"——完全治错病。一个真正懂车的司机是怎么做的?第一,抛锚了先看仪表盘——是燃油灯亮、机油灯亮还是刹车灯亮,而不是不管什么灯都条件反射地加油(这就是看 OOM 错误信息定位区)。第二,知道一辆车的"油耗 = 发动机效率 × 行驶距离",光加大油箱不优化发动机,跑得越远越亏(这就是堆越大 Full GC 越久的代价)。第三,选发动机要看用途——拉货卡车选大扭矩、跑车选高转速、城市通勤选省油,而不是一种发动机包打天下(这就是 GC 算法选型)。第四,长途出行要随车带行车记录仪,出了事故才有证据,而不是事后凭嘴说(这就是 GC 日志和 HeapDump)。第五,所有这些配置——油箱大小、机油型号、刹车油类型、轮胎气压——都要写在维护手册里有记录,而不是修车工今天觉得怎么好就怎么改(这就是 jvm.options 版本化)。一辆卡车能不能可靠地长途运营,从来不是"油箱够不够大"那么简单,它是发动机选型、维护配置、监控仪表、应急预案这一整套的事。JVM 调优也是同一个道理:服务能不能稳,从来不取决于"-Xmx 给了多大",而取决于这一整套"诊断—选型—监控—应急"的工程做没做扎实。
这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地跑,堆开 2G 都用不满;测试环境压几小时,对象分配率远不及生产的真实业务;你也不会专门去翻 GC 日志,因为根本没什么 GC 可看;更不会去试"故意制造 Metaspace 溢出"这种诡异场景。你测的那一阵子恰好都很顺,你就会觉得"JVM 嘛,设个 -Xmx 就完事"。真正会把问题撑爆的,是上线后的真实环境:真实业务跑几天几个月,对象分配率高、垃圾产生率高、GC 频率慢慢从一小时一次涨到每分钟一次;真实流量里总有那么一类操作,会触发大量动态类生成或大对象分配,把 Metaspace 或新生代搞爆;真实容器化部署里,K8s 会按容器内存严格限制,一旦 JVM 非堆超额就硬 Kill,连体面退出的机会都没有。这些场景,你本地那 2G 堆、那几小时压测,一个都模拟不到。所以如果你正在维护一个跑在 JVM 上的线上服务,别等 OOM 把容器 Kill 到 K8s 红一片、别等 Full GC 把 P99 拉到 10 秒,才回头怀疑你当初那个"-Xmx 给大就行"的方案。在你设第一个 JVM 参数之前就想清楚:OOM 时怎么留证据、堆该开多大、用哪种 GC 算法、GC 日志怎么开、非堆要给容器留多少余量、参数怎么版本化——把"让 JVM 在本地跑起来"和"让它在真实业务量、真实容器约束、真实长时间运行下依然稳"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。
—— 别看了 · 2026