「Java 进阶之路」系列 Day34
写在前面
上一篇讲了标记清除、标记整理、复制算法这三种基础回收算法,结尾留了个疑问:现代JVM从来不会只用一种算法从头到尾扫一遍堆,而是把堆拆成新生代、老年代分开处理。这篇就讲清楚"分代收集"这套思想到底是怎么落地的,以及CMS、G1、ZGC这几款常见收集器分别是怎么在这套思想上做取舍的。
一、是什么:分代收集把堆拆成几块分别处理
分代收集的核心假设是弱分代假说:绝大多数对象都是朝生夕死的,只有极少数对象能活得很久。基于这个假设,JVM把堆分成新生代(Young Generation)和老年代(Old Generation),对不同区域采用不同的回收算法和回收频率。
新生代内部又按8:1:1的比例分成一块Eden区和两块Survivor区(from、to)。新对象几乎都优先在Eden区分配;一次Minor GC发生时,Eden区和当前使用的Survivor区里的存活对象,会被复制到另一块空闲的Survivor区,然后把原来那两块整体清空------这正是上一篇讲的复制算法,因为新生代存活率低,复制成本极低。
对象每熬过一次Minor GC,年龄就加一;年龄达到一定阈值(默认15,可以通过-XX:MaxTenuringThreshold调整)之后,就会被"晋升"到老年代。老年代空间大、对象存活率高,更适合标记整理(或标记清除),对应的回收动作叫Major GC(或者叫Full GC,含义上会更宽泛一些,通常指连老年代、方法区一起回收的动作)。
二、为什么要这样设计:把"大多数时候很快"和"少数时候要彻底"分开
如果堆不分代,每次垃圾回收都要扫描整个堆------包括那些已经存活了很久、大概率还会继续存活的对象------这些扫描大部分都是白费功夫。分代收集的价值在于,用最匹配对象生命周期特征的算法去处理对应区域,把"频繁但便宜"的回收和"稀少但昂贵"的回收分开:
- 新生代对象死得快、存活率低 → 用复制算法做Minor GC,回收频繁但每次都很快(复制的对象少)
- 老年代对象活得久、存活率高 → 用标记整理做Major GC,回收次数少,但每次代价更高(要扫描和整理的存活对象多)
这样设计之后,用户线程大部分时间只会被频繁但短暂的Minor GC打断,真正开销大的Major GC很少发生------这正是分代收集相比"不分代、每次都全堆扫描"能大幅降低平均停顿时间的原因。
不过这套设计本身也带来一个新问题:老年代的对象可能引用新生代的对象(比如一个老对象把某个新建的对象设为自己的字段),Minor GC只扫新生代的话,怎么知道这个新对象被老年代引用着、不能被误判为垃圾?JVM用**记忆集(Remembered Set)+ 卡表(Card Table)**解决------把老年代划分成一个个"卡",只要某张卡里有对象引用了新生代,就把这张卡标记为"脏";Minor GC时只需要扫描这些被标记的卡,而不用扫描整个老年代去找跨代引用,这样既解决了跨代引用的问题,又没有牺牲"Minor GC只处理新生代"这个效率优势。
三、怎么用:CMS、G1、ZGC分别怎么取舍
分代收集是思想框架,具体落地成什么样的收集器,取决于对"停顿时间"和"吞吐量"这两个目标怎么取舍。
CMS:老年代第一款以低停顿为目标的收集器
CMS(Concurrent Mark Sweep)专注老年代,核心思路是把标记过程和用户线程并发执行,尽量减少"stop-the-world"独占CPU的时间:
只有初始标记(标记GC Roots直接关联的对象)和重新标记(修正并发标记期间用户线程造成的变动)这两步需要停顿,且耗时都很短;耗时最长的并发标记、并发清除都能和用户线程一起跑。代价是:用的是标记清除算法,天生有内存碎片问题;而且和用户线程并发运行会占用CPU资源,吞吐量会下降;此外CMS已经在JDK9标记为过时(deprecated),JDK14中被彻底移除,生产环境基本被G1取代。
G1:把堆拆成很多小Region,哪块垃圾最多先回收哪块
G1(Garbage First)不再严格区分连续的新生代、老年代物理空间,而是把整个堆拆成很多大小相等的小Region,每个Region可以被动态划分成Eden、Survivor或Old的角色。回收时G1会优先挑选回收收益最高(垃圾最多)的那些Region来回收------这也是"Garbage First"这个名字的来源。
因为每次只挑一部分Region回收(不需要整个老年代一起处理),G1可以让用户配置一个期望的最大停顿时间目标 (-XX:MaxGCPauseMillis),G1会尽量按这个目标去控制每次回收处理的Region数量。跨Region的引用问题,G1同样是用记忆集来解决,只是粒度从"老年代整体"细化到了"每个Region"。整体上G1兼顾了停顿时间和吞吐量,是JDK9之后的默认收集器。
ZGC:把停顿时间压到几乎和堆大小无关
G1的停顿时间虽然可控,但堆越大,标记、整理这些阶段能并发的部分之外仍有一些和存活对象数量相关的停顿。ZGC的目标更激进:不管堆有多大(从几百MB到几TB),停顿时间都要控制在几毫秒以内 。它做到这一点的关键手段是染色指针(Colored Pointer)和读屏障(Load Barrier) ------把对象的部分标记信息直接存在指针本身的多余比特位里,配合读屏障在用户线程访问对象引用的同时完成对象的转移、重定位,把原本需要停顿处理的工作,尽可能挪到和用户线程并发执行的阶段去做,只在少数几个必须同步的时间点上有极短的停顿。代价是染色指针对指针位数有限制、需要额外的CPU和内存带宽开销,但换来的低延迟对于电商大促、金融交易这类对响应时间敏感的场景价值很高。
一句话总结这条演进路线:CMS解决了"老年代能不能并发",G1解决了"停顿时间能不能可控、可预期",ZGC解决了"停顿时间能不能几乎不受堆大小影响"。
四、面试追问
Q1:什么是分代收集?为什么要分代?
分代收集基于弱分代假说------大多数对象朝生夕死,只有少数能长期存活,所以把堆拆成新生代、老年代分别处理:新生代存活率低,用复制算法做频繁但快速的Minor GC;老年代存活率高,用标记整理做次数少但代价更高的Major GC。这样能把大部分回收工作控制在开销很小的Minor GC里,减少真正昂贵的全堆扫描的发生频率。
Q2:Minor GC时怎么处理老年代对象引用新生代对象的情况?
用记忆集和卡表。JVM把老年代划分成一个个"卡",只要某张卡里的对象引用了新生代对象,就把这张卡标记为"脏"。Minor GC时只需要扫描被标记为脏的卡,而不用扫描整个老年代,既能正确识别这些跨代引用避免误回收,又不会牺牲"Minor GC只处理新生代"带来的效率优势。
Q3:CMS收集器最大的问题是什么?
因为CMS的清除阶段用的是标记清除算法,不会整理内存,所以会产生大量不连续的内存碎片,可能导致明明总的空闲内存够、但因为没有足够大的连续空间而提前触发一次Full GC。另外并发标记、并发清除阶段会和用户线程抢占CPU资源,导致吞吐量下降,且CMS已在JDK14中被移除。
Q4:G1是怎么做到停顿时间可控的?
G1把整个堆拆成很多大小相等的Region,不再要求新生代、老年代各自是连续空间。每次回收时优先挑选垃圾最多、回收收益最高的那些Region来处理,而不需要一次性处理整个老年代。用户可以配置期望的最大停顿时间目标,G1会据此动态调整每次回收涉及的Region数量,从而把停顿时间控制在目标范围内。
Q5:ZGC相比G1的核心突破是什么?
ZGC的目标是让停顿时间几乎不随堆大小增长,不管堆是几百MB还是几TB,停顿都能控制在几毫秒以内。它靠染色指针把部分标记信息编码进指针本身,配合读屏障,让对象的转移、重定位等原本需要停顿完成的工作也能和用户线程并发执行,只在极少数必须同步的点上有极短停顿,适合对响应时间非常敏感的大内存场景。
下一篇预告
Day35 讲对象到底什么时候会被判定为垃圾------可达性分析算法的原理,以及强引用、软引用、弱引用、虚引用这四种引用类型分别解决了什么问题。