经典垃圾收集器
Serial垃圾收集器
这是最基础,历史最悠久的收集器,它是一个单线程收集器,每次执行都进行停顿(stw)、简单高效,内存消耗小、没有线程交互;
Serial Old是它的老年代版本,使用标记整理算法,而新生代部分用标记复制算法。
Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
ParNew收集器
它实际上是Serial收集器的多线程版本,服务端模式。除了同时使用多条线程进行垃圾收集之
外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:
PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规
则、回收策略等都与Serial收集器完全一致。
值得注意的是,除了Serial收集器外,目前只有这个收集器可以和CMS搭配使用。
并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线
程在协同工作,通常默认此时用户线程是处于等待状态。
并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾
收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于
垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
Parallel Scavenge收集器
也是一款新生代收集器,同样基于标记—复制算法实现,也能够并行收集,但更关注吞吐量,他的目标就是达到一个可控的吞吐量。
能够自适应调节,在停顿时间和吞吐量之间找到平衡,比ParNew更智能。
Serial Old收集器
是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记—整理算法,主要供给客户端模式下的HotSpot虚拟机使用。
Palallel Old收集器
是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记—整理算法实现。
Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
CMS收集器
CMS收集器是一款以获取最短回收停顿时间为目标的收集器。基于标记—清除实现,它的运作过程分为以下四个步骤:
1)初始标记(CMS initial mark)
2)并发标记(CMS concurrent mark)
3)重新标记(CMS remark)
4)并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC
Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对
象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重
新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的
标记记录(详见3.4.6节中关于增量更新的讲解),这个阶段的停顿时间通常会比初始标记阶段稍长一
些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的
对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
CMS是一款优秀的收集器,具有并发收集、低停顿的优点,但它远未达到完美的程度,主要有三个缺点:
1.对处理器资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,
CMS对用户程序的影响就可能变得很大。为了缓解这种情况,在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些。
2.并发标记、并发清理阶段,用户线程未暂停,仍然会有新的垃圾产生无法清理,只能等到下一次,长此以往仍然会导致并发失败,虚拟机启动冻结用户线程,造成时间损耗。
3.因为基于标记清除实现,所以必会有大量内存碎片产生,导致内存分配难。
G1收集器
G1收集器是垃圾收集器发展历史上里程碑式的结果,开创了收集器面向局部收集的思路和基于Region的内存布局。
G1是面向服务端应用的垃圾收集器,JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。
作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause
Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段
内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中
软实时垃圾收集器特征了。
具体要实现这个目标,就必须改变现有的分代回收策略,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(MajorGC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。其中,还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一Region容量一半的对象即可判定为大对象。
虽然G1保留了新生代和老年代的概念,但这俩也不是死概念了,他们在G1下变成了一系列区域的动态集合。G1将Region作为最小回收单元。G1根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默 认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
G1收集器仍然有一些问题需要解决:
譬如,将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?解决的思
路我们已经知道(见3.3.1节和3.4.4节):使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这
种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更
复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃
圾收集器有着更高的内存占用负担。
怎么保证用户线程和收集线程互不干扰?(从新垃圾产生和新对象分配两方面看)
- G1使用原始快照实现、
- G1给每个Region设计了两个指针,把Region中的一部分空间划分出来用于存放并发回收过程中的新对象分配。
G1收集器的运作大致可以分为一下四个步骤:
初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS
指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要
停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际
并没有额外的停顿。
·并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
·最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
·筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回
收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region
构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行
完成的。
G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。当然,设置的期望停顿时间必须符合实际。
G1和CMS收集器的对比:
相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动
态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS
的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region
之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存
空间碎片,垃圾收集完成之后能提供规整的可用内存。
说了优点的同时,不意味着G1完爆了CMS:
- 就内存占用来说,这俩都用卡表处理跨代指针的问题,但G1的卡表结构明显更复杂且数量多,占用的内存也更多,而CMS的卡表就要简单多了,只要唯一一份,而且只用处理老年代到新生代的指针。
2.在执行负载的角度上,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行
同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索
(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照
搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,
但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作
要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现
为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。
综上所述,在实际应用中,小内存用CMS的效果大概率优于G1,而在大内存中则是G1的主场。
垃圾收集影响因素和垃圾收集器的选择
衡量垃圾收集器的标准主要有三个:内存占用、吞吐量、延迟,这三者基本不可能都达到,只能寻找平衡点。
如何选择?
应用程序的主要关注点是什么?如果是数据分析、科学计算类的任务,目标是能尽快算出结果,
那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务
超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是
不可忽视的。
·运行应用的基础设施如何?譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是
ARM/Aarch64;处理器的数量多少,分配内存的大小;选择的操作系统是Linux、Solaris还是Windows
等。
·使用JDK的发行商是什么?版本号是多少?是ZingJDK/Zulu、OracleJDK、Open-JDK、OpenJ9抑
或是其他公司的发行版?该JDK对应了《Java虚拟机规范》的哪个版本?一般来说,收集器的选择就从以上这几点出发来考虑。
内存分配策略
对象优先在Eden区分配:
大多数情况下都是如此,若Eden区没有足够空间,进行Minor GC。
大对象直接进入老年代:
大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者
元素数量很庞大的数组。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配。
长期存活的对象进入老年代:
每个对象定义了一个对象年龄计数器,若经过第一次收集后仍然存活,并且能被Survivor接纳,该对象会进入s区域,年龄设置为1,之后每熬过一次Minor GC,年龄++,当年龄达到一定数值时(一般默认是15),会进入老年代。可以通过参数-XX:MaxTenuringThreshold设置到达多少年岁会晋升。
动态对象年龄判定:
如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
空间分配担保:
在发生MinorGC之前
- 检查老年代连续空间是否大于新生代所有对象总空间,如果大于那么Minor Gc是安全的;
- 检查是否允许担保失败发生,如果允许,检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,若大于则进行Minor GC,若小于或者不允许冒险,则进行Full GC。