1 经典垃圾收集器
图3-6展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用(jdk 9字样表示在jdk 9时废弃),图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
1.1 Serial收集器
是HotSpot虚拟机运行在客户端模式下的默认新生代收集器
-
历史地位: 最基础、历史最悠久的收集器,曾是JDK 1.3.1之前HotSpot虚拟机新生代收集器的唯一选择。
-
工作方式: 单线程工作,进行垃圾收集时需暂停其他所有工作线程(“Stop The World”)。
-
优点
-
单/少核处理器优势: 在单核/核心数较少的环境中,由于没有线程交互开销,可以提供最高的单线程收集效率。
-
简单高效: 相比其他收集器,Serial收集器的额外内存消耗最小,在内存资源受限的环境下具有优势。
因此Serial收集器对运行在客户端模式下的虚拟机来说是很好的选择
-
1.2 ParNew收集器
-
基本概念: ParNew收集器实质上是Serial收集器的多线程并行版本。
-
与Serial收集器的关系: 除了同时使用多条线程进行垃圾收集之外,与Serial收集器共享其他行为和参数。
-
在HotSpot中的应用
-
服务端模式: ParNew收集器是许多运行在服务端模式下的HotSpot虚拟机的首选新生代收集器,尤其在JDK 7之前。
-
原因: ParNew是除了Serial之外唯一能与CMS(Concurrent Mark-Sweep)收集器配合工作的新生代收集器。
-
JDK 9的变化: 从JDK 9开始,ParNew加CMS的组合不再是官方推荐的服务端模式下的收集器解决方案,官方更推荐使用G1收集器。
ParNew收集器和CMS收集器的结合变得更紧密,它们与其他收集器组合的支持在jdk 9中被取消,意味着ParNew实际上成为了CMS的专用新生代部分。
-
-
专业术语:
- 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
- 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
1.3 Parallel Scavenge收集器
-
基本概念: 基于标记-复制算法的多线程并行收集的新生代收集器。
-
主要目标: 达到可控制的吞吐量(Throughput)。
-
吞吐量定义:
-
适用性:
- 适用于后台计算和少交互的任务,优先考虑高效利用处理器资源。
-
可配置参数
- -XX: MaxGCPauseMillis: 控制最大垃圾收集停顿时间。
- -XX: GCTimeRatio: 直接设置吞吐量大小。
- -XX: +UseAdaptiveSizePolicy: 开启自适应调节策略。
-
自适应调节策略:
- 当启用-XX: +UseAdaptiveSizePolicy参数后,无需人工指定新生代大小、Eden与Survivor区的比例、晋升老年代对象大小等。虚拟机会根据运行情况动态调整这些参数,以达到最优的停顿时间或最大吞吐量。
-
与ParNew收集器的区别:
- Parallel Scavenge收集器与ParNew收集器的主要区别在于它的自适应调节策略,使其在运行时更加智能和高效。
1.4 Serial Old收集器
- 基本概念:
- Serial收集器的老年代版本
- 使用标记-整理算法的单线程收集的老年代收集器。在进行垃圾收集时,会暂停其他所有的工作线程(“Stop The World”)。
- 主要用途:
- 客户端模式: 主要供客户端模式下的HotSpot虚拟机使用。
- 服务端模式:
- 在JDK 5及之前版本中与Parallel Scavenge收集器搭配使用。
- CMS收集器的后备预案: 在CMS收集器遇到并发模式失败(Concurrent Mode Failure)时,Serial Old作为后备方案启动。
1.5 Parallel Old收集器
- 基本概念:
- Parallel Scavenge收集器的老年代版本
- 使用标记-整理算法的多线程并行收集的老年代收集器。在进行垃圾收集时,会暂停其他所有的工作线程(“Stop The World”)。
- 历史背景:
- Parallel Old收集器在JDK 6中引入,解决了之前Parallel Scavenge收集器搭配老年代收集器的局限性。
- 在Parallel Old出现之前,使用Parallel Scavenge收集器的新生代,老年代的选择仅限于Serial Old(也称PS MarkSweep),单线程的老年代收集限制了垃圾收集的性能和效率,从而导致吞吐量降低。
- 重要性:
- 名副其实的搭配: Parallel Old的引入为“吞吐量优先”的收集策略提供了完整的解决方案,使得Parallel Scavenge收集器和Parallel Old收集器组合成为处理器资源优化的理想选择。
- 适用场景: 在注重吞吐量或处理器资源相对紧张的应用场景中,Parallel Scavenge加Parallel Old收集器的组合是一个优先考虑的选择。
1.6 CMS收集器
- 基本概念:CMS(Concurrent Mark Sweep)收集器是为了满足互联网或B/S系统服务端应用对低停顿时间的需求而设计的。
- 优势:并发收集和低停顿
- 主要流程:
- 初始标记(CMS initial mark):标记GC Roots能直接关联到的对象。这个步骤需要停顿所有的工作线程,但通常很快。
- 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图,这个过程较长,但可以与用户线程并发执行。
- 重新标记(CMS remark):修正并发标记期间因用户程序运行而导致标记发生变动的对象。这一步需要短暂的停顿,通常会比初始标记阶段稍长一 些。
- 并发清除(CMS concurrent sweep):清理掉死亡对象,这个阶段也可以与用户线程并发进行。
-
主要缺点:
-
处理器资源敏感:CMS在并发阶段占用了一部分处理器资源,可能导致应用程序运行变慢,降低总吞吐量。CMS默认启动的回收线程数是处理器核心数量的1/4,对于处理器核心数不足四个的情况,CMS对应用程序的影响较大。
-
无法处理浮动垃圾(基于增量更新):在CMS的并发标记和清除阶段,用户线程还在运行,可能会产生新的出现在标记过程结束以后的垃圾(浮动垃圾)。CMS无法立即处理这部分垃圾,所以有可能导致给用户程序预留的内存不够分配新对象,从而导致并发失败(Concurrent Mode Failure),进而触发一次完全的STW Full GC(通常使用serial old,暂停全部线程处理浮动垃圾)。
只有CMS能单独进行老年代收集,所以这里采用备选收集器时都是使用full gc。
-
空间碎片问题:基于标记-清除算法,CMS运行结束后会留下不连续的空间碎片,可能导致大对象分配困难,最终需要进行碎片整理的Full GC(跟上面一样是STW Full GC,但目的是为了处理空间碎片,当然也可能两种问题一起出现)来解决空间连续性问题。
为了缓解空间碎片问题,CMS提供了
-XX:+UseCMSCompactAtFullCollection
开关参数(在JDK 9开始废弃),允许在进行Full GC时进行空间整理。此外,-XX:CMSFullGCsBeforeCompaction
参数允许在执行若干次不整理空间的Full GC后,下一次Full GC进行碎片整理,帮助管理内存碎片,但这个参数也在JDK 9开始被废弃。
-
1.7 Garbage First收集器
-
发展历程
- 引入版本:G1收集器最早出现在JDK 6 Update 14的早期访问版本中,但直到JDK 7 Update 4,Oracle才宣布G1达到商用程度并移除了“Experimental”标识。JDK 8 Update 40添加了并发类卸载支持,使G1成为全功能的垃圾收集器。
- 默认收集器:自JDK 9起,G1取代了Parallel Scavenge和Parallel Old组合,成为服务端模式下的默认垃圾收集器。CMS收集器随后被声明为不推荐使用。
-
设计原理
-
局部收集与Region内存布局:G1通过将堆划分为多个大小相等的独立区域(Region),任何区域都可以视情况充当不同的年龄代,从而允许将任何regions组成回收集(collector set)进行局部收集,而不是仅限于整个新生代或老年代。
-
Humongous区域:Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。G1的大多数行为都把Humongous region作为老年代看待。
-
Young GC:当新生代用完时,并且老年代的内存占比低于用户的指定比例时,会参考目标的停顿时间,但是如果回收Young GC的停顿时间远小于目标停顿时间,则G1会继续给新生代分配regions,等待下一次新生代用完。
-
Mixed GC模式:当老年代的内存占比超过用户的指定比例时,在下一次进行Minor GC时,G1将进行Mixed GC,选择性地回收堆中的部分老年代Regions,基于回收收益最高的原则来优化收集效率。
-
停顿时间模型:G1利用停顿时间模型来允许用户指定目标停顿时间,通过衡量回收价值与回收成本 在目标停顿时间允许范围内 选择收益最高的Regions来组成回收集进行回收,以此来优化收集效率和吞吐量。
-
创新性:
- G1采用了创新性的局部收集与Region内存布局策略。
- 从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率 (Allocation Rate),并且在满足收集的速度跟得上对象分配的速度的前提下,最大化收集效率和吞吐量。
-
-
实现挑战
- 记忆集的实现:G1的每个Region都维护有自己的记忆集,这些记忆集十分特殊,记录了跨Region引用而且是双向的,这大大增加了内存占用,也增加了维护的执行成本。
- 并发标记与引用变化如何控制:G1使用过原始快照(SATB)保证对象图结构不被影响,对于并发回收过 程中的新对象分配,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针用于存放新分配的对象地址,这些对象不纳入回收范围。
- 停顿时间模型如何建立:G1通过衰减均值(“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响)为理论基础,计算出各种统计信息,然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
-
具体流程
- 初始标记(Initial Marking):仅仅标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。(会利用并发线程处理掉一部分STAB记录)
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。
-
相比CMS的优势:
- G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。
- 可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利
-
相比CMS的劣势:
-
在用户程序运行过程 中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载 (Overload)都要比CMS要高。
-
**执行负载:**G1除了使用写后屏障来进行与CMS同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索 (SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。
-
内存占用:G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;
CMS的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。但代价就是当CMS发生Old GC时(所有收集器中只有CMS有针对老年代的Old GC),要把整个新生代作为GC Roots来进行扫描
-
-
-
替代CMS:G1旨在长期替换CMS收集器,提供更好的表现,大概率是在大内存应用场景下(分界点为6GB至8GB之间)。
2 收集器的权衡
衡量垃圾收集器的三项最重要的指标是: 内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了⼀个“不可能三角”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。
- 收集器的选择主要受以下三个因素影响:
- 应用程序的主要关注点是什么?如果是数据分析、科学计算类的任务,目标是能尽快算出结果, 那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务 超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。
- 运行应用的基础设施如何?譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是 ARM/Aarch64;处理器的数量多少,分配内存的大小;选择的操作系统是Linux、Solaris还是Windows 等。
- 使用JDK的发行商是什么?版本号是多少?是ZingJDK/Zulu、OracleJDK、Open-JDK、OpenJ9抑 或是其他公司的发行版?该JDK对应了《Java虚拟机规范》的哪个版本?
3 虚拟机及垃圾收集器日志
在JDK 9以前,HotSpot并没有提供统一的日志处理框架,虚拟机各个功能模块的日志开关分布在不同的参数上,日志级别、循环日志大小、输出格式、重定向等设置在不同功能上都要单独解决。
直到JDK 9,这种混乱不堪的局面才终于消失,HotSpot所有功能的日志都收归到了“-Xlog”参数上,这个参数的能力也相应被极大拓展了:
-
此命令行包含以下参数:
-
选择器(Selector):由**标签(Tag)和日志级别(Level)**共同组成。
- 日志级别从低到高,共有Trace,Debug,Info,Warning,Error,Off六种级别,日志级别决定了输出信息的详细程度,默认级别为Info。
-
修饰器(Decorator):要求每行日志输出附加上的额外内容
-
time:当前日期和时间。
-
uptime:虚拟机启动到现在经过的时间,以秒为单位。
-
timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出。
-
uptimemillis:虚拟机启动到现在经过的毫秒数。
-
timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。
-
uptimenanos:虚拟机启动到现在经过的纳秒数。
-
pid:进程ID。
-
tid:线程ID。
-
level:日志级别。
-
tags:日志输出的标签集。
如果不指定,默认值是uptime、level、tags这三个,此时日志输出类似于以下形式:
-
-
4 内存分配与回收策略
4.1 对象优先在Eden分配
- 大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
4.2 大对象直接进入老年代
- 问题背景:大对象需要大量连续内存空间的Java对象,如何分配这些大对象需要进行考量,否则会造成GC被提前触发。
- 具体策略:HotSpot虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配。
- 目的:这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
4.3 长期存活的对象将进入老年代
- 问题背景:HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,所以如何按年龄区分对象就很重要。
- 具体策略:虚拟机给每个对象定义了一个对 象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程 度(默认为15),就会被晋升到老年代中。
4.4 动态对象年龄判定
- 问题背景:为了能更好地适应不同程序的内存状况,HotSpot虚拟机需要对对象晋升老年代的时机进行灵活变通。
- 具体策略:如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold中要求的年龄。
4.5 空间分配担保
- 问题背景:由于Minor GC通常采用标记-复制算法,所以需要采用空间分配担保的方法来容纳极端情况下survivor区域无法容纳的对象,以确保不会发生内存溢出。
- 具体策略:(JDK 6 Update 24之前)在发生Minor GC之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将-XX:HandlePromotionFailure开关打开,避免Full GC过于频繁。
- 历史变化:-XX:HandlePromotionFailure在JDK 6 Update 24之后被弃用,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC。