前几天晚上十一点多一个以前带过的同事突然给我发消息说线上服务每隔几分钟就触发一次Full GCCPU直接飙到百分之九十多问我是不是堆内存给小了要不要先加几个G。我让他先别动配置把jstat的输出和最近的GC日志发过来。这种问题我处理过不止一次多数情况下真凶不是堆不够大而是对象到底被谁引用着、生命周期为什么那么长。而这恰恰是把Java关键字、GC回收器和JVM调优三个话题串起来的一条主线。这篇文章我打算把这条线完整捋一遍先从关键字与JVM运行时行为的关系说起再梳理GC回收器家族然后教你怎么读GC日志最后用一次线上FGC排查来收尾。对刚接触JVM的同学这是一条从零到一的上手路径对已经有经验的工程师也可以借这个机会把自己零散的知识点串成体系面试时再遇到“关键字GC调优”的组合拳至少心里不慌。1. 从语法到运行时关键字是JVM行为规则的说明书所有Java程序员最开始学的都是关键字但大多数人学到“语法正确、能编译过”就停了很少去想每个关键字在字节码里留下什么痕迹运行时又会触发哪些机制。其实关键字就是JVM行为规则的说明书编译器看到volatile会给字段加上ACC_VOLATILE标志JVM在读写这个字段时插入内存屏障看到synchronized会生成monitorenter/monitorexit指令锁的实现要牵扯对象头里的Mark Word看到final会把常量值直接内联到使用处。这些机制表面上离GC很远实际上决定了对象能否被安全发布、锁竞争会不会加剧线程阻塞、常量对象会不会随着宿主对象一起被GC扫描。搞懂这一层很多JVM调优的“为什么”就自然通了。1.1 面试和实战中为什么总是先用关键字来摸底面试官爱问关键字不是因为他想考背诵而是因为一个关键字背后可以牵引出一条完整的知识链。比如你回答volatile是“保证可见性、防止指令重排”他会接着问底层怎么实现的内存屏障有哪几种为什么volatile不能保证原子性再往下还能追问到JMMJava内存模型、CAS、锁升级。这就是一条从语法到运行时再到并发设计的链路。同样的逻辑也适用于实战。你写一个双重检查锁的单例代码里少了volatile线上就可能出现拿到半初始化对象的情况你写缓存用static集合对象被类加载器钉在内存里GC怎么回收都收不掉。这些都不是语法层面的错误而是运行时行为出了问题。所以每次有人问我JVM调优从哪学起我的答案都是先把关键字层面的内存语义吃透否则后面看GC日志和堆dump脑子里没有一张“对象是怎么被引用”的图景。1.2 volatile、synchronized、final三个影响最大的关键字这三个关键字是JVM行为的关键开关先说volatile。它解决的是可见性和有序性问题。每个线程都有自己的工作内存普通字段的读写可能只发生在缓存里其他线程看不到。volatile字段则在读写时插入内存屏障强制刷新到主内存同时禁止屏障两侧的指令重排。用个生活化的类比普通变量像每个人工位上的草稿纸volatile像是走廊里的公告栏写操作必须贴到公告栏上读操作必须去看公告栏。但要注意volatile不解决原子性i这种“读-改-写”操作即便用volatile修饰多个线程一起跑照样会丢数据。再说synchronized。它编译后在字节码层面就是monitorenter和monitorexit两条指令JVM运行时通过对象头的Mark Word实现锁。锁有一个升级过程无锁、偏向锁、轻量级锁、重量级锁JDK 15之后偏向锁被默认关闭并最终废弃原因是现代应用线程竞争通常比较激烈偏向锁的撤销成本反而成了负担。实际写代码时synchronized最大的问题是粒度。如果你把一个大方法整体加锁里面包含耗时IO和大量对象创建等于把并发性能直接还给锁。我见过一个改造案例把synchronized方法改成synchronized块只锁真正共享的那几行代码吞吐量翻了接近一倍。最后说final。它有两个层次的效果编译期的常量折叠和运行期的安全发布。像static final String这种编译期常量使用处会直接被替换成字面量这也是为什么修改常量后需要重新编译所有引用它的类否则旧值还在字节码里。对象字段用final修饰在构造器内正确赋值后其他线程可以在不加锁的情况下安全读到完整对象因为JMM对final字段有特殊保证。从调优角度看尽量减少对象状态的可变性优先设计成final不可变对象不仅让GC扫描时对象结构更稳定也让并发代码少很多同步负担。1.3 static、transient、finalize容易被忽略的“生命周期关键字”static在JVM调优里是最容易埋雷的关键字。static字段属于类本身随类加载器驻留内存持有它的对象会被GC Roots直接可达无法回收。一个static集合如果无限往里塞数据老年代就会只增不减Full GC时间越来越长。后面第5章的线上案例就是这个问题。transient跟序列化相关字段加上它就可以跳过序列化过程。它和GC的直接关系不大但在缓存对象、大字段场景里很实用。比如一个对象里有几MB的图片字节数组序列化到Redis或者磁盘时你大概率不想带上它用transient标注后反序列化回来是默认值既省了网络带宽也减少了一次大数组的内存复制。再说finalize()。它不是关键字是Object的方法但很多人把它当成关键字一样“背下来了”。重写finalize()的对象会被JVM放入F-Queue由Finalizer线程在GC之后异步调用finalize方法对象甚至可能在这个方法里重新建立引用从而“复活”。这个机制带来的问题很多Finalizer线程处理不过来对象堆积在F-Queue里间接导致内存无法及时回收。我建议新代码一律不要用老代码能改就改成try-with-resources的显式清理。它属于典型的“看起来无害高峰期要命”的设计。2. GC回收器全图谱从Serial到ZGC谁在处理你的垃圾GC的底层理论基石是分代假说绝大多数对象朝生夕死活过第一轮Minor GC的对象比例很低。为此堆被分成年轻代和老年代年轻代用标记-复制算法因为存活对象少复制成本低老年代用标记-清除或标记-整理算法处理存活率高的对象。回收器虽然叫法不同本质上都是在这套分代框架下做工程化取舍用多少STWStop The World时间换多少吞吐和内存利用率。2.1 回收器演进每个时代解决一个核心痛点Serial是最初的单线程回收器GC时所有用户线程都暂停适合客户端和小型应用。Parallel Scavenge关注吞吐量适合后台计算和批量处理它配套的Parallel Old负责老年代。ParNew是年轻代的多线程版本后来主要配合CMS使用。CMS是第一款真正意义上的并发回收器它把最耗时的标记和清除阶段做到和应用线程并发执行把停顿时间压下来在Java 8时代非常流行。但CMS也有天生缺陷并发阶段占用CPU、会产生浮动垃圾、标记-清除产生内存碎片JDK 9之后被标记废弃JDK 14正式移除。G1在JDK 9成为默认回收器JDK 11开始大规模普及。它的设计思路是把堆划分成若干个Region每个Region可以在年轻代和老年代之间动态切换回收时以Region为单位不再要求整个年轻代或老年代一次整块回收。通过维护RSetRemembered Set记录跨Region引用G1能做到“增量回收”并用-XX:MaxGCPauseMillis参数软性控制停顿时间。ZGC则更进一步利用染色指针和读屏障把停顿时间压缩到毫秒甚至亚毫秒级别JDK 15之后不再是实验特性适合超大堆和超低延迟场景。各主流回收器的定位差异可以用一张表说清楚回收器核心目标STW特征典型适用场景启动参数Serial简单低开销完全STW停顿长客户端、小堆、单核-XX:UseSerialGCParallel Scavenge Parallel Old高吞吐多线程STW停顿长但总吞吐高批处理、离线计算-XX:UseParallelGCParNew CMS低停顿多数阶段并发停顿短Java 8时代的Web服务-XX:UseConcMarkSweepGCG1可预测停顿区域化增量回收停顿可控JDK 9默认通用服务-XX:UseG1GCZGC极低延迟停顿几毫秒内大堆、高并发、延迟敏感-XX:UseZGC2.2 CMS退出历史舞台的真正原因CMS在Java 8时代几乎是“低延迟服务标配”但它其实是把STW时间拆散了并没有完全消除。整个回收过程分四步初始标记和重新标记都需要STW并发标记和并发清除与用户线程并行。问题恰恰出在“并发”上并发标记阶段要占用CPU如果机器核数不多应用吞吐会明显下降并发清除阶段应用仍在产生新垃圾这些浮动垃圾只能等下一次GC处理所以CMS不能等到老年代满了再回收必须预留空间否则会触发Concurrent Mode Failure退化回Serial Old做完全STW的Full GC。再加上标记-清除算法天然留下大量空间碎片大对象分配时可能找不到连续内存触发连续的Full GC。这几张“隐形的账单”叠在一起让CMS在高并发、大堆场景下没那么好用了。G1把内存切成Region之后碎片问题被区域化分配天然缓解而且可以通过-XX:MaxGCPauseMillis设定停顿目标这是工程上更符合管理预期的方式。对还在用Java 8 CMS的老项目我的建议是如果堆在4G以上且GC是明显瓶颈尽早灰度切换到G1验证一下绝大多数现代服务场景下G1的表现会更稳。2.3 G1的Region世界和ZGC的激进路线G1把堆划分为若干1MB到32MB不等的Region年轻代和老年代不再是物理连续的空间而是一组Region的集合。跨Region引用通过RSet记录这样GC在回收某个Region时不需要扫描全堆去找谁引用了它。G1的回收优先级是“价值优先”每次挑回收收益最高的Region集合来处理这就是它能控制停顿的原因。不过G1的RSet本身也占内存大对象还会以Humongous Region的形式直接进入老年代所以生产环境需要给G1留出额外的内存开销不能把-Xmx压得太极限。ZGC走的是另一条路不依赖分代把对象引用标记和标记信息直接编码在指针里染色指针配合读屏障让大部分标记过程跟着应用线程的读操作顺路完成GC线程只在极短的暂停里处理必要步骤。ZGC在128G甚至更大堆上都能保持很低的停顿但代价是CPU开销更高且需要操作系统支持多映射内存。选型时别盲目追捧ZGC如果堆不到几十个G、业务对十几毫秒的停顿也不敏感G1往往更划算。3. 让GC“开口说话”日志解析与对象晋升的验证方法调优最怕没有数据支撑。有人凭感觉把-Xmx从4G改成8G重启完看着监控曲线说“好像好了一点”这种没有对照实验的做法本质上和碰运气没区别。GC日志是JVM留给你的最直接的“口供”学会读它调优才算正式开始。3.1 打开一份有效GC日志的正确姿势Java 8及以前常见的组合是-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.logPrintGCDetails输出详细回收数据PrintGCDateStamps打印人类可读的时间戳Xloggc指定日志文件。Java 9开始统一用了Xlog命令变成-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tagsJava 8的写法在9以上版本很多已失效PrintGCDetails在JDK 9里会被忽略。建议新项目直接按-Xlog格式配置老项目升级JDK之后也要同步改。线上有个容易被忽略的点一定要开启GC日志的循环覆盖或按大小切分比如-Xloggc配合-XX:UseGCLogFileRotationJDK 8或者-Xlog里的file数限制JDK 9不然日志文件会无限增长把磁盘撑爆。很多故障处理现场发现“GC日志没开”只能靠jstat现场采集信息量会少一大截。3.2 一行GC日志里藏着多少信息拿一段真实的Minor GC日志举例[GC (Allocation Failure) [PSYoungGen: 61440K-5120K(61440K)] 61440K-49625K(198144K), 0.0025310 secs] [Times: user0.01 sys0.00, real0.00 secs]逐个拆开看GC表示这是Minor GCAllocation Failure说明年轻代内存分配失败触发回收PSYoungGen指的是Parallel Scavenge回收的年轻代61440K-5120K是回收前后存活对象大小括号里61440K是年轻代总容量后面61440K-49625K是堆总量回收前后的变化括号里198144K是堆总容量最后0.0025310 secs是本次GC耗时。真正需要警惕的是Full GC日志[Full GC (Ergonomics) [PSYoungGen: 0K-0K(18432K)] [ParOldGen: 34458K-34458K(68608K)] 34458K-34458K(87040K), 0.2022965 secs]看两个信号年轻代回收前后都是0K说明年轻代已经没有可回收对象了老年代34458K回收后还是34458K意味着老年代里的对象一个都没清掉。这种Full GC一次就是几百毫秒如果频繁出现基本可以断定老年代存在大量无法回收的存活对象下一步就是dump堆找根因而不是继续调大小参数。3.3 用代码验证关键字和GC的联动效果读日志是“被动接收”写一段可控代码去触发GC行为则能帮你在本地提前验证很多判断。比如验证大对象直接进入老年代配置-XX:PretenureSizeThreshold1m后执行public class BigObjectDemo { public static void main(String[] args) { for (int i 0; i 10; i) { byte[] big new byte[2 * 1024 * 1024]; // 2MB大对象 System.out.println(big.length); } } }配合-XX:PrintGCDetails你会看到这些2MB的对象没有经历年轻代复制而是直接分配到了老年代日志里老年代区使用量会一步步增长。这个参数对经常创建大数组、大缓冲的服务很有意义大对象在年轻代和Survivor之间反复复制代价远高于直接放入老年代但也要注意老年代碎片问题所以一般只在确定大对象频繁创建时才用。再比如验证static引用的“锁死”效果public class StaticCacheDemo { static final Listbyte[] CACHE new ArrayList(); public static void main(String[] args) throws Exception { for (int i 0; i 100; i) { CACHE.add(new byte[1024 * 1024]); // 持续向static集合塞数据 Thread.sleep(200); } } }跑起来之后盯着GC日志看你会发现Minor GC之后老年代占用持续上升直到触发Full GC但CACHE持有的对象因为被static引用钉死永远不会被回收。这个Demo基本复现了线上“缓存导致内存泄漏”的模型也印证了第1章说的static关键字在生命周期管理上的危险性。4. JVM调优的思考框架从堆参数到代码习惯很多团队所谓的JVM调优就是运维给一个模板大家copy到各个服务里改个堆大小然后祈祷不出问题。这种做法的最大问题是没有目标。调优不是参数越花哨越好而是要回答一个业务问题你是想要低延迟还是高吞吐还是内存占用尽量小答案不同参数方向完全不同。4.1 动手之前先回答三个问题第一调优目标是什么。Web接口服务通常要低延迟用户不希望请求因为Full GC卡住几百毫秒离线的数据处理任务则更看重总吞吐停顿长一点无所谓只要跑完总时间短。第二当前现象是什么。是FGC频繁还是单次FGC时间过长还是OOM现象决定切入点。第三数据在哪。GC日志、jstat、jmap、堆dump先采集足够数据再动手。这里有个反直觉的结论堆不是越大越好。GC停顿时间和堆大小强相关堆越大标记和清理的范围越大单次STW时间就越长。有人把-Xmx调成物理内存几乎全给堆结果FGC频率低了但每次FGC从200ms变成2s用户感受到的延迟反而更糟。合理堆大小应该是业务对象活跃数据量的2到3倍留出GC和碎片缓冲而不是“能开多大开多大”。4.2 核心参数组合与背后的取舍逻辑以典型的Java 8服务为例一套比较稳的起点参数-Xms8g -Xmx8g -Xmn3g -XX:SurvivorRatio8 -XX:MaxTenuringThreshold15 -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:MetaspaceSize512m -XX:MaxMetaspaceSize512m逐个解释-Xms和-Xmx设成一致避免JVM运行中因为堆扩容和缩容而反复调整内存布局这是一条近乎公认的经验。年轻代大小-Xmn设3G既保证短生命周期对象能快速回收又不会因为年轻代过大导致每次Minor GC复制量太大。SurvivorRatio8意味着Eden区是单个Survivor的8倍也就是Eden约2.4G两个Survivor各300M。MaxTenuringThreshold15是默认值一般不需要改改小会让对象过早进入老年代改大则可能让对象在Survivor之间反复复制。G1配合MaxGCPauseMillis100让JVM尽量把GC停顿控制在100毫秒内但它只是一个软目标不代表绝对保证。MetaspaceSize很少有人主动设。默认的元空间可能在用到一定程度才触发Full GC去扩容线上遇到过因为元空间持续膨胀导致的偶发FGC。主动把初始值和最大值设成一样能让元空间启动时就分配到位减少运行时扩容开销。注意这只是“起点模板”每台机器的内存、每个服务的对象特征都不同线上要根据GC日志动态调整。4.3 被滥用的参数和调优洁癖这些年我见过最容易被误用的参数排第一的是-XX:DisableExplicitGC。这参数把System.gc()屏蔽掉目的是防别人代码里乱调Full GC。但有些框架和组件是依赖System.gc()去触发堆外内存回收的尤其是DirectByteBuffer。你把这个参数一加堆内Full GC没了堆外内存可能因为Cleaner没被及时执行而持续增长最后OutOfMemoryError堆外内存爆了。这个坑在Netty场景里很经典现在Netty自己会做内存池管理影响小一些但老版本和老代码仍然存在。第二是盲目网上抄参数。很多人看到推荐就照抄-XX:MaxGCPauseMillis10结果G1为了这个目标疯狂调整回收策略回收量不足反而导致周期性地“目标失败”重新回到更长停顿。停顿目标要按业务真实感受设置不是越小越好。第三是混用新老收集器参数。CMS和G1的参数体系有重叠也有冲突有些参数在G1下会被忽略有些在ZGC下会直接报错。升级JDK前务必对照release notes清理旧参数不然启动即失败。4.4 真正的调优主力在代码里参数调优能解决的往往只是“资源配置不合理”的问题对象生命周期管理不当导致的GC问题再怎么调参数都是治标不治本。我在代码评审里最常提的几条其实都能回到第一章的关键字语义能用final修饰的字段就用final对象状态越不可变并发和GC行为越可预测synchronized尽可能缩小范围锁内不创建大对象、不做耗时IO锁竞争下降阻塞线程减少GC Roots扫描压力也会下降volatile只用于状态标志不用于计数器或多字段联合不变式对象里的大字段、不需要序列化的字段用transient跳过循环体内避免new大对象尽量复用缓冲区用完的集合显式clear或置null能用WeakHashMap的场景就不要用普通HashMap。这些习惯看起来零碎但累积效果比任何一组JVM参数都明显。我调过的项目里代码层面清理掉几个不合理引用后FGC从分钟级降到小时级的案例并不少见。参数调优只需要几分钟代码调优才考验基本功。5. 一次线上FGC频繁问题的完整排查链路最后用一个我实际处理过的案例串起前面所有知识点。这个服务是一个推荐系统的查询接口8C16G的机器堆设了8G用的G1JDK 11。某天监控告警Full GC频率从平时的每小时一次飙升到每5分钟一次接口P99延迟从80ms涨到1.2s。5.1 告警来了先保现场别急着重启团队里新来的同学第一反应是“重启试试”被我叫停了。JVM的堆dump和GC现场是一次性证据重启之后就什么都没了。正确顺序是先把现场数据留下来再讨论修复方案。我先在机器上跑了几条命令jstat -gcutil pid 1000 10 jmap -histo:live pid | head -30jstat能看到Eden、Survivor、Old区和FGC次数随时间的变化jmap的实时直方图能快速看到哪些类型的对象占用了最多内存不需要完整dump秒级出结果。第一条命令的输出显示Old区使用率一路从60%涨到95%FGC每5分钟一次Eden和Survivor的回收倒是正常的——典型的老年代空间快速消耗模型。5.2 用jmap和MAT把可疑对象挖出来jmap的histo里排在前列的有一个可疑类叫UserProfileCache实例数量不是特别多但每个实例都持有一个几百KB的Map加起来占了老年代好几个G。为了看引用链我执行了完整dumpjmap -dump:formatb,file/data/heap_20240115.bin pid把dump拉到本地用MAT打开Dominator Tree里那个UserProfileCache对象被一个static字段直接引用引用的源头是一个工具类里的静态MapMap的key是userIdvalue就是UserProfileCache对象。这下根因就清楚了一半static集合持有了大量本该过期的数据GC Roots沿着静态引用看过去认为这些对象全部存活老年代自然只涨不降。5.3 根因分析static、finalize、ThreadLocal三类典型引用病这个案例的根因是典型的static引用问题。工具类里定义了一个静态Map当作本地缓存往里面写用户画像数据当时图省事没做过期淘汰。用户量一涨老年代里堆积的画像对象越来越多G1回收不了它们只能反复Full GC。这类“引用病”在线上排查中经常三选一static集合持有大对象。对应第1章的static语义静态引用是天然GC Roots必须限制容量或改为弱引用结构。重写finalize()的类产生延迟回收。那些对象会被扔进F-Queue如果Finalizer线程跟不上生产速度堆积比直接泄漏还隐蔽。ThreadLocal未清理。线程池里的线程长期存活ThreadLocalMap的key是弱引用但value是强引用。线程不销毁、不调用removevalue就永远挂在线程上。如果value里还有大对象同样会造成老年代持续增长。排查顺序我一般这样走先jmap -histo看占用大头再用MAT或者jhat看引用链最后回到源码确认引用为什么没有被断开。5.4 修复与验证FGC从5分钟一次降到8小时一次修复方案分两步。第一步紧急止血把那个静态Map换成Guava的CacheBuilder配置maximumSize和expireAfterWrite并声明WeakKeys同时清理代码里其他几处类似的无界static集合。第二步是把类里一个老旧的finalize()清理方法改成基于try-with-resources的显式关闭顺手排查了一波ThreadLocal未remove的问题。代码上线后同一台灰度机器的GC日志对比非常明显指标修复前修复后Full GC频率每5分钟一次每8小时左右一次单次Full GC耗时300-500ms100ms以内P99延迟1.2s95ms老年代使用率峰值95%55%这个结果说明什么堆大小参数从头到尾没动过代码层把无界引用断掉之后8G的堆完全够用。后面我又让团队加了GC日志的按天轮转和FGC次数的监控告警再遇到类似问题能更早介入。整个排查链路就是看GC日志确认问题、采jstat看趋势、dump堆找引用链、回到代码断根因、灰度验证再观察。最后分享一个我自己动手时的习惯拿到线上FGC问题第一件事不是读代码而是先跑一条jstat并复制GC日志然后再开始分析因为JVM的现场数据是一次性的重启就全没了。另外如果代码里还有不少基于finalize()的老写法能换就换它就是一个藏在角落里的GC调优隐患。这个排查思路用一次就记住了希望这篇整理也能让你少走点弯路。