前几天晚上十一点多,一个以前带过的同事突然给我发消息,说线上服务每隔几分钟就触发一次Full GC,CPU直接飙到百分之九十多,问我是不是堆内存给小了,要不要先加几个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不能保证原子性?再往下还能追问到JMM(Java内存模型)、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的对象比例很低。为此堆被分成年轻代和老年代,年轻代用标记-复制算法,因为存活对象少,复制成本低;老年代用标记-清除或标记-整理算法,处理存活率高的对象。回收器虽然叫法不同,本质上都是在这套分代框架下做工程化取舍:用多少STW(Stop 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为单位,不再要求整个年轻代或老年代一次整块回收。通过维护RSet(Remembered Set)记录跨Region引用,G1能做到“增量回收”,并用-XX:MaxGCPauseMillis参数软性控制停顿时间。ZGC则更进一步,利用染色指针和读屏障,把停顿时间压缩到毫秒甚至亚毫秒级别,JDK 15之后不再是实验特性,适合超大堆和超低延迟场景。
各主流回收器的定位差异,可以用一张表说清楚:
| 回收器 | 核心目标 | STW特征 | 典型适用场景 | 启动参数 |
|---|---|---|---|---|
| Serial | 简单低开销 | 完全STW,停顿长 | 客户端、小堆、单核 | -XX:+UseSerialGC |
| Parallel Scavenge + Parallel Old | 高吞吐 | 多线程STW,停顿长但总吞吐高 | 批处理、离线计算 | -XX:+UseParallelGC |
| ParNew + CMS | 低停顿 | 多数阶段并发,停顿短 | Java 8时代的Web服务 | -XX:+UseConcMarkSweepGC |
| G1 | 可预测停顿 | 区域化增量回收,停顿可控 | JDK 9+默认,通用服务 | -XX:+UseG1GC |
| ZGC | 极低延迟 | 停顿几毫秒内 | 大堆、高并发、延迟敏感 | -XX:+UseZGC |
2.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:+UseGCLogFileRotation(JDK 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: user=0.01 sys=0.00, real=0.00 secs]逐个拆开看:GC表示这是Minor GC,Allocation 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:PretenureSizeThreshold=1m后执行:
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 List<byte[]> 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:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m逐个解释:-Xms和-Xmx设成一致,避免JVM运行中因为堆扩容和缩容而反复调整内存布局,这是一条近乎公认的经验。年轻代大小-Xmn设3G,既保证短生命周期对象能快速回收,又不会因为年轻代过大导致每次Minor GC复制量太大。SurvivorRatio=8意味着Eden区是单个Survivor的8倍,也就是Eden约2.4G,两个Survivor各300M。MaxTenuringThreshold=15是默认值,一般不需要改,改小会让对象过早进入老年代,改大则可能让对象在Survivor之间反复复制。G1配合MaxGCPauseMillis=100,让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:MaxGCPauseMillis=10,结果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,用的G1,JDK 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。为了看引用链,我执行了完整dump:
jmap -dump:format=b,file=/data/heap_20240115.bin <pid>把dump拉到本地用MAT打开,Dominator Tree里那个UserProfileCache对象被一个static字段直接引用,引用的源头是一个工具类里的静态Map,Map的key是userId,value就是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是强引用。线程不销毁、不调用remove,value就永远挂在线程上。如果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-500ms | 100ms以内 |
| P99延迟 | 1.2s | 95ms |
| 老年代使用率峰值 | 95% | 55% |
这个结果说明什么?堆大小参数从头到尾没动过,代码层把无界引用断掉之后,8G的堆完全够用。后面我又让团队加了GC日志的按天轮转和FGC次数的监控告警,再遇到类似问题能更早介入。整个排查链路就是:看GC日志确认问题、采jstat看趋势、dump堆找引用链、回到代码断根因、灰度验证再观察。
最后分享一个我自己动手时的习惯:拿到线上FGC问题,第一件事不是读代码,而是先跑一条jstat并复制GC日志,然后再开始分析,因为JVM的现场数据是一次性的,重启就全没了。另外,如果代码里还有不少基于finalize()的老写法,能换就换,它就是一个藏在角落里的GC调优隐患。这个排查思路用一次就记住了,希望这篇整理也能让你少走点弯路。