先说点实在的:很多学JVM的人卡在“听懂了原理,一到调优就懵”。原因很简单,原理讲的是“JVM怎么设计”,调优讲的是“你的应用到底在什么场景下怎么死”。这两者之间隔着一层东西,就是GC参数和运行时数据区里那些容易被人忽略的区域。这篇文章把这块补上,重点聊GC常用参数、Method Area(方法区)的实际行为,以及一个完整的调优案例怎么一步步拆出来。
1. GC参数:别背参数列表,先搞清三类问题
网上各种GC参数大全一搜一大把,但说实话,真到排查问题的时候,你根本用不上那么多。我自己的经验是,把GC参数按用途分成三类记,思路会清晰很多:一类是设置堆和分代大小的,一类是选择GC算法和触发时机的,一类是输出GC日志用来排查的。这三类各司其职,混在一起记只会越记越乱。
1.1 堆大小参数:为什么-Xms和-Xmx要设成一样
先看最基础的一组:
-Xms:初始堆大小 -Xmx:最大堆大小 -XX:NewSize:新生代初始大小 -XX:MaxNewSize:新生代最大大小 -XX:NewRatio:老年代与新生代的比值,默认2,即老年代:新生代 = 2:1 -XX:SurvivorRatio:Eden区与一个Survivor区的比值,默认8,即Eden:Survivor = 8:1很多新手上来就问“-Xms和-Xmx到底要不要设成一样”,我刚入行时也觉得设成一样浪费,后来在某个项目里吃了亏才明白。那个项目-Xms设了512M,-Xmx设了4G,看起来“给JVM留了弹性空间”,结果是每次GC完堆缩回去,过一会儿又要扩,频繁触发堆扩容和收缩。堆扩容是要向操作系统申请内存的,这个过程涉及系统调用,代价一点不低。更麻烦的是,堆扩缩会导致GC时的停顿时间忽高忽低,用监控工具看就是一条锯齿线,你根本没法判断这个JVM到底稳不稳定。
后来我把两个值设成一样,GC停顿立刻平稳了。道理其实很朴素:堆大小确定后,JVM就不用反复跟操作系统讨内存,GC的行为会更可预测。生产环境没有特殊理由,一律-Xms等于-Xmx,这是很多大厂内部规范里明文要求的一条。
1.2 日志参数的组合用法:线上排查全靠它
日志参数是排查问题的眼睛,少配一个都可能让你事后抓瞎。我常用的组合是这样的:
-Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=50m这行命令在JDK 11+里是标准写法。老一点的JDK 8则用:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xloggc:/data/logs/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=50M其中%t会在日志文件名里带上JVM启动时间,避免多个应用实例日志互相覆盖。filecount=10和filesize=50m控制日志轮转,防止磁盘被GC日志撑爆。这一套配下来,即使线上出了问题,也能靠日志回放当时的GC节奏,而不是靠猜。
我自己踩过的坑是:只配了-Xloggc忘了配轮转,结果某个高并发服务运行两个星期后,GC日志文件涨到了20多G,把磁盘直接写满。那次事故之后我养成了一个习惯:任何生产环境JVM,必须同时确认GC日志路径和日志轮转策略,缺一不可。
2. GC算法参数选型:从Serial到ZGC,每个选择都有代价
GC算法的选型不是越新越好,也不是“默认就是最好”,而是要看你的应用场景到底能不能承受某种停顿。
| 垃圾收集器 | 适用场景 | 关键参数 | 典型停顿 |
|---|---|---|---|
| Serial GC | 单核小应用、客户端程序 | -XX:+UseSerialGC | 秒级甚至更高 |
| Parallel GC | 吞吐量优先、后台任务 | -XX:+UseParallelGC | 秒级,但吞吐高 |
| CMS | 低延迟、互联网Web应用 | -XX:+UseConcMarkSweepGC | 平均几十毫秒到百毫秒级 |
| G1 | 大堆、低延迟与吞吐兼顾 | -XX:+UseG1GC | 目标可配置,一般几十毫秒 |
| ZGC | 超大堆、极低延迟 | -XX:+UseZGC | 平均低于1ms |
2.1 Parallel GC的典型配置与适用场景
Parallel GC的核心思路是“多线程并行回收,换高吞吐”。它适合那种不在乎单次停顿时间,只在乎单位时间里能干多少活的场景,典型就是离线计算、批量任务、大数据的MapReduce作业。
一个典型的配置:
-Xms8g -Xmx8g -XX:+UseParallelGC -XX:ParallelGCThreads=8 -XX:SurvivorRatio=8ParallelGCThreads在CPU核数为N时默认值是:当N小于等于8时等于N,大于8时等于3 + (5 * N / 8)。这个公式是有讲究的,因为GC线程太多反而会因为线程切换和争抢导致效率下降。如果你的机器是16核,默认值大概是13,但如果你的业务线程本身就吃满CPU,那13个GC线程会让业务线程卡得很难受,这时候手动压到8可能是更合理的选择。
2.2 G1的内存模型差异:为什么它的停顿可控
G1和传统的分代收集器有一个本质区别:它不再要求物理上的连续Eden、Survivor、老年代区域,而是把整个堆划分为若干大小相等的Region。每个Region在逻辑上可以扮演Eden、Survivor或Old区,这一设计让G1可以用“增量回收”的方式控制停顿。
G1的核心参数:
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:G1HeapRegionSize=4m -XX:InitiatingHeapOccupancyPercent=45MaxGCPauseMillis只是目标值,不是硬保证。G1会通过调整回收的Region数量来尽量把停顿压到目标值以内,但如果你把目标定得太低,G1回收速度跟不上分配速度,反而会导致FULL GC更频繁。InitiatingHeapOccupancyPercent=45表示老年代占用达到堆的45%时启动并发标记周期。这个值不是越大越好,调大了可以减少标记频率,但调太高会让并发标记启动过晚,最终老年代堆积到不得不FULL GC。
2.3 ZGC:低延迟但要付出的代价
ZGC把STW时间压缩到了1ms以内,原理是染色指针+读屏障,GC过程中大部分工作与应用线程并发执行。听起来很完美,但代价是CPU占用率明显上升,因为读屏障在每个对象访问路径上都加了一层判断。
如果你的应用是那种对延迟极其敏感、堆又很大的在线服务(比如某些交易系统),ZGC值得试。但如果你的机器CPU资源已经吃紧,ZGC的额外开销可能会让你得不偿失。选型时永远记住:没有最好的收集器,只有最适合当前场景的收集器。
3. Method Area:最容易产生误解的一段内存
方法区这个名字,从《Java虚拟机规范》的角度和从HotSpot实现的角度,理解上是两回事。不少人把这两者混为一谈,排查问题时就容易走弯路。
3.1 规范里的方法区 vs HotSpot的元空间
《Java虚拟机规范》里规定JVM要有方法区,它存放类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。但规范没规定方法区具体怎么实现。
HotSpot对方法区的实现经历过几个阶段:
| JDK版本 | 实现方式 | 大小控制参数 |
|---|---|---|
| JDK 6及以前 | 永久代(PermGen) | -XX:PermSize/-XX:MaxPermSize |
| JDK 7 | 永久代,但逐步移除字符串常量池 | 同上 |
| JDK 8+ | 元空间(Metaspace) | -XX:MetaspaceSize/-XX:MaxMetaspaceSize |
JDK 8把永久代彻底移除,改用元空间,最核心的变化是元空间使用本地内存,不再占用JVM堆内存。也就是说,类的元数据不再受堆大小限制,而是受操作系统可用内存限制。这个改动解决了一个长期痛点:以前永久代不够用会抛OutOfMemoryError: PermGen space,现在只要物理内存够,类可以随便加载。
3.2 方法区溢出怎么排查:两个真实场景
场景一:某个系统频繁动态生成代理类。有一次某开发者遇到OutOfMemoryError: Metaspace,第一反应是堆不够,于是不停调大-Xmx,结果毫无缓解。后来看了日志才发现,是代码里有个循环在用Proxy.newProxyInstance创建代理类,而且类加载器没有复用,导致每次迭代都生成新的类元数据进元空间。解决方式是改用缓存的类加载器,并在代码级别复用代理类。
场景二:某个Web应用不断热部署,每次重新部署WAR都会创建新的类加载器,旧的类加载器没有被回收。理论上类加载器不可达后,它加载的类元数据也应该从元空间释放,但实际中如果存在ThreadLocal、静态集合等隐式引用,旧的类加载器永远不会被回收,元空间就持续上涨。这种情况下-XX:MaxMetaspaceSize只能当兜底,真正要解决的是代码里对类加载器的引用泄漏。
3.3 这个案例能学到什么
方法区溢出很少是单纯的“内存不够”,绝大多数是类加载器泄漏或动态生成类失控。所以排查方法区问题时,第一件事不是调参数,而是用jmap -clstats <pid>看类加载器数量和每个加载器加载的类数量,找到异常的类加载器,再顺着代码找是谁在创建它、谁持有它。
4. 一个真实调优案例:从日均多次FullGC到稳定运行
前面讲了不少参数,但参数只有落到实际问题上才有意义。下面分享一个我参与过的真实案例,完整还原排查和调优过程,虽然经过了脱敏处理,但思路可以原样复用。
4.1 现象:接口偶尔超时,监控显示老年代持续上涨
某业务系统,高峰期QPS在两千左右,主要接口的P99延迟时有恶化,监控平台显示老年代使用量持续上涨,每天会出现多次Full GC,每次Full GC停顿最长达数秒。用户反馈是“系统偶尔卡一下,过几秒自己恢复”。这其实就是典型的老年代持续增长导致Full GC频繁的形态。
当时服务的启动参数是这样的:
-Xms4g -Xmx4g -XX:+UseConcMarkSweepGC -XX:NewRatio=2 -XX:SurvivorRatio=8表面看没什么问题:堆4G,老年代约2.7G,新生代约1.3G,CMS收集器,适合低延迟场景。
4.2 排查链路:先看日志,再看对象,最后定位代码
第一步,看GC日志。日志里能看到老年代从2G左右开始,随着运行时间线性增长,直到触发CMS并发标记,但并发标记后老年代没有明显下降。这个信号说明:有大量对象在老年代不断累积,而且这些对象多数是活着的,否则CMS并发回收后老年代使用量应该回落。
第二步,用jmap -histo:live <pid>看存活对象分布。结果Top对象里有大量byte[]和某个内部缓存类的实例。byte[]在Java内存分析里是最常见的“背锅侠”,因为它确实可能是业务数据,也可能是序列化缓存,也可能是日志框架的buffer,必须结合业务代码判断。
第三步,用jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>导出堆快照,用MAT分析。这里有个关键细节:-dump:live会先触发一次Full GC,所以导出来的堆里只剩下存活对象,这正好符合我们需要分析“谁一直活着占据老年代”的需求。
MAT分析结果指向了一个静态Map:这个Map是一个全局缓存,key是用户ID,value是最近一个月该用户的订单列表快照。代码逻辑是每次查询订单时先查缓存,缓存没有再去数据库查并放入Map。看起来是标准缓存方案,问题在于:这个Map没有过期时间和容量上限,而缓存的值是“某个用户一个月的全部订单”,包含完整的商品明细、地址、发票信息,一条记录可能几十KB。一个月活跃用户往上积累,这个Map就以每天几百MB的速度增长。
4.3 修复方案:缓存加容量限制和时间过期
修复不复杂,但需要对业务有一定理解。最终方案是三管齐下:
- 引入Caffeine本地缓存,配置最大条目数5000,写入后30分钟过期;
- 针对大对象字段做裁剪,列表接口只返回概要字段,明细字段按需单独查询;
- 调整GC参数,老年代与新生代比例从
NewRatio=2调整为NewRatio=3。
第三步很多人不理解,我解释一下。原来的场景里新生代偏大,对象在新生代里活了比较久才进入老年代,但问题恰恰是很多长生命周期对象被当成了短生命周期对象。调整为NewRatio=3后,新生代变小,短期对象更快被回收,长生命周期对象更快进入老年代,由CMS统一管理,反而减少了新生代到老年代的晋升波动。当然,这一步只起辅助作用,真正的收益来自前两步。
4.4 调优结果与复盘
调整后观察三天,Full GC从每天多次降为零,老年代使用量稳定在1G以内,P99延迟从偶尔数秒恢复到几十毫秒。
复盘时几个关键认知值得记住:
- 监控图上老年代持续上涨,第一反应应该是“有对象泄漏或缓存失控”,而不是“堆不够大”。堆调大只是推迟问题爆发,不是解决问题。
byte[]占据堆内存Top不代表问题出在byte[]本身,要顺着引用链找到是谁创建了它。- 调优参数只是手术刀,真正病灶往往在业务代码里。参数调得再漂亮,缓存无限增长的问题依旧存在。
5. 调优路上的几个共识与反共识
做了一段时间JVM调优,我自己的体会是:有些道理看似是共识,实际换到不同场景就要重新审视。挑了三个最容易让人钻牛角尖的点聊聊。
5.1 “FULL GC一定有害”是个误解
Full GC停顿时间长,没人喜欢,但它不完全是坏事。CMS的Remark阶段和G1的并发标记周期都需要特定触发条件,Full GC往往是在这些机制来不及回收时的兜底手段。如果你的系统偶尔一次Full GC后明显释放了大量内存,而且之后很长一段时间不再出现,那么这次Full GC更像是一次“成功的大扫除”,不一定是坏事。
真正要警惕的是两种形态:Full GC频繁,比如一分钟一次;或者Full GC后老年代使用量不下降,说明有大量对象是“死不了、又活不长”的,这种对象最折磨GC。
5.2 调优参数不是越多越好
见过不少团队,启动参数拉了几十行,从-XX:+PrintGCDetails到-XX:MaxGCPauseMillis到-XX:+HeapDumpOnOutOfMemoryError,看起来专业,但其中一半是互相冲突的。比如同时开CMS和G1的参数,JVM只会认最后一个;再比如-XX:MaxGCPauseMillis调得很低,但堆开得特别大,那G1只能频繁启动并发标记周期,CPU狂飙,停顿反而没降下来。
我的建议是:先保证基础参数正确,再针对具体问题加参数。基础参数就是我前面提到的堆大小、GC算法、日志输出,这三样配好,已经能覆盖80%的日常需求。等真遇到问题,再根据监控数据针对性新增参数,每次只改一个,观察效果后决定是否继续调。
5.3 监控优先于调优
很多人在调优前根本没建立监控,这是本末倒置。没有监控数据,你不知道老年代是怎么涨的、对象是怎么分配的、停顿是什么时候发生的,调优就只能靠猜。
一套最轻量但足够用的监控方案是:GC日志做轮转落盘,定期抓取堆使用量和GC次数汇总,加上jstat -gcutil <pid> 1000观察实时变化。这三样配合起来,已经足够定位绝大多数JVM问题。先把监控搭起来,再谈调优,顺序不能反。
6. 调优案例的另一种走向:内存泄漏的定位思路
上一节的案例是缓存失控,属于“对象本不该长期存活却因为被引用一直活着”,严格说属于逻辑层面的内存增长。但还有一种更隐蔽的情况是真正的内存泄漏,对象已经没有任何业务意义,却被某个静态引用链死死拽住。这种问题用同样的排查链路,定位方法稍有不同。
6.1 如何确认是泄漏还是增长
判断泄漏还是增长,看GC后老年代趋势:如果每次Full GC后老年代使用量都能降到一个稳定低点,那就是普通增长;如果Full GC后使用量虽然下降,但下一次增长到更高水平再触发Full GC,低点也在缓慢抬高,那基本就是泄漏。
这个区别决定了调优方向:普通增长优先查缓存策略和对象生命周期设计;泄漏优先查引用链和类加载器。
6.2 常用工具链
jstat:实时看GC状态,快速判断当前JVM是否健康。jmap -dump:导出堆快照,配合MAT分析引用链。jcmd:JDK自带诊断命令,可以查看系统属性、堆信息,还能触发GC。- MAT的Dominator Tree:找出“如果回收这个对象,能释放多少内存”的大对象,先看这些,事半功倍。
我用MAT有一个习惯:拿到hprof文件后,不是先看Leak Suspects报告,而是先看Dominator Tree,找到最占内存的几个对象,右键->Path to GC Roots->exclude weak references。这一步能直接告诉你:这个对象到底是被谁强引用着、为什么一直回收不掉。如果是通过WeakReference或者SoftReference引用的,先排除掉,剩下的强引用链才是真正要查的。
6.3 定位到代码后的修改思路
假设你通过Path to GC Roots找到一条链:ThreadLocal -> 某个Context对象 -> 大的业务对象。修复思路通常是:
- 检查ThreadLocal是否被正确remove。使用线程池时尤其要注意,因为线程池里的线程是复用的,ThreadLocal值不会被自动清理,必须在使用完的finally块里调用
remove()。 - 如果对象是经过某个框架或工具类持久的引用,考虑改用弱引用或软引用容器。
- 修改后跑一轮长时间压力测试,确认老年代低点不再持续抬高。
这一步特别容易犯的错是“改完代码觉得好了”,实际上一次Spring容器重启后ThreadLocal又被重建了,问题藏得更深。要彻底验证,必须压测到原来的问题规模再往上走一截,观察至少24小时的GC曲线。
7. 给新手的调优路径建议
最后这部分写给刚开始接触JVM调优的读者。我踩过的坑不想让你再踩一遍,按下面这条路走,能少走不少弯路。
7.1 第一步:把日志和监控先做好
不要一上来就调参。先确认GC日志完整输出、日志轮转开启、堆转储参数配置好:
-Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump.hprof -Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=50m这套配置同时开了堆转储和GC日志,已经能应对绝大多数排查需求。然后配合监控平台(任意开源的或商业的都可)定期记录堆使用量、GC次数、GC耗时,坚持观察一周,你就能摸清应用的GC节奏。
7.2 第二步:带着问题调参,而不是为了调参而调参
每次调参都要有明确假设。比如“我觉得老年代涨得太快,所以把SurvivorRatio调大,让对象在新生代多待一会”,这就是一个可以验证的假设。改完参数跑一段时间,观察GC日志和监控,看看假设是否成立。
如果假设不成立,不要继续叠加参数,先回退再看数据。这是很多人没做到的:调参失败了也不回退,参数越堆越多,最后根本不知道是哪个参数起了作用。
7.3 第三步:学会从监控曲线反推问题
看GC曲线时,养成从图形反推状态的思维。比如老年代使用量是一条平缓向上的线,说明有对象持续进入老年代且无法回收;如果是一条阶梯线,说明每次GC都能回收一部分,但总有残留;如果是一条锯齿线,说明GC频繁且回收能力不足。不同的曲线形态,对应的排查方向完全不同。看到图形的那一刻,你就应该能说出接下来该打开jmap还是该看代码。
我个人的经验是:调优的终点不是把某个参数调到完美,而是对应用的GC行为形成稳定的预期。当你看到一条GC曲线,能大致说出这个应用正在干什么、接下来会发生什么,你就已经通过了JVM调优这一关。后面即使遇到更复杂的场景,只要排查链路清晰、监控数据完整,就不会慌。