ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JVM G1垃圾收集器详解:从原理到调优实践

JVM G1垃圾收集器详解:从原理到调优实践 1. 闲聊开始网上搜“G1”你到底想干嘛坦白讲收到“闲聊GC-G1”这个题目时我第一反应是去翻了翻所谓的全网热词看看现在搜“G1”大家都想看什么。果然不出意外搜出来一头雾水git拉取代码一直fetch的时候提示“see help gc for manual housekeeping”这是让我去清垃圾对象G1主观赋权法——当我看完这词条第一反应是哦原来是搞数据的统计指标权重的再看看还有宇树G1机器人拆解和遥操作。在座各位后端工程师、Java开发应该和我一样看到这些“G1”第一眼能愣住但紧接着脑子里瞬间映射出的是一个无比熟悉的概念JVM Garbage First垃圾收集器。没错在Java技术栈里但凡你说“聊GC聊G1”不管热搜榜上挂着的是机器人还是数学公式咱们的话题只有一个为什么我的服务时不时卡顿为什么并发一高JVM的GC日志全屏都是Pause为什么G1明明号称“可控停顿”却还是把我们的接口给憋成了慢响应这篇文章就当成是工作间隙的一段闲聊不讲教科书级别的源码阅读就聊我自己在线上环境里调G1、看G1日志、排查G1导致的各种幺蛾子时攒下来的经验。做后端时间长了你会发现JVM的垃圾回收这件事你逃不掉也躲不开。既然躲不开那就把它掰开揉碎聊清楚。适合刚接触JVM调优的新人也适合那些已经用了G1几年但总是在“背参数”却不太知道每个参数背后到底在干嘛的老兵。一句话先说透G1不是银弹它只是把原本一次性的“大清扫”拆成了很多次“小块巡逻”让“随机长停顿”变成“可控的短停顿”。但代价是什么呢代价就是它引入了一堆极其绕的概念和配置项比如Region、SATB、RSet、Mixed GC。你要是对这几个名词心里没底那G1这辆车你开起来早晚会翻。2. 为什么会有G1从CMS的“死亡”开始聊2.1 CMS的老大难碎片化与Concurrent Mode Failure咱们这批人谁没被老一代CMS收拾过。CMSConcurrent Mark Sweep的设计思路和它的名字一样是“并发标记清理”。它的核心卖点就是那些需要和用户线程并发的阶段标记、清理不用从头到尾停止整个世界STW。听起来很美但用过的都知道CMS有几个从娘胎里带出来的毛病。第一个毛病是内存碎片化。CMS用的是“标记-清除”算法不整理堆内存。所以它跑得越久老年代越像年久失修的出租屋这一块空着、那一块空着零星散落着好几块巴掌大的可用区域。这时候如果新进来的大对象又要塞进老年代找半天找不到一块连续空间容纳它CMS就会触发它最恐怖的一个机制Concurrent Mode Failure。一看到这个单词基本就等于告诉你兄弟没辙了接下来要请出Serial Old进来做一次全停顿的标记-整理-Full GC。那感觉就像是你买了个号称“无损并发”的扫地机器人结果它扫到一半自己卡住了还得你亲自上手把全屋家具搬开手动擦地。第二个毛病是CPU资源吃紧。CMS并发阶段虽然不会STW但它是实实在在要抢CPU的。如果服务机器核数不够CMS并发标记阶段一跑应用线程的QPS就会往下掉延迟蹭蹭往上冒。那时候在4核8G的小机器上部署服务最怕的就是半夜CMS开始“勤奋工作”。2.2 G1的颠覆性思路大块头切小块分而治之G1在JDK 7中首次亮相JDK 9直接把它设置为默认垃圾收集器JDK 17时代更是妥妥的生产主力。G1的设计哲学完全推翻了CMS的“整堆扫描、整堆清理”思路。官方名字叫Garbage-First直译过来是“垃圾优先”。什么意思就是我哪块区域的垃圾最多、回收起来性价比最高我就先清哪块。要做到这一点G1把整个堆内存划分成了一个个大小相等的小格子官方术语叫Region。每个Region既可以充当年轻代的Eden也可以充当Survivor还可以是Old区甚至还有专门的Humongous区来存放超大对象。这么一搞G1就可以把这些Region当作一张巨大的拼图每次GC决策时它不是非把整个堆翻一遍不可而是挑选一批“最值得回收”的Region加入本次收集集合Collection Set简称CSet。这就是G1最核心的目标暂停时间可控。通过参数-XX:MaxGCPauseMillis你告诉JVM每次GC的STW时间尽量别超过这个值。G1内部维护了一套“暂停时间预测模型”它会根据历史回收记录来估算如果这次挑这100个Region大约花费150ms那行就按这个量来如果昨天数据波动大它就会调整策略减少或增加Region数量朝着你设定的目标尽量逼近。打个不严谨但好懂的比方。以前CMS是做大扫除一年大扫除一次平时脏乱差忍忍就行但大扫除那天从早到晚干12小时干净是干净了全天家里没法住人。G1呢是每天下班后花半小时整理一个房间今天收拾客厅明天收拾厨房长期坚持下去屋子里一直是能住人的状态。代价就是你每天都要花点时间去收拾没法像CMS那样装作看不见。3. G1核心机制拆解Region、RSet和SATB到底是个啥3.1 RegionG1的世界观里没有“老年代”这堵墙咱们先记住一个结论G1里其实没有物理意义上的“年轻代”和“老年代”。从内存块的大小和形态来看所有Region长得一模一样。只是在逻辑上G1会动态地给不同Region分配角色。-XX:G1HeapRegionSize这个参数就是用来指定每个Region大小的。如果不指定G1会根据你的堆大小自动算一个合适的值出来。有个经验公式大家可以参考G1的目标是让堆被划分为大约2048个Region。比如我们的服务-Xmx设置成了4GB那默认Region大小就是 4GB / 2048 ≈ 2MB。所以我一般习惯显式地指定一下比如用-XX:G1HeapRegionSize4m或-XX:G1HeapRegionSize8m避免自动计算带来一些边界瑕疵。Region太小会有什么问题一个对象如果超过Region大小的50%官方就把它归为大对象Humongous比如你有一个80MB的大对象需要分配但Region是2MB那这个大对象就要独占连续40个Region。Humongous对象的分配和回收都比较“霸道”。分配的时候要找到一段连续的Region来放它触发一次特殊的停顿叫G1 Humongous Allocation回收的时候更麻烦因为没人能移动它它只有在并发标记后确认是垃圾才能被回收。如果大对象在服务里频繁创建你会看到GC日志里频繁出现 “Humongous Allocation” 相关的停顿接口响应时间直接往上飙。3.2 RSet跨Region引用的“备忘录”年轻代和老年代之间互相引用的追踪在G1里也换了一套玩法。以前CMS要找到老年代里哪些对象被年轻代引用最笨的办法就是把整个老年代扫一遍找出哪些Card是dirty的这效率极低。G1引入了Remembered SetRSet的概念。每个Region都有一个RSet记录了“有哪些Region中的对象引用了我这个Region中的对象”。通俗地讲这就是一张备忘清单。每当有一个对象A引用了另一个Region里的对象B时JVM内部会通过一个写屏障Write Barrier在B所在Region的RSet里记上一笔“喂A在那边那个Region里你要找我的话去那边找。”有了这个备忘录GC在回收某个Region时就不必遍历整个堆去找谁在引用我直接查我自己的RSet名单就行。这样一来回收的粒度就可以精确控制。这也是为什么G1能实现“挑选部分Region进行回收”而CMS做不到的原因。RSet是G1的核心优势也是G1内存开销的一个大头。RSet本身也会占用堆外或者堆内空间如果你的应用里对象之间的引用特别“错综复杂”RSet的体积会相当可观这部分内存开销是很多人容易忽略的。3.3 SATB并发标记时的“快照”保底G1的并发标记阶段用的是SATBSnapshot-At-The-Beginning起始快照算法。这个东西是为了解决一个在“GC线程和应用线程并发运行”时很头疼的问题你这边正在判断一个对象是否活着那边应用线程已经把它的引用干掉了或者新造出了一个引用。你到底该不该把它标记为存活如果漏标了就可能导致“明明是活对象却被回收掉”的严重事故直接引发JVM崩溃。CMS时代为了解决这个使用了“增量更新”Incremental Update策略代价就是“写屏障”逻辑极其复杂还容易扫描漏掉。G1换了个思路干脆不追求标记时刻的绝对精确我以并发标记开始那个瞬间的堆状态为基准做一个“快照”。在这个快照时刻只要某个对象是可达的那么在整个并发标记期间我就默认它一直是可达的绝对不会把它当垃圾清掉。换而言之G1允许“过度标记”宁可错杀一千不放过一个存活对象但绝不允许“漏标”。那如何保证呢G1在应用代码每次进行对象引用变更的时候插入一个“预写屏障”Pre-Write Barrier把变更前的那份引用记录下来放入一个叫SATB的队列里。GC线程会去处理这份记录确保快照时刻不可达、但在标记过程中变成可达的对象也能被识别为存活。这么做确实增加了并发阶段的开销但换来的是极低的风险和相对稳定的停顿时间。实际经验来看绝大部分线上服务这种SATB队列压力都不大除非你的那个服务真的是眨眼之间对象图疯狂变化。3.4 Mixed GC为什么G1回收老年代叫“混合”刚才说G1把堆分成Region那么怎么回收老年代对象G1没有CMS那样“全堆一次并发清理”的流程。它把整个回收过程拆成了两个大类Young GC和Mixed GC。Young GC就是只回收年轻代Region。这个过程是STW的把Eden区和Survivor区里存活下来的对象复制到新的Survivor或者直接晋升到老年代Region。这个过程完全复制/移动所以不会产生碎片整理得干干净净。Mixed GC就不一样了它除了回收年轻代Region还会顺带挑选一部分老年代Region加进CSet里一起回收。这才是G1英文名里“Garbage-First”的精髓每次Mixed GC都会重点挑选那些“垃圾占用比例最高”的老年代Region先回收这样能以最小的工作量腾出最多的空间。但要注意Mixed GC不是你想触发就能触发的。它需要在“占用率足够高”时触发并发标记周期Concurrent Cycle经历初始标记、并发标记、重新标记、清理这几个阶段标记出老年代里哪些Region垃圾多。然后G1会根据目标停顿时间、垃圾占比等参数分多轮去执行Mixed GC。所以G1的老年代回收是“细水长流”式不像CMS那样突然来一次大的。4. G1参数和配置实例别乱调但也不能完全不调4.1 基础参数配置速查聊了这么多概念不落地点配置参数总感觉少了点什么。下面这张表是我自己一直放在收藏夹里的线上服务启动参数基本脱不开这张表。参数名默认值我的建议-XX:UseG1GC开启JDK9明确加上避免摸不清默认收集器-XX:Xms -Xmx视场景生产环境务必设成相同值避免堆抖动-XX:MaxGCPauseMillis200ms一般设定为100-300ms别设太小-XX:G1HeapRegionSize自动计算建议显式指定堆3-8G用2M或4M-XX:G1NewSizePercent5%年轻代最小比例别乱动-XX:G1MaxNewSizePercent60%年轻代最大比例-XX:InitiatingHeapOccupancyPercent45%常被改成50-60%触发并发标记阈值-XX:ConcGCThreadsParallelGCThreads的1/4并发标记线程数-XX:ParallelGCThreadsCPU核数并行GC线程数-XX:G1HeapWastePercent5%回收候选区垃圾占比低于此值垃圾回收会提前结束-XX:G1MixedGCLiveThresholdPercent85%Mixed GC对象存活率高于此值的Region不回收-XX:G1MixedGCCountTarget8控制Mixed GC轮数-XX:ExitOnOutOfMemoryError关闭推荐开启OOM时直接退出让容器重启4.2-XX:MaxGCPauseMillis千万别设置成1ms很多初学者一看到“可控停顿时间”上来就把-XX:MaxGCPauseMillis1心想既然美妙的停顿时间可控那就让它控制在1毫秒内呗。结果一上线JVM确实疯狂响应目标把GC周期切得非常碎每隔几百毫秒就来一次Young GC每次Young GC只收集那么一丢丢Region。这个行为你从GC日志里看暂停时间确实没超过1ms但GC频率暴增CPU大部分都在跑GC业务吞吐量直接腰斩。我踩过这个坑。有一次给一个活动页面的接口调优我把停顿时间设成了20ms。结果线上观察到老年代的对象还没来得及晋升就被反复扫描触发了一连串的并发标记周期。整个服务的TPS从3000降到了1200GC消耗的CPU占比直接冲到30%以上。后来改回150ms世界立刻安静下来。经验是G1的暂停时间预测模型本质上是拿“回收频率”和“GC线程CPU开销”来换“单次停顿时间”。你强行把目标压得太低它就只能用极高的频率去打扫整体开销反而更大。一般来说线上业务系统设置为100ms到300ms是一个比较健康的区间再低就属于自找麻烦。4.3-XX:InitiatingHeapOccupancyPercent是并发周期的发令枪IHOP默认是45%意思是整个堆的使用率达到了45%G1就开始准备执行并发标记周期。这个参数直接影响老年代的回收节奏。如果堆的使用率长期在60%上下波动但IHOP还是45%那么G1就会频繁地启动并发标记标记完发现老年代其实也没多少垃圾或者Mixed GC没挑到几个值得回收的Region空跑了一轮。空跑的代价是CPU持续消耗在并发标记上。反过来如果你把IHOP设置的过高比如80%那老年代垃圾堆积得很严重时才触发标记很有可能标记完还没开始Mixed GC回收老年代就彻底放不下新晋升的对象了于是G1被迫退化为Full GC暂停整个世界做Serial Old式整理。这比CMS晚节不保时的Concurrent Mode Failure好点但一样让你脑溢血。所以我建议的做法是先看监控里老年代的占用曲线。如果长期在60-70%徘徊就把IHOP调到55%或者60%左右留出安全余量让G1在你有压力之前提前开始“囤积情报”。同时配合-XX:G1HeapWastePercent控制一下在清理阶段如果dirty region太少就及时终止避免浪费资源。4.4 G1调优的禁忌清单不要无脑调-XX:G1NewSizePercent。这玩意儿改小了会导致年轻代过早晋升老年代压力陡增改大了虽然Young GC频率降低但一次性晋升的大对象可能撑爆Survivor。新手不建议动它。不要在同一台机器上塞太多JVM实例。每个G1实例都有并发标记线程即便设置了ConcGCThreads你想想一台宿主机上跑10个Java进程每个进程5个并发GC线程再加上业务线程CPU核数不够时GC就变成了群体活动互相拖累。不要以为G1没有Full GC。G1只有在并发标记和回收进展顺利的情况下才能避免Full GC。一旦你制造了大量大对象或者程序里有严重的内存泄漏堆被塞满G1照样会触发Full GC而且这位老兄的Full GC是串行Single Thread的速度巨慢。说白了Full GC在G1里就是“终极保底手段”真到了那一步稳定性和性能早就无处可寻了。5. 实操现场从一次线上报警看 G1 日志到底怎么读5.1 一次让人头秃的“接口波动”事件前阵子我们有个核心交易服务某个大促预热期间业务方反映说接口响应时间从正常的30ms突然波动到500ms以上而且这种波动不是偶发是每隔十几分钟就来一波每次持续一两分钟。我们一查监控发现Full GC指标的小时次数从0涨到了4同时GC耗时曲线呈周期性冒尖。我第一反应是该不会是用了CMS的老服务没迁移了一看Java版本和启动参数发现这个服务是标准的JDK 17 G1。那就怪了G1在正常情况下是不太容易周期性大停顿的。我们通过监控平台拉取了实时的GC日志片段开始逐行分析。5.2 G1日志逐行解读生产上可以通过-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags这个参数打开详细GC日志记录。等日志拉下来你会看到类似下面这种我来带你走一遍[GC pause (G1 Evacuation Pause) (young) (initial-mark) [Parallel Time: 92.5 ms] [GC Worker Start (ms): 12345.6 12345.7 ...] [Ext Root Scanning (ms): 15.2 16.8 ...] [Update RS (ms): 8.4 9.1 ...] [Scan RS (ms): 6.2 7.4 ...] [Object Copy (ms): 50.3 51.7 ...] [Termination (ms): 2.1 1.8 ...] [GC Worker Other (ms): 0.8 0.9 ...] [GC Workers: 8] [Code Root Fixup: 0.2 ms] [Code Root Purge: 0.1 ms] [Clear CT: 0.6 ms] [Other: 10.0 ms] [Eden: 1024M(1024M)-0.0B(1024M) Survivors: 128M-128M Heap: 3072M(4096M)-2048M(4096M)] [Times: user0.82 sys0.12, real0.11 secs]这段日志告诉我们几个信息这是一次年轻代Evacuation暂停同时附带initial-mark说明要开始并发标记周期了。Parallel Time约92ms这期间Ext Root Scanning花了15ms说明GC Root扫描耗时有点高通常和应用的线程数、类加载器数量、JNI引用数量有关。Update RS和Scan RS分别是8.4和6.2这说明RSet更新和扫描的代价还行没到炸裂的程度。Object Copy用了50ms这个时间段主要是把存活对象复制到新Region如果内存里大对象多这个值会非常难看。结尾的Eden统计里Eden区从1024M直接被清空到0说明刚经历了一次比较完整的年轻代回收。而Heap区从3072M降到2048M算一下大概释放了1G内存回收量理论上并不低。5.3 报警背后的元凶Humongous 对象在作祟光有上面这段日志还没法定位到那个周期性的“Full GC”。我们接着看后续日志发现GC日志里频繁出现这样的片段[GC pause (G1 Humongous Allocation) (young) (initial-mark) [Parallel Time: 210.3 ms] ... [GC concurrent-mark-start] ... [GC concurrent-mark-end] ... [Full GC (Allocation Failure) ...看到G1 Humongous Allocation和Full GC (Allocation Failure)联系在一起基本就能确定问题方向了这个服务里一定有大量的大对象在频繁创建。大对象Humongous Object因为要占据连续Region在分配时本身就比小对象更容易触发停顿。更致命的是G1的并发标记在处理Humongous对象时要扫它的RSet效率比常规Region低不少。我们查了代码发现这个服务在预热期间增加了一个定时任务每隔5分钟会把一批大报文一次性加载进内存做业务计算。这批报文每条都有几十MB存到一个缓存Map里等下一个周期再替换掉。这就导致了每隔十几分钟就会触发一轮“大对象分配失败”带来的Full GC。解决方案不复杂但很能说明问题把大报文拆小或者改成异步流式处理避免一次性全部加载到堆中临时调整-XX:G1HeapRegionSize从2M改成4M让部分大对象不至于占满几十个Region稍微缓解一下分配压力同时把-XX:InitiatingHeapOccupancyPercent从默认的45%调到55%让G1没必要那么早进入并发标记省掉一部分重复的并发周期最后给那个定时任务默认的线程池设置了一个合理的等待队列上限防止任务堆积时把内存瞬间吃干。这一套组合拳下来Full GC的次数直接从每小时4次降到了0次接口的P99响应时间从500ms降回了45ms附近。5.4 读GC日志要盯的核心数据有人问我GC日志那么多行到底该看哪些指标我一般按优先级从高到低看这几个先看线路里有没有Full GC字样有就意味着大事不妙说明G1的并发回收已经跟不上了系统在走下策STW时间会非常长。如果没有Full GC那看Parallel Time和Ext Root Scanning。Parallel Time是整个暂停的主体如果它一直过高说明应用线程数可能太多GC Root扫描开销太大。再看[Eden ...]释放量和Survivors大小。如果Survivor频繁溢出大量对象直接晋升老年代那么老年代压力会很大GC频率也会跟着提高。如果 GC 日志中出现concurrent-mark-start到concurrent-mark-end时间间隔特别长比如超过几百毫秒甚至秒级说明并发标记阶段应用线程的争抢非常激烈往往是CPU被大量占用导致的。记好这几点你去看GC日志就不会像看天书一样抓重点的能力直接上升一个档次。6. 选型经验谈G1、Parallel和ZGC到底该怎么挑6.1 G1并不是所有场景的默认解虽然G1现在是JDK默认收集器也确实是绝大多数后端服务的安心之选但它绝对不适用于所有场景。有些服务内存分配压力极大追求的是“吞吐量优先”比如离线批处理、数据仓库ETL任务。这类任务的特点是对单个请求的延迟不敏感但对整个任务的完成时间敏感。此时Parallel Scavenger Serial OldPS Serial Old这种组合往往是更好的选择。Parallel收集器可以尽可能把GC线程开满用更高的并发回收率换取更高的吞吐量一两秒钟的STW在离线任务里根本不是事。所以我通常的建议是如果你跑的是实时在线接口、RPC调用、网关、微服务实例选G1默认参数微调即可稳妥。如果你跑的是离线任务、一次性批量跑数、或者单机内存特别大比如超过64G且不想折腾那么Parallel依然是好同志。如果你对延迟极其敏感比如量化交易、在线游戏服务器、高频交易风控要求GC停顿必须小于10ms那你应该去拥抱ZGC或Shenandoah。JDK 17之后的ZGC已经实用了吞吐量和稳定性都经得起考验。它的思路更进一步利用染色指针、读屏障等手段把绝大部分GC阶段变成了并发STW时间几乎被压到了极致。6.2 一个简单的决策表格应用类型极致吞吐量离线批处理在线后台服务标准微服务低延迟交易系统推荐收集器ParallelG1ZGC核心优势高吞吐吞吐与延迟的平衡极低停顿劣势STW不可控大对象仍可能引发Full GC内存开销大CPU开销高堆大小经验区间任意适合大堆8G-32G都行32G以上优势明显6.3 我对选型的个人体会最初用G1的时候我也迷信“调一调参数就能包治百病”。后来线上踩的坑多了逐渐明白一个道理垃圾收集器管的是“清理垃圾”这件事它本身没法让你的代码不去制造垃圾。无论G1怎么进化如果你的业务逻辑里疯狂创建大对象、缓存不设上限、批量循环里频繁字符串拼接再牛的收集器也救不了你。所以我现在做性能优化时的顺序是先看代码再看堆转储最后才调GC参数。GC调优是兜底策略不是第一手段。最后分享一个很实用的小习惯生产环境JVM启动参数里我都建议加上一个-XX:ExitOnOutOfMemoryError。这样一旦真发生堆溢出JVM会直接退出进程让容器调度器重新拉起一个干净实例而不是让服务继续在半死不活的状态下带病运行。配合监控告警你会发现好多“莫名其妙的长时间Full GC”问题实际都是内存泄漏的预警信号。与其耗在那里让人工介入不如快速fail-fast先恢复业务再慢慢通过dump文件定位问题。这也是这些年我和G1“相爱相杀”过程中最想提醒大家的一条实践经验。
返回列表