【JVM】一文掌握JVM垃圾回收机制

作为Java程序员,除了业务逻辑以外,随着更深入的了解,都无法避免的会接触到JVM以及垃圾回收相关知识。JVM调优是一个听起来很可怕,实际上很简单的事。
感到可怕,是因为垃圾回收相关机制都在JVM的C++层实现,我们在Java开发中看不见摸不着;而实际很简单,是因为它说到底,也只是JVM替我们实现的垃圾对象回收机制,也是普通的程序代码,只要理解了垃圾回收器的底层设计思想,掌握JVM调优并非难事!

一、JVM内存模型

JVM内存模型

元数据区:JDK8之前是方法区。存放虚拟机加载的:类型信息,域(Field)信息,方法(Method)信息,常量,静态变量,即时编译器编译后的代码缓存
虚拟机栈:虚拟机栈中保存了每一次方法调用的栈帧信息,栈帧中包含以下信息:

  • 局部变量表:保存函数 (即方法) 的局部变量
  • 操作数栈:保存计算过程中的结果,即临时变量
  • 动态链接:指向方法区的运行时常量池。字节码中的方法调用指令以常量池中指向方法的符号引用为参数。
  • 方法的返回地址

本地方法栈:和虚拟机栈功能上类似,它管理了native方法的一些执行细节,而虚拟机栈管理的是Java方法的执行细节。
程序计数器:程序计数器记录线程执行的字节码行号,如果当前线程正在运行native方法则为空。每个线程都有自己的计数器
:JVM中产生的实例对象的存储位置

所谓的垃圾回收,主要就是回收JVM中堆内存的区域

二、垃圾定义

  1. 引用计数(ReferenceCount):存在循环引用的问题,漏掉循环引用的垃圾
  2. 根可达算法(RootSearching):判断对象是否可通过引用寻到JVM的根节点,不能则是垃圾

三、垃圾回收算法

  1. 标记清除(mark sweep) - 位置不连续 产生碎片 效率偏低(两遍扫描)

标记清除

  1. 拷贝算法 (copying) - 没有碎片,浪费空间

拷贝算法

  1. 标记压缩(mark compact) - 没有碎片,效率偏低(两遍扫描,指针需要调整)

标记压缩

四、垃圾回收器

通过以上三种算法的排列组合,产生了各种各样的垃圾回收器

堆内存逻辑分区

堆内存逻辑分区

常见垃圾回收器

? Serial:单线程STW垃圾回收器,工作在年轻代。采用拷贝算法
? Serial Old:单线程STW垃圾回收器,工作在老年代。采用标记清除加压缩算法
? Parallel Scavenge:并行垃圾回收,工作在年轻代。采用拷贝算法
? Parallel Old:并行垃圾回收,工作在老年代。采用标记清除加压缩算法
? ParNew:并行垃圾回收,工作在年轻代。专门配合CMS使用
? CMS(Concurrent Mark-Sweep):并发标记清除,工作在老年代,采用标记清除算法。
? G1(Garbage First):垃圾优先算法,采用拷贝算法
? ZGC(Z Garbage Collector):一种可伸缩的低延迟垃圾回收器,旨在处理TB级别的堆,同时保持低毫秒级别的停顿时间。它通过使用读屏障和染色指针来实现这一点,并且在垃圾回收过程中几乎不需要暂停应用线程
? Shenandoah GC:是一种旨在实现低停顿时间的垃圾回收器,它通过并发的方式来回收内存。Shenandoah的目标是减少停顿时间,而不是优化吞吐量,适用于需要大内存和低延迟的应用

垃圾回收器

垃圾升级过程

  • 创建对象产生在eden区
  • ygc触发,把eden区和s0区不是垃圾的对象复制到s1区,并对非垃圾对象的头部的分代年龄加一,然后清除eden区和s0区
  • ygc触发,把eden区和s1区不是垃圾的对象复制到s0区,并对非垃圾对象的头部的分代年龄加一,然后清除eden区和s1区
  • 当对象头记录的分代年龄达到15(默认最大分代年龄)时,jvm将把他从年轻代升级到老年代

eden区和s0、s1的默认比例是8:1:1,可通过参数**-XX:SurvivorRatio**配置

对象头的年龄可通过**-XX:MaxTenuringThreshold**参数配置,但由于对象头中只用4个比特位存储分代年龄,因此它的区间是0-15

CMS垃圾回收器

CMS是用于回收老年代的垃圾回收器,它采用的是标记清除算法。CMS的诞生的目的在于提供在多核环境下的并发处理中大型堆(MB~GB)垃圾的能力

特点

  1. 并发收集:CMS的主要特点是它允许垃圾回收线程与应用程序线程同时运行。这减少了应用程序的停顿时间,特别是在长时间运行的老年代垃圾回收过程中。
  2. 低停顿时间:由于并发执行,CMS旨在减少垃圾回收引起的停顿时间,这对于延迟敏感的应用程序非常重要。
  3. 存在内存碎片:CMS通常不执行堆压缩,这意味着它不会重新安排存活对象来消除空闲空间之间的碎片。这可以减少停顿时间,但可能导致更多的内存碎片。
  4. CPU资源密集型:CMS需要更多的CPU资源来执行并发的垃圾回收。在多核处理器上,这通常不是问题,但在CPU资源受限的环境中,可能会影响应用程序的性能。
  5. 并发模式失败:在极端情况下,如果老年代在CMS回收过程中被填满,会发生“并发模式失败”。这时,JVM会退回到传统的完全停顿式垃圾回收,以清理老年代。
  6. 适用于中到大型堆:CMS适合于中到大型的堆,尤其是在有足够CPU资源和对停顿时间敏感的应用场景中。
  7. 需要调优:为了获得最佳性能,CMS可能需要通过JVM参数进行调优,如设置堆的大小、老年代的大小、并发线程数等。

垃圾回收过程

  1. 初始标记:找到根上的垃圾,会有非常短暂的STW
  2. 并发标记:标记垃圾,这一步可能产生漏标(扫描完不是垃圾之后,突然失去引用变成了垃圾),也可能产生多标(扫描完事垃圾之后,突然重新被引用变成不是垃圾)
  3. 重新标记:重新标记的目的是纠正上一步所产生的错误标记,会有时间不算很长的STW
  4. 并发清理:清理前面步骤所标记出来的垃圾

三色标记算法

作用于并发标记阶段

对象标记为黑白灰三个颜色,记录当前扫描标记的位置。

  • 黑色:自己已经被标记,自己的子引用也都标记完成
  • 白色:没有遍历到的节点
  • 灰色:自己已经被标记,自己的子引用还没被全部标记完成

三色标记的bug

由于并发标记是与用户线程并行的,所以在并发标记的过程中对象的引用是可能发生变化的,所以可能会产生多标和漏标。并且重新标记为了减少STW的时间不会再标记黑色对象,而是扫描灰色对象的直接引用

  • 多标:会导致产生浮动垃圾,需要在下一次判断引用再回收,无大碍
  • 漏标:会导致不应该被回收的对象被回收,问题严重

多标漏标

如上图:在并发标记的过程中,同时产生这两种情况时就会发生回收错误问题:A和C断开了引用,A又引用了D。

  • 对于对象C:应该回收的对象现在是黑色,留了下来
  • 对于对象D:被引用了但还是白色,由于重新标记时不会再扫描黑色对象,这样会导致对象D被当作垃圾而回收,产生严重bug

CMS对于三色标记的错标处理

CMS的处理方式是Increment Updater(增量更新),即当已经被扫描完的黑色对象如果产生了新的引用,则把自己标记为灰色,等待下次扫描重新标记。

但在上述的多标案例中,CMS存在却依然并发标记Bug,如下时序图

三色标记

当两个垃圾回收线程m1和m3加上一个业务线程m2同时标记一个对象时,m3认为应该标灰,但m1认为应该标黑,如果最终m1的标记覆盖了m3的标记,那么对象的颜色标记错误,它下面新增的引用也不会被扫描到

CMS对于这个严重的bug的解决方案是,在重新标记阶段重新扫描时,必须从头扫描一遍,这样就增加了STW的时间

G1垃圾回收器

G1(Garbage-First)垃圾回收器是Java虚拟机(JVM)的一个高级垃圾回收器,旨在为具有大内存的多处理器机器提供高吞吐量和低延迟。G1垃圾回收器的主要特点包括:

特点

  1. 分区堆结构:G1将堆内存分割成多个大小相等的区域(Region),这些区域可以被划分为Eden区、Survivor区或Old区。这种分区方法有助于更有效地管理堆空间。
  2. 并发和并行处理:G1结合了并发和并行的垃圾回收机制,以优化性能和减少停顿时间。
  3. 可预测的停顿时间:G1的一个关键目标是提供可预测的停顿时间,允许用户指定期望的停顿时间目标(例如,不超过50毫秒),G1将尽量在这个时间范围内完成垃圾回收。
  4. 增量式清理:G1通过逐步清理堆中的区域来管理垃圾回收,这有助于控制停顿时间。
  5. 记忆集(RSet):G1使用记忆集来跟踪跨区域引用的对象,这有助于在垃圾回收时快速确定哪些对象是存活的。
  6. 混合收集:G1可以同时回收Young和Old区域。在进行混合收集时,G1会根据需要和停顿时间目标选择性地回收一部分Old区域。
  7. 高效的大对象处理:G1能够更有效地处理大对象,因为它可以跨多个区域分配这些对象。
  8. 自适应调整:G1会根据应用程序的行为和指定的停顿时间目标自动调整堆占用和回收策略。
  9. 适用于大堆:G1特别适合于大堆(多GB)的应用,因为它能够更好地管理大内存并保持合理的停顿时间。

分区算法(Region)

G1的物理分区从分代变成了分区(Region),逻辑上分代,物理上则取消了分代,把堆整体划分成了多个(2048)相同大小的小格子(Region)

G1

其中,每个Region的大小可通过**-XX:G1HeapRegionSize设定,取值范围为1-32MB**,且必须为2的N次幂,即只能为2,4,8,16,32这五个数

每一个Region都可以根据需要充当新生代的Eden区、S区(G1取消了S0和S1,只使用一个Survivor区)或者老年代。在一般的垃圾收集中对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。 G1的大多数行为都把H区作为老年代的一部分来看待。当一个对象的大小超过了一个Region容量的一半,即被认为是大对象

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,而是一系列区域(不需要连续,逻辑连续即可)的动态集合。由于G1这种基于Region回收的方式,可以预测停顿时间。G1会根据每个Region里面垃圾“价值”的大小,在后台维护一个优先级列表,每次根据用户设定的允许收集停顿的时间(-XX:MaxGCPauseMillis,默认为200毫秒)优先处理价值收益最大的Region。

垃圾回收过程

G1采用的复制(copying)算法进行回收

  1. 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能够在Region上正确的分配对象。这个阶段需要STW,耗时很短,而且是借用MinorGC(上一轮垃圾回收时触发GC)时候同步完成的。
  2. 并发标记:从GC Roots 开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象,这个过程耗时较长,但是是与用户线程并发执行的。对象扫描完之后还需要重新处理STAB记录下的在并发时有引用变动的对象。
  3. 最终标记:这个阶段也需要STW,用于处理并发阶段结束后仍然遗留下来的最后少量的STAB记录。
  4. 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本排序,根据用户期望的停顿时间来执行回收计划,然后把决定回收的Region里的存活对象复制到空的Region,然后清空旧Region的空间。由于涉及到对象的移动,所以这个阶段也是需要STW的。

从上述可以看出,除了并发标记,其他阶段都是需要STW的,G1收集器不单单是追求低延迟的收集器,也衡量了吞吐量,所以在延迟和吞吐量之间做了一个权衡。

G1对于三色标记的错标处理

从上述过程可以看出G1的处理方式是SATB(snapshot at the begining),即在并发标记中,如果出现引用的变更,G1的垃圾回收器会记录在SATB中,每次线程切回来进行垃圾回收时,先读取SATB中的记录。

RememberedSet

简称RSet,记录了其他Region的对象到本Region的引用,使得垃圾回收器不需要扫描整个堆找到谁引用了当前分区的对象,只需扫描RSet即可