JVM 内存调优完全指南:从一次"OOM 就加 -Xmx 越调越卡"看懂为什么堆大小不是越大越好

2023 年我维护一个 Java 数据处理服务上线一年都跑得稳稳的某次业务量翻倍后开始频繁 OOM 我的第一反应跟很多人一样加内存把 -Xmx 从 4G 调到 8G 再调到 16G 我心里很笃定堆越大能装的对象越多 OOM 自然就解决了可等我真把内存加上去一串麻烦冒了出来第一种最先把我打懵内存加大之后服务确实不 OOM 了可接口延迟从 100 毫秒涨到了 800 毫秒 GC 日志里满是 Full GC 单次 5 到 6 秒整个服务卡得跟死了一样第二种最难缠我把容器内存从 4G 加到 16G 以为有了更大的腾挪空间结果 GC 反而更频繁了 young GC 几秒一次第三种最离谱我又把 -Xmx 加到 32G 监控里堆只占了一半服务还是挂了 OOM 异常里写的是 Metaspace 不是 heap space第四种最莫名其妙同一份代码本地压测怎么都不复现生产环境一上线半小时就 OOM 我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 JVM 内存就是堆调优就是把堆调大可这个认知是错的本文从头梳理 JVM 内存到底分几块 OOM 有哪几种不同根因堆为什么不是越大越好 Parallel CMS G1 ZGC 该怎么选 GC 日志怎么读出真相以及一些把 JVM 调优做扎实要避开的工程坑

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

避坑清单

  1. 不要看到 OOM 就加 -Xmx:先看错误信息后两个词,定位是哪一类 OOM。
  2. 不要以为 -Xmx 就是 JVM 总内存:还有 Metaspace 线程栈堆外代码缓存,加起来才是真实占用。
  3. 不要让 Metaspace 无上限:JDK 8+ 默认无上限,动态类生成失控会吃光物理内存,务必设 MaxMetaspaceSize。
  4. 不要以为堆越大越稳:堆越大 Full GC 停顿越长,16G 堆停 3-5 秒是家常便饭。
  5. 不要在 8G 以上堆还用 Parallel GC:换 G1 设 MaxGCPauseMillis,大堆低延迟换 ZGC。
  6. 不要不开 GC 日志:开启几乎零成本,不开等于闭着眼睛调,务必上线就开。
  7. 不要不配 HeapDumpOnOutOfMemoryError:OOM 没现场证据,事后只能猜,这条是命根。
  8. 不要让容器内存等于 -Xmx:留至少 25%-30% 余量给非堆部分,否则被 K8s 硬 OOMKilled。
  9. 不要在生产用 jmap -dump:live:会触发 Full GC,卡几秒业务直接受影响。
  10. 不要让 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

RAG 检索增强生成完全指南:从一次"知识库问答系统答非所问还编造"看懂为什么 RAG 不是切块加搜索

2026-5-24 13:22:49

技术教程

LLM 应用工程化完全指南:从一次"内部工具被运营用一周烧掉几千块"看懂为什么不是调 API 就完了

2026-5-24 13:39:32

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索