线上服务每隔两三小时就出现一次数秒停顿报警刷屏成一片。我远程登录上去先敲了那句Java程序员都背过、但排障时总想不起来的命令jstat -gcutil。输出里Full GC连续飙升老年代占用接近100%。那一刻曾经背过的JVM垃圾回收器八股文才真正有了实感垃圾回收器决定了服务生死而不是一道面试选择题。这篇文章我想按自己排查线上问题、调各种参数的思路来写不搞面试大全。从垃圾回收到底在回收什么到七代回收器各自的设计哲学再到今天主流的G1和ZGC原理最后落到选型和调优排查。适合有一定Java基础、被Full GC困扰过、或者想系统理解垃圾回收器的读者。1. 垃圾回收到底在回收什么先厘清JVM内存与对象的生死判定1.1 堆内存分代设计与对象的一生很多刚接触GC的同学第一个困惑就是垃圾回收器不是一个东西吗为什么又Serial、Parallel、CMS、G1这么多名字原因得从堆内存结构说起。JVM的堆内存并不是一块均质的空间而是被分割成多个区域。默认情况下分为新生代Young Generation和老年代Old Generation新生代里又细分出一个Eden区和两个Survivor区S0、S1。之所以要分代背后是多年对象存活统计得出的结论——绝大多数对象朝生夕灭活不过几轮Minor GC真正能存活到老年代的只有少数被长期持有的对象。这个统计规律直接决定了回收策略的设计新生代适合复制算法因为存活率低复制成本小回收效率高老年代适合标记-整理或标记-清除因为对象存活时间长不能频繁复制。每次看到有人把堆分区比例乱调一通我都会先问一句你清楚自己的对象存活曲线吗不清楚的话调参就是瞎猜。一个对象从创建到回收的典型路径是这样的先在Eden区分配经历第一次Minor GC时如果还存活就被拷贝到S0再经历一次GC时从S0拷贝到S1同时对象年龄加1当年龄达到阈值默认15可通过-XX:MaxTenuringThreshold调整就从Survivor晋升到老年代。还有两种情况会直接进老年代一是大对象直接分配二是Survivor放不下时由JVM动态决定。1.2 可达性分析与GC Roots判断对象死亡的唯一标准引用计数法是最直观的判定思路——每个对象被引用一次就计数加1引用失效就减1计数归零就是垃圾。但它有个致命缺陷循环引用。A引用B、B引用A两边的计数器都不归零但对外而言这对对象已经没有意义了。所以主流的JVM实现都用可达性分析算法Reachability Analysis。可达性分析的思路很朴素从一组称为GC Roots的根节点出发顺着引用关系往下走能走到的对象就是活的走不到的就是垃圾。GC Roots包括这样几类虚拟机栈栈帧中的本地变量表中引用的对象也就是方法里还在用的局部变量方法区中静态变量引用的对象方法区中常量引用的对象本地方法栈中Native方法引用的对象被synchronized持有的锁对象这里有个容易忽略的细节GC Roots的枚举本身需要暂停所有业务线程这就是Stop The WorldSTW——所有调优者最头疼的东西从最古老的Serial一直伴随JVM到今天。回收器之间比拼的核心能力本质上就是怎么让STW更短、更少、更可控。很多人以为垃圾回收的优化目标是快其实准确的说法是在尽量少停顿的前提下把回收这件事做干净。还有一点值得单独拿出来说不是只有内存不够了才会触发GC。System.gc()可以主动请求JVM不保证一定执行元空间不足时也会触发Full GC来尝试回收废弃的类元数据。所以有时候你看GC日志堆明明很空却莫名其妙出现了Full GC先排查一下是不是有代码在随手调System.gc()或者元空间被什么动态生成类撑满了。1.3 四种引用类型对回收行为的影响有些同学会问既然对象生死由可达性分析决定那Java里的软引用、弱引用又是干什么的这里要区分两个层面引用类型影响的是垃圾回收器认为对象应被回收的优先级而不是对象是否死亡的判定本身。强引用Strong Reference平时最常见的Object obj new Object()。只要强引用还在GC永远不回收这个对象哪怕OOM。软引用Soft Reference内存充足时不回收内存将要溢出时才回收。适合做缓存比如把大图片对象用软引用包装避免缓存把堆塞爆。弱引用Weak Reference下一次GC就被回收。ThreadLocal的ThreadLocalMap键就用了弱引用这也是ThreadLocal内存泄漏问题的根源之一——key被回收了value还被人为引用着一直清理不掉。虚引用Phantom Reference最弱的一种甚至通过get()都拿不到对象主要用途是在对象被回收时收到一个系统通知比如NIO中DirectByteBuffer的回收通知。理解这四种引用类型对排查内存泄漏非常有帮助。比如一个服务内存缓慢增长用MAT分析堆快照时看到大量软引用对象堆积那就需要考虑是不是缓存的设计没有考虑清理策略。记住一句话引用类型改变的是回收的优先级而可达性分析决定的是生死。2. 七代回收器演进全景从单线程Serial到分代并行再到G12.1 新生代三剑客Serial、ParNew与Parallel ScavengeSerial回收器是所有回收器里资格最老的名字也直白——串行。它工作时只有一个GC线程回收期间所有业务线程全部暂停。在单核CPU时代这不是问题反正只有一个核心但在现在的多核服务器上用Serial回收一个几GB的堆停顿时间可以到秒级甚至分钟级。它的价值是简单、可靠、没有并发开销适合客户端型应用也适合资源受限的边缘节点。ParNew提升了一点——它把新生代的回收线程从1个变成多个利用多核并行回收所以叫Parallel New。但它本质上还是复制算法还是STW只是停顿时间被并行分担了。ParNew在历史上最大的意义不是有多快而是它是CMS在新生代的搭档——CMS只负责老年代新生代必须搭配一个收集器没有比ParNew更合适的。这也解释了为什么JDK 8时代ParNew CMS是那么经典的组合。Parallel Scavenge则是另一个独立分支。它和ParNew都支持多线程并行回收新生代但设计目标完全不同ParNew追求暂停时间越短越好Parallel Scavenge追求的是吞吐量最大化。什么叫吞吐量就是用户代码运行时间 /用户代码运行时间 GC时间。假设服务跑了100分钟其中1分钟在GC吞吐量就是99%。Parallel Scavenge还提供了自适应调节策略-XX:UseAdaptiveSizePolicyJVM会自动调整Eden和Survivor的比例来适配当前负载这对懒得手动调参的团队很友好。2.2 老年代回收器Serial Old、Parallel Old与CMS的取舍新生代回收器解决不了所有问题老年代同样需要回收器。Serial Old是Serial的老年代版本用标记-整理算法Parallel Old是Parallel Scavenge的老年代版本同样用标记-整理。这两对组合很好理解Serial Serial Old、Parallel Scavenge Parallel Old是经典的吞吐量优先和稳妥配置。CMSConcurrent Mark Sweep是Java历史上第一款真正以低停顿为目标的回收器。它的名字已经自带了算法——标记-清除。CMS把整个回收过程拆成四个阶段初始标记Initial MarkSTW但只标记GC Roots直接关联的对象非常快并发标记Concurrent Mark和业务线程一起跑从GC Roots往下遍历所有引用耗时长但不停顿重新标记RemarkSTW修正并发标记期间业务线程修改引用导致的遗漏并发清除Concurrent Sweep和业务线程一起跑把标记为死亡的对象内存回收这个设计让最长的STW只出现在初始标记和重新标记两个阶段其余时间都在并发停顿显著下降。但CMS的代价也很实在标记-清除会产生内存碎片碎片积累到一定程度时就不得不触发Full GC来整理并发阶段本身会占用CPU在CPU资源紧张的服务上反而可能拖慢吞吐量还有一个著名的浮动垃圾问题——并发清除期间业务线程新产生的垃圾对象只能留到下一次GC再处理。JDK 9开始CMS被废弃JDK 14正式移除现在新项目基本不建议再碰它了。2.3 G1的出现区域化堆与可预测停顿模型G1Garbage First在JDK 7u4发布JDK 9里被设成默认回收器。它最大的设计革命是把整个堆不再简单分成新生代和老年代两块而是划分成一个个大小相等的Region区域。这些Region在逻辑上仍然分别属于新生代、老年代但物理上不再连续而且Region的数量和大小可以动态调整。为什么要这样做因为按区域回收让回收器有了全局视角的调度能力它不需要每次都全量回收整个堆而是优先回收垃圾最多、回收成本最低的那些Region配合-XX:MaxGCPauseMillis参数在目标停顿时间内尽量多回收垃圾。这就是G1名字里Garbage First的由来——哪里的垃圾多就先收拾哪里。G1的出现也标志着JVM垃圾回收从物理分代走向逻辑分代区域化为后面的ZGC、Shenandoah铺平了道路。如果你去对比CMS和G1的GC日志会发现一个明显差异CMS的日志里几乎每次都是整堆回收而G1的日志里是某个集合的Region回收。这个细节体现了两种回收器在调度粒度上的本质区别。3. 现代回收器的核心机制与原理拆解G1与ZGC深度剖析3.1 G1的两大核心机制Region分区与SATBG1的Region是理解整个G1工作机制的第一把钥匙。默认情况下堆被分成约2048个Region每个Region大小在1MB到32MB之间具体由堆大小决定堆越大Region越大大概是堆大小 / 2048再取2的幂。比如一个4GB的堆每个Region约2MB。G1还专门设计了Humongous区域用来存放超过Region大小50%的大对象这类对象直接被分配到Humongous区在GC时单独处理。Region物理不连续但G1怎么知道某个Region里的对象被哪些其他Region引用了呢这就引出了第二个核心机制——Remembered SetRSet。每个Region都维护一个RSet记录有哪些其他Region引用了本Region里的对象。回收一个Region时只需要扫描它的RSet就能找到所有外部引用不必全堆扫描。RSet本质上是把跨Region引用的信息用空间换时间地预计算好代价是读写引用时会有额外的维护开销。这也是为什么G1在GC频率很高时RSet的维护开销会成为性能瓶颈之一。SATBSnapshot-At-The-Beginning是G1并发标记阶段的关键算法。这个算法看着抽象核心就一句话并发标记开始时给堆拍一张快照之后业务线程把某个对象引用改掉了只要在快照里这个对象是活的就依旧把它当作存活对象处理不会漏标。为什么必须这样因为在并发标记期间GC线程和业务线程同时在跑如果业务线程恰好把一个引用删掉而新引用指向的对象还没来得及被GC线程看到就可能出现漏标——把该活的对象当垃圾回收了这是绝对不允许的事。SATB的代价是浮动垃圾快照之后新创建的对象或快照里本可以回收但被业务线程重新引用的对象这次GC都收不掉只能留给下一次。所以你会发现G1堆里偶尔有一些看着是垃圾但没被清走的对象这不是bug而是SATB算法的预期成本。3.2 G1的回收流程Young GC、Mixed GC与Full GCG1日常的回收分为几种场景。Young GC是最频繁的新生代Region满了之后触发同样会STW但回收区域只涉及新生代停顿通常很短。G1会从空闲Region中划拨新的Region给新生代把存活对象拷贝到Survivor或晋升到老年代。当老年代占用达到阈值默认是堆大小的45%由-XX:InitiatingHeapOccupancyPercent控制G1会启动一个并发标记周期。这个周期和CMS很像初始标记、并发标记、重新标记、清理。清理阶段之后G1知道了每个Region里垃圾的占比于是进入Mixed GC阶段——它不再只回收新生代而是把新生代Region 垃圾占比最高的老年代Region混在一起回收尽量在-XX:MaxGCPauseMillis的目标内多清老年代垃圾。如果G1的回收速度跟不上对象分配速度老年代持续膨胀最后还是会触发Full GC。G1的Full GC退化为单线程的标记-整理停顿时间相当可观这通常是最危险的信号。所以说G1调优的核心场景其实就是如何让Mixed GC跑得足够勤快让Full GC永不发生。这句话值得贴在所有GC调优文档的封面。3.3 ZGC是如何做到毫秒级停顿的染色指针与读屏障ZGC在JDK 11发布实验版本JDK 15转正。G1已经把停顿压到几十毫秒级别ZGC的目标更激进不管堆有多大停顿时间都稳定在个位数毫秒。ZGC的核心机密之一叫染色指针Colored Pointer。它在指针本身的64位空间里借了几个比特位用来记录对象的三色标记状态白灰黑、是否可重映射等信息。GC线程修改对象颜色时不需要访问对象本身直接改指针比特位即可这让并发标记和并发整理可以非常高效地协作。ZGC的另一个关键组件是读屏障Load Barrier。每次Java线程读取对象引用时都会经过一道屏障检查如果发现指针的颜色和当前GC阶段不一致就执行修正——例如把对象拷贝到新地址后更新指针。由于读屏障只在读路径上做廉价判断绝大多数时候只是一个位运算级别的操作对吞吐量的影响被控制在极低水平。配合并发整理把存活对象搬迁到新页并重映射ZGC把STW压缩到只剩下极短的并发阶段开始和结束时的同步点。ZGC的代价也在这些激进设计里染色指针意味着早期的ZGC无法同时使用压缩指针后续版本才逐步放开读屏障和并发算法对内存带宽有一定消耗GC线程与业务线程并行工作对CPU核数有要求核数太少时反而可能不划算。不过从JDK 21开始ZGC引入了分代模式进一步降低了并发GC的CPU消耗让超低延迟回收器在普通服务器上变得更实用。4. 选型判断与调优实战不是所有业务都需要ZGC4.1 如何给业务选一个合适的回收器很多同学一看到ZGC停顿只有几毫秒就想给线上服务全部换ZGC。我的态度一直是先看业务再看硬件最后才是情怀。选型的基本逻辑可以这么梳理如果主要诉求是吞吐量比如离线计算、批处理任务用户不会因为多停顿几百毫秒而感知到延迟那么Parallel Scavenge Parallel Old依然是最稳的选择吞吐量高、实现简单、参数成熟。如果是典型的在线Web服务对延迟敏感但要求不算极致G1是默认且稳妥的选择。绝大多数Java服务在默认配置或轻微调参下用G1就能把停顿控制得很好。如果业务对延迟极度敏感比如高并发交易、实时竞价这类场景堆又很大甚至几十GB才值得换ZGC。注意ZGC对CPU核数有要求——并发阶段需要空闲CPU资源核数太少时GC线程和业务线程互相抢时间片收益会打折扣。客户端小应用、单核低配机器Serial反而最省事。判断依据很简单先量一量你的服务对GC的敏感度。如果一次Full GC停顿2秒只有日志告警而业务无感那没必要折腾如果一次500ms的停顿就会导致调用方超时雪崩那ZGC才值得投入精力。调优最大的错误是为了调优而调优。4.2 GC日志与常用参数看不懂日志就别谈调优排查GC问题的第一步永远是看日志而不是猜。JDK 8及以后推荐用统一日志体系开启-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags这条命令会在指定路径输出GC日志包含时间戳、运行时长、级别和标签。JDK 8之前的写法-XX:PrintGCDetails -XX:PrintGCDateStamps也能用但新项目直接上Xlog。日志文件建议按天滚动别让单个文件无限增长用-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount10,filesize20m控制轮转。常用参数整理成一张表参数作用-Xms/-Xmx初始堆/最大堆生产上通常设为一致避免运行时扩容带来的抖动-XX:NewRatio新生代与老年代比例默认2表示老年代是新生代的2倍-XX:SurvivorRatioEden与Survivor比例默认8即Eden占新生代的8/10-XX:MaxGCPauseMillisG1目标停顿时间默认200ms-XX:ParallelGCThreadsGC并行线程数默认随CPU动态调整-XX:UseG1GC/-XX:UseZGC切换回收器-XX:HeapDumpOnOutOfMemoryErrorOOM时自动dump堆快照-XX:InitiatingHeapOccupancyPercentG1触发并发标记周期的老年代占比阈值默认45给一段典型的GC日志解析[GC (Allocation Failure) [PSYoungGen: 189432K-15478K(189440K)] 189432K-180892K(501248K), 0.0312341 secs]这段日志的意思是年轻代发生了一次垃圾回收原因是分配新对象时空间不足。回收前新生代用了约189MB回收后还剩15MB新生代容量为189MB整个堆回收前用了189MB回收后还有180MB——因为大量存活对象晋升到了老年代本次回收耗时31ms。看完这一段你就知道新生代回收正常但老年代占用在快速增长如果持续这样下一步就是Full GC。4.3 常见性能问题的排查链路我按实际踩过的坑整理了三个最典型的场景。场景一频繁Full GC老年代持续打满先别急着调参数。用jstat -gcutil PID查看各区域占用和GC次数重点看老年代O列是否一直90%以上。如果老年代打满优先做堆内存dumpjmap -dump:formatb,fileheap.hprof PID再用MAT等工具分析GC Roots路径看看是哪些对象占着内存不放。绝大多数情况下Full GC背后都藏着内存泄漏或集合类对象无界增长调大堆内存只是把问题往后推迟。我自己处理过一个案例某服务每天凌晨Full GC一次dump出来发现是一个定时任务把查询结果全部塞进了一个静态HashMap从来没有清理。场景二G1频繁触发Full GC但堆占用看起来并不高这个坑很隐蔽。G1的Full GC不只是老年代满了才会触发Humongous区域大对象区满了同样会触发。比如业务代码里频繁分配超大byte数组超过Region大小的一半这些大对象直接进Humongous区而Humongous区回收条件苛刻很容易积累到满。遇到这种情况可以先用jcmd GC.heap_info看一下Humongous区域的占用。解决思路有两条一是用-XX:G1HeapRegionSize把Region调大让大对象的判定阈值提高二是在业务侧检查是否真的有必要分配这么大的对象很多时候用缓冲区池化或分段处理就能解决。我在压测环境就因为这个坑复现过线上Full GC好在改完Region大小后整个测试周期再没出现过Full GC。场景三ZGC上生产后CPU飙升先确认是不是GC线程占的CPU太多。ZGC没有传统的GC次数统计需要用jcmd GC.heap_info或ZGC自带的统计信息查看。ZGC的并发标记和读屏障都会消耗一定CPU如果服务本来的CPU余量就不足10%上ZGC可能让吞吐量明显下降。这时候要么换回G1要么给ZGC留足CPU余量。记住低延迟不是免费的它是拿CPU和内存带宽换的。如果一个服务本身平均负载就在80%以上你还想上ZGC那大概率会踩到新的坑。最后分享一个我自己的习惯每次调GC参数只改一个变量跑足一个业务周期对比前后GC日志里的GC次数、平均停顿、最坏停顿和吞吐量然后再改下一个。参数调优最忌讳的就是一次改五六项出问题了都不知道是哪个参数引起的。所有线上GC参数调整都必须灰度验证先在压测环境把负载压到峰值的1.5倍确认没有异常再推线上。有一次我就是在压测环境发现了G1的Region过小导致Humongous频繁的问题避免了一场线上事故。这个习惯比任何回收器选型都重要。
阅读完成 · 觉得有帮助?