
做JVM调优这些年我啃过最久的一块硬骨头就是垃圾收集器。刚开始学的时候很多人跟我一样把Serial、Parallel、CMS、G1这些名字背得滚瓜烂熟参数也能搭出好几套组合可真到了线上出问题——老年代涨到90%以上、Full GC把请求延迟打上天花板看着GC日志里那一串串陌生的字段还是会懵。后来我才意识到堆怎么分代、对象怎么分配、收集器之间怎么配合这些问题归根结底都指向一个底层机制可达性分析。而现代垃圾收集器能够在应用线程几乎不停顿的情况下完成回收靠的核心算法就是三色标记算法。这篇文章我想把自己从理论到实战的整套理解整理出来从分代模型的来龙去脉讲到G1、ZGC里三色标记的工程化改造再落到具体的调优参数和排错手法希望对正在啃JVM的读者能有点实实在在的帮助。1. 为什么要理解垃圾收集器从一次线上OOM排查说起1.1 一个真实的故障场景先讲个我在实际项目中遇到的案例这样大家能直观感受到不懂收集器原理排查问题有多被动。那是一个活动页服务堆内存配置为8GB新生代3GB老年代5GB跑在JDK 8上用的垃圾收集器是默认组合ParNew CMS。平时请求量平稳P99延迟在80ms左右。结果活动上线当天监控面板突然报警CPU使用率冲到90%以上P99延迟直接飙到2秒大量请求超时。我登录服务器一看GC日志里反复出现Promotion failed和Concurrent Mode Failure紧接着就是一连串的Full GC。当时的第一反应是老年代不够了加堆内存。但加内存不是线上随口一说就能做的操作而且要改配置就得重启服务代价很大。于是我沉下心去分析为什么老年代会这么快满日志里透露的信息到底说明了什么这一深挖才发现问题根本不在于内存总量不够而在于对象晋升的速率远超我的预期CMS并发回收的速度跟不上对象分配的速度。说白了是收集器选型和参数搭配跟业务负载不匹配。这个场景我想很多做Java开发的人都遇到过。它真正的问题是如果你只停留在GC是JVM自动干的我不用管的层面出事的时候除了加内存、重启、换大机器几乎没有别的招。而如果你懂收集器背后的标记、清理、并发、STW这些机制就能从日志里读出真实原因做出更合理的调整。1.2 GC日志里的第一手线索GC日志是理解JVM行为最直接的入口。很多人怕看日志觉得字段太多、看不懂。其实搞懂几个核心字段就够了。以我当时看到的日志片段为例[GC (Allocation Failure) [ParNew: 2798102K-349674K(3145728K), 0.0673510 secs] 3798102K-1299364K(8388608K), 0.0675818 secs] [GC (Allocation Failure) [ParNew: 3145727K-3145726K(3145728K), 0.1376030 secs] 4294891K-4294890K(8388608K), 0.1376746 secs] [Full GC (Allocation Failure) [ParNew: 2621440K-2621440K(3145728K), 0.0000000 secs] [CMS: 5242880K-5242870K(5242880K), 4.2000000 secs] 7864320K-7864320K(8388608K), [Metaspace: 84562K-84512K(1056768K)], 4.2000000 secs]第一行是正常的Young GC新生代从2.7G降到349M总堆从3.7G降到1.3G说明大部分对象被回收了一切正常。但第二行开始不对劲新生代明明已经占了3.1G的上限可GC之后还是3.1G总堆也几乎没降。这说明新生代里的对象根本回收不掉全部晋升到了老年代。紧接着第三行就触发了Full GCCMS并发收集还没跑完应用线程又需要分配新对象结果等不及了只能退化成Serial Old式的Full GCSTW时间长达4.2秒。这4.2秒里应用完全卡死线上延迟自然就炸了。日志不会骗人。但如果你不读它就只能在监控图前干瞪眼。所以我想先说一句GC调优的第一步不是调参数而是会看日志。2. 从分代模型到收集器选型JVM内存管理的底层逻辑2.1 堆内存的分代设计为什么至今不过时要看懂垃圾收集器得先从堆的分代设计说起。JVM把堆分成新生代和老年代这个设计从早年沿用至今不是没有原因的。它背后是两条统计规律业界称之为弱分代假说和强分代假说。弱分代假说说的是绝大多数对象的生命周期都非常短活过第一次GC的对象很少。举例来说一个典型的Web请求处理过程里会产生大量中间对象——DTO、临时集合、字符串拼接结果、循环变量这些对象在处理完请求之后就没用了。它们中的绝大部分都可以在新生代被回收掉。强分代假说则是说那些熬过了多次GC的对象往往还会继续存活下去。老年代里积累的就是这类老顽固。基于这两个观察JVM把堆分成两个区域采用不同的回收策略新生代对象分配密集、死亡也快适合用复制算法。复制算法不需要关心内存碎片问题只需要把存活对象搬到另一块区域剩下的空间整体清空。老年代对象存活率高、回收频率低适合用标记-清除或标记-整理算法。因为这里大部分对象都是稳定的用复制算法反而会因为存活对象太多而效率低下。所以分代设计的本质是因地制宜不同区域的对象特征不同用不同的算法去处理整体效率才能最大化。这也是现代垃圾收集器无论怎么演进都没有彻底抛弃分代思想的根本原因——包括G1虽然它是Region化的但仍然分年轻代和老年代。2.2 主流垃圾收集器的进化与适用场景理解了分代设计再来看垃圾收集器的选型就顺理成章了。Java生态里出现过很多垃圾收集器主流的有Serial、Parallel、CMS、G1、ZGC和Shenandoah。它们不是一个互相替代的简单关系而是沿着更高的并发度、更低的停顿时间、更复杂的工程实现这条路一路演化过来的。Serial收集器是最早的单线程收集器工作时会STWStop The World暂停所有应用线程。它的优点是没有线程切换开销简单可靠适合客户端模式或者堆内存很小的场景。Parallel收集器也叫吞吐量优先收集器核心思想是让GC线程并行工作尽量缩短GC总耗时适合对吞吐量敏感、对延迟要求不高的后台计算任务。而CMS是第一个真正意义上的并发收集器它在标记和清理阶段尝试和应用线程同时运行目标是降低延迟。但它有两个出名的问题内存碎片化、并发回收失败率高这也是后来G1逐渐取代它的原因。G1的设计思路就更先进了相当于面向服务端多核机器的分区式并发收集器。它把整个堆划分成若干个大小相同的Region每个Region可以独立地扮演Eden、Survivor或者Old区回收时优先处理垃圾最多的Region这个思想叫Garbage First也是G1名字的来源。ZGC和Shenandoah则是新一代低延迟收集器的代表目标是把STW时间压到10ms以内甚至接近零。为了方便对比我整理了一张表标注了各收集器的关键定位和适用场景收集器回收算法线程模型核心目标说明Serial复制 标记整理单线程简单可靠客户端、小堆Parallel复制 标记整理多线程并行高吞吐量后台计算、批处理CMS标记清除并发低延迟已被G1取代G1复制 标记整理分区并发可预测停顿JDK 9默认ZGC标记复制并发接近零停顿JDK 15可用Shenandoah标记复制并发接近零停顿部分JDK版本内嵌今天我主要聚焦CMS、G1和ZGC因为它们最能反映三色标记算法在工程实践中的演进。不过有一点要特别提醒选收集器不是越新越好、越高级越棒要根据业务对延迟和吞吐的诉求来做选择。ZGC停顿虽低但并发标记和读屏障本身有额外开销Parallel停顿虽高但总吞吐量在某些CPU密集场景下反而更好。2.3 收集器选型的关键参数与组合JVM里设置收集器的方式通常是在启动参数里显式指定。适用范围和组合方式总结如下年轻代收集器-XX:UseSerialGCSerial、-XX:UseParallelGCParallel、-XX:UseParNewGCParNew以及G1、ZGC各自的年轻代回收逻辑。老年代收集器-XX:UseParallelOldGC、-XX:UseConcMarkSweepGCCMSJDK 14之后移除、G1和ZGC则不需要单独指定老年代收集器。首次设置时可以遵循两条线索一是业务对延迟的敏感度二是当前JDK版本的默认值。JDK 8及以前的默认组合是Parallel Scavenge Parallel OldJDK 9开始默认就是G1。而如果你想用CMS需要显式加-XX:UseConcMarkSweepGC。在JDK 14以后CMS已经被官方移除就别再在废弃版本里选CMS了。跟堆内存相关的几个经典参数有必要记牢-Xms和-Xmx分别指定堆初始大小和堆最大值生产环境建议直接设成一样避免运行期动态扩容带来的性能损耗。-XX:NewRatio控制老年代和新生代的比例。默认是2代表老年代占堆的2/3新生代占1/3。如果服务里短生命周期对象很多可以适当调大新生代比例但也要小心晋升失败。-XX:SurvivorRatio控制Eden区和Survivor区的比例默认8:1:1。-XX:MaxTenuringThreshold设置对象进入老年代的年龄阈值默认15。对象每历经一次Young GC年龄加1达到阈值后晋升到老年代。参数本身不难记难的是理解调整这些参数会如何影响GC行为。比如你把新生代调大了Young GC发生的频率会降低但每次GC的耗时可能会变长而且晋升到老年代的对象特征也会变化。这些关联影响只有理解了分代和回收算法之后才能判断。3. 三色标记算法并发标记的理论基石与实现细节3.1 黑色、白色、灰色到底在标记什么现代垃圾收集器不管名字多花哨做可达性分析时共同的理论基础都是从GC Roots出发遍历对象图判断对象是否存活。GC Roots包括栈帧里的局部变量、静态字段、JNI引用、活动线程等这些都是JVM认为的根。凡是从根出发能遍历到的对象就是存活对象遍历不到的对象就是垃圾。早期的收集器做这一步是STW的也就是应用线程全部停下GC线程慢慢遍历。那时候不存在并发标记的问题所以也不需要三色标记算法。但CMS、G1、ZGC这些并发收集器希望GC线程和应用线程能同时跑——应用线程在标记过程中还在不停创建新对象、修改引用关系。这就出现了一个问题当GC线程正在遍历对象图时对象引用已经变了怎么保证最终标记结果是正确的三色标记算法就是为了解决这个并发遍历问题而设计的抽象模型。它把遍历过程中每个对象的状态分成三种颜色白色表示对象尚未被GC线程访问到或者遍历结束后仍然没有被访问到。在标记结束时白色对象就是会被判定为不可达的垃圾对象。灰色表示对象本身已经被GC线程访问到了但它的引用字段还没有被全部扫描完。灰色对象是标记工作的当前活跃边界GC线程的工作队列里存的就是灰色对象。黑色表示对象以及它的所有引用字段都已经被扫描完了不再需要处理。如果你觉得抽象可以把标记过程想象成一场考试阅卷白色区域是还没改到的那叠试卷灰色区域是正在批改的这叠试卷黑色区域是批改完成、已经封存归档的试卷。阅卷人GC线程必须先把灰色区域的试卷批完才能不断推进到新的白色区域。标记过程可以简单表示为这样一个循环从灰色对象集合中取出一个对象把它的所有白色引用对象染成灰色然后把自己染成黑色反复执行直到没有灰色对象。这个过程如果用伪代码来表示大概是这样的// 标记栈中保存灰色对象 while (!markStack.isEmpty()) { Object obj markStack.pop(); // 取出一个灰色对象 for (Field field : obj.getFields()) { Object ref field.getReference(); if (ref ! null ref.isWhite()) { ref.setGray(); // 把白色引用对象标记为灰色 markStack.push(ref); } } obj.setBlack(); // 当前对象所有字段扫描完标记为黑色 }这就是三色标记算法的骨架。它本身不复杂复杂的地方在于并发如果应用线程在标记过程中改动了对象引用这套染色逻辑就会被破坏。这就是下一节要讲的漏标问题。3.2 为什么并发标记会漏标两个必要条件在纯STW的标记中三色划分是稳定的遍历完了就是完了。可是并发场景下应用线程会在GC线程标记的同时修改对象引用。最典型的情况有两种一是把原本没有被引用的对象重新挂到一个已扫描对象下面二是把原本从某个灰色对象可达的对象摘掉。这里先说结论并发标记要发生漏标即应该存活的对象被当成垃圾回收掉必须同时满足两个条件一个黑色对象新增了对一个白色对象的引用。所有原本指向该白色对象的灰色对象对该白色对象的引用被删除了。为什么必须同时满足因为黑色对象已经被GC线程认定已经扫描完所有引用字段如果它此刻新增了一个指向白色对象的引用而GC线程不会再来检查它那么从GC线程的视角看这个白色对象就不存在了。此时如果原来的灰色对象还持有这个白色对象的引用那么GC线程在遍历那个灰色对象时仍然能发现这个白色对象把它染成灰色就不会漏标。举个例子。对象A黑色和对象B灰色对象B本来引用对象C白色。如果应用线程执行了A.c C给黑色对象A增加对C的引用并且B.c null删除了灰色对象B对C的唯一引用那么GC线程在后续标记中扫描B时发现B不再引用C它又不会重新扫描A于是C就一直是白色最终被当作垃圾回收。但实际上C仍然被A引用着程序还在用这就发生了漏标后果就是隐性OOM或者数据丢失。理解这个条件的价值在于它解释了所有并发收集器为什么都要靠写屏障来兜底。无论CMS还是G1都不可能在标记阶段阻止应用线程修改引用但它们可以通过写屏障感知到引用的修改从而打破上述两个条件的任意一个保证不漏标。3.3 增量更新与SATB两种写屏障的取舍有了漏标的认知接下来最关键的问题就是并发收集器如何防止漏标业界给出了两条技术路线增量更新Incremental Update和原始快照SATBSnapshot At The Beginning。CMS走的是增量更新路线。它的思路是既然漏标需要黑色对象新引用了白色对象这个条件那就在发生这个行为时把这位黑色对象拉回灰色。具体来说JVM在对象引用写入时触发写屏障如果发现新引用是指向一个还没被标记的白色对象的就把持有新引用的黑色对象重新标记为灰色放进标记栈等待重新扫描。这样GC线程虽然不会主动重新扫描所有黑色对象但可以通过写屏障的记录找到那些需要被重新检查的黑色对象在后面某个阶段集中处理。G1走的是SATB路线。它的思路是既然漏标还需要灰色对象对白色对象的引用被删除这个条件那干脆在引用被删除时把这个旧引用记录下来确保并发标记开始时已经可达的对象不会因为中途引用被切断而丢失。你可以理解为SATB给标记过程拍了一张初始快照在并发标记期间只要是标记开始时还在对象图里的对象就算后来引用关系变了也仍然会被当作存活对象处理。这样做的代价是浮动垃圾会增加——某些理论上已经没人引用的对象因为快照的存在这一轮标记不会被回收只能留给下一轮GC。两种方案各有利弊。增量更新的优点是更贴近实际存活对象浮动垃圾少但实现时需要在并发标记阶段反复处理被重新标记为灰色的对象重新标记阶段STW时间会更长。SATB则让标记过程更平滑STW时间更可控代价是可能保留更多本来可以回收的浮动垃圾。G1为什么选择SATB一个重要的原因是G1的目标是可预测的暂停时间SATB在并发标记阶段的额外工作更少更符合它Region化、可预测停顿的定位。这个取舍到了ZGC里演化得更为激进就是下一章要讲的染色指针。4. 从CMS到G1再到ZGC三色标记如何演变4.1 CMS的增量更新与并发清除CMSConcurrent Mark Sweep是第一个把并发回收引入老年代的里程碑式收集器。它的回收过程可以拆成四个阶段初始标记STW只标记从GC Roots直接可达的对象范围很小停顿很短。并发标记从初始标记的对象出发并发遍历对象图。这时应用线程不停止。重新标记STW修正并发标记期间因应用线程修改引用而产生的变化。CMS在这里应用增量更新处理写屏障记录下来的黑色对象重新变为灰色的情况。并发清除并发地清理白色对象回收内存空间。CMS里程碑式的意义在于它把最耗时的遍历和清除都放到了并发阶段应用线程不用长时间停顿。但它的缺点也很明显。第一标记-清除算法不压缩内存会产生大量碎片。CMS运行久了老年代内存中的空闲空间可能分散成很多小碎片即使总剩余空间足够也没有连续空间容纳一个较大对象于是触发一次Serial Old的Full GC来整理。第二并发模式失败。如果并发回收期间应用线程分配对象的速度太快老年代空间被快速耗尽CMS就不得不放弃并发退化成STW的Serial Old回收也就是我文章开头那个故障场景里出现的Concurrent Mode Failure。第三CMS无法处理浮动垃圾必须预留空间给并发期间新产生的垃圾通常用-XX:CMSInitiatingOccupancyFraction控制触发阈值。从三色标记角度看CMS的关键创新是增量更新写屏障。每一个指向白色对象的新引用都会被写屏障捕捉到把持有者重新标记为灰色。这也决定了CMS重新标记阶段需要遍历这些记录时间不会特别短。4.2 G1的SATB与Region化改造G1在JDK 9以后成为默认收集器很大程度上是因为它修正了CMS的多个痛点。核心思路是把堆分成若干大小相等的Region每个Region大小默认1MB到32MB可以独立进行回收。年轻代不再是两块大区域而是由若干Region动态组成老年代也由Region承载且允许不作为连续物理内存分布。这样G1既能实现分代回收又能做到整堆统一视图。在并发标记层面G1采用的是SATB方案。它在并发标记开始前利用-XX:UseTlab相关的对象分配机制为“初始快照”建立好基础同时通过写屏障记录并发标记期间被删除的引用。具体实现中G1的写屏障是Pre-Write Barrier在引用赋值之前触发把旧值记录到一个SATB队列里。GC线程在并发标记阶段会处理这个队列确保初始快照中的存活对象不会漏标。G1还有个重要组件叫记忆集Remembered Set用来记录跨Region引用。因为G1回收时只回收部分Region它必须知道有多少其他Region的对象指向当前要回收的Region才能正确处理对象图。记忆集的维护依赖写屏障——每当程序修改了对象引用写屏障不仅要把信息反馈给SATB队列还要更新记忆集。这里是G1工程实现中最复杂的一部分但也正是这些数据结构保证了G1能实现可预测的停顿时间。用三色标记的语言来说G1的标记流程依然是白、灰、黑三色转换但它在整体流程上做了两件事一是和CMS一样标记前需要“初始标记”STW去枚举GC Roots二是饱和地使用SATB队列保证并发标记期间引用变化不会导致漏标。代价是浮动垃圾可能比CMS更多但因为Region可以动态回收整体内存利用率反而更高。4.3 ZGC染色指针用指针本身承载标记信息要说三色标记算法最彻底的工程化改造还得看ZGC。ZGC的目标是把STW时间控制在10ms以内实现方式是让标记、转移、重映射都尽量并发执行。为了实现这一点ZGC引入了一个非常巧妙的机制染色指针Colored Pointer。传统JVM在对象引用里存放的是对象的地址GC标记信息通常得另存数据结构里。ZGC则把一部分标记信息直接编码在指针的多个未用位中。以64位Linux为例ZGC支持高达4TB到16TB的堆实际地址位并没有用到全部64位于是它把其中几位用来存储颜色状态。一个指针可以同时携带诸如Marked0、Marked1、Remapped、Finalizable这类状态信息。简单说一个对象的颜色不需要额外查表直接从这个指针上就能读出来。这就和经典三色标记模型对应上了白色对象在指针上表现为还没有被标记的状态灰色对象对应正在被处理的状态黑色对象对应已标记完成的状态。ZGC的并发标记通过原子性地更新指针里的标记位来改变对象的颜色同时利用读屏障在应用线程访问对象时修正颜色状态。因为状态信息都在指针里GC线程不需要反复扫描对象头或额外的标记表大幅度降低了并发阶段的同步成本。ZGC的这种设计还解决了更复杂的转移阶段问题当对象被移动后旧地址上的指针怎么处理传统方案是加一层转发指针ZGC则利用染色指针把Remapped状态记录在指针里应用线程通过读屏障可以自动转到新地址。这相当于把对象的生命周期状态跟指针绑定在了一起。理解这一点就能明白ZGC为什么能做到接近零停顿——大量工作都被打散到了应用线程通过读屏障时顺带完成真正的STW步骤被压得极短。所以从CMS的增量更新到G1的SATB再到ZGC的染色指针三色标记算法的核心思想没变变的只是如何让并发环境下的标记仍然可信的工程手段。越往后的收集器越倾向于在指针层面或者无锁层面下功夫用更小的STW窗口换来更低的延迟。5. 实践中的GC调优参数、工具与典型案例5.1 常用调优参数与日志解读讲理论是为了能落地落到实践上第一件事就是会配参数、会读日志。从JDK 9开始GC日志的配置方式已经从传统的-XX:PrintGCDetails迁移到了统一的-Xlog所以如果你的应用跑在JDK 11上别再用老参数了新参数更灵活。常用的参数我整理如下参数作用-Xms/-Xmx设置堆初始和最大值生产建议等值-XX:NewRatio老年代与新生代比例-XX:MaxTenuringThreshold晋升老年代的年龄阈值-XX:UseG1GC启用G1收集器-XX:MaxGCPauseMillisG1期望的最大停顿时间软目标-XX:G1HeapRegionSizeG1 Region大小一般由JVM自动决定-XX:UseZGC启用ZGCJDK 15-XX:UnlockExperimentalVMOptions实验参数解锁部分老版本ZGC必经-Xlog:gc*:filegc.log:time,uptime,level将GC日志输出到文件带时间和级别现代日志示例大概是这样的[0.208s][info][gc] GC(0) Pause Young (Concurrent Start) 512M-128M(1024M) 3.2ms [0.210s][info][gc] GC(1) Concurrent Cycle 0.8ms [0.305s][info][gc] GC(2) Pause Young (Normal) 640M-256M(1024M) 5.3ms重点看三个信息GC类型、堆内存前后变化、停顿耗时。如果频繁出现 Pause Young 且耗时持续上升通常是短期对象压力大、新生代配置偏小如果出现 Pause Full 或 Concurrent Mode Failure那就要分析老年代是不是晋升过快或者到达上限。工具层面我最常用的组合是jstat -gcutil pid 1000实时观察年轻代和老年代使用率配合GC日志文件做离线分析jmap -heap pid查看堆配置和当前使用情况jhsdb jmap或jdb在做内存泄漏分析时偶尔用得上。对线上服务jstat每隔1秒采样一下就够看出趋势别频繁执行太重的命令。5.2 一个典型的大对象分配场景只用参数和日志还不够我再给一个非常典型的案例大对象分配导致频繁GC。假设一个批处理服务每批处理10000条数据每条数据都需要构造一个比较大的缓存结构单个对象大概2MB。默认情况下如果-XX:PretenureSizeThreshold设置得比较高或者没设置大对象会尝试直接进入老年代取决于具体版本和收集器。当大对象频繁进入老年代时老年代空间快速消耗触发CMS或G1的老年代回收频率大幅上升甚至可能引发Full GC。解决思路通常有三个方向提高新生代容量让更多偶数大小的对象能容纳在新生代中而不是直接晋升。通过调整-Xmn或-XX:NewSize实现。合理设置-XX:PretenureSizeThreshold但要注意这个参数只对Serial和ParNew有效在G1下不是标准控制手段。从业务侧入手减少一次性大对象的生成比如改用池化对象、分片处理。我见过太多人遇到这类问题第一反应就是把堆内存加到32GB、64GB。堆加大确实能暂时缓解但GC停顿时间会跟着变长尤其是Full GC64GB堆整理一次可能让服务卡几十秒。所以这是表面的解决方案不是根本解。更好的路径是通过GC日志确认对象在哪个区域分配和晋升再对症下药。以G1为例假如日志里显示 Humongous Allocation 次数很多就说明有超过Region大小一半的巨型对象在分配。这时优先考虑调整-XX:G1HeapRegionSize让Region更大以容纳更多普通对象同时也要看看业务层能不能避免大对象。5.3 面试与生产中常见误区的澄清最后这部分我想把JVM垃圾收集器相关的几个高频误区拎出来说清楚。这些误区不光面试时经常踩生产环境里也常常导致错误的调优方向。第一个误区把Full GC等价于老年代满了。实际上Full GC可能由多种原因触发包括元空间不足、System.gc()被显式调用、晋升失败、G1的Humongous分配失败等。不分析具体触发原因上来就加老年代内存常常没什么效果。第二个误区认为STW时间越短越好。低延迟是有代价的ZGC和Shenandoah的读屏障会给每次引用访问带来额外开销高吞吐场景下Parallel这种STW久但总时间少的收集器可能更合适。比如离线计算任务停顿30秒也没问题但千万不能为了延迟优化把吞吐量牺牲掉。第三个误区把G1当成万能默认选择。G1确实好但它的停顿模型依靠-XX:MaxGCPauseMillis来做软目标如果这个值设置得不合理反而会导致GC频繁发生。对很小的堆Serial或Parallel未必更差。选择收集器永远要基于业务需求而不是潮流。第四个误区以为System.gc()一定会立即触发Full GC。这个行为受-XX:DisableExplicitGC影响如果该参数开启System.gc()调用会被忽略。在RMI远程调用框架中还可能出现系统周期性触发Full GC的情况可以通过-XX:ExplicitGCInvokesConcurrent或-XX:DisableExplicitGC来控制。第五个误区认为三色标记算法就没有浮动垃圾。恰恰相反并发标记期间新生成的对象以及SATB快照带来的本可以回收但保留到下一轮的对象都是浮动垃圾。理解浮动垃圾之后就能明白为什么CMS需要预留空间、为什么G1老年代回收不能等到完全满了才开始。这些看似奇怪的参数阈值背后都是为了给浮动垃圾留出缓冲。我个人的体会是JVM垃圾收集器的学习路径完全可以反过来走先从一次故障或一份GC日志入手反向追究到三色标记、写屏障、记忆集这些底层概念比自己从书本上按顺序啃要深刻得多。三色标记算法这个知识点之所以在面试中高频出现也正是因为它能把并发标记如何保证不漏标这个问题层层拆开直到引出增量更新和SATB这两个工程方案。如果你现在正准备排查自己的服务我最想给的建议是先别急着改参数花一小时把GC日志落下来用-Xlog配好输出文件观察一个业务周期内GC的频率、停顿和堆占用变化。拿到第一手数据之后再决定要不要调整收集器、调整代际比例或者干脆从业务代码减少对象分配。这套思路比任何万能调优参数表都管用。