《深入理解Java虚拟机》十款HotSpot虚拟机的垃圾收集器


本文内容是阅读《深入理解Java虚拟机》第三章的学习笔记。


垃圾收集器

经典垃圾收集器

Serial 收集器

  • Serial收集器是最基础、历史最悠久的收集器。
  • 这个收集器是一个单线程工作的收集器,但它的“单线程”的意义不仅仅说明它只会使用一个处理器或一条手机线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。“Stop the world”,这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。
  • 工作过程:新生代采取复制算法(单线程),暂停所有用户线程;老年代采取标记-整理算法(单线程),暂停所有用户线程。
  • 迄今为止,Serial收集器依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比)。对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的(额外内存消耗:在这里指为保证垃圾收集能够顺利高效地进行而存储的额外信息);对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

ParNew 收集器

  • 实质上是Serial收集器的多线程并行版本。除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
  • 工作过程:新生代采取复制算法(多线程并行收集)暂停所有用户线程;老年代采取标记-整理算法(单线程)暂停所有用户线程。
  • 除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集。有一个与功能、性能无关的原因:除了Serial收集器外,目前只有它能与CMS收集器配合工作。
  • 从JDK 9开始,ParNew+CMS的组合不再是官方推荐(官方希望CMS能被更先进的G1收集器完全取代),取消了ParNew与其他收集器组合的支持,ParNew和CMS从此只能互相搭配使用。ParNew合并入CMS,成为它专门处理新生代的组成部分。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。
  • ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的为双核处理器环境中都不能百分之百保证超越Serial收集器。

Parallel Scavenge 收集器

  • Parallel Scavenge收集器也是一款新生代收集器,同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器
  • Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(吞吐量:处理器用于运行用户代码的时间于处理器总消耗时间的比值)。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量(垃圾收集花费的时间占比小)则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
  • 垃圾收集停顿时间缩短是以牺牲吞吐率和新生代空间为代价换取的:系统把新生代调的小一些,收集300MB新生代肯定比500MB快,但这也直接导致垃圾收集发生得更频繁。这时停顿时间的确在下降,但吞吐率也降下来了。
  • Parallel Scavenge收集器也常被称为“吞吐量优先收集器”。可以通过参数控制最大垃圾收集停顿时间及直接设置吞吐率大小等,收集器将尽力保证内存回收花费的时间不超过用户设定的值。还可以通过设置一个开关参数,从而实现不需要人工置顶新生代大小、Eden与Survivor区的比例、晋升老年代对象大小等细节参数,虚拟机会根据当前系统的允许情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量。这种调价方式称为垃圾收集器的自适应调节策略
  • 自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特征。
  • 局限性:在JDK 6之前,新生代Parallel Scavenge收集器一直处于相当尴尬的状态,因为如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上获得吞吐率最大化的效果。同样,由于单线程的老年代手机中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规则比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来得优秀。

Serial Old 收集器

  • Serial Old是Serial收集器老年代版本,同样是一个单线程收集器,使用标记-整理算法。它的主要意义也是供客户端模式下的HotSpot虚拟机使用。
  • 工作过程:新生代采取复制算法暂停所有用户线程(单线程);老年代采取标记-整理算法暂停所有用户线程(单线程)

Parallel Old 收集器

  • Parallel Old是Parallel Scavenge收集器老年代版本,支持多线程并发收集,基于标记-整理算法实现。
  • 在上面我们了解到新生代Parallel Scavenge受限于老年代Serial Old收集器,不能很好的体现其性能。直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐率或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge(新生代)加Parallel Old(老年代)收集器这个组合。
  • 工作过程:新生代和老年代都采用多线程并发收集。

CMS 收集器

  • CMS收集器(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器。
  • CMS收集器是基于标记-清除算法实现的,它的运作过程较复杂,整个过程分为四个步骤:
    1. 初始标记:需要“Stop The World”。仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
    2. 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
    3. 重新标记:需要“Stop The World”。为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的哪一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也用于比并发标记阶段的时短。
    4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
    • 整个过程中耗时最长的并发表及和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
  • CMS是一款优秀的收集器,它最主要的优点是:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”。CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但它还远达不到完美的程度,至少有以下三个明显的缺点:
    1. CMS收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。
    2. 由于CMS收集器无法处理“浮动垃圾”,有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随着有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉,这一部分垃圾就成为“浮动垃圾”。
      同样由于在垃圾收集阶段用户线程在需要持续运行,因此还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用
      要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”,这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这一停顿的时间就很长了。
    3. CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况
  • 在JDK 9,CMS沦落至被声明为不推荐使用(Deprecate)的收集器。但作为一款曾被广泛运用过的收集器,CMS代码与Hotspot的内存管理、执行、编译、健康等子系统都有千丝万缕的关系,这是历史原因导致的,并不符合职责分离的设计原则。为此,JDK 10规划功能目标时,HotSpot虚拟机提出了“统一垃圾收集器接口”,将内存回收的“行为”与“实现”进行分离,CMS以及其他收集器都重构成基于这套接口的一种实现。以此为基础,以后要移除或加入某一款收集器,都会变得容易许多,风险也可控。

Garbage First 收集器(G1)

  • G1是一个面向全堆的垃圾收集器,不再需要其他新生代收集器的配合工作。开创了收集器面向局部收集的设计思路和基于Regin的内存布局形式。到JDK 8 Update 40时,G1提供并发的类卸载的支持。此版本后背Oracle官方称为“全能的垃圾收集器”。
  • 在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集器的目标要么是整个新生代,要么是整个老年代,再要么是整个Java堆。而G1跳出了这个樊笼,它可以面对堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

不同分代名词

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集器,其中又分为:
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。(Major GC在不同资料有不同所指,需根据上下文区分是老年代收集还是整堆收集)
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
  • G1是一款主要面向服务端应用的垃圾收集器。
  • 基于Region的堆内存布局:G1也是遵循分代收集理论设计的,但G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间、或老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获得很好的收集效果。
    Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。对于超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来看待。
  • 虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的,它们是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的思路是让G1区跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的Region,这也是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
  • G1收集器至少有以下的关键的细节问题需妥善解决:
    1. 将Java堆分成多个独立的Region后,Region里面存在的跨Region引用对象如何解决?
      使用记忆集,但G1收集器是那个记忆集的应用复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在那些卡页的范围之内。
      G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显多得多,因此G1收集器比其他传统的垃圾收集器有着更高的内存占用负担
    2. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
      首先要解决用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,在CMS中采用增量更新算法实现,而G1通过原始快照(STAB)算法来实现。
      此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上,G1默认在这个地址以上的对象默认存活,不纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间的“Stop The World”。
    3. 怎样建立可靠的停顿预测模型?
      G1收集器的停顿预测模型是以衰减均值(比平均值更准确地代表“最近的”平均状态)为理论基础来实现的。因此Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
  • 若不计算用户线程运行过程中的动作,G1收集器的运作过程大致可划分为以下四个步骤:
    1. 初始标记:只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值(让下一阶段用户线程并发运行时,能正确地在可用的Region中分配对象)。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1在这个阶段实际没有额外的停顿。
    2. 并发标记:从GC Root对堆中对象进行可达性分析,找出要回收的对象(需要重新处理STAB记录下的在并发时有引用变动的对象)。耗时较长,但可与用户线程并发执行。
    3. 最终标记:对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的少量STAB记录。
    4. 筛选回收:更新Region的统计数据,根据用户期望的停顿时间制定回收计划,将决定回收的Region集(任意多个Region构成的回收集)的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成。
    • G1除了并发标记外,其余阶段也是要完全暂停用户线程的。换而言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量。
    • 用户指定期望的停顿时间:通过设置不同的时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。期望的停顿时间应该是符合实际的,如果设置的停顿目标过低,会导致每次选出的回收集只占堆内存的一小部分,会导致垃圾慢慢堆积,最终占满堆引发Full GC反而降低性能。

记忆集

  • 为了解决对象跨代引用所带来的的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。(不只是新生代、老年代之间才有跨代引用问题,所有涉及部分区域收集行为的垃圾收集器都有跨代引用问题)。
  • 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象结构。在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。

低延迟垃圾收集器

  • 衡量垃圾收集器的三项最重要的指标是:内存占用、吞吐量和延迟,三者共同构成了一个“不可能三角”。一款优秀的收集器通常最多可以同时达成其中两项。
  • 在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益凸显,因为随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一些内存;硬件性能增长,对软件系统的处理能力使有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果。
  • Shenandoah和ZGC收集器,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的堆容量下,实现垃圾收集的停顿都不超过十毫秒的目标。这两款收集器目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”。

Shenandoah 收集器

  • 由于不是Oracle公司虚拟机团队开发的HotSpot收集器,不可避免的受到了排挤。因此,Shenandoah是一款只有在OpenJDK才会包含,而OracleJDK里反而不存在的收集器,“免费开源版”比“收费商业版”功能更多。
  • 这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器。意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。
  • Shenandoah像G1的下一代继承者,它们在很多地方都高度一致(堆内存布局;初始标记、并发标记阶段的处理思路),甚至还直接共享了一部分实现代码,这使得部分对G1的打磨改进和Bug会同时反映在Shenandoah之上,而由于Shenandoah加入所带来的一些新特性,有一些也会出现在G1收集器中。
    • 同:Shenandoah也是基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回售策略也同样优先处理回收价值最大的Region……
    • 异:在管理堆内存方面,(1)Shenandoah支持并发的整理算法;(2)目前默认不使用分代收集(处于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置);(3)摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”的全局数据结构来记录夸Region的引用关系,降低了处理跨代指针时的记忆集维护小号,也降低了伪共享问题的发生概率。连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格第N行M列中打上一个标记,在回收时通过这张表格就可以得出哪些Region之间产生了跨Region的引用。
  • Shenandoah的工作过程大致可分为九个阶段:
    1. 初始标记:和G1一样,首先标记与GC Roots直接关联的对象,这个阶段是“Stop The world”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
    2. 并发标记:与G1一样,遍历对象图,标记处全部科大的对象,是与用户线程一起并发的,时间长短取决于堆中存活对象的数量及对象图的结构复杂程度。
    3. 最终标记:与G1一样,处理剩余SATB扫描,并统计出回收价值最高的Region组成回收集。有一小段短暂的停顿。
    4. 并发清理:用于清理整个区域内一个存活对象都没找到的Region。
    5. 并发回收:Shenandoah把回收集里面存活的对象先复制到一份其他未使用的Region中,与其他收集器的核心差异是Shenandoah不需要停止用户线程,是与用户线程并发执行的,Shenandoah通过读屏障和被称为“Brooks Pointers”的转发指针解决移动对象时用户线程的读写访问。本阶段的时间长短取决于回收集的大小。
    6. 初始引用更新:并发回收阶段复制对象结束后,该阶段用来把堆中所有指向旧对象的引用修正到复制后的地址。这个阶段只是为了建立并发回收阶段的线程集合点,确保所有并发回首阶段中进行的收集器线程都已经完成分配给它们的对象移动任务而已。本阶段更新时间很短,会产生一个非常短暂的停顿。
    7. 并发引用更新:真正开始进行引用更新操作。与并发标记不同,不再沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。与用户线程并发,时间长短取决于内存中涉及引用数量的多少。
    8. 最终引用更新:修正存在GC Roots中的引用。是Shenandoah最后一次停顿,停顿时间于GC Roots数量有关。
    9. 并发清理:此时,整个回收集中所有的Region再无存活对象,此时调用并发清理来回收Region内存空间,供以后新对象分配使用。
  • 在Brooks提出转发指针之前,要实现对象移动与用户程序并发,通常是在被移动对象原有的内存上设置保护陷阱,一但用户程序访问到归属于旧对象的内存空间就会产生自陷中断,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程并发,但如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到和心态,代价非常大,不能频繁使用。
  • Brooks提出的转发指针,只需要在原有对象布局结构最前面统一添加一个新的引用字段。在正常不处于并发移动的情况下,该引用指向对象自己,移动后应该指向新对象的地址。转发指针与Java虚拟机使用过的句柄定位有一些相似之处,两者都是一种间接性的对象访问方式,差别是:句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。
    • 转发指针加入后,当对象拥有一份新的副本时,只需要修改旧对象转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上自动工作。
    • 所有间接对象访问技术的缺点都是相同的:每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令。但对象定位会被频繁使用到,仍然是一笔不可忽视的执行成本,只是比起内存保护陷阱的方案已经好很多。
  • Brooks形式的转发指针存在多线程竞争问题。如果只是并发读取,那么收集器线程与用户线程无论读取到旧对象还是新对象结果都是一样的。但如果是并发写入,那就必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中(有可能收集器复制了新的对象副本,还没修改转发指针的引用时,用户线程修改了旧对象某个字段后,收集器线程才更新转发指针的引用值)。因此必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户现场对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。Shenandoah 收集器通过CAS操作来保证并发时对象的访问正确性。
  • 转发指针还需注意执行频率的问题。对面向对象语言来说,对象的读取、写入、比较、计算哈希值、加锁等操作,都属于对象访问的范畴,要保证并发时原对象与复制对象的访问一致性,Shenandoah 不得不同时设置读、写屏障去拦截。为了实现转发指针,Shenandoah 在读、写屏障中都加入了额外的转发处理,尤其是使用读屏障的代价会比写屏障更大。而在代码中对象的读取频率比对象的写入频率要高,读屏障的数量自然比写屏障要多,所以读屏障的使用必须更加谨慎,不允许任何重量级操作。开发者也意识到这点,计划在JDK 13将Shenandoah的内存屏障模型改进为基于引用访问屏障。所谓引用访问屏障就是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能省去大量原生类型场景中设置内存屏障所带来的消耗。
  • 目前看,Shenandoah并没有实现最大停顿时间控制在十毫秒内的目标,但其停顿时间比其他几款收集器确实有了质的飞跃,而吞吐量方面则有明显的下降,其运行时间较长。

ZGC 收集器

  • ZGC与Shenandoah的目标是高度相似的:在尽可能对吞吐率影响不大的前提下,实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器。但ZGC与实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器的实现思路有显著差异。
  • ZGC是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
  • 与Shenandoah和G1不同,ZGC的Region具有动态性——动态创建和销毁、以及动态的区域容量大小,在x64硬件平台下,分为大中小三类容量:小型容量固定2MB;中型容量固定32MB;大型容量不固定,可以动态变化,但必须是2MB的整数倍,用于放置4MB及以上的大对象(因此实际容量可能小于中型Region),每个大型Region中只会存放一个大对象,且在ZGC实现中不会被重分配(因为复制一个大对象的代价非常高)
  • ZGC并发整理算法的实现:采用染色指针技术
  • 在追踪式收集算法的标记阶段可能存在只跟指针打交道(例如访问对象的哈希吗、锁记录等信息)而不必设计指针所引用的对象本身的场景,可以在对象标记过程打上标记,这些标记只和对象的引用有关,和对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够影响它的存活判定结果。在HotSpot几款收集器中,标记的实现方案有的直接把标记记录在对象头上(Serial收集器)、有的把标记记录在与对象相互独立的数据结构上(G1、Shenandoah),而ZGC的染色指针直接将标记信息记在引用对象的指针上。
  • 染色指针是一种直接将少量额外信息存储在指针上的技术。通过压缩64位Linux原本只有46位的地址空间中的高四位,用来存储四个标志信息:是否移动过、是否只能通过finalize()方法才能访问到,这也直接导致了ZGC能够管理的内存不可超过4TB,不支持32位平台,不支持压缩指针等。
  • 尽管受到这么多限制,染色指针的优势还是很明显:
    1. 染色指针可以使得一单某个Region的存活对象被移走之后,这个Region立即能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。
      相比Shenandoah, ZGC使得理论上只要有一个空闲Region就能完成收集,而Shenandoah需要等到引用更新阶段结束后才能释放回收集中的Region(当堆中所有对象都存活时,需要1:1复制对象到新的Region,即必须有一半的空闲Region来完成收集)
    2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。实际上到目前为止ZGC并未使用过任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC现在不支持分代收集,天然没有跨代引用的问题)
    3. 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
  • 问题:Java虚拟机作为一个普通的进程,这样随意定义内存中某些指针的几位,操作系统是否能够支持?处理器是否支持?程序代码最终都会转换为机器指令流交付给处理器执行,而处理器不会区分指针中哪部分是标志位哪部分是寻址地址,只会将整个指针都视为一个内存地址来对待。
    把染色指针中的标志位看作地址的分段符,只要将这些不同的地址段都映射到同一物理内存空间,经过多重映射转换后,就可以使用染色指针正常的寻址。
  • ZGC的运作过程大致可分为以下四个大的阶段(全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段):
    1. 并发标记:遍历对象图做可达性分析,前后也要经过类似G1、Shenandoah的初始标记、最终标记的短暂停顿,且停顿的目标也类似。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked0、Marked1标志位。
    2. 并发预备重分配:根据特定的查询条件统计本次收集过程要清理的Region组成重分配集,但ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。相反ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此,ZGC的重分配集只是决定里面的存活对虾会被重新复制到其他Region中,里面的Region会被释放,并不能说回收行为只是针对这个集合里面的Region进行,因为标记过程是全堆的。JDK 12的ZGC开始支持的类卸载和弱引用处理也在本阶段完成。
    3. 并发重分配:重分配是ZGC执行过程中的和新阶段,这个过程要把重分配集中的存活对虾复制到心得Region上,并未重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系。如果用户现场在此时并发访问了位于重分配集中的对象,这次访问会被预置的内存屏障截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其指向新对象,ZGC将这种行为称为指针的“自愈”能力,即只有第一次访问旧对象会陷入转发。
    4. 并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,对ZGC来说并不是迫切要完成的任务,因为即使是旧引用也可以自愈。重映射清理这些旧引用的目的是为了不变慢,清理结束后还可以释放转发表(一旦请所有指针都修正后,原来记录新旧对象的转发表就可以释放)。由于不是很迫切,ZGC巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段完成,因为它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。
  • ZGC的设计理念与Azul System公司的PGC和C4收集器一脉相承,是迄今垃圾收集器研究的最前沿成果,它与Shenandoah一样做到了几乎整个收集过程都全程可并发,短暂停顿也只有GC Roots大小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。
  • ZGC完全没有使用记忆集,甚至连分代都没有,所以给用户线程带来的运行负担也很小,但ZGC的这种选择限制了它能承受的对象分配速率不会太高。因为在并发收集时如果用户线程分配了对象都会当做存活对象来看待,因此产生了大量的浮动垃圾。目前唯一的办法就是尽可能的增加堆容量的大小,获得更多喘息时间。但若要从根本上提升对象分配速率,还是要引入分代收集,让新生对象都在一个专门的区域中创建,然后针对这个区域进行更频繁、更快的收集。
  • ZGC支持“NUMA-Aware”的内存分配,NUMA是一种为多处理器或者多核处理器的计算机所涉及的内存架构。在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。

特殊的垃圾收集器

Epsilon 收集器

  • Epsilon是一款不能够进行垃圾收集的收集器。
  • 事实上只要Java虚拟机能够工作,垃圾收集器便不可能是真正“无操作”的。原因是“垃圾收集器”并不能形容它全部职责,更贴切的应该是“自动内存管理子系统”。因为一个垃圾收集器除了垃圾收集这个本职工作之外,还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,其中至少堆的管理和对象的恩培这部分功能是Java虚拟机能够正常运作的必要支持,是一个最小化功能的垃圾收集器也必须实现的内容。
  • Java体系很长一段时间的发展重心都在面向长时间、大规模的企业级应用和服务端应用,但近年来大型系统从传统单体应用向微服务化、无服务化发展的趋势越来越明显。对短时间、小规模的服务形式来说,如果应用只需要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然负载小、没有任何回收行为的Epsilon是很恰当的选择。
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值