【JVM系列】- Java虚拟机垃圾收集器
一、经典垃圾收集器
针对HotSpot虚拟机的垃圾收集器:
图中两个收集器之间存在连线,就说明它们可以搭配使用;所处区域表示属于什么代
不存在最好和万能的收集器,选择的只是对具体应用最合适的收集器
1. Serial收集器 - 新生代
-
算法:标记-复制算法
-
优点:简单高效,额外内存消耗最小,适合内存资源受限的情况;没有线程交互开销,收集效率高
-
缺点:单线程工作,即它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束
-
适合:客户端模式
-
搭配:Serial Old、CMS
2. ParNew收集器 - 新生代
-
算法:标记-复制算法
-
ParNew收集器实质上是Serial收集器的多线程并行版本
-
适合:服务端模式
-
搭配:Serial Old、CMS
-
并行vs并发:
-
并行:描述多条垃圾收集器线程之间的关系
-
并发:描述的是垃圾收集器线程与用户线程之间的关系
-
3. Parallel Scavenge收集器 - 新生代
- 算法:标记-复制算法
- 能并行收集的多线程收集器
- Parallel Scavenge目标是达到一个可控制的吞吐量
- 吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)
- 搭配:Serial Old、Parallel Old
- 停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务
4. Serial Old收集器 - 老年代
-
Serial Old是Serial收集器的老年代版本
-
算法:标记-整理算法
-
搭配:
- 供客户端模式下的HotSpot虚拟机使用
- 在服务端模式下,在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,或作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用
-
Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,但其与Serial Old的实现几乎是一样的
5. Parallel Old收集器 - 老年代
- Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集
- 算法:标记-整理算法
- 在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合
6. CMS收集器 - 老年代
-
最短回收停顿时间为目标;首次实现了让垃圾收集线程与用户线程(基本上)同时工作
-
目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,关注服务的响应速度,希望系统停顿时间尽可能短
-
算法:标记-清除算法
-
整个过程分为四个步骤:
- 初始标记(CMS initial mark)
- 需要“Stop The World”
- 仅标记一下GC Roots能直接关联到的对象,速度很快
- 并发标记(CMS concurrent mark)
- 从GC Roots的直接关联对象开始遍历整个对象图
- 耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
- 重新标记(CMS remark)
- 需要“Stop The World”
- 修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,增量更新
- 停顿时间比初始标记稍长,但也远比并发标记的时间短
- 并发清除(CMS concurrent sweep)
- 清理删除掉标记阶段判断已死亡的对象,不需要移动存活对象,故可以与用户线程并发
- 初始标记(CMS initial mark)
-
优点:并发收集、低停顿;整个过程中耗时最长的阶段,垃圾收集器线程都可以与用户线程一起工作
-
缺点:三个
- CMS收集器对处理器资源非常敏感
- 在并发阶段,会因为占用了一部分处理器计算能力导致应用程序变慢,降低总吞吐量
- 当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大
- 为缓解这种情况,提供“增量式并发收集器”的CMS收集器变种,但效果很一般,后废弃
- CMS收集器无法处理“浮动垃圾”(Floating Garbage)
- 浮动垃圾:CMS的并发阶段,用户线程还在继续运行,会伴随有新的垃圾对象产生,但标记阶段已过,只能下次垃圾收集再清理掉
- 不能等老年代几乎被填满了再收集,须预留部分空间供并发收集时的程序运作使用
- 降低内存回收频率,获取更好的性能
- 但要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不冻结用户线程,临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间很长
- 收集结束时会有大量空间碎片产生
- 因为基于“标记-清除”算法,会产碎片
- 进行若干次不整理空间的Full GC之后,需要整理一次碎片
- CMS收集器对处理器资源非常敏感
-
搭配:Serial、ParNew
7. G1收集器 - 全堆
-
G1取代Parallel Scavenge+Parallel Old,成为服务端模式下的默认垃圾收集器
-
目标:建立“停顿时间模型”,指能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒
-
G1收集器的Mixed GC模式:面向局部收集,堆内存任意部分来组成回收集CSet进行回收,衡量标准不再是属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大
-
基于Region的内存布局:不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域Region,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或老年代空间
- Region中还有一类特殊的Humongous区域,专门用来存储大对象,通常作为老年代看待;G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象
-
原理:G1收集器跟踪各个Region里面的垃圾堆积的“价值”,即回收所获得的空间大小以及回收所需时间的经验值,在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来
-
需解决的问题:
- 跨Region引用对象:每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内,G1中记忆集的存储本质是哈希表
- 并发阶段线程干扰:
- 用户线程改变对象引用关系:不能打破原本的对象图结构,通过原始快照(SATB)算法使标记结果不出现错误
- 回收过程中新创建对象的内存分配:G1为每一个Region设计了两个名为TAMS的指针,并发回收时新分配的对象地址都必须要在这两个指针位置以上,默认这个地址上的对象是存活的,不纳入回收范围
- 与CMS类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”
- 建立可靠的停顿预测模型:以衰减均值为理论基础实现,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息;衰减平均值更准确地代表“最近的”平均状态
-
G1运行过程:
- 初始标记:仅标记GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象;需停顿线程,但耗时很短,且是进行Minor GC时同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象;耗时较长,但可与用户程序并发执行;当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象
- 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
- 筛选回收:更新Region的统计数据,对各个Region的回收价值和成本排序,根据用户所期望的停顿时间制定回收计划,选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间,移动存活对象
-
G1收集器并非纯粹地追求低延迟,而是在延迟可控的情况下获得尽可能高的吞吐量
-
G1 VS CMS:
-
G1整体基于标记-整理算法,局部基于标记-复制算法;CMS基于标记-清除算法
-
G1优点:G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存,有利于程序长时间运行;设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡;按收益动态确定回收集
-
G1缺点:为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高
-
目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其
优势,堆容量平衡点通常在6GB至8GB之间
-
二、低延迟垃圾收集器
衡量收集器的“不可能三角”:内存占用,吞吐量,延迟
-
CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发;但标记后的处理阶段都得停顿
-
Shenandoah和ZGC收集器几乎全过程都是并发的,称为低延迟垃圾收集器
1. Shenandoah收集器
-
在管理堆内存方面,Shenandoah与G1至少有三个不同之处:
- 最重要的是Shenandoah支持并发的整理算法
- Shenandoah默认不使用分代收集,不会有专门的新生代或老年代Region存在,没有实现分代
- Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率
-
连接矩阵:如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,在回收时通过其就可以得出哪些Region之间产生了跨代引用
- 工作过程 :9阶段
- 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象
- 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象
- 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)
- 并发清理(Concurrent Cleanup):清理整个区域内没一个存活对象的Region
- 并发回收(Concurrent Evacuation):Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中,Shenandoah通过读屏障和“Brooks Pointers”转发指针来解决移动时原对象被读写、移动后地址修改的问题
- 初始引用更新(Initial Update Reference):把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新;引用更新的初始化阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务
- 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,与用户线程并发的,时间长短取决于内存中涉及的引用数量的多少
- 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用
- 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,最后再调用一次并发清理过程来回收这些Region的内存空间
-
Brooks Pointer 转发指针:Shenandoah用以支持并行整理的核心概念,实现对象移动与用户程序并发,解决并发回收阶段(复制阶段)⽤户线程对被移动对象进行读写访问的问题
- 具体实现:类似句柄,但句柄存储在专门的句柄池中,转发指针是分散存放在每一个对象头前面
- 在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己
- 只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上
- 对于并发写入,必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中
- 具体实现:类似句柄,但句柄存储在专门的句柄池中,转发指针是分散存放在每一个对象头前面
2. ZGC收集器
以低延迟为要目标,但实现思路与Shenandoah有显著差异
ZGC收集器基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法
三、选择合适的垃圾收集器
1. 收集器的权衡
主要受以下三个因素影响:
- 应用程序的主要关注点是什么?数据分析、科学计算类的任务,目标是尽快出结果,主要关注吞吐量;SLA应用,停顿时间直接影响服务质量,甚至导致事务超时,主要关注延迟;客户端应用或者嵌入式应用,主要关注垃圾收集的内存占用
- 运行应用的基础设施如何?譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是ARM/Aarch64;处理器的数量多少,分配内存的大小;选择的操作系统是Linux、Solaris还是Windows等
- 使用JDK的发行商是什么?版本号是多少?是ZingJDK/Zulu、OracleJDK、Open-JDK、OpenJ9抑或是其他公司的发行版?该JDK对应了《Java虚拟机规范》的哪个版本?
2. Epsilon收集器
Epsilon收集器不能进行垃圾收集
如果应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择
“自动内存管理子系统”:一个垃圾收集器除了垃圾收集这个本职工作外,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,其中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持,最小化功能的垃圾收集器也必须实现
3. 虚拟机及垃圾收集器日志
从JDK9开始,HotSpot所有功能的日志都收归到了“-Xlog”参数上
命令行:-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
-
最关键的参数是选择器(Selector),由标签(Tag)和日志级别(Level)共同组成
- 垃圾收集器的标签名称为“gc”
- 日志级别从低到高,共有Trace, Debug, Info, Warning, Error, Off六种级别,日志级别决定了输出信息的详细程度,默认级别 为Info
-
还可以使用修饰器(Decorator)来要求每行日志输出都附加上额外的内容,支持附加在日志行上的信息包括:
- time:当前日期和时间
- uptime:虚拟机启动到现在经过的时间,以秒为单位
- timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出
- uptimemillis:虚拟机启动到现在经过的毫秒数
- timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出
- uptimenanos:虚拟机启动到现在经过的纳秒数
- pid:进程ID
- tid:线程ID
- level:日志级别
-
如果不指定, 默认值是uptime、level、 tags这三个
四、内存分配与回收策略
自动内存管理最根本的目标是自动化地解决两个问题:
- 自动给对象分配内存
- 自动回收分配给对象的内存
要学习怎么写程序验证分配规则,因为使用不同的垃圾收集器时内存分配和回收策略存在差异
本节例子均使用HotSpot虚拟机,以客户端模式运行,验证的实际是使用Serial加Serial Old收集器组合下的内存分配和回收的策略
1. 对象优先在Eden区分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
HotSpot虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集时打印内存回收日志,并在进程退出时输出当前内存各区域的分配情况
2. 大对象直接进入老年代
-
大对象指需要大量连续内存空间的Java对象,如很长的字符串或元素数量很庞大的数组
-
HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,目的在于避免在Eden区及两个Survivor区之间来回复制,产⽣大量的内存复制操作
-
在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们;而复制对象时,大对象意味着高额的内存复制开销
3. 长期存活的对象将进入老年代
-
虚拟机给每个对象定义了⼀个对象年龄(Age)计数器,存储在对象头中
-
对象通常在Eden区⾥诞⽣,如果经过第⼀次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁;对象在Survivor区中每熬过⼀次Minor GC,年龄就增加1岁,当它的年龄增加到⼀定程度(默认为15),就会被晋升到老年代中
-
对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置
4. 动态对象年龄判定
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的⼀半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄
5. 空间分配担保
发生Minor GC之前,虚拟机必须先检查:
-
➀老年代最大可用的连续空间是否大于新生代所有对象总空间
-
➁-XX:HandlePromotionFailure参数的设置值是否允许担保失败,即允许冒险
-
➂老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
-
➀不满足则进行➁,➁满足则进行➂,➂满足则尝试⼀次MinorGC;➁或➂不满足则进行Full GC
“冒险”是冒了什么风险:新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况,需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代
- JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC