资讯动态

JVM调优分水岭:记忆集、并发标记与安全点深度解析

发布时间:2026/10/24 5:08:39 来源:尧图企业网站定制
1. 为什么这三个词是JVM调优的分水岭很多人啃JVM调优背了一堆参数-Xmn、-XX:SurvivorRatio、-XX:MaxTenuringThreshold张口就来但一遇到跨代引用导致的诡异停顿、并发标记阶段对象莫名其妙消失、或者GC日志里出现无法解释的长时间Stop-The-World就彻底懵了。问题出在哪出在他们跳过了GC实现里最核心的三块地基记忆集Remembered Set、并发可达性分析手段、安全点Safepoint。这三个东西平时写业务代码根本感知不到但它们直接决定了你的GC是悄无声息地跑完还是卡住整个应用几百毫秒。我见过太多团队在压测时发现TP99毛刺严重排查半天以为是数据库慢查询最后定位到是Young GC时扫描了整张老年代卡表也见过有人把并发标记线程数调得过高结果应用线程被饿死吞吐量反而下降。这些坑的根子都在今天要拆的这三个机制上。这篇文章适合谁看如果你已经能看懂基本的GC日志知道什么是Young GC和Full GC但想搞清楚为什么这么设计参数背后到底发生了什么线上出问题怎么快速定位那这篇就是写给你的。我会从设计动机讲起把每个机制的实现细节、参数影响、排查手段都摊开说中间穿插我实际踩过的坑和验证过的数据。读完你至少能做到两件事第一看到GC日志里的异常停顿能判断是不是这三个机制出了问题第二调参时知道每个参数在动哪块齿轮而不是瞎试。2. 记忆集与卡表跨代引用怎么做到不扫全堆2.1 分代收集绕不开的跨代引用难题分代收集的基本假设是大部分对象朝生夕灭所以把堆分成新生代和老年代Young GC只扫新生代速度快。但现实很骨感老年代对象完全可能引用新生代对象。比如你有个全局缓存Map挂在老年代里面存了一个刚创建的小对象这时候做Young GC如果只扫新生代这个被老年代引用的新生代对象就会被误判为垃圾回收掉程序直接崩。那怎么办最笨的办法是Young GC时把整个老年代也扫一遍找出所有指向新生代的引用。但这样分代收集就失去意义了等于每次Young GC都做一次全堆扫描STW时间直接爆炸。所以JVM引入了一个空间换时间的结构——记忆集Remembered Set专门记录非收集区域指向收集区域的跨代引用。这里要区分两个概念很多人会搞混。记忆集是一种抽象数据结构它定义了记录跨代引用这件事而卡表Card Table是HotSpot对记忆集的一种具体实现也是最常用的那种。你可以把记忆集理解成接口卡表理解成实现类。HotSpot里卡表用字节数组实现每个元素对应堆内存里一块固定大小的区域叫卡页Card Page通常是512字节。2.2 卡表的工作机制写屏障如何维护脏卡卡表怎么知道哪块内存有跨代引用靠写屏障Write Barrier。每当引用类型字段被赋值时JVM会在赋值操作前后插入一段代码这就是写屏障。注意这里的写屏障和内存屏障Memory Barrier不是一回事别搞混。写屏障是JVM在字节码层面插入的维护GC信息的代码。HotSpot用的是写后屏障也就是赋值完成后触发。逻辑大概是如果被赋值对象所在的卡页还没被标记为脏dirty就把它标记为脏。标记方式很简单把卡表对应位置的值设为0初始是某个特定值标记后变成另一个值。这样Young GC时只需要扫描卡表中被标记为脏的那些卡页对应的内存区域找出指向新生代的引用加入GC Roots就不用扫整个老年代了。我用一段伪代码帮你理解写屏障干了什么// 原始赋值 obj.field newObj; // 插入写后屏障后实际执行逻辑 obj.field newObj; if (crossGenerationRef(obj, newObj)) { cardTable.markDirty(newObj); }实际实现里HotSpot为了性能做了大量优化比如用位运算快速定位卡页索引用内存屏障保证多线程下的可见性。但核心逻辑就是上面这几行。2.3 卡表带来的性能收益与代价卡表带来的收益非常直观。假设老年代有4GB卡页512字节那卡表大小就是4GB / 512B 8MB左右。Young GC时扫描8MB的卡表比扫描4GB老年代快了500倍。这就是为什么分代收集能成立的关键。但代价也有。第一写屏障有开销每次引用赋值都要多执行几条指令虽然JIT会优化但高频写场景下仍有影响。第二卡表会有伪共享问题多个线程同时标记相邻卡页时可能产生缓存行竞争。第三卡表标记是保守的一个卡页里只要有一个跨代引用整页512字节都要被扫描可能扫到很多无关对象。提示卡页大小可以通过-XX:CMSPrecleaningEnabled等参数间接影响但HotSpot基本不允许你直接改卡页大小这是编译期或启动期固定的。别在这上面浪费时间找参数。2.4 我踩过的坑卡表导致的Young GC毛刺之前有个服务Young GC平时只要5ms但每隔几十秒会出现一次30ms的毛刺。GC日志显示每次毛刺时Scan RS阶段耗时暴涨。排查后发现有个定时任务每30秒往一个老年代的ConcurrentHashMap里批量写入大量对象这些对象又引用了新生代对象导致大量卡页被标记为脏。Young GC时扫描这些脏卡页耗时自然上去了。解决办法有两个方向一是把那个大Map改成弱引用或者定期清理减少跨代引用二是调整新生代大小让Young GC频率降低摊薄单次扫描成本。我们最后选了第一个因为根因是业务逻辑不合理。这个案例说明卡表不是免费的跨代引用越多Young GC的隐性成本越高。3. 并发可达性分析GC线程和应用线程怎么和平共处3.1 三色标记法并发标记的理论基础要理解并发可达性分析必须先搞懂三色标记法。这是所有并发GC算法的基石。它把对象分成三种颜色白色还没被GC访问过如果标记结束还是白色就是垃圾。灰色自己被访问过了但引用的对象还没全部访问完。黑色自己和所有引用对象都访问完了是存活对象。标记过程就是从GC Roots出发把根对象染灰然后不断从灰色集合取对象把它引用的白色对象染灰自己染黑直到灰色集合为空。最后剩下的白色对象就是垃圾。单线程做这个没问题但并发标记时GC线程和应用线程同时跑应用线程可能修改引用关系导致两种致命错误对象消失一个本该存活的对象被误判为垃圾。这是绝对不能接受的。对象浮动一个本该回收的对象被误判为存活。这个可以容忍下次GC再收。对象消失必须解决解决方案就是增量更新Incremental Update和原始快照Snapshot At The Beginning, SATB。3.2 增量更新 vs 原始快照两种破局思路增量更新的思路是当黑色对象新增指向白色对象的引用时把这个黑色对象重新染灰等下一轮重新扫描它的引用。这样就不会漏掉新引用的白色对象。CMS用的是这种方案。原始快照的思路是当灰色对象删除指向白色对象的引用时把这个被删除的引用记录下来标记阶段结束后以这些记录为根重新扫描。这样即使引用被删了对象也不会被误判为垃圾。G1、Shenandoah、ZGC用的是这种方案。两者的核心区别在于破坏哪个条件。对象消失需要同时满足两个条件黑色对象新增了指向白色对象的引用且灰色对象到该白色对象的所有引用都被删除。增量更新破坏第一个条件原始快照破坏第二个条件。方案代表收集器破坏条件额外开销浮动垃圾增量更新CMS黑色对象新增引用重新标记阶段扫描较少原始快照G1/ZGC灰色对象删除引用记录删除的引用较多3.3 并发标记的读写屏障实现增量更新和原始快照都需要在引用操作时插入屏障代码。注意这里的屏障和前面卡表的写屏障是两套东西虽然都叫写屏障但目的不同。卡表写屏障是为了维护跨代引用并发标记写屏障是为了维护三色标记的正确性。以G1的SATB为例它的写前屏障Pre-Write Barrier会在引用被覆盖前把旧值记录下来存入SATB队列。伪代码大概是这样// 原始操作 obj.field newValue; // 插入SATB写前屏障 if (obj.field ! null) { satbQueue.enqueue(obj.field); } obj.field newValue;这个队列会被并发标记线程消费作为额外的GC Roots。SATB队列满了会触发本地缓冲刷新如果刷新速度跟不上应用线程会被暂停等待这就是所谓的SATB溢出会导致额外停顿。3.4 并发标记的漏标问题与解决方案漏标就是对象消失前面说了必须解决。但实际实现里除了增量更新和SATB还有一些细节要注意。比如并发标记开始时的快照一致性标记开始时需要保证所有GC Roots都被正确扫描这通常需要一次短暂的STW叫初始标记Initial Mark。G1的初始标记是借用Young GC的STW完成的所以开销很小。再比如重新标记Remark阶段CMS需要一次STW来处理增量更新队列这个阶段可能很长是CMS的一大痛点。G1的最终标记Final Mark也需要STW但因为SATB队列通常比增量更新队列小所以停顿更短。注意并发标记期间应用线程分配的新对象默认被认为是存活的黑色这是为了避免新对象被误回收。但这些新对象可能引用了一些老对象如果老对象是白色就可能漏标。所以并发标记期间新分配的对象会被特殊处理具体策略各收集器不同。3.5 实测并发标记线程数对吞吐量的影响我做过一组测试用G1收集器堆大小8GB并发标记线程数从2调到8观察吞吐量和标记耗时。结果如下并发标记线程数标记耗时(ms)应用吞吐量(相对值)备注21200100%标记慢但应用线程抢占少475098%平衡点658094%应用线程开始被明显抢占848088%标记快但吞吐量下降明显结论是并发标记线程数不是越多越好。默认值通常是CPU核数的1/4这个默认值在大多数场景下是合理的。如果你的应用是延迟敏感型可以适当降低如果是吞吐量优先可以适当提高但要监控应用线程的CPU占用。4. 安全点为什么GC必须让所有线程立正4.1 安全点的本质线程状态的一致性快照安全点Safepoint是JVM里一个非常精妙的设计。它的核心问题是GC需要知道所有线程的引用关系但线程在运行时引用关系随时在变怎么保证扫描时拿到一致的状态答案是让所有线程在某个安全的位置停下来这个位置就是安全点。安全点不是随便哪里都行。它必须满足一个条件在这个位置上线程的栈帧和寄存器状态是明确的GC能准确找到所有引用。比如方法调用、循环回边、异常跳转这些位置JVM会插入安全点轮询代码。线程执行到这些位置时会检查一个标志位如果GC发出了进入安全点的请求线程就主动挂起。4.2 安全点轮询与主动式中断HotSpot用的是主动式中断。GC不直接挂起线程而是设置一个标志线程在安全点轮询时发现标志被设置就自己挂起。轮询点通常插在方法返回、循环回边、调用返回等位置。轮询代码非常轻量就是读一个内存地址判断是否为0JIT会把它优化成一条test指令。为什么不用抢占式中断因为抢占式需要操作系统支持而且线程可能在任意位置被挂起GC很难准确找到引用。主动式中断虽然需要线程配合但状态可控实现简单。4.3 安全点与安全区域的区别安全区域Safe Region是安全点的补充。有些线程处于Sleep、Blocked状态没法响应安全点轮询这时候就需要安全区域。线程进入安全区域时标记自己GC就不用管它了线程离开安全区域时检查GC是否完成没完成就等待。安全区域主要解决的是线程不执行字节码的场景比如JNI调用、线程睡眠。没有安全区域GC可能永远等不到这些线程进入安全点导致STW无限延长。4.4 安全点停顿的排查与优化安全点停顿Time to Safepoint, TTS是很多诡异停顿的元凶。GC日志里通常有Total time for which application threads were stopped但这里面包含了TTS和实际GC时间。如果TTS很长说明线程进入安全点慢而不是GC本身慢。TTS长的常见原因循环次数太多一个大循环里没有安全点轮询线程要跑完整个循环才能进入安全点。JIT会对计数循环做优化但有些场景优化不掉。线程数太多每个线程都要轮询线程越多全部进入安全点的时间越长。CPU负载高线程被操作系统调度延迟轮询响应慢。排查手段用-XX:PrintSafepointStatistics -XX:PrintSafepointStatisticsCount1打印安全点统计看vmop和time字段。如果time里spin和block占比高说明TTS有问题。优化方向减少线程数、拆分大循环、降低CPU负载。我遇到过一个案例一个服务开了2000个线程每次GC的TTS高达50ms后来把线程池核心数降到200TTS降到5ms以内。提示-XX:SafepointTimeout -XX:SafepointTimeoutDelay1000可以在TTS超过阈值时打印警告帮你定位问题线程。5. 三者联动一次Young GC的完整生命周期5.1 从触发到结束的完整链路把这三个机制串起来看一次Young GC的完整流程是这样的触发GCEden区满触发Young GC。进入安全点JVM发出安全点请求所有应用线程在轮询点挂起。这一步的耗时就是TTS。扫描GC Roots包括栈上的引用、全局引用、以及卡表里的跨代引用。卡表让JVM不用扫整个老年代。并发标记如果是并发GC对于G1等并发收集器标记阶段可能和应用线程并发执行靠SATB或增量更新保证正确性。复制/清理把存活对象复制到Survivor区或老年代清理Eden。退出安全点应用线程恢复运行。这三个机制各司其职安全点保证状态一致卡表减少扫描范围并发标记手段保证并发正确性。缺一个GC要么慢得没法用要么正确性出问题。5.2 参数联动调一个动全身调参时这三个机制是联动的。比如你调大新生代Young GC频率降低但单次扫描的卡表可能更多你调高并发标记线程数标记快了但应用线程被抢占可能影响TTS你调大-XX:AutoBoxCacheMax之类的参数可能影响写屏障的开销。我一般建议按这个顺序调先定堆大小和分代比例再调GC收集器最后微调并发线程数和安全点相关参数。每次只调一个观察GC日志和TP99变化。5.3 线上问题排查的黄金组合线上遇到GC问题我通常按这个组合排查-Xlog:gc*JDK 9或-XX:PrintGCDetailsJDK 8看GC日志。-XX:PrintSafepointStatistics看安全点停顿。-XX:PrintAdaptiveSizePolicy看G1的自适应调整。jstat -gcutil看实时GC统计。jstack看线程状态排查是否有线程卡在安全点外。这套组合能覆盖90%的GC问题。剩下的10%可能需要async-profiler或JFR做更细粒度的分析。6. 常见问题速查与避坑指南6.1 问题速查表现象可能原因排查手段解决方向Young GC毛刺卡表脏页过多GC日志看Scan RS耗时减少跨代引用调整新生代Full GC频繁并发标记失败GC日志看concurrent mode failure提前触发调大老年代TTS过长线程多/大循环PrintSafepointStatistics减线程拆循环对象莫名消失并发标记漏标几乎不可能除非JVM bug升级JDK浮动垃圾多SATB队列大GC日志看浮动垃圾量调低IHOP提前触发6.2 避坑心得坑一以为卡表是免费的。跨代引用越多Young GC越慢。设计缓存时尽量用弱引用或者定期清理别让老年代对象大量引用新生代。坑二并发标记线程数拉满。标记快了但应用线程被抢占吞吐量下降。默认值通常够用别乱调。坑三忽视TTS。很多人看GC日志只看GC时间不看TTS。实际上TTS可能比GC本身还长。一定要开PrintSafepointStatistics。坑四安全点轮询被JIT优化掉。有些循环JIT会做int计数优化把安全点轮询去掉导致线程长时间不进安全点。可以用-XX:-UseCountedLoopSafepoints关闭这个优化但会牺牲性能慎用。坑五G1的IHOP设太高。IHOP是触发并发标记的阈值设太高会导致并发标记启动太晚最后退化成Full GC。一般设45%左右具体看对象分配速率。6.3 一个真实的排查案例某服务TP99突然从50ms涨到200msGC日志显示Young GC时间没变但应用停顿变长。开PrintSafepointStatistics后发现TTS从2ms涨到80ms。进一步排查发现有个新上线的定时任务开了500个线程做批量计算每个线程都在跑大循环。线程数暴增导致TTS飙升。把线程池改成50个TTS回到3msTP99恢复正常。这个案例说明GC问题不一定是GC本身的问题可能是应用代码导致的。安全点机制把应用代码的问题暴露在了GC停顿上。7. 我个人在实际操作中的体会这三个机制里安全点是最容易被忽视的但往往是线上诡异停顿的真凶。我现在的习惯是任何新服务上线前必开PrintSafepointStatistics跑一轮压测确认TTS在可接受范围内。卡表的问题通常和业务设计相关缓存设计得好卡表压力就小。并发标记手段的选择主要看收集器CMS和G1的差异很大选型时就要考虑清楚。最后分享一个小技巧如果你不确定TTS是不是问题可以临时把-XX:SafepointTimeout打开设一个阈值比如500ms如果日志里出现超时警告就说明TTS确实有问题。这个参数开销很小适合线上临时排查用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑