JVM中的垃圾回收机制

在JVM(Java虚拟机)中,垃圾收集(Garbage Collection, GC)是管理内存的重要机制。JVM通过垃圾收集算法自动回收不再使用的对象,释放内存空间。常见的垃圾收集算法主要包括以下几种:

垃圾收集算法

标记-清除算法(Mark-Sweep)

工作原理

  1. 标记阶段:从根对象(根集合)开始,标记所有可达的对象。
  2. 清除阶段:遍历堆内存,清除未标记的对象,回收它们占用的内存。

优点

  • 实现简单,不需要移动对象。

缺点

  • 内存碎片化严重。
  • 标记和清除阶段需要扫描整个堆,性能较低。

标记-复制算法(Copying)

img

工作原理

  1. 将内存分为两个相等的区域:一个活跃区(From区),一个空闲区(To区)。
  2. 活跃区存放活动对象,当需要进行垃圾收集时,复制活动对象到空闲区。
  3. 交换两个区域的角色。

优点

  • 内存分配简单,高效。
  • 没有内存碎片化问题。

缺点

  • 需要两倍的内存空间。
  • 复制成本较高,适合存活对象较少的情况。

标记-整理算法

../post_img/2022-05-14-JVM中垃圾回收机制/94590

工作原理

  1. 标记阶段:标记所有可达对象。
  2. 压缩阶段:将所有存活对象压缩到内存的一端,整理成连续的内存块,回收掉端区的所有内存。

优点

  • 没有内存碎片化问题。
  • 不需要额外的内存空间。

缺点

  • 压缩阶段移动对象,成本较高。

分代收集算法(Generational Collection)

工作原理

将堆内存划分为几代,主要包括新生代和老年代。新生代又分为Eden区和两个Survivor区。

  • 新生代:主要存放新创建的对象,采用复制算法进行回收。
  • 老年代:存放生命周期较长的对象,采用标记-清除或标记-压缩算法进行回收。

优点

  • 根据对象生命周期特点优化收集策略,提高效率。
  • 大部分对象在新生代中很快被回收,减少老年代的GC压力。


垃圾收集器

Serial GC(串行垃圾收集器)

img

工作原理

  • 新生代收集器:使用复制算法。
  • 老年代收集器:使用标记-压缩算法。

特点

  • 单线程:所有垃圾收集动作(包括标记和清理)都在一个线程上完成。
  • 适用场景:适用于单核处理器或者较小堆内存的应用。

优点

  • 实现简单,效率高。

缺点

  • 由于是单线程操作,停顿时间较长,不能充分利用多核CPU的优势。

配置

1
2
-XX:+UseSerialGC
-XX:+UseSerialOldGC

ParNew收集器

它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

img

  • 和Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
  • 新生代采用复制算法

Parallel Scavenge(并行垃圾收集器)

img

  • Parallel收集器实际上是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余的行为(控制参数、收集算法、回收策略)
  • 默认线程线程数和CPU核数相同。可以通过-XX:ParallelGCThreads参数修改
  • 关注点是吞吐量,CMS关注点是用户线程的停顿时间。

工作原理

  • 新生代收集器:使用复制算法。
  • 老年代收集器:使用标记-整理算法
1
2
3
4
# 年轻代
-XX:+UseParallelGC
# 老年代
-XX:+UseParallelOldGC

Serial Old

收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案

Serial 收集器


Parallel Old

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

Parallel Old收集器运行示意图


CMS GC(Concurrent Mark-Sweep)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS 收集器

工作原理

  • 老年代收集器:使用标记-清除算法,并且大部分工作与应用线程并发执行。
  • 新生代收集器:使用ParNew(并行的新生代收集器)。

特点

  • 并发:标记和清除阶段大部分工作与应用线程并发执行。
  • 适用场景:适用于对低停顿时间要求较高的应用。

优点

  • 低停顿时间,提高应用的响应速度。

缺点

  • 可能会产生内存碎片。
  • 并发标记和清除阶段会占用一定的CPU资源。
  • 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理
  • 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收

配置

1
-XX:+UseConcMarkSweepGC

清理过程

  1. 初始标记暂停所有线程(STW),并记录下GC Root能直接引用的对象,速度很快
  2. 并发标记:从GC Root的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程并发运行。
  3. 重新标记暂停所有线程(STW),为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(主要是处理漏标问题),这个阶段的停顿时间会比初始标记阶段的时间稍长,但是远远比并发标记的时间短。主要用到三色标记中的增量更新算法。
  4. 并发清理:开启用户线程,同时GC线程开始对未标记的区域清扫,这个阶段如果有新增的对象会被标记为黑色不做任何处理(三色标记算法详解)
  5. 并发重置:重置本次GC过程中的标记数据

核心参数

参数名 作用
-XX:+UseConcMarkSweepGC 启用CMS
-XX:ConcGCThreads 并发的GC线程数
-XX:+UseCMSCompactAtFullCollection FullGC之后做压缩整理(减少碎片)
-XX:CMSFullGCsBeforeCompaction 多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次 。
-XX:CMSInitiatingOccupancyFraction 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
-XX:+UseCMSInitiatingOccupancyOnly 只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),
如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
-XX:+CMSScavengeBeforeRemark 在CMS GC前启动一次minor gc,降低CMS GC标记阶段 (也会对年轻代一起做标记,
如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)
时的开销,
一般CMS的GC耗时 80%都在标记阶段
-XX:+CMSParallellnitialMarkEnabled 表示在初始标记的时候多线程执行,缩短STW
-XX:+CMSParallelRemarkEnabled 在重新标记的时候多线程执行,缩短STW;

G1 GC(Garbage-First)

工作原理

  • Region:将堆内存划分为多个小区域(Region),每个Region可能属于新生代或老年代。

    img

    • JVM的目标是不超过2048个region(JVM中的TARGET_REGION_NUMBER定义),可以大于该值,但是不推荐。
    • 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,也可以用参数-XX:G1HeapRegionSize手动指定Region大小。
    • 年轻代默认占堆内存的5%,可以通过-XX:G1NewSizePercent设置占比。
    • 在系统运行的过程中,系统针对需要会不断地新增年轻代的region,但是不会超过堆内存的60%,可以通过-XX:G1MaxNewSizePercent来设置。
    • 年轻代中,Eden和Survivor区域的region数量比值仍然为8:1:1
    • region区域的功能是不一定的,可能是年轻代,也可能是老年代。
  • Humongous:专门用来奉陪大对象的。

    • 大对象的判定:超过了一个region大小的50%,比如说一个region的大小为2MB,则当一个对象的大小超过1MB的时候就会被放到Humongous区。
    • 对象太大的话,可以跨region存放
    • Full GC的时候,除了清理年轻代和老年代,也会清理Humongous区域。
  • 并发:多数操作与应用线程并发执行,通过Region的方式进行增量式垃圾收集。

特点

  • 低延迟:设计目标是以可控的停顿时间优先回收垃圾最多的Region。
  • 适用场景:适用于大内存和需要低停顿时间的应用。

优点

  • 可以配置停顿时间,较好地平衡了吞吐量和延迟。

缺点

  • 实现复杂,对调优有一定要求。

配置

1
-XX:+UseG1GC

清理过程

G1收集器一次GC(Mixed GC)的步骤:

img

  • 初始标记(initial mark,STW):暂停所有的其他线程,并记录下GC Roots直接能引用的对象,速度很快

  • 并发标记(Concurrent Marking):同CMS的并发标记

  • 最终标记(Remark,STW):同CMS的重新标记

  • 筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数:-XX:MaxGCPauseMillis指定)来制定回收计划。

    • CMS会尽量根据我们设置的时间来控制GC的回收的时间,设置的时间内,能回收多少region,就回收多少region。
    • 回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。
    • CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,ZGC,Shenandoah就实现了并发收集。

当然-XX:MaxGCPauseMillis这个值也不能设置的过小,否则会导致有衡多垃圾虽然被标记了,但是没有被回收,累积到最后出发Full GC。

核心参数

参数名 作用
-XX:+UseG1GC 使用G1收集器
-XX:ParallelGCThreads 指定GC工作的线程数量
-XX:G1HeapRegionSize 指定分区大小(1MB~32MB,且必须是2的N次幂),
默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis 目标暂停时间(默认200ms)
-XX:G1NewSizePercent 新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)
-XX:G1MaxNewSizePercent 新生代内存最大空间
-XX:TargetSurvivorRatio Survivor区的填充容量(默认50%),
Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和
超过了Survivor区域的50%,
此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold 最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent 老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),
比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,
则可能就要触发MixedGC了
-XX:G1MixedGCLiveThresholdPercent (默认85%) region中的存活对象低于这个值时才会回收该region,
如果超过这个值,存活对象过多,回收的的意义不大。
-XX:G1MixedGCCountTarget 在一次回收过程中指定做几次筛选回收(默认8次),
在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,
这样可以让系统不至于单次停顿时间过长。
-XX:G1HeapWastePercent (默认5%),gc过程中空出来的region是否充足阈值,
在混合回收的时候,对Region回收都是基于复制算法进行的,
都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,
这样的话在回收过程就会不断空出来新的Region,
一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,
意味着本次混合回收就结束了。

收集行为分类

  • YoungGC

    YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMillis 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMillis 设定的值,那么就会触发Young GC

  • MixedGC

    不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

  • Full GC

    停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)


ZGC(Z Garbage Collector)

工作原理

  • Region:类似于G1,将堆划分为多个Region。
  • 并发标记-压缩:使用并发标记-压缩算法,尽量减少垃圾收集停顿时间。

特点

  • 超低延迟:设计目标是将停顿时间控制在10ms以内。
  • 适用场景:适用于大内存(TB级别)和极低延迟要求的应用。

优点

  • 支持TB量级的堆
  • 最大GC停顿时间不超10ms
  • 最糟糕的情况下吞吐量会降低15%

缺点

  • 实现复杂,需要特定的JVM版本(JDK 11及以上)和硬件支持。
  • 最大的问题就是浮动垃圾
    • 由于停顿时间极其短(10ms以下),但是ZGC的执行时间远远大于这个时间,可能会产生大量新创建的对象,这些对象无法进入本次GC,将在下次GC中才能回收。

配置

1
-XX:+UseZGC

清理过程

ZGC运作过程

ZGC的运作过程大致可划分为以下四个大的阶段:

0

  • 并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新颜色指针(见下面详解)中的Marked 0、 Marked 1标志位。
  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
  • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障(见下面详解))所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。

​ ZGC的颜色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢, 一旦重分配集中某个Region的存活对象都复制完毕后, 这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉, 因为可能还有访问在使用这个转发表。

  • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。

颜色指针

Colored Pointers,即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中。

0

每个对象有一个64位指针,这64位被分为:

  • 18位:预留给以后使用。
  • 1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问。
  • 1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合)。
  • 1位:Marked1标识。
  • 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC。
  • 42位:对象的地址(所以它可以支持2^42=4T内存)。

为什么有2个mark标记?

每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。

GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。

GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。

通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。

颜色指针的三大优势:

  1. 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
  2. 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
  3. 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

读屏障

之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。

在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。

那么我们该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。

那么,JVM是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要「slow path」,修正指针;如果指针是Good Color,那么正常往下执行即可。

0

❝ 这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新读取。而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。❞

后面3行代码都不需要加读屏障:Object p = o这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。

正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销:

0

那么,判断对象是Bad Color还是Good Color的依据是什么呢?就是根据上一段提到的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。

目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T。

核心参数

参数名 作用
-XX:+UnlockExperimentalVMOptions 开启实验功能
-XX:+UseZGC 启用ZGC比较简单

触发时机

  • 定时触发:默认为不使用,可通过ZCollectionInterval参数配置。
  • 预热触发:最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用。
  • 分配速率:基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC(耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间)。
  • 主动触发:(默认开启,可通过ZProactive参数配置) 距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发。

Shenandoah GC

工作原理

  • Region:将堆内存划分为多个Region。
  • 并发标记-压缩:与应用线程并发执行,尽量减少停顿时间。

特点

  • 超低延迟:设计目标是尽量减少垃圾收集的停顿时间。
  • 适用场景:适用于需要高实时性的应用。

优点

  • 极低的停顿时间。

缺点

  • 实现复杂,需要JDK 12及以上版本支持。

配置

1
-XX:+UseShenandoahGC

总结

选择合适的垃圾收集器需要根据应用的具体需求来决定。需要考虑的因素包括应用的响应时间要求、吞吐量需求、可用内存大小以及系统的硬件配置。了解各个垃圾收集器的工作原理和特点,有助于在不同场景下进行优化配置。



垃圾收集算法实现

三色标记算法

在并发标记的过程中,因为用户线程还在运行当中,所以对象间的可能会发生变化,多标漏标的情况就又可能发生。

  • 黑色:表示对象已经被垃圾收集器访问过,且对象的所有引用都已经扫描过。如果有其他对象指向了黑色对象,不需要重新扫描,黑色对象不可能直接指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象至少存在一个引用还没被扫描过。
  • 白色:表示对象还没有被垃圾收集器访问过。刚开始的时候所有对象都是白色,如果再分析结束之后,对象依旧为白色,则说明该对象不可达

多标(浮动垃圾)

在并发标记过程中,如果由于方法运行结束导致部分局部变量(GC Root)被销毁,这个GC Root引用的对象之前又被扫描过(被标记为废垃圾对象),那么本轮GC不会回收这部分内存。这部分内存在本轮垃圾回收中应该被回收,但是却没有被回收,所以被称之为“浮动垃圾”,浮动垃圾并不会影响垃圾运行的正确性,只是需要等到下一轮垃圾回收才会被清除。


针对并发标记开之后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除,这部分对象期间可能也会变成垃圾,这也算是浮动垃圾的一部分。


漏标

漏标会导致引用对象当成垃圾被误删除的情况,这个问题非常严重。使用CPU的读写屏障来解决,主要的实现方法有两种:

  1. 增量更新(Incremental Update)

    当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了

  2. 原始快照(Snapshot At The Begin)

    原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)

以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。

写屏障

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/**
* @param field 某对象的成员变量,如 a.b.d 
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) { 
    *field = new_value; // 赋值操作
} 



// 实现写屏障之后
void oop_field_store(oop* field, oop new_value) {  
    pre_write_barrier(field);          // 写屏障-写前操作
    *field = new_value; 
    post_write_barrier(field, value);  // 写屏障-写后操作
}

写屏障实现STAB

1
2
3
4
void pre_write_barrier(oop* field) {
    oop old_value = *field;    // 获取旧值
    remark_set.add(old_value); // 记录原来的引用对象
}

写屏障实现增量更新

1
2
3
void post_write_barrier(oop* field, oop new_value) {  
    remark_set.add(new_value);  // 记录新引用的对象
}

写屏障SATB的实现源码:

G1SATBCardTableModRefBS::enqueue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
  // Nulls should have been already filtered.
  // 在调用该方法之前,所有的空引用应该已经被过滤掉。
  // 断言确保传入的引用不是空,并且是一个有效的对象指针。
  assert(pre_val->is_oop(true), "Error");

  // 如果SATB标记队列集未激活,则直接返回,不进行入队操作。
  if (!JavaThread::satb_mark_queue_set().is_active()) return;

  // 获取当前线程。
  Thread* thr = Thread::current();

  // 检查当前线程是否是Java线程。
  if (thr->is_Java_thread()) {
    // 将当前线程强制转换为Java线程类型。
    JavaThread* jt = (JavaThread*)thr;

    // 将pre_val引用入队到当前Java线程的SATB标记队列中。
    jt->satb_mark_queue().enqueue(pre_val);
  } else {
    // 如果当前线程不是Java线程,则使用全局锁保护共享的SATB队列。
    // 进入临界区,确保多线程环境下的安全。
    MutexLockerEx x(Shared_SATB_Q_lock, Mutex::_no_safepoint_check_flag);

    // 将pre_val引用入队到共享的SATB标记队列中。
    JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
  }
}

读屏障

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
oop oop_field_load(oop* field) {
    pre_load_barrier(field); // 读屏障-读取前操作
    return *field;
}


// 采用读屏障之后
void pre_load_barrier(oop* field) {  
    oop old_value = *field;
    remark_set.add(old_value); // 记录读取到的对象
}

不同垃圾收集器的处理

  • CMS:写屏障 + 增量更新
  • G1,Shenandoah:写屏障 + SATB
  • ZGC:读屏障

记忆集与卡表

在新生代做GC Root的可达性扫描的时候,可能会碰到跨带引用的对象,这时候如果再去扫描一遍老年代,那么代价就有点大了。

为此新生代引入了一种数据结构:Remember Set,记录了从非收集区域到收集区域的指针集合,避免把整个老年代都加入GC Root的扫描范围。

G1、 ZGC和Shenandoah收集器, 都会面临这样的问题。

hotspot使用一种叫做“卡表”(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比为Java语言中HashMap与Map的关系。

卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。

hotSpot使用的卡页是2^9大小,即512字节

img

一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.

GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。

卡表的维护

卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。

Hotspot使用写屏障维护卡表状态。

安全点与安全区域

安全点

安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。

这些特定的安全点位置主要有以下几种:

  1. 方法返回之前
  2. 调用某个方法之后
  3. 抛出异常的位置
  4. 循环的末尾

大体实现思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的。

安全区域

安全区域又是什么?

Safe Point 是对正在执行的线程设定的。

如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。

因此 JVM 引入了 Safe Region。

Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。




相关内容

0%