垃圾收集器
如果两个收集器之间存在连线, 就说明它们可以搭配使用(这个关系不是一成不变的, 由于维护和兼容性测试的成本, 在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173) , 并在JDK 9中完全取消了这些组合的支持(JEP214)), 图中收集器所处的区域, 则表示它是属于新生代收集器抑或是老年代收集器。
垃圾收集器中的并行与并发
- 并行(parallel):并行描述的是多条垃圾收集器线程之间的关系, 说明同一时间有多条这样的线
程在协同工作, 通常默认此时用户线程是处于等待状态。 - 并发(concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系, 说明同一时间垃圾收集器线程与用户线程都在运行。 由于用户线程并未被冻结, 所以程序仍然能响应服务请求, 但由于垃圾收集器线程占用了一部分系统资源, 此时应用程序的处理的吞吐量将受到一定影响
经典收集器
serial收集器
最基础,历史最悠久的收集器,曾经(在JDK 1.3.1之前) 是HotSpot虚拟机新生代收集器的唯一选择。
特点
- 单线程,这个收集器是一个单线程工作的收集器, 但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作, 更重要的是强调在它进行垃圾收集时, 必须暂停其他所有工作线程
目前为止,仍然是新生代的默认收集器,简单高效(用于gc的额外内存消耗小),尤其再资源受限情况下,不错的选择。cpu资源受限的情况下,serial收集器没有线程交互开销,专心做垃圾收集,获得最高的单线程收集效率。很适合用户桌面的应用场景以及近年来流行的部分微服务应用中(分配给jvm的内存不会很大),收集几十兆,一两百兆的新生代,垃圾回收停顿时间只要十几,几十ms,最多100ms内。只要不频繁收集,这些停顿完全可以接受。
Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
parnew收集器
ParNew收集器实质上是Serial收集器的多线程并行版本, 除了同时使用多条线程进行垃圾收集之外, 其余的行为包括Serial收集器可用的所有控制参数(例如: -XX: SurvivorRatio、 -XX:PretenureSizeThreshold、 -XX: HandlePromotionFailure等) 、 收集算法、 Stop The World、 对象分配规则、 回收策略等都与Serial收集器完全一致, 在实现上这两种收集器也共用了相当多的代码。
是不少运行在服务端模式下的HotSpot虚拟机, 尤其是JDK 7之前的遗留系统中首选的新生代收集器, 其中有一个与功能、 性能无关但其实很重要的原因是: 除了Serial收集器外, 目前只有它能与CMS收集器配合工作
CMS作为老年代的收集器, 却无法与JDK 1.4.0中已经存在的新生代收集器ParallelScavenge配合工作
ParNew收集器是激活CMS后(使用-XX: +UseConcMarkSweepGC选项) 的默认新生代收集器, 也可以使用-XX: +/-UseParNewGC选项来强制指定或者禁用它
自JDK 9开始, ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。 官方希望它能完全被G1所取代, 甚至还取消了ParNew加Serial Old以及Serial加CMS这两组收集器组合的支持(其实原本也很少人这样使用) , 并直接取消了-XX: +UseParNewGC参数, 这意味着ParNew和CMS从此只能互相搭配使用, 再也没有其他收集器能够和它们配合了。 读者也可以理解为从此以后, ParNew合并入CMS, 成为它专门处理新生代的组成部分。 ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。
可以使用-XX: ParallelGCThreads参数来限制垃圾收集的线程数。
parallel scavenge收集器
Parallel Scavenge收集器也是一款新生代收集器, 它同样是基于标记-复制算法实现的收集器, 也是能够并行收集的多线程收集器
吞吐量 = 用户代码运行时间/(用户代码运行时间+运行垃圾收集时间)
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同, CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间, 而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)
停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序, 良好的响应速度能提升用户体验; 而高吞吐量则可以最高效率地利用处理器资源, 尽快完成程序的运算任务, 主要适合在后台运算而不需要太多交互的分析任务。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量:
控制最大垃圾收集停顿时间:-XX: MaxGCPauseMillis 允许的值是一个大于0的毫秒数, 收集器将尽力保证内存回收花费的时间不超过用户设定值。
直接设置吞吐量大小: -XX: GCTimeRatio 一个大于0小于100的整数, 也就是垃圾收集时间占总时间的比率, 相当于吞吐量的倒数。 譬如把此参数设置为19, 那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)) , 默认值为99, 即允许最大1%(即1/(1+99)) 的垃圾收集时间。
自适应调节 -XX: +UseAdaptiveSizePolicy:这是一个开关参数, 当这个参数被激活之后, 就不需要人工指定新生代的大小(-Xmn) 、 Eden与Survivor区的比例(-XX: SurvivorRatio) 、 晋升老年代对象大小(-XX: PretenureSizeThreshold) 等细节参数了, 虚拟机会根据当前系统的运行情况收集性能监控信息, 动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。 这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)
serial-old
是serial的老年代版本。 这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。 如果在服务端模式下, 它也可能有两种用途:
- 一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用(Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集, 并非直接调用Serial Old收集器, 但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的, 所以在官方的许多资料中都是直接以Serial Old代替PS MarkSweep进行讲解)
- 另外一种就是作为CMS收集器发生失败时的后备预案, 在并发收集发生Concurrent Mode Failure时使用。
parallel old 收集器
parallel scavenge 收集器的老年代版本。支持多线程并发消费,基于标记-整理算法实现。
jdk6时才出现, “吞吐量优先”收集器终于有了比较名副其实的搭配组合, 在注重吞吐量或者处理器资源较为稀缺的场合, 都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
cms收集器
CMS(concurrent mark sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。 目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上, 这类应用通常都会较为关注服务的响应速度, 希望系统停顿时间尽可能短, 以给用户带来良好的交互体验。 CMS收集器就非常符合这类应用的需求。基于标记清除算法
步骤过程:
- 初始标记(CMS initial mark) : stop the word,仅仅只是标记一下GCRoots能直接关联到的对象, 速度很快
- 并发标记(CMS concurrent mark) : GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行;
- 重新标记(CMS remark) : stop the word,为了修正并发标记期间, 因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些, 但也远比并发标记阶段的时间短;
- 并发清除(CMS concurrent sweep) : 清理删除掉标记阶段判断的已经死亡的对象, 由于不需要移动存活对象, 所以这个阶段也是可以与用户线程同时并发的。
由于在整个过程中耗时最长的并发标记和并发清除阶段中, 垃圾收集器线程都可以与用户线程一起工作, 所以从总体上来说, CMS收集器的内存回收过程是与用户线程一起并发执行的。
优点:
- 并发收集
- 低停顿
缺点:
- 对处理器资源非常敏感。并发阶段,因为占用了一部分线程(或者说处理器的计算能力) 而导致应用程序变慢, 降低总吞吐量(CMS默认启动的回收线程数是(处理器核心数量+3) /4, 也就是说, 如果处理器核心数在四个或以上, 并发回收时垃圾收集线程只占用不超过25%的处理器运算资源, 并且会随着处理器核心数量的增加而下降。 但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。 )
- 由于CMS收集器无法处理“浮动垃圾”(Floating Garbage) , 有可能出现“Con-current ModeFailure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
- 收集结束时会有大量空间碎片产生。 空间碎片过多时, 将会给大对象分配带来很大麻烦, 往往会出现老年代还有很多剩余空间, 但就是无法找到足够大的连续空间来分配当前对象, 而不得不提前触发一次Full GC的情况。 为了解决这个问题,CMS收集器提供了一个-XX: +UseCMS-CompactAtFullCollection开关参数(默认是开启的, 此参数从JDK 9开始废弃) , 用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程, 由于这个内存整理必须移动存活对象, (在Shenandoah和ZGC出现前) 是无法并发的。 这样空间碎片问题是解决了, 但停顿时间又会变长, 因此虚拟机设计者们还提供了另外一个参数-XX: CMSFullGCsBeforeCompaction(此参数从JDK 9开始废弃) , 这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定) 不整理空间的Full GC之后, 下一次进入Full GC前会先进行碎片整理(默认值为0, 表示每次进入Full GC时都进行碎片整理)
garbage first收集器
g1收集器开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
设计
作为CMS收集器的替代者和继承人, 设计者们希望做出一款能够建立起“停顿时间模型”(PausePrediction Model) 的收集器, 停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内, 消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标, 这几乎已经是实时Java(RTSJ) 的中软实时垃圾收集器特征了。
g1收集器开创的基于Region的堆内存布局时它能够实现这个目标的关键,虽然G1也仍是遵循分代收集理论设计的, 但其堆内存的布局与其他收集器有非常明显的差异: G1不再坚持固定大小以及固定数量的分代区域划分, 而是把连续的Java堆划分为多个大小相等的独立区域(Region), 每一个Region都可以根据需要, 扮演新生代的Eden空间、 Survivor空间, 或者老年代空间。 收集器能够对扮演不同角色的Region采用不同的策略去处理, 这样无论是新创建的对象还是已经存活了一段时间、 熬过多次收集的旧对象都能获取很好的收集效果
Region中还有一类特殊的Humongous区域, 专门用来存储大对象。 G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。 每个Region的大小可以通过参数-XX: G1HeapRegionSize设定, 取值范围为1MB~32MB, 且应为2的N次幂。 而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待
虽然G1仍然保留新生代和老年代的概念, 但新生代和老年代不再是固定的了, 它们都是一系列区域(不需要连续) 的动态集合。
G1收集器之所以能建立可预测的停顿时间模型, 是因为它将Region作为单次回收的最小单元, 即每次收集到的内存空间都是Region大小的整数倍, 这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小, 价值即回收所获得的空间大小以及回收所需时间的经验值, 然后在后台维护一个优先级列表, 每次根据用户设定允许的收集停顿时间(参数-XX: MaxGCPauseMillis指定, 默认值是200毫秒) , 优先处理回收价值收益最大的那些Region, 这也就是“Garbage First”名字的由来。这种使用Region划分内存空间, 以及具有优先级的区域回收方式, 保证了G1收集器在有限的时间内获取尽可能高的收集效率
难点与解决方案
跨region的引用对象
在G1收集器上记忆集的应用其实要复杂很多, 它的每个Region都维护有自己的记忆集, 这些记忆集会记录下别的Region指向自己的指针, 并标记这些指针分别在哪些卡页的范围之内。 G1的记忆集在存储结构的本质上是一种哈希表, Key是别的Region的起始地址, Value是一个集合, 里面存储的元素是卡表的索引号。 这种“双向”的卡表结构(卡表是“我指向谁”, 这种结构还记录了“谁指向我”) 比原来的卡表实现起来更复杂, 同时由于Region数量比传统收集器的分代数量明显要多得多, 因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。 根据经验, G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作
在并发标记阶段保证收集线程与用户线程互不干扰地运行
这里首先要解决的是用户线程改变对象引用关系时, 必须保证其不能打破原本的对象图结构, 导致标记结果出现错误, CMS收集器采用增量更新算法实现, 而G1收集器则是通过原始快照(SATB) 算法来实现的。 此外, 垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上, 程序要继续运行就肯定会持续有新对象被创建, G1为每一个Region设计了两个名为TAMS(Top at Mark Start) 的指针, 把Region中的一部分空间划分出来用于并发回收过程中的新对象分配, 并发回收时新分配的对象地址都必须要在这两个指针位置以上。 G1收集器默认在这个地址以上的对象是被隐式标记过的, 即默认它们是存活的, 不纳入回收范围。 与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似, 如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行, 导致Full GC而产生长时间“Stop The World”。
建立可靠的停顿预测模型
用户通过-XX: MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值, G收集器的停顿预测模型是以衰减均值(Decaying Average) 为理论基础来实现的, 在垃圾收集程中, G1收集器会记录每个Region的回收耗时、 每个Region记忆集里的脏卡数量等各个可测量步骤花费的成本, 并分析得出平均值、 标准偏差、 置信度等统计信息。 这里强调的“衰减平均值是指它会比普通的平均值更容易受到新数据的影响, 平均值代表整体平均状态, 但衰减平均值更确地代表“最近的”平均状态。 换句话说, Region的统计状态越新越能决定其回收的价值。 然后过这些信息预测现在开始回收的话, 由哪些Region组成回收集才可以在不超过期望停顿时间的约下获得最高的收益。
收集步骤
- 初始标记(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从整体来看是基于“标记-整理”算法实现的收集器, 但从局部(两个Region之间) 上看又是基于“标记-复制”算法实现, 无论如何, 这两种算法都意味着G1运作期间不会产生内存空间碎片, 垃圾收集完成之后能提供规整的可用内存。 这种特性有利于程序长时间运行, 在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。
历史与发展
到了JDK 8 Update 40的时候, G1提供并发的类卸载的支持, 补全了其计划功能的最后一块拼图。 这个版本以后的G1收集器才被Oracle官方称为“全功能的垃圾收集器”(Fully-Featured Garbage Collector)
JDK 9发布之日, G1宣告取代Parallel Scavenge加Parallel Old组合, 成为服务端模式下的默认垃圾收集器, 而CMS则沦落至被声明为不推荐使用(Deprecate) 的收集器[1]。 如果对JDK 9及以上版本的HotSpot虚拟机使用参数-XX: +UseConcMarkSweepGC来开启CMS收集器的话, 用户会收到一个警告信息, 提示CMS未来将会被废弃:
作为一款曾被广泛运用过的收集器, 经过多个版本的开发迭代后, CMS(以及之前几款收集器) 的代码与HotSpot的内存管理、 执行、 编译、 监控等子系统都有千丝万缕的联系, 这是历史原因导致的, 并不符合职责分离的设计原则。 为此, 规划JDK 10功能目标时, HotSpot虚拟机提出了“统一垃圾收集器接口”[2], 将内存回收的“行为”与“实现”进行分离, CMS以及其他收集器都重构成基于这套接口的一种实现。 以此为基础, 日后要移除或者加入某一款收集器, 都会变得容易许多, 风险也可以控制, 这算是在为CMS退出历史舞台铺下最后的道路了
低延迟垃圾收集器
完美收集器
衡量垃圾收集器三项最重要的指标(不可能三角)
- 内存占用(footprint)
- 吞吐量(吞吐量)
- 延迟(latency)
日益发展的硬件性能,导致低延迟成为垃圾收集器最被重视的指标了
浅色阶段表示必须stop the world
CMS使用标记清除算法,会导致大量空间碎片,难以避免垃圾收集器带来的停顿.
g1虽然按照更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但是也要暂停
shenandoah,zgc,只有初始标记阶段和最终标记阶段有短暂的延迟(这部分的停顿基本是固定,与堆容量,堆中数量对象没有正比例关系),几乎整个部分都是并发.
实际上, 它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆) 堆容量下, 实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、 匪夷所思的目标。 这两款目前仍处于实验状态的收集器, 被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector) 。
shenandoah收集器
不由Oracle(包括以前的Sun) 公司的虚拟机团队所领导开发的HotSpot垃圾收集器, 只有openjdk中有.
shenandoah与g1有者类似的堆内存布局,甚至共享了一部分实现代码.初始标记,并发阶段等许多阶段的处理思路高度一致.
与g1的相似点:
- 同样基于region的堆内存布局
- 同样用于存放大对象的
humongous region
- 默认回收策略同样优先处理回收价值最大的
不同点
- 支持并发的整理算法(g1回收阶段可以多线程并行,但是不能与用户现场并发)
- 默认不使用分代收集(换言之, 不会有专门的新生代Region或者老年代Region的存在, 没有实现分代, 并不是说分代对Shenandoah没有价值,这更多是出于性价比的权衡, 基于工作量上的考虑而将其放到优先级较低的位置上。)
- 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集, 改用名为“连接矩阵”(ConnectionMatrix) 的全局数据结构来记录跨Region的引用关系, 降低了处理跨代指针时的记忆集维护消耗, 也降低了伪共享问题发生概率。 连接矩阵可以简单理解为一张二维表格, 如果Region N有对象指向Region M, 就在表格的N行M列中打上一个标记, 如图3-15所示, 如果Region 5中的对象Baz引用了Region 3的Foo, Foo又引用了Region 1的Bar, 那连接矩阵中的5行3列、 3行1列就应该被打上标记。 在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。
收集流程
- 初始标记(initial marking):与g1一样,标记与gcroots直接关联的对象(
stop the world
),但是停顿时间与堆大小无关,与gc roots数量有关 - 并发标记(concurrent marking):g1 一样,遍历对象图,标记全部可达对象,整个阶段与用户线程一起并发,时间长短取决堆中存活对象的数量以及对象图结构复杂程度.
- 最终标记(final marking):与g1一样,处理剩余的
satb(SNAP AT THE BEGING)
扫描,在这个阶段统计出回收价值最高的region,将这些一起构成回收集合(collection set
),最终标记也会有一小段时间停顿 - 并发清理(concurrent cleanup):用于清里整个区域内连一个存活对象都没有的region(immediate garbage region)
- 并发回收(concurrent evacuation):与hotspot其他收集器的核心差异.在这个阶段, Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。 复制对象这件事情如果将用户线程冻结起来再做那是相当简单的, 但如果两者必须要同时并发进行的话, 就变得复杂起来了。 (采用读屏障和
brooks pointers
转发) - 初始引用更新(initial update reference):并发回收阶段复制对象后,把堆中所有指向旧对象的引用修正到复制后的新地址.会产生一个短暂的停顿,初始引用更新时间很短.
- 并发引用更新(current update reference):真正开始进行引用更新操作.这个阶段是与用户线程一起并发,时间长短取决于内存中涉及的引用数量的多少.并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值
- 最终引用更新(final update refernce):解决了堆中引用更新后,还要修正存在于gc roots中的引用.这个阶段是
shenandoah
最后一次停顿,停顿时间只与gc roots数量有关 - 并发清理(concurrent cleanup):经过并发回收和引用更新后,所有的region再无存货对象,这些region都变成了
immediate garbage regions
,最后在调用一次并发清理过程来回收这些region的内存空间,供以后的对象使用再分配.
总体分为三大阶段:并发标记,并发回收,并发引用更新.
核心
实现对象移动与用户程序并发方案:
转发指针brooks pointer:在原有对象布局结构的最前面统一增加一个新的引用字段, 在正常不处于并发移动的情况下, 该引用指向对象自己.(发生并发写入时,hotspot采用了casdd保证了操作正确性)
原有方案:要做类似的并发操作, 通常是在被移动对象原有的内存上设置保护陷阱(Memory
Protection Trap) , 一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段, 进入预设好的异常处理器中, 再由其中的代码逻辑把访问转发到复制后的新对象上。 虽然确实能够实现对象移动与用户线程并发, 但是如果没有操作系统层面的直接支持, 这种方案将导致用户态频繁切换到核心态,代价是非常大的, 不能频繁使用
zgc
jdk11加入的具有实验性质的低延迟垃圾收集器
ZGC和Shenandoah的目标是高度相似的, 都希望在尽可能对吞吐量影响不太大的前提下, 实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟.
概括特征: ZGC收集器是一款基于Region内存布局的, (暂时)不设分代的, 使用了读屏障、 染色指针和内存多重映射等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器。
zgc的region
具有动态性——动态创建和销毁, 以及动态的区域容量大小。 在x64硬件平台下, ZGC的Region可以具有如图3-19所示的大、 中、 小三类容量:
- 小型region(small region):容量固定为2mb,用于放置小于256kb的对象
- 中型region(medium region):容量固定为32mb,用于存放大于等于256kb小于4mb的对象
- 大型region(large region):容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被
重分配
并发整理算法的实现-染色指针
Shenandoah使用转发指针和读屏障来实现并发整理, ZGC虽然同样用到了读屏障, 但用的却是一条与Shenandoah完全不同, 更加复杂精巧的解题思路。
三色标记:
HotSpot虚拟机的几种收集器有不同的标记实现方案, 有的把标记直接记录在对象头上(如Serial收集器) , 有的把标记记录在与对象相互独立的数据结构上(如G1、 Shenandoah使用了一种相当于堆内存的1/64大小的, 称为BitMap的结构来记录标记信息) , 而ZGC的染色指针是最直接的、 最纯粹的, 它直接把标记信息记在引用对象的指针上.
染色指针是一种直接将少量额外的信息存储在指针上的技术, 可是为什么指针本身也可以存储额外信息呢? 在64位系统中, 理论可以访问的内存高达16EB(2的64次幂) 字节。 实际上, 基于需求(用不到那么多内存) 、 性能(地址越宽在做地址转换时需要的页表级数越多) 和成本(消耗更多晶体管) 的考虑, 在AMD64架构中只支持到52位(4PB) 的地址总线和48位(256TB) 的虚拟地址空间, 所以目前64位的硬件实际能够支持的最大内存只有256TB。 此外, 操作系统一侧也还会施加自己的约束, 64位的Linux则分别支持47位(128TB) 的进程虚拟地址空间和46位(64TB) 的物理地址空间, 64位的Windows系统甚至只支持44位(16TB) 的物理地址空间
尽管Linux下64位指针的高18位不能用来寻址, 但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。 鉴于此, ZGC的染色指针技术继续盯上了这剩下的46位指针宽度, 将其高4位提取出来存储四个标志信息。 通过这些标志位, 虚拟机可以直接从指针中看到其引用对象的三色标记状态、 是否进入了重分配集(即被移动过) 、 是否只能通过finalize()方法才能被访问到, 如图3-20所示。 当然, 由于这些标志位进一步压缩了原本就只有46位的地址空间, 也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)
染色指针的优势
- 染色指针可以使得一旦某个Region的存活对象被移走之后, 这个Region立即就能够被释放和重用掉, 而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。 这点相比起Shenandoah是一个颇大的优势, 使得理论上只要还有一个空闲Region, ZGC就能完成收集, 而Shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region, 这意味着堆中几乎所有对象都存活的极端情况, 需要1∶ 1复制对象到新Region的话, 就必须要有一半的空闲Region来完成收集。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量, 设置内存屏障, 尤其是写屏障的目的通常是为了记录对象引用的变动情况, 如果将这些信息直接维护在指针中, 显然就可以省去一些专门的记录操作。 实际上, 到目前为止ZGC都并未使用任何写屏障, 只使用了读屏障(一部分是染色指针的功劳, 一部分是ZGC现在还不支持分代收集, 天然就没有跨代引用的问题) 。
- 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、 重定位过程相关的数据, 以便日后进一步提高性能。
多重映射(Multi-Mapping)
Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping) 将多个不同的虚拟内存地址映射到同一个物理内存地址上, 这是一种多对一映射, 意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。 把染色指针中的标志位看作是地址的分段符, 那只要将这些不同的地址段都映射到同一个物理内存空间, 经过多重映射转换后, 就可以使用染色指针正常进行寻址了, 效果如图所示。
zgc的收集步骤
ZGC的运作过程大致可划分为以下四个大的阶段。 全部四个阶段都是可以并发执行的, 仅是两个阶段中间会存在短暂的停顿小阶段, 这些小阶段,譬如初始化GC Root直接关联对象的Mark Start, 与之前G1和Shenandoah的Initial Mark阶段并没有什么差异
并发标记(concurrent mark):类似g1,shenandoah一样.并发标记是遍历对象图做可达性分析的阶段, 前后也要经过类似于G1、 Shenandoah的初始标记、 最终标记(尽管ZGC中的名字不叫这些) 的短暂停顿, 而且这些停顿阶段所做的事情在目标上也是相类似的。 与G1、 Shenandoah不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新染色指针中的Marked 0、 Marked 1标志位。
并发预备重分配(concurrent prepare for relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region, 将这些Region组成重分配集(Relocation Set) 。 重分配集与G1收集器的回收集(Collection Set) 还是有区别的, ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。 相反, ZGC每次回收都会扫描所有的Region, 用范围更大的扫描成本换取省去G1中记忆集的维护成本。 因此, ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中, 里面的Region会被释放, 而并不能说回收行为就只是针对这个集合里面的Region进行, 因为标记过程是针对全堆的。 此外, 在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理, 也是在这个阶段中完成的
并发重分配(concurrent relocate):重分配是ZGC执行过程中的核心阶段, 这个过程要把重分配集中的存活对象复制到新的Region上, 并为重分配集中的每个Region维护一个转发表(ForwardTable) , 记录从旧对象到新对象的转向关系。 得益于染色指针的支持, ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中, 如果用户线程此时并发访问了位于重分配集中的对象, 这次访问将会被预置的内存屏障所截获, 然后立即根据Region上的转发表记录将访问转发到新复制的对象上, 并同时修正更新该引用的值, 使其直接指向新对象, ZGC将这种行为称为指针的“自愈”(SelfHealing) 能力。 这样做的好处是只有第一次访问旧对象会陷入转发, 也就是只慢一次, 对比Shenandoah的Brooks转发指针, 那是每次对象访问都必须付出的固定开销, 简单地说就是每次都慢,因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。 还有另外一个直接的好处是由于染色指针的存在, 一旦重分配集中某个Region的存活对象都复制完毕后, 这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉) , 哪怕堆中还有很多指向这个对象的未更新指针也没有关系, 这些旧指针一旦被使用, 它们都是可以自愈的。
并发重映射(concurrent remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用, 这一点从目标角度看是与Shenandoah并发引用更新阶段一样的, 但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务, 因为前面说过, 即使是旧引用, 它也是可以自愈的, 最多只是第一次使用时多一次转发和修正操作。 重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益) , 所以说这并不是很“迫切”。 因此, ZGC很巧妙地把并发重映射阶段要做的工作, 合并到了下一次垃圾收集循环中的并发标记阶段里去完成, 反正它们都是要遍历所有对象的, 这样合并就节省了一次遍历对象图[9]的开销。 一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了
选择合适的收集器
收集器的权衡
我们应该如何选择一款适合自己应用的收集器呢? 这个问题的答案主要受以下三个因素影响:
- 应用程序的主要关注点是什么? 如果是数据分析、 科学计算类的任务, 目标是能尽快算出结果,那吞吐量就是主要关注点; 如果是SLA应用, 那停顿时间直接影响服务质量, 严重的甚至会导致事务超时, 这样延迟就是主要关注点; 而如果是客户端应用或者嵌入式应用, 那垃圾收集的内存占用则是不可忽视的
- 运行应用的基础设施如何? 譬如硬件规格, 要涉及的系统架构是x86-32/64、 SPARC还是ARM/Aarch64; 处理器的数量多少, 分配内存的大小; 选择的操作系统是Linux、 Solaris还是Windows等
- 使用JDK的发行商是什么? 版本号是多少? 是ZingJDK/Zulu、 OracleJDK、 Open-JDK、 OpenJ9抑或是其他公司的发行版? 该JDK对应了《Java虚拟机规范》 的哪个版本?
jdk比较落后,根据内存规模考虑用cms,g1,否则可用zgc。
虚拟机及垃圾收集器日志
直到jdk9之后,日志才统一。所有功能的日志都收归到了“-Xlog”参数上-Xlog[:[selector][:[output][:[decorators][:output-options]]]]=[=-[]]
功能 | jdk9之前 | jdk9之后 |
---|---|---|
查看gc基本信息 | -XX: +PrintGC | -Xlog:gc |
查看gc详细信息 | -XX: +PrintGCDetails | -X-log: gc* |
查看gc前后的堆,方法区可用容量变化 | -XX +PrintHeapAtGC | -Xlog: gc+heap=debug |
查看用户线程ing发时间以及停顿时间 | -XX:+PrintGCApplicationConcurrentTime -XX: +PrintGCApplicationStoppedTime |
-Xlog:safepoint |
查看收集器Ergonomics机制( 自动设置堆空间各分代区域大小、 收集目标等内容, 从Parallel收集器开始支持) 自动调节的相关信息 | -XX: +PrintAdaptive-SizePolicy | -Xlog: gc+ergo*=trace |
查看熬过收集后剩余对象的年龄分布信息 | -XX: +PrintTenuring-Distribution | -Xlog: gc+age=trace |
… |
内存分配与回收策略
象的内存分配, 从概念上讲, 应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配)
在经典分代的设计下, 新生对象通常会分配在新生代中, 少数情况下(例如对象大小超过一定阈值) 也可能会直接分配在老年代。 对象分配的规则并不是固定的,《Java虚拟机规范》 并未规定新对象的创建和存储细节, 这取决于虚拟机当前使用的是哪一种垃圾收集器, 以及虚拟机中与内存相关的参数的设定