JVM系列 | 对象的消亡3——垃圾收集器的对比与实现细节

垃圾收集器

文章目录

各收集器简单对比

收集器名称 出现时间 淘汰时间 目标 采用技术 线程数 STW 分代 备注
无名 JDK 1.3之前 JDK 1.3 标记清除 1 不分代
Serial JDK 1.3 至今仍作为客户端默认的收集器 标记复制 1 新生代/老年代 由于简单而高效,仍作为客户端系统的默认垃圾收集器。
ParNew JDK 1.4 自JDK9开始只能与CMS搭配只用 高效的回收垃圾 标记复制 默认与核心线程数一样多 新生代 可以看做是Serial的多线程版本
Parallel Scavenge JDK 1.4 至今仍活跃,尤其是需要吞吐量的场景,如大量计算等 最大吞吐量 标记复制 默认与核心线程数一样多 新生代 吞吐量优先收集器(吞吐量需满足下方公式)
CMS JDK 5 JDK9标记为过时/JDK14被正式移除 最短回收停顿时间 标记清除 (处理器核心数量+3)/4 老年代 第一个可以与用户线程同时工作的垃圾收集器,工作时长更长了,但是用户线程停顿更短饿了
Serial Old 不明确/1.2之前 JDK之前与Parallel Scavenge,现在一般作为CMS失败的备用方案 标记整理 1 老年代 Serial的老年版
Parallel Old JDK 6 至今 最大吞吐量 标记整理 N 老年代 Parallel Scavenge的老年斑
G1/Garbage First JDK 7 至今 时间可控/全功能 标记复制 N Region分区 G1淘汰了CMS
Shenandoah OpenJDK 12 至今 10毫秒内垃圾收集 标记整理 N Region分区 RedHat开发;JDK中没有,只有OpenJDK中才有
ZGC JDK 11 至今 10毫秒内垃圾收集 标记整理 N Region分区 使用了读屏障、染色指针和内存多重映射等技术

名词解释:STW(Stop The World)指停止用户线程,该值为否则表示可以与用户线程同时进行。(也许不是整个清理阶段都与用户线程并行,可能只是其中某些阶段)

吞吐量 = 运行用户代码时间 运行用户代码时间 + 运行垃圾收集时间 吞吐量={运行用户代码时间\over{运行用户代码时间+运行垃圾收集时间}} 吞吐量=运行用户代码时间+运行垃圾收集时间运行用户代码时间

收集器启动参数

在本部分中有一些启动项目的命令,这里采用Github开源项目·Zfile进行示例。

收集器名称 启动该收集器
Serial -XX:+UseSerialGC
ParNew -XX:+UseParNewGC
Parallel Scavenge -XX:+UseParallelGC
CMS -XX:+UseConcMarkSweepGC
Serial Old 使用Serial收集器时,会自动启动Serial Old收集器
Parallel Old -XX:+UseParallelOldGC
G1/Garbage First -XX:+UseG1GC
Shenandoah -XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC
bash 复制代码
# Serial
java -XX:+UseSerialGC -jar zfile.jar

# ParNew
java -XX:+UseParNewGC -jar zfile.jar 

# ParNew + CMS
java -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -jar zfile.jar

# Parallel Scavenge
java -XX:+UseParallelGC -jar zfile.jar
java -XX:+UseParallelGC -XX:GCTimeRatio=1 -jar zfile.jar # 垃圾收集器占比不超过1%
java -XX:+UseParallelGC -XX:+UseAdaptiveSizePolicy -jar zfile.jar # 虚拟机优化内存

# CMS
java -XX:+UseConcMarkSweepGC -jar zfile.jar

# Parallel Old收集器
java -XX:+UseParallelOldGC -jar zfile.jar

# G1
java -XX:+UseG1GC -jar zfile.jar

# Shenandoah | 由于现在还在测试阶段,需要使用 -XX:+UnlockExperimentalVMOptions
java -XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC -jar zfile.jar

各收集器详细说明

JDK 1.3 之前

JDK 1.3 之前并没有一个真正像样的垃圾收集器(据说Serial Old是1.2之前出现的,其余不详),那个时代的垃圾收集器甚至没有一个名字,资料只能搜索到"标记-清除垃圾收集器",很明显是基于标记清除算法实现的,特点是单线程,需要停止用户线程,没有分代,效率低下等...

JDK 1.3 | Serial

Serial收集器采用标记-清除算法,单线程处理,在进行垃圾收集的时候必须停止用户线程(STW/Stop The World),Serial收集的比较"慢",这也是一开始都诟病Java慢的主要原因。

但是,Serial的优点是简单,它不需要太多的内存空间,也没有线程切换的资源消耗,就像是一个扫地僧一样默默地进行着自己的工作。而且虽然"慢",但是一般来说也就只有几十~上百毫秒,虽然这个时间对于服务端程序来说可能十分宝贵,但是对于客户端程序来说无伤大雅,可能你使用的Java App一直每隔一段时间暂停几十毫秒,而你根本察觉不到。正是由于它简单(不消耗太多内存等)高效(相对于其它多线程版本的一条线程来说),所以Serial被广泛使用在客户端程序中。

Serial收集器的工作过程如下(图片来源于《深入理解Java虚拟机》):

可以看出,每次需要进行垃圾收集的时候,都需要停顿用户线程,且每次只有一个线程在进行垃圾收集

JDK 1.4 | ParNew

ParNew收集器是Serial收集器的多线程版本,在操作上除了是采用多线程的方式来进行的以外,其余与Serial区别不大。需要注意的是:由于ParNew还需要有现成切换的成本,所以其单线程的效率是比Serial低的,也就是说核心数为1时,其工作效率不如Serial收集器。

所以ParNew收集器更适合于核心线程数尚且客观(4~6个)的一些老项目中。

JDK 1.4 | Parallel Scavenge

吞吐量公式说明

Parallel Scavenge是一款吞吐量收集器,主要关注程序的吞吐量,首先了解下吞吐量计算公式:

吞吐量 = 运行用户代码时间 运行用户代码时间 + 运行垃圾收集时间 吞吐量={运行用户代码时间\over{运行用户代码时间+运行垃圾收集时间}} 吞吐量=运行用户代码时间+运行垃圾收集时间运行用户代码时间

如果程序运行需要99毫秒,垃圾回收需要1毫秒,那么吞吐量就是 99/(1 + 99) = 99%。

相关参数

我们可以通过下面一些指令来控制吞吐量,也可以直接控制最大垃圾收集停顿时间等,或者直接将这些参数交给虚拟机来进行控制。

指令 接收参数 说明
-XX:MaxGCPauseMillis 大于0的毫秒数 控制最大垃圾收集停顿时间
-XX:GCTimeRatio 大于0小于100的整数,也就是垃圾收集时间占总时间的比率 直接设置吞吐量大小
-XX:+UseAdaptiveSizePolicy 允许虚拟机自动对内存进行调优,从而控制吞吐量

JDK 5 | CMS 收集器

CMS简介

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

CMS收集器采用标记清除算法,其工作分为四个步骤:

  1. 初始标记:初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快

  2. 并发标记(CMS concurrent mark) 与用户线程同时:并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行

  3. 重新标记(CMS remark):重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短

  4. 并发清除(CMS concurrent sweep) 与用户线程同时:最并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

并发清除一定会面临两个问题:浮动垃圾与消失对象问题,CMS采用的是增量更新的解决方案,在之前的博客中已经介绍过了,请点此查看

CMS的缺点

  1. **处理器资源敏感:**CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低

  2. Full GC问题 :由于CMS收集器无法处理"浮动垃圾"(Floating Garbage),有可能出现"Concurrent Mode Failure"失败进而导致另一次完全"Stop The World"的Full GC的产生(我的理解是由于浮动垃圾的存在导致浪费了一定的空间,并且垃圾收集过程中用户线程还在继续,还在产生新的垃圾,最终内存空间不足导致直接触发FullGC)。因此CMS一般会在内存空间达到一定阈值后直接进行垃圾回收,JDK5默认是68%,JDK6默认为92%,可以通过-XX:CMSInitiatingOccupancyFraction调整阈值大小。

  3. 空间碎片问题:由于CMS是基于标记清除算法进行工作的,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

CMS运行示意图

JDK 7 | G1

G1收集器具有里程碑式意义,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。简单来说,传统的垃圾收集器都是一个老年代,一个新生代,老年代用来存放存活时间较长的对象(或一些大对象),新生代用来存放新对象。而G1收集器采用的是

在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

简单来说,G1收集器不再是传统的老年代与新生代,而是将连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要扮演老年代、新生代的角色。G1收集器会根据

Region是最小的的单位,每次垃圾收集的内存空间一定是Region的整数倍。G1会根据回收收益情况,判断回收哪些Region空间。

Region中还有一类特殊的Humongous区域,**专门用来存储大对象。**G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

产生的问题与解决方案

**跨区引用问题:**每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种"双向"的卡表结构(卡表是"我指向谁",这种结构还记录了"谁指向我")比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。

**收集线程与用户线程互不干扰的运行:**首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误(见《HotSpot实现细节-原是快照》);此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的"Concurrent Mode Failure"失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间"Stop The World"。

**怎样建立起可靠的停顿预测模型?**用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的"衰减平均值"是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表"最近的"平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

回收过程

  1. **初始标记(Initial Marking):**仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

  2. **并发标记(Concurrent Marking):**从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。

  3. **最终标记(Final Marking):**对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

  4. **筛选回收(Live Data Counting and Evacuation):**负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

相关推荐
CS Beginner5 分钟前
【搭建】个人博客网站的搭建
java·前端·学习·servlet·log4j·mybatis
JavaTree201741 分钟前
【Spring Boot】Spring Boot解决循环依赖
java·spring boot·后端
lang201509281 小时前
Maven 五分钟入门
java·maven
cj6341181501 小时前
SpringBoot配置Redis
java·后端
用坏多个鼠标1 小时前
Nacos和Nginx集群,项目启动失败问题
java·开发语言
TangKenny2 小时前
基于EasyExcel的动态列映射读取方案
java·easyexcel
安冬的码畜日常2 小时前
【JUnit实战3_19】第十章:用 Maven 3 运行 JUnit 测试(下)
java·测试工具·junit·单元测试·maven·junit5
lemon_sjdk2 小时前
软件开发模式架构选择
java·架构·软件开发·前后端分离
董广明2 小时前
单元测试(JUnit、Mockito、PowerMock )
java·经验分享·junit·单元测试
Lisonseekpan2 小时前
Java Stream 流式编程
java·后端