ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JVM调优实战:从内存结构到Full GC、垃圾回收与参数应用的完整路径

JVM调优实战:从内存结构到Full GC、垃圾回收与参数应用的完整路径

我最早系统性啃 JVM,是因为一个看起来并不复杂的服务在线上每两个小时必来一次大批量的 Full GC,接口响应时间像过山车。当时查了一堆资料,越看越乱,内存结构、GC 算法、分代模型、参数调优、堆外内存、元空间,每个概念都能单独写一本书,但真正落到“我这个进程为什么会卡顿”上,反而没人能给我一个清晰的操作路径。

后来我才明白,JVM 这块内容最大的学习门槛不是难,而是东西太散。你如果按“内存区域 -> 对象分配 -> 垃圾回收 -> 参数调优”这个顺序去学,会发现前后知识点是互相纠缠的。调优本身其实不是玄学,核心就是在回答三个问题:你的对象活多久?你的内存够不够用?你的 GC 停顿能不能被业务接受?

这篇文章我会把 JVM 从入门到调优的完整路径讲清楚,重点放在推演过程而不是死记参数。适合刚接触 JVM 的开发者,也适合那些已经写过一些 JVM 参数、但遇到线上问题依然不知道怎么下手的同学。全文偏实战,很多内容来自我自己的踩坑记录和排查体会,不保证是标准答案,但保证能给你一条清晰的思考主线。

1. 从“黑盒”到“白盒”:学 JVM 到底在学什么

1.1 先搞清楚 JVM 的“地盘”:运行时数据区

JVM 在启动的时候会从操作系统申请一块内存,然后在这个范围内规划自己的运行空间。这块空间的格局,就是你学 JVM 的第一张地图。

先说线程私有的部分:程序计数器、虚拟机栈、本地方法栈。这三个区域是每个线程一份,互不干扰,生命周期跟随线程。其中虚拟机栈,也就是俗称的“栈”,每调用一个方法就压入一个栈帧,栈帧里存了局部变量表、操作数栈、动态链接、方法出口。栈深度不够的时候会抛StackOverflowError,这不是 JVM 的锅,通常是无限递归或者方法调用层级过深。如果你给每个线程分配的栈内存过大,反过来会压缩可用线程数量,这也是一个常见的隐蔽坑——线程栈大小默认在 1MB 左右,如果你开 500 个线程,光栈就吃掉 500MB。

线程共享的部分才是重头戏:堆(Heap)和元空间(Metaspace)。

堆是对象分配的主战场,也是垃圾回收(GC)作用的重点区域。堆内部又分了新生代和老年代,新生代里再细分 Eden 区和两个 Survivor 区(通常叫 From 和 To,或者 S0 和 S1)。对象优先在 Eden 区分配,经过一轮 Minor GC 仍然存活的对象会被移动到 Survivor 区,存活年龄够了再晋升到老年代。这套分代设计的核心理由很简单:绝大多数对象朝生夕死,分代让 GC 可以区别对待,年轻对象用复制算法快速回收,老对象用标记整理或标记清除来控制碎片,整体效率远高于每次都对整个堆做一次全量回收。

元空间这个名字挺容易让人误解,它其实主要存类的元数据信息——类的结构、方法信息、字段描述、常量池等。在 JDK 8 之前这类信息放在方法区(PermGen,永久代),JDK 8 之后移到了本地内存。为什么移?因为永久代的大小很难估算,你永远不知道一个应用在运行期会加载多少个类,尤其是动态代理、反射生成类比较多的框架,一旦 PermGen 大小设置不当,很容易 OOM。挪到本地内存后,默认情况下只受物理内存限制,极大降低了这一类 OOM 的概率。

内存区域的划分不是孤立知识点,它是后面理解 GC 行为和对象分配逻辑的基础。比如你看到一个对象的 GC 日志里显示的PSYoungGen、ParOldGen,如果心里没有这张内存地图,日志就是天书。

1.2 从对象分配到内存模型:理解“谁在花钱”

有了内存地图,接下来要理解的是“钱”是怎么花出去的。这个“钱”指的就是堆内存,支出的方式就是对象的创建和晋升。

一个 Java 对象从诞生到死亡,大致经历这样的路径:new 出来的对象,如果在新生代有空间,优先分配在 Eden 区。注意这里有个预设前提——现在的商业 JVM 普遍使用 TLAB(Thread Local Allocation Buffer,线程本地分配缓冲)机制,每个线程在 Eden 区划一小块私有的缓冲区域,线程内分配对象先在自己这块缓冲区里做,避免多线程并发分配时的锁竞争。如果一个大对象在 TLAB 内分配不了,会直接尝试在 Eden 区用 CAS 方式分配,如果 Eden 区空间也不够,就会触发一次 Young GC 来腾空间。

这里有一个经常被误读的点:大对象直接进入老年代。很多教程喜欢画一条线说“大对象直接进老年代”,但准确的表达应该是——超过-XX:PretenureSizeThreshold设定的对象会直接分配到老年代。这个参数只在 Serial 和 ParNew 这两个回收器下生效,在 G1 或 Parallel Scavenge 回收器下,这个阈值并不生效。另外,在 G1 下大对象有专门的 Humongous 区域分配,超过区域大小一半的对象会被判定为巨型对象。所以“大对象直接进老年代”这句话在新生代的语境内只对特定回收器成立,你不能拿着一条通用性不强的结论去做全局推理。

对象经历过一轮 Young GC 后,如果还活着,年龄加 1,存活的就移动到 Survivor 区。Survivor 区之间会互相拷贝,年龄到达阈值(默认 15,也可以通过参数-XX:MaxTenuringThreshold修改)就晋升到老年代。这里有个动态年龄判定规则值得注意:如果 Survivor 区中相同年龄的所有对象大小总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象可以直接进入老年代,不必等到阈值。这个设计是为了避免 Survivor 区被长期存活对象占满,但有些时候会让对象比预期更早晋升,是调优中容易忽略的因素。

老年代的对象越来越多了会怎样?就会触发 Major GC 或 Full GC。老年代回收的代价远高于新生代,所以调优思路通常围绕两个方向展开:一是减少进入老年代的对象数量,二是让老年代的 GC 发生得更晚、更少。理解了对象分配和晋升模型,你看到 GC 日志才能倒推“为什么这个对象会在老年代”而不是“它本来就应该在老年代”。

2. 核心细节解析与实操要点:垃圾回收与内存管理的门道

2.1 垃圾回收判定的两种主流算法

判定谁是垃圾,是回收的前提。主流方案有两种:引用计数法和可达性分析算法。

引用计数法思路简单,给每个对象维护一个计数器,被引用加 1,引用失效减 1,到 0 就是垃圾。但一个致命的缺陷是循环引用无法解决——A 引用 B,B 引用 A,外部没有入口,计数器永远不为 0,这两个对象就永远回收不掉。所以现在主流的 JVM 都使用可达性分析算法:从 GCRoots(根对象集合)出发做引用链遍历,凡是不可达的对象视为垃圾。

这里“GCRoots 是什么”很关键。通俗地说,就是那些“不经过任何对象引用也能直接访问到”的起点,包括虚拟机栈中引用的对象、静态变量引用的对象、常量池引用的对象、JNI 指针引用的对象、被同步监视器锁定的对象等。很多线上问题排查到最后,发现某个对象无法被 GC,原因就是被某个全局静态集合长期持有,这个是可达性分析在实践中的经典案例。

顺带提一句,System.gc()触发的不一定是 Full GC,具体行为取决于-XX:+DisableExplicitGC参数和回收器特性。很多框架(比如某些 RPC 框架)在特别注重响应延迟的场景下会主动加上-XX:+DisableExplicitGC,防的就是业务代码里调用了System.gc()导致无谓的全停顿。这也是你在线上排查 GC 频繁问题时,第一个要排除的干扰项——找一下代码里有没有显式调用 GC。

2.2 主流垃圾回收器的适用场景与选型

选回收器之前,先明确一个基本事实:没有最好的回收器,只有最匹配你业务场景的回收器。

串行回收器(Serial)单线程回收,适合客户端、桌面应用和极小堆内存场景,因为停顿时间长但实现简单,不会带来 CPU 竞争。

并行回收器(Parallel Scavenge + Parallel Old)关注吞吐量,适合后台计算型任务——批处理任务、日志分析任务、能容忍垃圾回收时短暂停顿的业务。比如一个大数据的离线计算任务,跑 10 分钟,中间停顿 10 秒完全可以接受,那就选并行回收器,把资源最大化用于计算而不是回收。

CMS(Concurrent Mark Sweep)曾一度是互联网应用的主流选择,设计目标是最小化停顿时间,用“并发标记、并发清理”来降低 STW 时间。但 CMS 有三大硬伤:一是对 CPU 资源敏感,并发阶段会占用 CPU 和业务线程抢资源;二是浮动垃圾问题——并发清理时业务线程还在继续产生垃圾,需要在回收完成后递延处理;三是老年代碎片化问题,标记-清除算法不整理空间,长期运行后可能出现“空间还够但找不到连续空间分配”的尴尬局面,最终退化为 Serial Old。CMS 在老版本 JDK 8 中还能见到,JDK 14 之后官方已经移除了 CMS 支持。

G1(Garbage First)是 JDK 9 之后默认的回收器,设计模型和前几个完全不同。它把堆拆成一系列大小相等的 Region,逻辑上分代但物理上不再严格连续。G1 的回收过程是把部分 Region 回收掉来腾出空间,不再像以前那样对整代做回收,它通过维护一个优先级列表,优先回收价值最大(垃圾最多、回收成本最低)的 Region,因此得名 Garbage First。G1 引入了 Remembered Set 来处理跨区域引用,有点空间换时间的意思,这也是 G1 内存开销略高的原因。

JDK 11 引入 ZGC,JDK 15 引入 Shenandoah,它们把停顿时间推到了几毫秒甚至亚毫秒的级别。ZGC 的着色指针和读屏障设计非常精妙,它是通过一次性把整个堆空间的引用映射到多个视图,利用指针的额外位来记录对象状态,在并发阶段对引用做图变换。ZGC 支持 TB 级堆,停顿时间不随堆大小增长,但代价是 CPU 占用和内存开销较高。如果你的业务要求极高响应、低延迟,且服务器 CPU 核心够多,ZGC 是非常值得尝试的方向。虚拟线程普及之后,大量短生命周期对象会显著增加 GC 压力,ZGC 这类低停顿回收器的价值会更突出。

2.3 调优不止是加大内存

这是一条必须反复强调的经验:遇到 OOM 或 GC 频繁,第一反应不应该是加内存,而是先分析内存都去哪了。

加内存这个操作确实能让 Full GC 频次降低,因为老年代空间大了,触发 GC 的阈值会延迟。但你同时扩大了 GC 的回收范围,单次 Full GC 的停顿时间反而会被拉长,对延迟敏感的接口来说,这是火上浇油。更关键的是,加内存掩盖了根因。比如你用了一个 HashMap 做缓存,没有设置过期策略,数据持续堆积,你加到 8G,只是把爆发时间推迟到三个月后。到时候再排查,数据量更大,现场更难复原。

调优的正确副产品是让你发现业务代码层面的问题。比如我发现很多 OOM 的根因是:单个对象异常巨大(比如一次查了全表)、一次性加载了海量数据到内存、线程池未设置合理队列上限、使用了 ByteBuffer 申请堆外内存未释放。这些问题不是 JVM 参数能解决的,是你必须从业务侧下手。

参数层面的调优,核心目标是让对象都在新生代内完成生命周期,让老年代足够稳定。那就要看新生代到底放得下多少对象、Survivor 区能不能承接住存活对象、晋升阈值是否匹配业务对象存活特征。每次调参,你都要能找到逻辑支撑,而不是今天看一个博客说加大新生代就加大新生代,明天看另一个说调大晋升阈值就调大晋升阈值。参数背后一定有代价,比如新生代变大,老年代相对变小,Full GC 频率可能上升;晋升阈值变大,Survivor 区可能溢出导致对象提前晋升。

3. 实操过程与调优落地:从启动日志到参数矩阵

3.1 先摸清家底:常用诊断命令与可视化工具

调优第一步不是改参数,而是摸清楚应用当前的运行状态。这一步像医生问诊,只有先问清楚病情,才能开药。

推荐几条最常用的命令路径。

jps查看当前机器上的 Java 进程列表,拿到 PID。jinfo <pid>查看进程当前的 JVM 参数,确认生效的参数和启动命令里配置的做对比,很多时候你会发现某些参数没生效(比如写进了代码但启动脚本没同步)。jstat -gc <pid> 1000每秒输出一次 GC 统计信息,重点看FGC(Full GC 次数)、FGCT(Full GC 总耗时)、YGC(Young GC 次数)和堆各区域的使用率。jmap -heap <pid>看一下堆内各区域的实际用量。jmap -dump:live,format=b,file=heap.hprof <pid>导堆快照,线下分析用。

然后是jstack <pid>,看线程栈,排查死锁和线程阻塞的现场非常有用。jcmd <pid> GC.heap_info是 JDK 8 之后比较推荐的命令,可以替代一部分jmap -heap的用途,且更安全。

可视化工具方面,JDK 自带的jvisualvm和jconsole在本地开发调试够用了,但线上通常不具备 GUI 访问条件,建议学会用命令行和离线分析工具。离线分析堆快照,用 Eclipse MAT 或者 JProfiler 都可以。MAT 的Dominator Tree视图能直观看出“哪个对象支配了多少内存”——这是寻找内存泄漏入口最重要的一张表。GC Roots查询功能可以告诉你一个对象为什么没有被回收,定位到某一根引用链。

如果你可以安装中间件或工具包,Arthas 值得尝试。它最大的价值是可以在线 dashboard 看内存和线程状态,还能用trace命令动态追踪方法调用耗时和对象分配热点,很多 JVM 运行时问题用它可以快速定位到具体业务代码行,而不只是停留在“JVM 层面有问题”这种模糊结论。我在排查一个 CPU 使用率忽高忽低的问题时,就是用 Arthas 的trace命令发现某个第三方 SDK 内部在频繁做反射调用,继而定位到是对象池参数配置不当导致的。

3.2 算清楚你的内存账本:堆大小的计算思路

确定堆大小不是拍脑袋,而是基于“业务负载 + 对象特征 + 预留余量”三条线的综合估算。

先说业务负载。假设你的服务每秒处理 500 个请求,每个请求短期内大约产生 2MB 的临时对象,存活周期在 100 毫秒以内。那么在 1 秒的时间片内,新生代至少要有能力容纳 500 * 2MB = 1000MB 的瞬时对象。按 Eden 区与 Survivor 区比例 8:1:1 来算,新生代总大小 1250MB 左右才能比较宽裕地完成一次新生代内的对象分配和回收。当然这个计算很粗糙,因为对象产生的速率不是均匀的、部分对象会在 Survivor 区周转、TLAB 也有内部浪费,但作为第一版估算模型,它能让你对“新生代到底需要多大”有一个合理的下限判断。

再看对象特征。如果你的服务里有一批“慢对象”——比如长连接会话对象、配置缓存对象、定期更新一次的全局数据对象,它们会稳定进入老年代。你统计一个 Full GC 周期内老年代的增长速率,乘以你期望的 Full GC 间隔,就能估算老年代需要多大。比如老年代每天增长 4GB,你希望 Full GC 至少 3 天发生一次,那么老年代至少要 12GB,再加上 30% 的冗余,就是 16GB 左右。

然后是预留。堆内存之外还有元空间、线程栈、堆外缓冲(如 Direct Buffer)、Socket 缓冲区,这些不占用堆但占用进程内存。你别把容器内存全部塞给堆,至少要留出 20%~30% 给非堆内存和操作系统缓存。我有一个很痛的教训:早期我部署应用时把堆设为容器内存的 80%,结果频繁掉进 Swap 交换,GC 时间突然飙升到秒级。后来查证发现机器上的其他进程(比如监控 Agent 和日志采集)也在占用内存,再加上堆外分配,物理内存已经吃紧。之后我养成了习惯:容器内存 8G,堆设 4.5G,剩下 3.5G 留给 JVM 自己吃。

如果一个 JVM 进程承载的业务类型差异很大(比如同时处理异步消息和同步 API 请求),那么在评估堆时要把两边的流量叠加在一起,别只看综合指标,否则流量高峰时会出现意外。

3.3 制定调优方案:从 GC 日志到参数矩阵

GC 日志是调优最重要的“病例记录”。没有日志,一切分析都是猜测。所以启动参数里必须加日志输出配置。

以 JDK 8 为例,常用的一组参数是:

-verbose:gc -Xloggc:/data/logs/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime

JDK 9+ 之后日志参数统一为-Xlog,例如:

-Xlog:gc*=info:file=/data/logs/gc-%t.log:time,uptime,level,tags

日志是事后分析的基石。你会从中看到每次 GC 前后的堆占用、各个分代的变化、停顿时间。多采集几轮,你就能摸出一个规律:新生代每次 GC 前占用率是多少、存活对象多少、晋升对象多大、老年代以什么速度增长。把几组关键数据填到表格里,调优方案自然浮现。

指标含义调优相关性
YGC 频率与耗时新生代 GC 次数和平均停顿时长频率过高说明新生代太小或对象分配过多
FGC 频率与耗时Full GC 次数和总耗时频率上升说明老年代或元空间压力过大
晋升平均大小每次 Young GC 晋升到老年代的对象总量数值过大容易触发 Full GC
Survivor 区占用GC 后 Survivor 区实际使用量占用率接近 100% 可能导致对象提前晋升
老年代增长速率两次 Full GC 之间老年代的使用增量用于估算老年代大小是否合理

调参不是一步到位,我习惯每次只改一个变量,观察两到三个完整业务周期的 GC 日志再决定下一步。一批常用的调优参数组合:

-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio=8 -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+ParallelRefProcEnabled -XX:G1HeapRegionSize=4m

这里有个取舍原则:-Xms和-Xmx设置一致,避免 JVM 运行期动态扩缩容带来的性能损耗。-Xmn则要根据你前面算出来的新生代需求来设。启动时直接把堆预分配到位,减少扩容 GC。

G1 的核心参数是-XX:MaxGCPauseMillis,它是软目标不是硬性指标,G1 的IHOP(Initiating Heap Occupancy Percent)决定并发标记周期启动时机,默认值是 45,意味着堆使用率达到 45% 时开始并发标记。你调整参数时不要只看停顿目标,还要配合老年代的增长速率。如果老年代增长很快,45% 的阈值启动标记可能太迟,可以适度调低到 35% 左右,让 G1 更早介入。但调低也意味着并发标记周期更频繁,整体回收开销增大,这个平衡只能靠实际 GC 日志来验证。

JDK 17 和更高版本,G1 和 ZGC 都是可以组合的策略选项,虚拟线程(Project Loom)正式化之后,超大量轻量线程带来的分配压力也在影响 GC 参数选型。技术在往前走,但调优的核心方法论没变:日志驱动、量化分析、小步验证。

4. 常见问题与排查技巧实录

4.1 典型 OOM 场景及排查路径

OOM 不是故障,而是 JVM 在向外界呼救。不同区域的 OOM 有不同的排查方向。

堆内存 OOM 最常见,报错通常是java.lang.OutOfMemoryError: Java heap space。第一步用jmap导快照,装 MAT 或 JProfiler 分析。在 MAT 里重点看Dominator Tree和Leak Suspects报告,前者告诉你谁占内存最大,后者直接帮你圈出可疑的泄漏入口。我排查过一个内存泄漏案例,Leak Suspects直接指出某个静态 HashMap 一直膨胀,顺着引用链发现是某个业务模块把每次请求的历史对象都塞进了一个“待通知列表”,因为异常分支没有清理,数据越积越多。修复其实非常简单——在 finally 块中移除引用,但定位过程花了大半天,原因是没有第一时间导堆快照,前面净靠瞎猜了。

java.lang.OutOfMemoryError: Metaspace则是元空间问题,常见于使用动态代理、CGLIB 增强类、热部署框架和脚本引擎时。排查路径侧重查看加载的类数量和类加载器,jstat -pid的MCMC和MCMU列可以看元空间大小。如果是大量重复生成类,重点检查代码里是否有循环创建代理类的逻辑;如果是热部署,检查类加载器是否泄漏。有一类隐蔽场景是 Groovy 脚本引擎每次执行都编译新的脚本类,元空间消耗快且回收不了,最终 OOM。这种情况需要从使用方式上改,而不是调大 MetaspaceSize。

Direct buffer memory的 OOM 也不少见,根因通常是堆外内存未释放。排查时建议打开-XX:MaxDirectMemorySize并限制堆外内存,同时用jcmd查看直接缓冲区的使用量。我之前查过 linux 上 NIO 相关 OOM,发现 ByteBuffer 的明确释放依赖显式clear()或将引用置空,否则依赖于 GC 回收对象时关联的 cleaner 机制,不可控。

4.2 高 CPU 和频繁 Full GC 的排查

GC 导致的 CPU 高特别容易误判——你看到 CPU 100%,以为业务代码有死循环,实际上就是 Full GC 在打转。

排查路径第一步用top找高 CPU 的 Java 进程 PID,然后用top -Hp <pid>找出进程内的高 CPU 线程号,再转成十六进制,用jstack <pid> | grep -A 20 <nid>匹配到线程栈,看这个线程在干什么。如果栈顶是GCTaskThread或VMThread之类,就基本确定是 GC 线程在疯狂工作。用jstat -gcutil <pid> 2000看 O(老年代)的使用率的变化速度,几分钟内从 30% 涨到 95%,那必然有异常流量在快速填充老年代,或者 GC 本身出现了问题。

一个我印象深刻的案例:某服务老年代隔几分钟就从 40% 涨到 90%,Full GC 后回落到 5%,如此往复。从业务侧看,每次 Full GC 的回收到 5% 说明前一次的老年代垃圾确实被清掉了,说明不是泄漏,而是一个周期性的大对象分配在塞满老年代。用jmap -dump抓一个 Full GC 前的堆快照,发现有个byte[][]占了 70% 老年代,顺着调用链定位到是某个定时任务每 10 分钟加载一次全量数据做比对,直接 new 了 2GB 的数组。稍微改下数据结构,用分段读取替换全量内存加载后,问题彻底消失。这个案例提醒我:频繁 Full GC 的问题,根因往往不在 GC,而在某个业务环节的“内存吞吐”设计。

还有一种低延迟场景特有的大坑是G1的ConcurrentMarking线程在并发标记时触发高 CPU,这不是故障,是 G1 的正常工作周期。如果并发标记阶段 CPU 飙升频繁,你要检查堆使用率阈值和对象产生速度,别把正常现象当异常处理。

4.3 我的几条避坑经验

  • 不要把系统内存全部交给堆。JVM 本身、线程栈、元空间、Direct Buffer、日志缓冲、监控 Agent 全都要内存,堆设置过高会直接挤占系统页面缓存和进程内存,GC 反而更慢。
  • 别用-Xmx无限加大。内存翻倍不一定能解决 OOM,可能只拖延了爆发时间,还带走了更长的 STW。
  • 打开 GC 日志是底线。没有日志就没有任何可以量化的依据。很多生产事故事后复盘,一大半时间都浪费在“重新模拟现状”上。
  • 一个进程不要承载超过一种跨度极大的业务。如果这个进程同时处理大量在线请求和离线批处理,堆内存分配会非常撕裂,GC 特征混乱,很难调优。
  • 关注元空间的回收行为。元空间扩容是均摊式的,频繁加载类时它可能反复扩容收缩,带来 JVM 层面无谓的停顿。必要的时候设一个偏大的-XX:MetaspaceSize初始值,能减少运行期的元空间扩容开销。
  • 实验环境尽量保持跟生产一致。JDK 版本、容器内存限制、CPU 配额这些都会影响 GC 的决策,你在本地 JDK 17 调出来的参数,拿到生产 JDK 8 裸奔,等于全部作废。

在真实排查中我发现过一些“现象很有迷惑性”的情况:jmap -heap显示堆占用只有 60%,但进程内存已经接近容器上限,这时候问题往往在堆外内存。排查方法是用jcmd <pid> VM.native_memory查看 JVM 原生内存的细分,结合-XX:NativeMemoryTracking=summary启动参数记录。早些年我踩过这个坑,一个用 Netty 的网关服务,堆内一切正常,结果进程反复 OOM,查到最后是ByteBuffer.allocateDirect频繁分配堆外内存且大量未释放,加上每连接缓冲区的默认大小过大,直接把业务进程的内存预算打爆了。

还有一次被重复加载的类坑了:同一个类被多个类加载器加载了几十份,元空间飙到 2GB。定位起来非常费劲,最后是线程栈里发现多个不同加载器创建的同名类实例,根源是应用容器配置了热部署且每次部署创建新的类加载器,老类加载器没有被卸载。这类问题在排查时几乎不会第一时间想到,如果你遇到元空间异常膨胀又找不到业务侧的类生成点,优先检查热部署的类加载器引用链。

最后说一点个人体会

我常说 JVM 调优是“七分监控、三分调整”。真正让线上服务稳定下来的,往往不是某个精妙参数,而是你把 GC 日志、内存指标、业务流量关联起来的那套分析习惯。每次调优之后,请把当时的 GC 日志、参数、业务场景记录下来,因为隔一段时间再回头看相同现象,你很可能会有新的理解。

如果你刚开始接触 JVM,先把运行时数据区、对象分配晋升流程、GC 日志里的字段含义吃透,再考虑去调-XX参数。没有地图就去分析跑偏的方向,只会越跑越远。按“地图 -> 分配流程 -> GC 行为 -> 日志分析 -> 参数验证”这条链走一遍,你也能建立属于自己的 JVM 问题排查体系。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进