经典垃圾收集器

Serial收集器

Serial收集器是最基础、历史最悠久的收集器. 这个收集器是一个单线程工作的收集器, 在他进行垃圾收集时, 必须暂停其他所有工作线程, 直到它收集结束.

它是HotSpot虚拟机运行在客户端模式下的默认新生代收集器, 它简单高效, 额外内存消耗最少.

ParNew收集器

ParNew收集器实际上是Serial收集器的多线程并行版本, 除了使用多线程进行垃圾收集之外,其他与Serial完全一致.

除了Serial收集器外, 目前只有它能与CMS收集器配合工作, 所有还有很多服务端模式下的虚拟机在使用它.

Parallel Scavenge收集器

是一款新生代收集器, 基于标记-复制算法实现, 并且可以并行处理.

Parallel Scavenge收集器的目标是达到一个可控制的吞吐量.

吞吐量 = 运行用户代码时间/ (运行用户代码时间+运行垃圾收集时间)

高吞吐量可以最高效率的利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务.

Serial Old收集器

Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法.

在服务端模式下,主要有两个用途:

  • 与Parallel Scavenge收集器搭配使用
  • 作为CMS收集器发生失败时的后备方案.

Parallel Old收集器

是Parallel Scavenge的老年代版本, 支持多线程并发收集, 基于标记-整理算法.

CMS(Concurrent Mark Sweep)收集器

是一种以获取最短回收停顿时间为目标的收集器. CMS收集器是基于标记-清除算法实现的, 它的运作过程主要分为以下四个步骤:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”.

  1. 初始标记: 标记一下GC Roots能直接关联到的对象, 速度很快.
  2. 并发标记: 从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时很长但是不需要停顿用户线程, 可以与垃圾收集器线程一起并发运行.
  3. 重新标记阶段: 为了修正并发标记期间, 因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录, 这个阶段的停顿时间通常会比初始标记阶段稍长一
    些, 但也远比并发标记阶段的时间短.
  4. 并发清除阶段: 清理删除掉标记阶段判断的已经死亡的对象, 由于不需要移动存活对象, 所有这个阶段也是可以与用户线程同时并发.

由于在整个过程中耗时最长的并发标记和并发清除阶段中, 垃圾收集器可以与用户线程一起工作, 所有从总体上来说, CMS收集器的内存回收过程是与用户线程一起并发执行的.

主要优点是: 并发收集、低停顿.

缺点是:

  • 对处理器资源非常敏感, CMS默认启动的回收线程数是 (处理器核心数量+3)/4. 当处理器在4核以上时,表现才会好, 并且核数越多性能越好. 但是当核数不足4个时,对用户线程的影响就会变得很大.
  • 无法处理“浮动垃圾”, 在CMS的并发标记和并发清理阶段, 用户线程还是在继续运行, 也就还会有新的垃圾对象产生. 但这一部分垃圾对象是出现在标记过程结束之后, CMS无法在当次处理掉它们, 只好留代下一次收集时再清理掉. 在垃圾收集阶段用户还需要持续运行, 就需要预留足够的内存空间提供给用户线程使用, 因此CMS收集器不能像其他收集器那样等待老年代几乎完全被填满了再进行收集, 需要预留一部分空间供并发收集时的程序运行使用. 如果CMS运行期间预留的内存无法满足程序分配新对象的虚脱, 就会出现一次“并发失败”(Concurrent Mode Failure). 这个适合虚拟机需要启动后备预案: 冻结用户线程的执行, 临时启用Serial Old收集器来重新进行老年代的垃圾收集. 可以通过参数XX:CMSInitiatingOccupancyFraction来设置CMS的触发百分比, 这个参数不宜设置的过大. 不然会出现并发失败错误.
  • 由于基于标记-清除算法实现, 意味着收集结束会有大量的空间碎片产生. 空间碎片过多时, 会给大对象分配带来麻烦. 从而触发Full GC. — CMS收集器提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从(JDK9开始废弃, 因为JDK9开始使用G1),用于在CMS收集器不得不进行FullGC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了, 但停顿时间又会变长, 因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBefore-Compaction(此参数从JDK9开始废弃,因为JDK9开始使用G1),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的FullGC之后,下一次进入FullGC前会先进行碎片整理(默认值为0,表示每次进入FullGC时都进行碎片整理)

Garbage First收集器(G1)

G1是垃圾收集器技术发展历史上的里程碑式的成果, 它开创了收集器面向局部收集的设计思路和基于Region的内存布局格式. 从JDK9开始成功服务端模式下默认的垃圾收集器.

停顿时间模型(Pause Prediction Model): 能够支持指定在一个长度为M毫秒的时间片段内, 消耗在垃圾收集器上的时间大概率不超过N毫秒.

G1可以面向堆内存任意部分来组成回收集(Collection Set, 简称CSet)进行回收, 衡量标准不再是它属于哪个分代, 而是哪块内存中存放的垃圾数量最多, 回收收益最大, 这就是G1收集器的Mixed GC模式.

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键. 虽然G1也仍遵循分代收集理论, 但是其堆内存的布局与其他收集器有非常明显的差异: G1不再坚持固定大小以及固定数量的分区区域划分, 而是把连续的Java堆划分为多个大小相等的独立区域(Region), 每一个Region都可以根据需要, 扮演新生代的Eden空间, Survivor空间, 或者是老年代空间. 收集器能够根据扮演不同角色的Region采用不同的策略去处理, 这样无论是新创建的对象还是对象已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果.

Region中还有一类特殊的Humongous区域, 专门用来存储大对象. G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象. 每个Region的大小可以通过参数XX:G1Heap RegionSize设定, 取值范围为1MB~32MB, 且应为2的N次幂. 而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中, G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待.
虽然G1仍然保留新生代和老年代的概念, 但新生代和老年代不再是固定的了, 他们都是一系列区域(不需要连续)的动态集合. G1收集器之所以能够建立可预测的停顿时间模型, 是因为它将Region作为单次回收的最小单元, 每次收集到的内存空间都是Region大小的整数倍, 这样可以有计划的避免在整个Java堆中进行全区域的垃圾收集. — 更具体的处理思路是G1收集器去追踪各个Region里面垃圾堆积的“价值”大小, 价值即回收所需的空间大小以及回收所需时间的经验值, 然后在后台维护一个优先级列表. 每次根据用户设定运行的收集停顿时间(使用参数-XX:M axGCPauseMillis指定,默认值是200毫秒), 优先处理回收价值收益最大的那些Region. 这也就是“Garbage First”名字的由来. 这种使用Region划分内存空间, 以及具有优先级的区域回收方式, 保证了G1收集器在有限的时间内获取尽可能高的收集效率.

问题:

  1. 在Java堆分为多个独立的Region后, Region里面存在的跨Region引用对象如何解决: 使用记忆集避免全堆作为GC Roots扫描, 但在G1收集器上记忆集的应用要复杂很多. 它的每个Region都维护自己的记忆集, 这些记忆集记录下别的Region指向自己的指针, 并标记这些指针分别在哪些卡页的范围之内. G1的记忆集在存储结构的本质上是一种“双向”的卡表结构, 比原来的卡表实现起来更复杂, 同时由于Region数量比传统收集器的分代数量明显多得多, 因为G1收集器要比其他垃圾收集器有着更高的内存占有负担. — G1至少要耗费大约相当于Java堆容量10%~20%的额外内存来维持收集器工作.
  2. 并发标记阶段如何保证收集线程与用户线程互不干扰地运行: 首先要解决的是用户线程改变对象引用关系时, 必须保证其不能打破原有的对象图结构, 导致标记结构出现错误. CMS收集器采用增量更新算法实现, 而G1收集器则是通过原始快照(SATB)算法来实现的.
  3. 新对象分配问题: G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针, 把Region中的一部分空间划分处理用于并发回收过程中的新对象分配, 并发回收时新分配的对象地址都必须要在这两个指针位置以上. G1收集器默认在这个地址以上的对象是被隐式标记果的, 即默认他们是存活的, 不纳入回收范围. 与CMS中
    的“Concurrent M ode Failure”失败会导致Full GC类似, 如果内存回收的速度赶不上内存分配的速度, G1收集器也要被迫冻结用户线程执行, 导致Full GC而产生长时间“Stop The World”
  4. 如何建立可靠的停顿预测模型: 用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值, 但G1收集器要怎么做才能满足用户的期望呢? G1收集器的停顿预测模型是以衰减均值(Decay ing Average)为理论基础来实现的, 在垃圾收集过程中, G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本, 并分析得出平均值、标准偏差、置信度等统计信息. 这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态. 换句话说, Region的统计状态越新越能决定其回收的价值. 然后通过这些信息预测现在开始回收的话, 由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益

G1收集器的运作过程大致可以划分为以下4个步骤:

  • 初始标记(Initial Marking) 标记GC Roots能直接关联到的对象, 并且修改TAMS指针的值, 让下一阶段用户线程并发时, 能正确的在可用的Region中分配对象. 这个阶段需要停顿线程, 但耗时很短
  • 并发标记(Concurrent Marking) 从GC Root开始对堆中对象进行可达性分析, 递归扫描整个堆内的对象图, 找出要回收的对象, 这个阶段耗时较长, 但可与用户线程并发执行. 当对象图扫描完成以后, 还要重新处理SATB记录下的在并发过程中有引用变动的对象.
  • 最终标记(Finial Marking) 对用户线程做另一个短暂的暂停, 用于处理并发阶段结束后仍遗留下来的最后那少量的STAB记录
  • 筛选回收(Live Data Counting and Evacuation) 复责更新Region的统计数据, 对各个Region的回收价值和成本进行排序, 根据用户所期望的停顿时间来制定回收计划, 可以自由选择任意多个Region构成回收集, 然后把决定回收的那一部分Region中的存活对象复制到空的Region中, 再清理掉整个旧Region的全部空间. 这里的操作涉及存活对象的移动, 是必须暂停用户线程, 由多条收集器线程并行完成的.

Shenandoah收集器

不仅支持并发的垃圾标记, 还支持并发的对象清理后的整理工作. 与G1有着相似的堆内存布局, 在初始标记、并发标记等许多阶段的处理思路上高度一致.

与G1至少有三个明显的不同之处:

  1. 支持并发的整理算法, G1的回收阶段是可以多线程并行, 但是不能与用户线程并发, 而Shenandoah则可以与用户线程并发.
  2. Shenandoah(目前)是默认不使用分代收集的, 也就是没有新生代和老年代的存在.
  3. 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集, 该用名为“连接矩阵”的全局数据结构来记录跨Region的引用关系. 连接矩阵可以简单理解为一张二维表格, 如果Region N有对象指向Region M, 就在表格N行M列中打上一个标记.

工作过程可以大致划分为9个阶段:

  1. 初始标记(Initial Marking) 与G1一样, 首先标记与GC Roots直接关联的对象, 这个阶段是“Stop The World”的, 停顿时间与堆大小无关, 至于GC Roots的数量相关.
  2. 并发标记(Concurrent Marking) 与G1一样, 遍历对象图, 标记出全部可达的对象, 这个阶段与用户线程并发执行, 时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度.
  3. 最终标记(Finial Marking) 与G1一样, 处理剩余的SATB扫描, 并在这个阶段统计出回收价值最高的Region, 将这些Region构成一组回收集(Collection Set). 最终标记也会有一小段短暂的停顿.
  4. 并发清理 (Concurrent Cleanup) 清理那些整个区域连一个存活对象都没有找到的Region(这类Region称为 Immediate Garbage Region)
  5. 并发回收(Concurrent Evacuation) 在这个阶段, Shenandoah要把回收集里面存活对象先复制一份到其他未被使用的Region之中. — 复制对象这件事情如果将用户线程冻结起来再做那是相当简单的, 但如果两者必须同时并发进行的话, 就变得复杂起来了. 困难点是在移动对象的同时, 用户线程仍然可能不停对被移动的对象进行读写访问, 移动对象是一次性的行为, 但移动之后整个内存中所有执向该对象的引用都还是就对象的地址, 这是很难一瞬间全部改变过来的. Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决. 并发回收阶段的运行时间长短取决与回收集的大小
  6. 初始引用更新(Initial Update Reference) 并发回收阶段复制对象结束后, 还需要把堆中所有指向旧对象的引用修正到复制后的新地址, 这个操作称为引用更新. 引用更新的初始化阶段实际上并未做什么具体的处理, 设立这个阶段只是为了建立一个线程集合点, 确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已. 初始引用更新时间很短, 会产生一个非常短暂的停顿.
  7. 并发引用更新(Concurrent Update Reference) 真正开始进行引用更新操作, 这个阶段是与用户线程一起并发的, 时间长短取决于内存中涉及的引用数量的多少. 并发引用更新与并发标记不同, 它不再需要沿着对象图来搜索, 只需要按照内存物理地址的顺序, 线性地搜索出引用类型, 把旧值改为新值即可.
  8. 最终引用更新(Finial Update Reference) 解决来堆中的引用更新后, 还要修正存在与GC Roots中的引用, 这个阶段是最后一次停顿, 停顿时间只与GC Roots的数量相关.
  9. 并发清理(Concurrent Cleanup) 经过并发回收和引用更新之后, 整个回收集中所有的Region中再无存活对象, 这些Region都变成来Immediate Garbage Regions了, 最后再调用一次并发清理过程来回收这些Region的内存空间, 供以后新对象分配使用.

Brooks Pointer - 用以支持并行整理的核心概念

此前, 要做类似的并发操作, 通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap), 一旦用户程序f到归属于旧对象的内存空间就会产生自陷中段, 进入预设好的异常处理器中, 再由其中的代码逻辑把访问转发到复制的新对象上. 这种方案虽然能够实现对象移动与用户线程并发, 但是如果没有操作系统层面的直接支持, 这种方案将导致用户态频繁切换到核心态, 代价非常大.

Brooks提出的新方案不需要用到内存保护陷阱, 而是在原有对象布局结构的最前面统一增加一个新的引用字段, 在正常不处于并发移动的情况下, 该引用指向对象自己. 当对象拥有了一份新的副本时, 只需要修改一处指针的值, 即旧对象转发指针的引用位置, 使其指向新对象, 便可将所有对该对象的访问转发到新的副本上. 这样只要就对象的内存依然存在, 未被清理掉, 虚拟机内存中所有通过旧引用地址访问的代码便仍然可用, 都会被自动转发到新对象上继续工作.

缺点是每次对象访问都会带来一次额外的转向开销, 但是它比起内存保护陷阱的方案已经好了很多.

Shenandoah收集器通过CAS操作来保证并发时对象的访问正确性. 并且同时设置了读、写屏障.

ZGC收集器

在JDK11加入的低延迟垃圾收集器, ZGC收集器是一款基于Region内存布局的, (暂时)不设分代的, 使用了读屏障, 染色指针和内存多重映射等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器.

ZGC也采用基于Region的堆内存布局, 但不同的是, ZGC的Region具有动态性—动态创建和销毁, 以及动态的区域容量大小. 在x86硬件平台下, ZGC的Region可以具有大中小三类容量:

  • 小型Region : 容量固定为2MB, 用于放置xiao y
  • 中型Region : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象
  • 大型Region : 容量不固定, 可以动态变化, 但必须是2MB的整数倍, 用于放置4MB或以上的大对象. 每个大型Region中只会存放一个大对象, 所以它的实际容量完全有可能小于中型Region. 大型Region在ZGC的实现中不会被重分配.

染色指针

从前, 如果我们要在对象上存储一些额外的, 只供收集器或者虚拟机本身使用的数据, 通常会在对象头中增加额外的存储字段, 如对象的哈希码、分代年龄、锁记录等就是这样存储的. 这种记录方式在有对象访问的场景下是很自然流畅的, 不会有什么额外负担. 但是如果对象存在被移动的可能性, 这种请问无法保证对象访问能够成功. 我们希望通过一些不会去访问对象, 但又能够得到该对象的某些信息.

可以通过指针或者与对象内存无关的地方得到这些信息或者能够看出对象被移动过.

ZGC的染色指针把标记信息记在引用对象的指针上, 染色指针是一种直接将少量额外的信息存储在指针上的技术, ZGC将64位指针中的高1922位提取出来存储4个标志信息. 通过这些标志位、虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入来重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到. 由于占用了4个标记位, 导致了ZGC能够管理的内存不可以超过4TB(2的42次幂), 并且不能支持32位平台.不能支持压缩指针 — 为什么使用1922位 : 在Linux下64位指针的高18位不能用来寻址.

染色指针的三个优势:

  1. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量, 设置内存屏障, 尤其是写屏障的目的通常是为了记录对象引用的变动情况, 如果将这些信息直接维护在指针中, 显然可以省去一些专门的记录操作. ZGC到目前为止都没有使用到任何写屏障, 只使用了读屏障(一部分是染色指针的功劳, 一部分是ZGC还不支持分代收集, 天然没有跨代引用的问题).
  2. 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据, 以便日后进一步提高性能. — 现在Linux下的64位指针还有前18位并未使用, 它们虽然不能用来寻址, 却可以通过其他手段用于信息记录. 如果开发了这18位, 既可以腾出已用的4个标志位, 将ZGC可支持的最大堆内存从4TB拓展到64TB, 也可以利用其余位置再存储更多的标志, 譬如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域.
  3. 染色指针可以使得一旦某个Region的存活对象被移走之后, 这个Region立即就能被释放和重用, 而不必等待整个堆中所有指向该Region的引用都被修正才能清理. — 由于其“自愈”特性

ZGC的运作过程

ZGC的运作过程大致可以划分位以下四个大的阶段, 全部四个阶段都是可以并发执行的, 仅是两个阶段中间会存在短暂的停顿小阶段, 这些小阶段譬如初始化GC Root直接关联对象的Mark Start与之前其他收集器并无差异.

  1. 并发标记 (Concurrent Mark) : 与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段, 前后也要经过类似与G1, Shenandoah的初始标记、最终标记的短暂停顿, 而且这些停顿阶段所做的事情在目标上也是相类似的. 与G1、Shenandoah不同的是, ZGC的标记是在指针上而不是对象上进行的, 标记阶段会更新染色指针中的Mark0、Mark1标志位.
  2. 并发预备重分配(Concurrent Prepare For Relocate) : 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region, 将这些Region组成重分配集(Relocation Set). ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收. 相反, ZGC每次回收都会扫描所有的Region, 用范围更大的扫描成本换取省去G1中记忆集的维护成本. 因此, ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中, 里面的Region会被释放, 而不是说回收行为就只是针对这个集合中的Region进行, 因为标记过程是针对全堆的. 此外, 在JDK12中的ZGC开始支持的类卸载以及弱引用的处理, 也是在这个阶段完成的.
  3. 并发重分配 (Concurrent Relocate) : 重分配是ZGC执行过程中的核心阶段, 这个过程要把重分配集中的存活对象复制到新的Region上, 并为重分配集中的每个Region维护一个转发表(Forward Table), 记录从旧对象到新对象的转向关系. 得益于染色指针的支持, ZGC收集器能仅从引用上就明确得知一个对象释放处于重分配集之中, 如果用户线程此时并发访问了位于重分配集中的对象, 这次访问将会被预置的内存屏障所截获, 然后立即根据Region上的转发表记录将访问转发到新复制的对象上, 并同时修正更新该引用的值, 使其直接指向新对象, ZGC将这种行为称为指针的“自愈”(Self Healing)能力. 这样做的好处是只有第一次访问就对象会陷入转发, 也就是只慢一次, 对比Shenandoah的Brooks转发指针, 那是每次对象访问都必须付出的固定开销, 简单地说就是每次都慢, 因此ZGC对用户程序的运行时负载要比Shenandoah来的要低一些. 还有另外一个直接的好处是由于染色指针的存在, 一旦重分配集中某个Region的存活对象都复制完毕后, 这个Region就可以立即释放用于新对象的分配 (但是转发表还得留着不能释放掉). 哪怕堆中还有很多指向这个对象的未更新指针也没有关系, 这些旧指针一旦被使用, 它们都是可以自愈的.
  4. 并发重映射(Concurrent Remap) : 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用, ZGC的并发重映射并不是一个必须要“迫切”去完成的任务, 因为即便是旧引用, 它也是可以自愈的, 最多只是第一次使用时多一次转发和修正操作(因为转发表还存在). 重映射清理这些旧引用的主要目的是为了不变慢, 并且清理结束后还可以释放转发表. ZGC很巧妙的把并发重映射阶段要做的工作, 合并到下一次垃圾收集循环中的并发标记阶段里完成, 反正它们都是要遍历所有对象的, 这样合并就节省了一次遍历对象图的开销. 一旦所有指针都被修正之后, 原来记录新旧对象关系之间的转发表就可以释放掉了.

ZGC的对象分配速率不能太高, 在对象分配速率太高时, 新对象只能被当作存活对象来看待, 就产生了大量的浮动垃圾, 导致每一次完整额并发收集周期变长, 回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间, 堆中剩余可腾挪的空间就越来越小了.

这时, 与其说可达性分析是遍历对象图来标记对象, 还不如说是遍历“引用图”来标记“引用”.