做Java后端这几年,有个很现实的现象:JDK版本一茬接一茬地出,但不少线上服务跑的还是JDK 1.8。不是大家不愿升级,而是对一个稳定运行的系统来说,升级JDK带来的风险往往比收益大。于是JDK 1.8下HotSpot的垃圾收集器(GC)和GC日志分析,就成了排查性能问题绕不开的基本功。今天我不打算堆概念,而是把GC这条线完整地过一遍:JDK 1.8里有哪些收集器、什么场景选哪个、怎么打开GC日志、日志里每个数字代表什么、遇到频繁Full GC又该怎么一步步查。想把JVM调优搞明白的人,或者被线上GC问题折磨过几次的同学,这篇应该能帮上忙。
1. 为什么JDK 1.8的GC知识依然值得下功夫
1.1 生产环境里的真实情况
先聊聊现实。虽然JDK 17、21已经出来很久,主流的中间件和框架也早就适配新版本,但你随便打开一家公司的服务端技术栈看,JDK 1.8仍然占据很大比例。原因无非那么几条:依赖的老库在新JDK上可能有兼容问题;升级涉及全链路回归测试,成本高;业务本身跑得好好的,没有动力去动底层运行时。
既然线上大量应用还跑在JDK 1.8上,那HotSpot在这套JDK里的GC行为和日志格式,就是日常运维、排查问题最需要熟悉的东西。尤其当你遇到接口突然超时、CPU飙高、服务卡死这类现象时,第一件事往往是翻GC日志。GC知识不是过时知识,而是Java服务端开发者必须具备的看家本领。
1.2 调GC到底在调什么
GC调优的本质,是在两个指标之间做平衡:吞吐量和低延迟。
吞吐量可以用“业务运行时间 /(业务运行时间 + GC时间)”来理解。GC越少、占用的CPU越少,单位时间内能处理的请求就越多。低延迟则是指每次GC造成的停顿时间要尽可能短,避免用户请求出现明显卡顿。
这两个指标常常互相矛盾。追求高吞吐,可能一次GC停顿时间很长;追求低停顿,又可能在GC上花费更多次数和额外CPU。所以学习GC的核心就三件事:知道有哪些收集器、理解每种收集器怎么工作、能从日志里快速判断当前GC状态是否正常。后面所有内容都围绕这三件事展开。
2. 先把内存模型和GC概念理清楚
2.1 JDK 1.8的堆内存划分
要读懂GC日志,必须先清楚JVM内存是怎么划分的。JDK 1.8里,堆主要分两大块:新生代(Young Generation)和老年代(Old Generation),另外还有一个在本地内存中的元空间(Metaspace),取代了JDK 7及之前的永久代(PermGen)。
新生代内部又分成Eden区和两个Survivor区,通常叫S0、S1。默认比例是Eden:S0:S1 = 8:1:1。大部分新建对象都在Eden区分配,Minor GC之后存活对象会被复制到S0或S1。对象每熬过一次Minor GC,年龄就加1,默认年龄到15时会晋升到老年代。这个年龄阈值可以用-XX:MaxTenuringThreshold调整。
大对象比较特殊,直接在老年代分配,比如很大的数组或者大字符串。控制这个行为的参数是-XX:PretenureSizeThreshold,超过指定大小的对象直接进老年代。如果应用里频繁创建大对象,老年代会涨得很快,后面GC日志分析时会看到明显特征。
2.2 Minor GC、Major GC与Full GC
GC按回收范围分几种:新生代回收叫Minor GC,也叫Young GC;老年代回收叫Major GC;对整个堆、包括元空间的回收,通常称为Full GC。
Minor GC频率很高,因为Eden区很快会被新对象填满。Minor GC通常很快,几十毫秒内完成。Full GC则慢得多,默认收集器是Parallel Scavenge + Parallel Old时,一次Full GC可能几百毫秒甚至几秒,这就是线上服务卡顿的主要来源。
日志分析第一步,永远是先分清看到的是Minor GC还是Full GC,因为两者的问题和处理思路完全不同。
2.3 Stop-The-World 和安全点
不管哪种收集器,GC过程中总会有一段或多段时间,业务线程必须暂停,让JVM安全地标记、清理对象。这个暂停叫Stop-The-World(STW)。
线程不是在任何位置都能停下来的,JVM会在线程执行到“安全点”(Safe Point)时才允许暂停。安全点通常位于方法调用、循环回跳、异常跳转等位置。所以STW时间其实包含两部分:线程到达安全点的等待时间,以及在安全点上真正执行GC操作的时间。
理解了STW,你才能理解为什么CMS和G1要搞那么复杂的并发标记流程,也才能理解GC日志里那些耗时数字的意义。看到停顿时间明显上升时,不要只想到加内存,先判断是不是线程太多导致到达安全点的时间变长了。
3. JDK 1.8可用的七种垃圾收集器
3.1 老牌组合:Serial 与 Serial Old
Serial是HotSpot历史上最早的收集器。它是单线程工作的,进行GC时所有业务线程都要暂停。Serial Old是它的老年代版本,使用标记-整理算法。
这组收集器在JDK 1.8里主要用于客户端模式,或者堆非常小(几百MB以内)、CPU核数很少的容器。虽然听起来很“落后”,但单线程在小堆上有两个优势:实现简单,没有线程切换开销;在小堆下停顿时间也不至于不可接受。如果在开发环境或者低配测试机上跑,Serial组合反而是最直接的选择。
启动参数是-XX:+UseSerialGC,表示新生代Serial、老年代Serial Old。
3.2 吞吐量优先:Parallel Scavenge 与 Parallel Old
Parallel Scavenge是JDK 1.8在服务器模式下的默认新生代收集器,核心特点是关注吞吐量。它使用多线程并行回收,但GC期间业务线程同样要暂停。Parallel Old是它的老年代版本,JDK 1.6之后可用,采用标记-整理算法。
这组收集器最有特色的能力是自适应调节:JVM会根据目标停顿时间和吞吐量目标,自动调整堆大小、新生代比例、晋升阈值等。控制参数主要有两个:
-XX:MaxGCPauseMillis:设置期望的GC停顿时间目标,只是一个软目标。-XX:GCTimeRatio:设置吞吐量目标,默认99,代表期望GC时间占总时间比例不超过1%。
如果你跑的是后台批处理、日志分析、离线计算这类对响应时间不敏感、只希望单位时间处理更多任务的业务,Parallel组合是默认首选。启动参数用-XX:+UseParallelGC或-XX:+UseParallelOldGC,效果等价。
3.3 响应优先的经典组合:ParNew 与 CMS
ParNew是Serial的多线程版本,和Parallel Scavenge一样是多线程收集新生代,但它的主要应用场景是配合CMS使用。
CMS(Concurrent Mark Sweep)是第一款把“并发”真正落到实处的收集器。它让部分回收工作与业务线程同时执行,极大减少了STW时间。CMS的分四个阶段:
- 初始标记(Initial Mark):STW,只标记GC Roots直接关联的对象,很快。
- 并发标记(Concurrent Mark):与业务线程并发,遍历对象图。
- 重新标记(Remark):STW,修正并发标记期间产生变化的对象。
- 并发清理(Concurrent Sweep):与业务线程并发,清理死对象。
CMS的优点是停顿短,但代价也不小:占用额外CPU;使用标记-清除算法会产生内存碎片;无法处理浮动垃圾;如果并发模式失败(Concurrent Mode Failure),会退化到Serial Old做一次全停顿的Full GC,停顿时间反而更夸张。
启动参数是-XX:+UseConcMarkSweepGC,JVM会默认使用ParNew + CMS + Serial Old(作为CMS出问题时的后备方案)。如果你的业务是Web应用、接口服务,对RT敏感,JDK 1.8下CMS仍然是经典选择。
3.4 面向大堆的G1收集器
G1(Garbage First)在JDK 1.8里已经可以使用,尤其在8u40之后的版本,成熟度明显提升。JDK 1.8默认不启用,需要手动加-XX:+UseG1GC。
G1的设计思路是把整个堆划分为多个大小相等的Region,逻辑上仍然分代,但新生代和老年代不再是物理连续的区域。每次回收时,G1会追踪每个Region中垃圾堆积的价值,优先回收垃圾最多、收益最大的Region,所以叫Garbage First。
G1适合大堆场景,因为它的目标是让停顿时间可控。通过-XX:MaxGCPauseMillis可以设置期望停顿时间,默认200毫秒。G1会在这个目标下动态调整新生代大小、选定回收区域。如果堆达到几十GB,CMS的并发标记会非常吃力,G1反而是更稳的选择。
不过要注意,JDK 1.8的G1还不像后面的版本那样成熟,如果堆只有2GB-4GB,G1不一定比Parallel或CMS更优。选G1之前最好做一次压测对比。
3.5 收集器参数对照表
为了方便快速选型,把JDK 1.8下主要组合列成一张表:
| 收集器组合 | 启动参数 | 核心特点 | 适用场景 |
|---|---|---|---|
| Serial + Serial Old | -XX:+UseSerialGC | 单线程、实现简单、停顿长 | 小堆、单核、开发环境 |
| Parallel Scavenge + Parallel Old | -XX:+UseParallelGC | 吞吐量优先、自适应调节 | 批处理、计算密集、离线任务 |
| ParNew + CMS | -XX:+UseConcMarkSweepGC | 并发低延迟、会产生碎片 | Web服务、响应敏感应用 |
| G1 | -XX:+UseG1GC | Region化、停顿可预测 | 大堆、多核、混合型业务 |
4. 收集器选型逻辑与实际场景分析
4.1 选型看四个维度
选收集器不要跟风,先把这几个维度想清楚:
第一是堆大小。堆在1GB以内,Serial或Parallel都够用,G1的Region管理反而增加复杂度。堆在4GB以上,G1的优势才开始明显。
第二是响应时间要求。业务能不能接受每次GC有几百毫秒停顿?能接受,Parallel没问题;不能接受,考虑CMS或G1。
第三是CPU核数。并发收集器需要额外线程,通常和核数成正比。核数少的时候,CMS的并发阶段反而可能拖慢业务。
第四是吞吐量要求。离线任务只求快,用Parallel。在线服务求稳,优先控制停顿。
基于这四个维度,就能推导出绝大多数场景的合理选择,这也是面试时经常被追问“为什么选这个收集器”的底层逻辑。
4.2 三个典型场景的选型实例
场景一:某个内部报表系统,堆内存2GB,白天偶有查询,每天晚上跑批生成报表。这个场景吞吐优先,选Parallel Scavenge + Parallel Old,让跑批期间GC占用尽可能小,跑完就完事。
场景二:一个面向C端用户的交易接口服务,堆内存4GB,接口RT要求小于200ms。这里果断选ParNew + CMS,并把-XX:CMSInitiatingOccupancyFraction设置为70,让CMS在老年代用到70%时就开始并发回收,避免堆满后退化成Full GC。
场景三:一个数据中台服务,堆内存32GB,多核机器,业务上不能容忍超过500ms的停顿。这个场景选G1,设置-XX:MaxGCPauseMillis=200,让G1来控制停顿目标,同时预留足够的内存余量。实测中G1绝大多数停顿在100-200ms之间。
很多人有一个误区:以为 JDK 1.8 默认的Parallel是“官方推荐”,所以一定好用。官方默认设置其实更偏向吞吐优先,对大量在线Web应用来说,如果不调整,很容易在高峰期出现长停顿。这也是为什么这么多团队主动切到CMS或G1——不是Parallel不好,而是场景不对。
5. GC日志实战:从打开开关到逐行解读
5.1 用哪些参数打印GC日志
JDK 1.8打印GC日志的参数,先记住这几个:
-verbose:gc或-XX:+PrintGC:打印简要GC日志。-XX:+PrintGCDetails:打印详细GC日志,包含新生代、老年代的变化。-XX:+PrintGCDateStamps:在日志前加绝对日期时间。-XX:+PrintGCTimeStamps:加JVM启动后经过的相对秒数。-XX:+PrintHeapAtGC:打印GC前后的完整堆快照,信息量最大。-XX:+PrintReferenceGC:打印软引用、弱引用、虚引用处理耗时,排查特定问题时有奇效。-XX:+PrintGCApplicationStoppedTime:打印应用线程实际停顿时间,能看出STW到底有多长。-Xloggc:/path/gc.log:将日志输出到文件,避免刷屏。
生产环境我建议一套比较完整的组合:
-Xloggc:/export/log/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=128M这样日志会写到指定目录,文件名带启动时间戳,并且滚动保存5个文件,每个文件不超过128MB。日志文件不会无限增长,排查问题时又能拿到足够的历史数据。
需要注意,JDK 9之后的日志参数改成了统一的新语法-Xlog:gc*,但本篇所有内容基于JDK 1.8,所以直接沿用老参数。
5.2 一次Minor GC日志逐字段拆解
先看一段典型的Minor GC日志:
2019-03-15T10:22:33.789+0800: 123.456: [GC (Allocation Failure) [PSYoungGen: 65536K->8192K(76288K)] 65536K->8456K(2505024K), 0.0045678 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]逐字段解释:
2019-03-15T10:22:33.789+0800:绝对时间,来自-XX:+PrintGCDateStamps。123.456:JVM启动后第123.456秒,来自-XX:+PrintGCTimeStamps。GC (Allocation Failure):这是一次Minor GC,触发原因是Eden区分配新对象失败。PSYoungGen: 65536K->8192K(76288K):年轻代GC前使用量65536K,GC后使用量8192K,年轻代总容量76288K。这里PSYoungGen说明使用的是Parallel收集器,如果是CMS会显示ParNew。65536K->8456K(2505024K):整个堆GC前使用量65536K,GC后使用量8456K,堆总容量2505024K。0.0045678 secs:本次GC一共耗时4.5毫秒。[Times: user=0.01 sys=0.00, real=0.01 secs]:user是用户态CPU时间,sys是内核态CPU时间,real是实际墙钟时间。real远大于user时,说明发生了CPU调度等待或者其他干扰。
如果看到[GC (Allocation Failure)但回收后Eden立刻又涨满,说明对象的创建速率太快,新生代容量相对不足。
5.3 一次Full GC日志逐字段拆解
再来看一次Full GC日志:
2019-03-15T10:22:35.102+0800: 125.769: [Full GC (Ergonomics) [PSYoungGen: 8192K->0K(76288K)] [ParOldGen: 262144K->262128K(349568K)] 270336K->262128K(425856K), [Metaspace: 125872K->125828K(1259000K)], 0.4567890 secs] [Times: user=0.34 sys=0.12, real=0.45 secs]和Minor GC对比,有几个关键信号:
Full GC (Ergonomics):彻底的全堆回收,触发原因是Parallel收集器的自适应调节。常见触发还包括Allocation Failure(老年代分配失败)、Metadata GC Threshold(元空间不足)、System.gc()(显式调用)。PSYoungGen: 8192K->0K(76288K):年轻代被清空,说明GC后没有任何存活对象留在新生代。ParOldGen: 262144K->262128K(349568K):老年代几乎没回收,只从262144K降到262128K。这是最危险的信号:老年代里都是存活对象,GC根本回收不了空间。270336K->262128K(425856K):堆整体使用量从270336K降到262128K,回收量大约只有8MB,但堆总容量很大。Metaspace: 125872K->125828K(1259000K):元空间回收量同样很小。如果频繁出现Full GC (Metadata GC Threshold),要看是不是有类加载器泄漏。0.4567890 secs:本次Full GC耗时456毫秒,对很多在线接口来说已经会造成明显卡顿。
看到老年代回收不动的情况,基本可以判断是内存压力过大或者存在内存泄漏,光靠调参数很难根本解决。
5.4 用jstat和堆转储辅助判断
GC日志告诉你“发生了什么”,但要定位“谁占用了内存”,还要借助其他工具。JDK自带的jstat可以直接观察各区域使用率:
jstat -gcutil <pid> 1000每秒输出一次新生代、老年代、元空间的使用率以及GC次数和耗时。如果老年代使用率一路爬升且不回落,就要考虑泄漏点。
想进一步确认对象来源,可以导出堆转储:
jcmd <pid> GC.heap_dump /tmp/heap.hprof要注意一点:jmap -dump:live会先触发一次Full GC,生产环境慎用。jcmd的方式相对温和,但同样会带来停顿时风险,最好在流量低峰或者先摘流再操作。拿到hprof文件后,用内存分析工具看Dominator Tree(支配树),找出持有多数存活对象的类,基本上就是问题所在。
6. 频繁Full GC的排查实战记录
6.1 第一步:确认GC类型和频率
有一次我排查一个线上服务频繁超时的问题,监控告警显示服务进程的Full GC发生频率高得吓人。当时线上已经开了GC日志,所以第一件事就是过滤日志里的Full GC行:
grep "Full GC" gc.log | head -50从结果看到大量Full GC (Allocation Failure),并且每次老年代回收量都很小。说明不是元空间的问题,而是老年代空间真的满了,连大对象分配都失败。GC日志能直接把问题方向框定在这里,后面就不用瞎猜了。
6.2 第二步:定位内存压力来源
紧接着用jstat观察内存变化,发现老年代使用率从启动后一路爬升,中间没有像样的回落。此时怀疑两件事:要么有对象一直被人强引用,要么有一个很大的数据结构在不断膨胀。
然后导出堆转储,用内存分析工具打开,查看Dominator Tree,发现某个业务缓存组件的静态Map占掉了大部分老年代。再看代码,那个Map只往里面放数据,没有任何清理机制,高并发下数据量呈线性增长。对象因为被全局静态引用链持有,GC完全无法回收。
这就是典型的“老年代内存泄漏”,和日志里“回收不动”的现象完全吻合。
6.3 第三步:调整参数和代码验证
定位到根因后,处理分成两步。
第一步先缓解症状。临时把堆从4GB调整到6GB,同时切换成ParNew + CMS,把-XX:CMSInitiatingOccupancyFraction设置为75,让CMS提前并发回收,给大对象进入老年代留出空间。这里只调参数是治标不治本,但能争取到修改代码的时间窗口。
第二步修代码。给缓存加上容量上限和过期时间,清理掉无效的静态Map写入。重新上线后,通过GC日志对比Full GC频率,从每分钟20多次降到了接近0,老年代使用率稳定在一个合理水位。
整个排查流程总结下来就是:先看GC日志里Full GC类型和回收效果,再用jstat确认趋势,最后用堆转储精确找到泄漏源,修完代码后对比日志验证。这套顺序在任何Java内存问题排查里都能复用。
7. 关于JDK 1.8的GC,我自己常用的思维导图
最后把我平时梳理GC知识用的结构放出来,作为一张可复制的“文本版思维导图”:
JDK 1.8 垃圾收集器与日志分析 ├── 收集器选择 │ ├── 小堆/单核 → Serial + Serial Old │ ├── 吞吐优先 → Parallel Scavenge + Parallel Old │ ├── 响应优先 → ParNew + CMS │ ├── 大堆/低延迟 → G1 ├── 核心参数 │ ├── -Xms / -Xmx 堆大小 │ ├── -XX:NewRatio / -Xmn 新生代大小 │ ├── -XX:SurvivorRatio 幸存区比例 │ ├── -XX:MaxTenuringThreshold 晋升年龄 │ ├── -XX:MaxGCPauseMillis 停顿目标 │ └── -XX:CMSInitiatingOccupancyFraction CMS触发阈值 ├── 日志分析流程 │ ├── 确认GC类型:Minor还是Full │ ├── 对比各区域回收前后大小 │ ├── 看耗时和触发原因 │ ├── 结合jstat观察趋势 │ └── 通过堆转储定位根因 └── 调优验证 ├── 调参后观察Full GC频率 ├── 对比一度二度现象 └── 最终以业务指标为准做GC分析这行干久了,我最深的体会是:GC日志一定要在上线时就打开,不要等线上出问题了才临时加参数。日志是事故现场的第一手证据,没有它,排查内存问题就像摸着黑找人。另一个心得是,调参永远是缓解手段,发现问题要追到代码本身。很多“Full GC频繁”的案子,最后查出来都是某个静态集合、某个缓存没有清理,参数把堆调得再大,也只是拉长了内存耗尽的时间而已。希望这篇能把JDK 1.8下GC这条线讲透,下次你再看到满屏GC日志时,能快速读懂它想告诉你什么。