各类垃圾收集器整理
各类垃圾收集器整理
本文整理在Java虚拟机里面常用的垃圾收集器。上图中,如果俩个收集器之前有连线,就说明他们可以搭配使用,图中收集器所处的区域,则表示他是属于新生代收集器或者是老年代收集器。
一、Serial收集器
Serial收集器是最基础,历史最悠久的收集器,曾经在jdk(1.3.1之前)是hotspot虚拟机新生代收集器的唯一选择。
它是一个单线程工作的收集器,它只会使用一个处理器或者一个线程去完成垃圾收集工作,它在进行垃圾收集的时候,必须暂停其他的工作线程,直到它收集结束。现在是hotspot虚拟机运行在客户端模式下的默认新生代收集器。
二、ParNew收集器
parnew收集器实质上是serial收集器的多线程版本,除了serial收集器外,目前只有它能与cms收集器配合工作。
三、Parallel Scavenge收集器
parallel scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够进行并行收集的收集器,它的特点是它的关注点和其他的收集器不同,cms等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而parallel scavenge 收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗的时间的比值。即:用户代码时间/(用户代码时间+垃圾收集时间)
停顿时间越短就越适合需要与用户交互或者需要保证服务响应质量的程序,良好的响应速度能提升用户的体验。而高吞吐量则可以最高效利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
parallel scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX: MaxGcPauseMillis以及直接设置吞吐量大小的-XX:GcTimeRatio
- -XX: MaxGcPauseMillis 允许设置一个大于0的毫秒值,收集器将尽量保证垃圾收集的停顿时间不超过用户设定设定值
- -XX:GcTimeRatio 允许设置一个0-100的值,代表垃圾收集的停顿时间占总时间的比率。
- -XX:useAdaptiveSizePolicy 这是一个开关参数,当这个参数被激活后,就不需要人工指定新生代的大小(-Xmn),eden和survivor区的比例(-xx:SurvivorRatio),晋升老年代对象大小等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式被称为垃圾收集的自适应的调节策略。
四、Serial Old收集器
serial old收集器是serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法,这个收集器的主要意义也是供客户端模式下的hotspot虚拟机使用。它也可能有两种用途:一种是jdk1.5以前与parallel scavenge收集器搭配使用,另外一种是作为cms收集器发生失败时的后备预案,并在收集发生concurrent mode failure使用。
五、Parallel Old收集器
parallel old是parallel scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。jkd6提供。
六、CMS收集器
cms收集器是一种以获取最短回收停顿时间为目标的收集器,其基于标记-清除算法实现,它的运作过程包括四个过程:1、初始标记;2、并发标记;3、重新标记;4、并发清除。
- 其中初始标记和重新标记这个俩个步骤仍然需要停止用户线程。
- 初始标记仅仅只是标记Gc Roots能直接关联到的对象,速度很快
- 并发标记阶段就是从Gc Roots直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户的线程
- 重新标记阶段是为了修正并发标记阶段因用户程序继续运作导致标记产生变动的那一部分对象的标记记录(增量更新)
- 并发清除阶段清理删除掉标记阶段的判断死亡的对象。由于不需要移动对象这个阶段也可以和用户线程并发
cms的缺点:
- cms收集器对处理器资源比较敏感。在并发阶段虽然不会停止用户的线程,但是会占用一部分线程,导致应用程序变慢,吞吐量下降;默认回收的线程 = (处理器核心数据 + 3 ) /4,因此四个以上占用不超过25%
- cms无法处理浮动垃圾,有可能出现concurrent mode failure 导致一次完全stop all world的full gc。在并发标记和并发清除的阶段用户线程在运行,不可避免的会产生垃圾,但是这部分垃圾在本次的收集中是不会收集的,只能留待下次。同时由于用户线程在垃圾收集的时候在运行,就需要给预留足够的内存供用户线程使用,所以需要在内存达到一定的占用比例的时候,提前触发垃圾收集。jdk6 92%在此之前 68%。
- cms是一款基于标记-清除算法的收集器,因此不可避免的会产生空间碎片,当空间碎片过多的时候,将会给大对象分配带来很大的麻烦,如果找不到足够大的连续空间来分配对象,虚拟机不得不提前进行一次full gc。为了解决这个情况cms提供了两个参数,一个是在虚拟机不得不进行full gc的时候是否开启(默认开启)内存碎片的整理,如果开启在移动对象的过程中用户线程就需要停顿;还有一个参数是在cms收集器在执行了n次不整理空间的full gc之后,下次full gc前会进行碎片整理(默认值为0)。
七、Garbage First收集器
G1 收集器开创了面向局部收集的设计思路和基于region的内存布局形式,它是一款主要面向服务端的垃圾收集器,在jdk7-8成熟。
在规划jdk10的功能目标的时候,hotspot虚拟机提出了“统一垃圾收集器接口”,将内存回收的行为和实现分离。
G1可以面向堆内存任何部分来组成回收集进行回收,权衡的标准不再是它属于那个分代,而是那块内存中存放的垃圾数据最多,回收收益最大。这就是g1收集器的mixed gc模式。
G1开创的基于region的堆内存布局是它能够实现这个目标的关键。虽然G1也遵循分代收集理论设计,但其堆内存的布局和其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的java堆划分成多个大小相等的独立区域(region),每一个region都可以根据需要,扮演新生代的eden空间、survivor空间或者老年代空间。收集器可以对扮演不同角色的region采用不同的策略去处理。这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
region中还有一类特殊的humongous区域,专门用来存储大对象。G1认为只要超过region容量的一半就是大对象,对于超过了整个region容量的超大对象,将会被存放在多个连续的humongous region中,g1中大多数行为把humongous region当作老年代来看待。
虽然G1仍然保留新生代和老年代的概念,但是新生代和老年代不在固定了,他们都是一系列区域的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将region作为垃圾回收的最小单元,这样可以避免全堆扫描进行垃圾收集。更具体的思路是让G1收集器跟踪每个region垃圾堆积“价值”的大小,价值即回收的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设允许的收集停顿时间(-XX:MaxGCPauseMillis, 默认200ms),优先处理回收价值收益最大的那些region,这也是Garbage First名字的由来。这种使用region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
G1的关键细节问题:
- region之间的跨region引用如何解决?解决思路就是使用记忆集,避免全堆作为gc roots扫描,这里的记忆集比之前提到的要复杂一些,它的每个region都要维护自己的记忆集,这个记忆集会记录下别的region指向自己的指针,并标记这些指针在那些卡页的范围之内,这种“双向”的卡表结构比原来的卡表实现起来更复杂,同时由于region的数据比传统收集器的分代数量要多很多,因此G1收集器比其他传统收集器有着更好的内存占用负担。G1至少消耗java堆容量的10%-20%的额外内存来维持收集器饿工作。
- 并发阶段如何保证收集线程和用户线程互不干扰的运行?为了保证原本的对象图结构不被破坏,保证标记结果正确,G1使用原始快照的算法来实现。还有就是关于新对象的分配。G1为每个region设置了俩个名为TAMS(TOP AS MARK START)的指针,把region的一部分空间划分出来用户并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须在这俩个指针位置之上。G1收集器默认这个地址往上的对象时隐式标记过的,即默认对象存活,如果内存的回收速度跟不上内存分配的速度,G1也要被迫冻结用户线程,导致full gc。
- 怎样建立起可靠的停顿预测模型?G1收集器的停顿预测模型是以衰减均值为理论基础来实现的,在垃圾收集的过程中,G1会记录每个region的回收耗时、记忆集里面的脏卡数量等各个可测量步骤花费的成本,并得出平均值、标准偏差、置信度等统计信息,这里的衰减均值比普通的均值更容易受到最近数据的影响,却更能准确的代表“最近的”平均状态。即region统计越新越能决定其回收的价值。
G1的执行步骤:
- 初始标记:标记gc roots能直接关联到的对象,并且修改TAMS指针的值,让用户线程并发运行的时候,能正确地在可用region中分配对象,这个阶段用户线程需要停顿。
- 并发标记:从gc roots开始对堆中的对象进行可达性分析,可以与用户线程并发执行,当对象图扫描完成之后,需要重新处理原始快照记录下的并发时引用有变动的对象
- 最终标记:对用户线程做一个短暂的停顿,用户处理并发阶段结束后仍遗留下来的少量的原始快照的中引用有变动的记录。
- 筛选回收:负责更新region的统计数据,对各个region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以任意选多个region组成回收集,然后决定把那一部分存活的对象复制到空的region中,再清理旧的整个region的全部空间。这里涉及对象的移动,是必须暂停用户线程的,由多条收集器线程并行完成。
G1并非纯粹的追求低延迟,官方给它的设定目标是再延迟可控的情况下,获得尽可能高的吞吐量,所以才担得起“全功能收集器”的重任与期望。为什么在回收阶段不并发执行?1、g1只回收一部分region,停顿时间是用户可控的 2、保证吞吐量。
从G1开始,最先进的垃圾收集器的设计导向都不约而同的变为追求能够应付应用的内存分配速率,而不是追求一次把整个堆全部清理干净,与cms的标记-清除算法不同。g1从整体来看是基于标记-整理算法实现的收集器,但从局部来看是基于标记-复制的算法实现,这俩种算法都不会产生空间碎片,有利于程序长时间运行。但是g1的内存占用比较高。
衡量垃圾收集器的三项最重要的指标是:内存占用、吞吐量、延迟
各款收集器并发情况
浅色代表挂起用户线程、深色代表和用户线程并发执行
八、Shenandoah收集器
shenandoah是由redhat公司独立发展的新型收集器项目,opjdk-12- jep189; 这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内。
shenandoah更像是g1的继承者,它们有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分代码。
在内存管理方面它与g1至少有三处明显的不同1、支持并发的整理算法,2、目前shenandoah默认不使用分代收集,即不会有专门的新生代region和老年代region;3、摒弃了记忆集,改用“连接矩阵”的全局数据结构来记录跨region的引用关系,连接矩阵类似一个n*m的表格,如果在j和i的region之间存在跨region应用,就打上一个标记。
shenandoah收集器的工作过程划分为以下九个阶段:
- 初始标记:首先标记与gc roots直接关联的对象,这个阶段仍然stop the world
- 并发标记:遍历对象图,标记全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的复杂度。
- 最终标记:处理在剩余原始快照扫描,并在这个阶段统计出回收价值最高的region,将这些region构成一组回收集,这个阶段也会有一小段的停顿。
- 并发清理:用于清理那些整个区域连一个存活的对象都没有找到的region。
- 并发回收:并发回收阶段是shenandoah与以前其他的收集器的核心差异。在这个阶段shenandoah要把回收集里面存活的对象先复制一份到其他未被使用的region之中,这个阶段要和用户线程并发执行,其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但是移动之后整个内存的中所有指向对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于这个困难,shenandoah会通过读屏障和称为“brooks pointers”转发指针来解决,并发回收阶段运行的时间长短取决于回收集的大小。
- 初始引用更新:并发回收阶段复制对象结束之后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用跟新的初始化阶段并未有什么实质性的操作,设立这个阶段只是为了建立一个线程集合点。确保所有并发回收的线程都完成了分配给它们的对象移动任务。初始引用更新需要短暂的停顿。
- 并发引用更新:真正进行引用更新操作,这个阶段是与用户线程并发进行的,时间的长短取决于内存中涉及的引用数量多少,它需要按照物理内存地址的顺序,线性搜索引用类型,把旧值改成新值。
- 最终引用更新:解决了堆中引用更新后,还要修正存在与gc roots中的引用,这个阶段是shenandoah的最后一次停顿。
- 并发清理:经过并发回收和引用更新之后,需要把之前的region的空间回收。
brooks pointers转发指针和之前需要内存陷阱的转发指针(会涉及到用户态到和心态的转换)不同,它需要在原有的内存布局的最前面增加一个新的引用字段,在不处于并发回收的时候,该引用指向对象本身(每次对象的访问都会进行一次额外的转发操作)。这样必然会出现多线程竞争的问题,shenandoah收集器通过cas来保证对象访问的准确性,但是“对象访问”这个四个字比较重,对象访问的操作在代码里面比比皆是,要覆盖全部的对象访问操作,因此不得不设置读、写屏障去拦截(读操作很多,会有性能问题,jdk13计划优化为引用屏障来实现)。
九、ZGC收集器
zgc收集器是jdk11的时候加入的一款实验性质的收集器,zgc和Shenandoah收集器的目标高度相似,都希望尽可能在吞吐量影响不大的前提下,实现任意堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内。
关于内存布局,zgc也采用基于region的堆内存布局,zgc的region具有动态性--动态创建和销毁,以及动态的区域容量大小,在x64的硬件平台下,region分为
- 小型region:容量固定2m,用于存放小于256大小的小对象
- 中型region:容量固定32m,用于存放大于256但小于4m大小的小对象
- 大型region:容量不固定,可以动态变化,必须为2m的整数倍。
关于并发整理算法的实现,zgc虽然也用到了读屏障,但是有一个标志性的设计就是它采用的染色指针技术。他把标记的信息记录在引用对象的指针上,这时相当于是遍历引用图来标记引用。
- 为什么指针可以存储信息,在64位系统中,amd架构只支持52位地址总线和48(256tb)位虚拟地址空间,此外操作系统也会增加一些自己的约束,所以剩下的这些位可以用来存储一些信息,这也导致zgc管理的内存不能超过4tb 2^42次幂(此处可以理解为会占用一些原本的高位来记录信息,导致可管理的内存变小)。染色指针的优势:
- 一旦某个region存活的对象被移走后,这个region的立即就能够被释放和重用,不用等待堆中所有指向这个region的引用都被修正后再清理。
- 大幅减少垃圾回收过程中的内存屏障数量(读屏障)。
- 染色指针可以作为一个可扩展的存储结构用来记录更多的与对象标记重定位过程相关的数据,以便日后进一步提高性能。现在linux下64位还有18位未使用。
这里需要解决的前置的条件是:java虚拟机作为一个进程是怎么重新定义内存中某些指针的其中几位的。解决方案是虚拟内存映射技术,zgc使用了多重映射将多个不同的虚拟内存地址映射到同一个物理内存上。
zgc收集器的工作过程
- 初始标记:首先标记与gc roots直接关联的对象,这个阶段仍然stop the world
- 并发标记:并发标记是遍历对象图做可达性分析的阶段,前后也要经历初始标记、最终标记的短暂停顿,在这些停顿阶段做的事情的目标也相似的,zgc的标记在指针上而不是对象上进行的。标记阶段会更新染色指针中的marked0 和marked1标志位。
- 并发预备重分配:这个阶段需要根据特定的查询条件统计得出本次收集过程要清理那些整个region,将这些region组成重分配集。重分配集和g1的回收集还是有区别的,zgc划分region的目的并非为了像g1那样做收益优先的增量回收。相反,zgc每次回收都会扫描所有region,用范围更大的扫描成本换取省去g1中记忆集的维护成本,zgc的重分配集只是决定了里面的存活对象会被重新复制到其他的region中,里面的region会被释放掉,而并不能说回收行为只是针对这个集合里面的region进行,因为标记过程是针对全堆的。
- 并发重分配:这个过程要把重分配集中的存活对象复制到新的region上,并为重分配集里面的每个region维护一个转发表,记录旧对象到新对象的转向关系。得益于染色指针,zgc收集器仅从引用就能知道一个对象是否处于重分配阶段,如果用户线程此时并发访问了位于重分配集中的对象,这次的访问将会被预制的内存屏障截获,然后立即根据region上的转发表记录将访问转发到新复制的对象,并同时修正更新该引用的值,使其指向新对象。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次;一旦重分配集中的对象都复制完毕,这个region就可以立即释放用于新对象的分配(转发表保留)
- 并发重映射:重映射所做的工作就是修正整个堆中指向重分配集中旧对象的所有引用,但是并发重映射不是一个很迫切的任务,因为有转发表的存在,所以这一步骤会合并到下一次的并发标记阶段去完成
zgc不存在记忆集的这种取舍其实也限制了它能承受的对象分配的速率不是很高,由于全堆扫描的存在,堆足够大,分配速率很高,就会使得收集速度跟不上分配的速度。
参考:《深入理解Java虚拟机》 周志明