2021 G1新作

2021-01-24  本文已影响0人  西部小笼包

首先一些最基本的概念,可以参考我3年前写的G1 详解
看完那篇,如果你还有困惑,可以来看这篇更为深入和详细的介绍。不过那篇里提到的一些基础概念,需要你事先知道。不妨回答以下几个问题?

  1. GC的HEAP划分和之前的GC有什么不同?
  2. GC的YOUNG GC 是不是STW呢?
  3. GC的MIXED GC(并发标记过程)是用什么算法,会遇到什么问题
  4. 解决这个问题的STAB算法的手段是破坏了哪个条件
  5. RSET, CSET,CARD TABLE 之间的关系和有什么用?
    如果上述问题你都搞清楚了,说明之前那篇文章你已经掌握的很好了。

这一篇文章会更加深入的带你探索一下G1的世界。

目录

下面罗列了一些G1的核心名词:

新生代收集(前文的young gc)
混合收集(在本文中的意思,为真的混合收集,前文的MIXED GC是这里的并发标记的步骤 )
Full GC(如果混合收集还不足以回收垃圾的负担,会触发LONG STW的全量回收)
并发标记(前文的MIXED GC的步骤)
Refine (后台REFINE线程,用来做统计和处理dirty card queue set)
Evacuation (发现活跃对象,并将对象复制到新地址的过程。)

这些核心名词会作为文章的线索,贯穿文中。小伙们记得阅读途中琢磨一下上述几个概念。

G1的设计目标

CMS 长期的替代方案:为了实现这个目标,定了如下4个子目标

  1. CMS STW的时间不可控
  1. CMS吞吐量 比较占用cpu资源
  1. CMS调优参数非常庞大,调优困难
  1. CMS会造成内存碎片问题

G1 是怎么做垃圾回收的

G1的新生代回收比较简单粗暴。就是STW,然后用多个线程从根集开始用可达性分析,找到所有存活的对象。随后把他们复制到新的内存区域(新的survivor区)。

老年代的回收,不是那么直观。首先当总的内存使用率达到某个阈值会触发并发标记(也是前文提到的MIX GC,这篇文章之后都叫并发标记或OLD GC)


image.png

这里还有一个概念,这个并发标记,是piggyback于一个YOUNG GC的。


image.png
piggyback这个词很有意思,你百度图片搜一下 就知道什么含义了。
用具体的话说,每一次OLD GC前的第一步就是触发一次YGC,然后用YGC的输出(几个新的SURVIVOR区)作为OLD GC的根集合。在这个基础上做整个老年代的并发标记。

PPT里提到OLD GC 绝大多数时间耗费在并发标记, 因为这个过程不用STW,所以花多久对应用也是不会太有感知。
里面还有一个点是增量压缩,也就是说老年代的回收不是一个一次性的工作。它会被拆到好几次mixed gc 中(这里是真正的混合回收),每次MIXED GC 会回收全部的新生代和一部分老年代。
优先选择哪部分老年代,是会有REFINE线程做的统计信息(改HEAP REGION的垃圾占比有多少)去决定。优先选择那些垃圾多的区域回收,这样最经济。因为垃圾多,那么活跃对象少,那么只需要复制少量活对象,就可以释放这块HEAP REGION。 这也是G1的名字来源,garbage first。

下面我们来看下YOUNG GC的一些基础知识

YOUNG GC什么时候会被触发?

image.png

上面PPT的概念很好理解。因为它比较HIGH LEVEL。
不过有些文章里会提到一个TLAB的概念,这也是GC PERFORMANCE优化的一个手段。我们下面来说说这个TLAB。

通常我们所说的GC是指垃圾回收,但是在JVM的实现中GC更为准确的意思是指内存管理器,它有两个职能,第一是内存的分配管理,第二是垃圾回收。这两者是一个事物的两个方面,每一种垃圾回收策略都和内存的分配策略息息相关,脱离内存的分配去谈垃圾回收是没有任何意义的。

JVM堆是所有线程的共享区域。因此,从JVM堆空间分配对象时,必须锁定整个堆,以便不会被其他线程中断和影响。为了解决这个问题,TLAB试图通过为每个线程分配一个缓冲区来避免和减少使用锁。由于TLAB是属于线程的,不同的线程不共享TLAB,当我们尝试分配一个对象时,优先从当前线程的TLAB中分配对象,不需要锁,因此达到了快速分配的目的。

下图分区就是一个eden heap region. 各个线程会在这个里面先申请属于自己的一块TLAB,然后就可以在自己的TLAB里分配对象。


image.png

G1 的对象分配

快速分配

JVM快速分配TLAB对象流程

1.从线程的TLAB分配空间,如果成功则返回。

  1. 如果分配失败,则尝试先分配一个新的TLAB,再分配对象

JVM提供了参数 TLABSize来控制TLAB的大小,默认为0,JVM会自动推断这个值多大合适。

TLAB中的慢速分配

如果TLAB中的剩余空间很小(TLAB满了),说明这个空间通常不满足对象分配,可以直接丢弃,填充一个dummy对象,然后申请一个新的TLAB来分配对象。

如果TLAB剩余空间比较多,那就不能丢弃TLAB,这时候就直接将对象分配到堆中,不使用TLAB,直接返回。

image.png
慢速分配需要尝试对Heap加锁,扩展新生代区域或垃圾回收等处理后再分配。
·首先尝试对堆分区进行加锁分配,成功则返回。
·不成功,则判定是否可以对新生代分区进行扩展,如果可以扩展则扩展后再分配TLAB,成功则返回。
·不成功,判定是否可以进行垃圾回收,如果可以进行垃圾回收后再分配,成功则返回,在do_collection_pause完成。
·不成功,如果尝试分配次数达到阈值(默认值是2次)则返回失败。
·如果还可以继续尝试,再次判定是否进行快速分配,如果成功则返回。
·不成功重新再尝试一次,直到成功或者达到阈值失败。
所以慢速分配要么成功分配,要么尝试次数达到阈值后结束并返回NULL。
上述的慢速分配过程是TLAB的慢速分配,核心是去申请到新的TLAB空间,通过拓展新的EDEN REGION,和做YGC 或 MIXED GC来得到更多的空间。如果尝试失败后,会进入到直接分配进HEAP的慢速分配(不再自己的TLAB种分配了)

慢速分配

attempt_allocation尝试进行对象分配,如果成功则返回。
如果大对象,在attempt_allocatin_humongous分配,直接分配老年代.
如果分配不成功,则进行GC垃圾回收(主要是FullGC),然后再分配。
最终成功,或者尝试N次后失败,则分配失败。

G1 的对象分配对得总结

上述概念帮你梳理清楚,GC里的内存是如何布局的,什么时候会触发YOUNG GC, MIXED GC(需要申请TLAB块去分配,内存不够了,会触发)。 什么时候会触发FULL GC。(直接在堆中分配,还内存不够,说明之前已经努力尝试过YGC,MIXED GC,还不行,那只能FULL GC了)

g1新生代的大小

G1会根据预测时间动态改变新生代的大小。 来确保尽可能逼近你设置的STW参数。 所以每次垃圾回收后,会需要处理一些统计信息(每个区域有多少垃圾。处理现在这么多新生代的区域要花多久,和你设置的时间比是大了还是小了)。同时我们还有一些G1的数据结构(RSET, CARD TABLE)要维护。这里G1为了减少对应用的吞吐率影响。会单独开一类REFINE线程去处理,根据忙碌情况来决定REFINE线程的多少。如果REFINE线程用满了还是处理不过来,那么就会让应用线程一起帮忙。和现代管理学的思想还是很接近的。

在G1 要做YGC的时候,有一些新生代的对象,可能是被老年代的对象引用。 为了避免回收整个新生代,需要扫描所有老年代(为了找到新生代有没有被老年代的对象引用)。我们需要维护一个表,里面存了谁引用了我。这个就是RSET做的事情。


image.png

当然RSET要是直接存引用我的对象地址会非常占用内存。为了节约内存。G1把每一个内存区域按照一个CARD (512B)做划分。那么RSET里要存的东西其实是哪一个REGION的哪一张CARD就够了。具体要找到谁引用了我,需要在这个CARD中遍历。

一般我们发现老年代如果有对象引用了新生代,我们就认为这个老年代为根,我们不继续往上追溯去查看这个老年代的父亲或祖先是否被根集引用。所以这时可能会有浮动垃圾(错标,是垃圾的标为不是垃圾)
这个问题会在并发标记(OLD GC)的过程中被解决。FGC也可以把这些错标的给纠正出来

image.png

G1中使用Refine线程异步地维护和管理引用关系。因为要异步处理,所以必须有一个数据结构来维护这些需要引用的对象。
JVM在设计的时候,声明了一个全局的静态变量DirtyCardQueueSet(DCQS),DCQS里面存放的是DCQ,为了性能的考虑,所有处理引用关系的线程共享一个DCQS,每个Mutator(线程)在初始化的时候都关联这个DCQS。每个Mutator都有一个私有的队列,每个队列的最大长度由G1UpdateBufferSize(默认值为256)确定,即最多存放256个引用关系对象,在本线程中如果产生新的对象引用关系则把引用者放入DCQ中,当满256个时,就会把这个队列放入到DCQS中(DCQS可以被所有线程共享,所以放入时需要加锁),当然可以手动提交当前线程的队列(当队列还没有满的时候,提交时要指明有多少个引用关系)。而DCQ的处理则是通过Refine线程。

image.png

Refinement Zone
我们可以设置多个Refine线程工作,在不同的负载下启用的线程不同。这个工作负载就通过Refinement Zone控制。
G1提供3个值,Green,Yellow,Red,将整个Queue Set分为4个区。姑且称为白,绿,黄,红

[0,Green),对于该区,Refine线程不处理,交给GC线程来处理DCQ。

绿

[Green,Yellow),在该区中,Refine线程开始启动,根据Queue Set数值的大小启动不同的Refine线程来处理DCQ。
使用参数G1ConcRefinementThresholdStep来控制每个Refine线程消费队列的步长,如果不设置,则自动推断为Refine线程+1

[Yellow,Red),在该区中,所有的Refine线程(除了抽样线程)都参与DCQ处理。

[Red, + ∞),在该区中,不仅所有的Refine线程参与处理RSet,而且连Mutator线程也参与处理。

image.png

这3个值通过三个参数处理,默认值都为0,如果不设置,则G1自动推断三个值大小。
G1ConcRefinementGreenZone为ParallelGCThreads
G1ConcRefinementYellowZone 为 G1ConcRefinementGreenZone 的3倍
G1ConcRefinementRedZone为 G1ConcRefinementGreenZone 的6倍

REGINE 线程如何工作

Refine线程的初始化是在GC管理器初始化的时候进行。JVM通过wait和notify机制实现。
从 0 到 n-1 线程(n表示线程个数),当前一个线程发现自己太忙,则启动后面一个。
当线程发现自己太闲,则主动冻结自己。

第0个线程什么时候被激活?

当mutator线程尝试把DCQ放入DCQS时,如果发现0号线程没有被激活,则发送notify激活。
所以第0个线程是由任意mutator线程激活,1 到 n-1 线程只能由前一个refine线程激活。所以0号线程等待的monitor是个全局变量,而 1 到 n-1线程中的monitor是局部变量。

怎么更新RSET

RSet的更新流程简单总结就是:根据引用者找到被引用者,然后在被引用者的RSet中记录引用关系。
Refine线程执行的过程不会发生GC,所以不会产生对象的移动。
有可能过多的RSet更新会导致mutator很慢(mutator会主动帮忙Refine线程处理)

RSET的底层存储结构

我们可以使用RSet直接记录对象的地址,带来的问题就是RSet会急剧膨胀,一个位可以表示512个字节区域到被引用区的关系。RSet用分区的起始地址和位图表示一个分区所有的引用信息。

由于PointIn模式的缺点,一个对象可能被引用的次数不固定,为了节约空间,G1采用了三级数据结构来存储:

稀疏表:通过哈希表来存储,key是region index,value是card数组

细粒度PerRegionTable:当稀疏表指定region的card数量超过阈值时,则在细粒度PRT中创建一个对应的PerRegionTable对象,其包含一个C heap位图,每一位对应一个card

粗粒度位图:当细粒度PRT size超过阈值时,则退化为分区位图,每一位表示对应分区有引用到当前分区

image.png

我们假设在堆里初始了一个热门类。一开始有3,4个别的OBJECT引用过来。我们就朝这个类的RSET 稀疏表中 添加region 1-> [card1, card4], region 5 -> [card2, card8]
当有2位数个object引用过来了,这样存就太占空间了。比如region 1 -> [card1, card2,.....card30] 就会把这个region给抹除,同时把这条记录升级到细粒度 PerRegionTable来存。 region 1 -> bitmap(11111111011101111111...) 用一个bit来表示这个card 有没有引用我。来优化内存使用率
但是这个region heap的引用更多了,好多个region,都发生了这样的情况,存在了细粒度表。细粒度表里有 (region1 -> bitmap, region2->bitmap, .... region30->bitmap)
细粒度表的存储记录会被消除,统一升级到粗粒度表 ,会有一个bitmap(1111010101111110111)来表示所有region指向我的情况。这个时候我们会丢失这个REGION 哪个CARD 指向我的信息,但是因为这个REGION一定有很多CARD指向我,才会升级到粗粒度表。所以扫描整个REGION找到引用对象不会浪费太多CPU。

young gc 流程

image.png
image.png image.png
image.png

YHR表示young heap region
OHR表示old heap region

Obj1_YHR1.Field1 = obj4_OHR1;
Obj3_YHR2.Field1 = obj6_YHR1;
Obj5_OHR1.Field1 = obj2_YHR1;
Obj2_YHR1.Field1 = new Object;
Obj4_OHR1= NULL;
Obj5_OHR1 = NULL;

上述代表的内存结构如下:


image.png

第一步 root scan

GC发生时第一步就是选择收集集合CSet(表示这次GC回收哪些REGION),正如前面所说YGC会把所有的新生代分区加入到CSet,这里就是YHR1和YHR2。

根处理主要是从根出发,把活跃对象复制到新的分区,同时把对象的每一个field都加入到一个栈中(用于递归遍历),如下图所示。


image.png

根处理的过程是并行处理,并且不同的根负载可能不同,上文提到在处理的过程可能会发生负载均衡。
这里有三个对象Obj1_YHR1、obj2_YHR1和Obj3_YHR2可以从根直达。那么会将这三个对象复制到一个空白的分区中(空白分区中深颜色对象就是对象复制后的新位置)。复制结束后这些根的指针将会指向新的对象。同时老的对象里面的对象头也会发生改变,即对象头里面的指针会指向新的对象,且对象头的最后两位会被设置成11,表示该对象已经被标记。
上图中使用了三种类型连接线,其中实线表示原来对象的引用关系,虚线用于维持对象复制后新对象,点线是指因对象复制指向新位置对象的连接线。
对象Obj1_YHR1、Obj2_YHR1和Obj3_YHR2里面的field也会加入到栈中等待后续的进一步处理。

第二步 检查DIRTY CARD QUEUE里有没有没更新的任务

这里假设REFINE线程完成了任务,此时队列里为空, RSET都是最新状态

第三步 RSET 处理

image.png

在这一步中,根据前面对RSet的分析,因为Obj5_OHR1.Field1=obj2_YHR1,所以在YHR1的RSet中有一个指针指向卡表,这个卡表对应的是OHR1里面的一个512字节的区域。当处理到Obj5_OHR1,发现它对应的引用已经被复制,所以只需要更新指针即可。

第四步 复制

前面提到在处理根和RSet之后,对象里面field对应的对象会被放入一个栈中,所以在这一步,会处理在新分区YHR3里面的新对象Obj1_YHR3、obj2_YHR3和Obj3_YHR3,这里的处理指的是栈里面的对象地址。根据上图发现,只有Obj3_YHR3有一个字段指向Obj6_YHR1。另外要注意的是,Obj1_YHR3有一个字段指向老生代Obj4_OHR1的引用,但是这个对象不在CSet中,不需要处理。所以这一步只需要复制Obj6_YHR1,如下图所示。


image.png

Redirty的目的就是为了重构RSet,保证引用关系的正确性,我们发现因为对象发生了复制,此时Obj5_OHR1.Field1的引用指向Obj2_YHR3,相当于Obj5_OHR1.Field1=Obj2_YHR3,为了保持正确性,所以要重构RSet,如下图所示。


image.png

最后一步就是清空各种空间,把复制到的分区设置为Survivor等操作。所以最后整个堆空间的内存布局如下图所示。


image.png

OLD GC(并发标记)流程

并发标记算法是混合回收中最重要的算法。并发标记指的是标记线程和mutator线程并发运行。
并发标记算法设计了4个指针

  1. Bottom:底部位置
  2. Prev:指向上次并发处理后的地址
  3. Next:指向并发标记开始之前内存已经分配成功的地址
  4. Top:在并发标记开始后,如果有新的对象分配,可以移动top指针,使top指针指向当前内存分配成功的地址。

并发标记开始之前:


image.png

TAMS指的是Top-at-Mark-Start,并发标记结束后,NextBitMap标记了分区对象的存活情况。假定位图中黑色区域表示堆分区中对应的对象还活着,在并发标记的同时Mutator继续运行,所以Top会继续增长。

第二次标记开始,将NextBitMap值赋给PrevBitMap,将Next指针位置设置为Prev,将Top指针位置设置为Next指针。

并发标记结束状态


image.png

并发标记第二次开始前的状态


image.png

并发标记第二次结束状态


image.png

并发标记的问题

主要是GC在标记的时候,mutator线程可能正在改变对象的引用关系图,从而造成漏标和错标。

错标:不会影响程序正确性,只是会产生浮动垃圾
漏标:可能会导致可达对象被当成垃圾回收掉,从而影响程序的正确性。

STAB的写屏障

image.png

我们可以看到在赋值前,会有一个屏障,赋值后会有一个屏障


image.png

我们先看赋值前的
赋值前处理会调用G1SATBCardTableModRefBS:inline_write_ref_f ield_pre,这是一个模板方法,最终就是把要写的目标对象放入到STAB的队列中,代码如下所示

void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
  if (!JavaThread::satb_mark_queue_set().is_active()) return;
  Thread* thr = Thread::current();
  // 对于一般的Mutator直接放入到线程的队列中。
  if (thr->is_Java_thread()) {
    JavaThread* jt = (JavaThread*)thr;
    jt->satb_mark_queue().enqueue(pre_val);
  } else {
    // 对于本地代码则放入到全局共享队列中,因为是全局共享队列所以需要锁
    MutexLockerEx x(Shared_SATB_Q_lock, Mutex::_no_safepoint_check_flag);
JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
  }
}

赋值后处理主要是通过G1SATBCardTableLoggingModRefBS::write_ref_field_work完成,把源对象放入到dirty card队列,代码如下所示:

void write_ref_field_work(void* field,  oop new_val, bool release) {
// 这里是源对象的地址
  volatile jbyte* byte = byte_for(field);
  // 如果源对象是新生代则不处理,因为不需要记录到新生代的引用,新生代不管是在哪种回收中都
  // 会处理,所以不需要额外的记录。
  if (*byte == g1_young_gen) {
    return;
  }
  // 这里调用的storeload目的是为了保持数据的可见性
  OrderAccess::storeload();
  if (*byte != dirty_card) {
    *byte = dirty_card;
    Thread* thr = Thread::current();
    // 对于一般的Mutator直接放入到线程的队列中。
    if (thr->is_Java_thread()) {
JavaThread* jt = (JavaThread*)thr;
      jt->dirty_card_queue().enqueue(byte);
    } else {
    // 对于本地代码则放入到全局共享队列中,因为是全局共享队列所以需要锁
      MutexLockerEx x(Shared_DirtyCardQ_lock,
                      Mutex::_no_safepoint_check_flag);
      _dcqs.shared_dirty_card_queue()->enqueue(byte);
    }
  }
}

总结,就是一个写操作,写前可能会需要把原来的旧值丢进STAB的QUEUE中,之后把它们当做存活对象去标记。写后可能会把它们加入 DCQ中,用来给REFINE线程更新RSET

OLD GC的流程

image.png
image.png
image.png image.png

非空的老年代,会在下一次MIXED GC的时候,被垃圾回收(当然是选垃圾最多的先回收)

整个过程如下

  1. 首先触发一次YGC(piggy-back ygc),YGC会把EDEN+SURVIOR都复制到一些新的SURVIOR区。把这些新的SURVIOR区作为根,当然我们还要补上由根集直接指到OLD REGION的根。下面开始并发标记,标记过程用的三色标记算法,同时需要PrevBitMap, NextBitMap,来区分哪些是标记之后新创建出来的对象,以及标记的成果。
  2. 并发标记结束后,因为有写屏障,所以会有一部分数据在STAB QUEUE里。再标记(Remark)是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间,从STAB QUEUE里找出所有未被访问的存活对象,同时完成存活内存数据计算。
    如果不引入一个STW的再标记过程,那么应用会不断地更新引用,也就是说,会不断产生新的引用变更,STAB QUEUE里会一直有数据,因而永远也无法达成完成标记的条件。
  3. 清理
    再标记阶段之后进入清理子阶段,也是需要STW的。清理子阶段主要执行以下操作:

该阶段比较容易引起误解地方在于,清理操作并不会清理垃圾对象,也不会执行存活对象的拷贝。也就是说,在极端情况下,该阶段结束之后,空闲分区列表将毫无变化,JVM的内存使用情况也毫无变化。

  1. 未来的MIXED GC


    image.png

混合回收实际上与YGC是一样的:第一个步骤是从分区中选出若干个分区进行回收,这些被选中的分区称为Collect Set(简称CSet),MIXED GC中会加入几个老年代的分区和所有新生代的分区作为CSET;第二个步骤是把这些分区中存活的对象复制到空闲的分区中去,同时把这些已经被回收的分区放到空闲分区列表中。垃圾回收总是要在一次新的YGC开始才会发生的。

OLD GC 过程演示

初始标记是借助YGC阶段完成,这里我们仅仅关心和并发标记相关的部分。
图中,F-Free Region;S-Survivor Region;E-Eden Region;Old-Old Region。


image.png

在YGC阶段时,首先判定是否需要进行并发标记,如果需要,在对象复制阶段当发现有根集合直接到老生代的引用,那么这些对象会在YGC阶段被标记,如图中nextMarkBitmap所示。

根扫描主要是针对Survivor分区进行处理,所有的Survivor对象都将被认为是老生代的根,如下图所示。
注意这一阶段仅仅对Survivor里面的对象标记,而不会处理对象的field。图中深色区域是新增的活跃对象对应的区域。


并发标记根扫描

并发标记子阶段是并发执行的,主要处理SATB队列,然后选择分区,根据nextMarkBitmap中已经标记的信息,对标记对象的每一个field指向的对象递归地进行标记。
在下图中SATB队列的处理可能会涉及所有的分区,然后根据分区递归处理已经标记的对象的Field,直到所有的分区处理完毕。


并发标记结束的状态

再标记子阶段是并行执行的,主要是处理SATB,如下图所示。


再标记结束的状态

在清理子阶段主要的事情有:分区计数,如果有空的老分区或者大对象分区,则释放;把Old分区加入CSet Chooser。
并发标记的结果其实就是把垃圾比较多的老生代分区加入到CSet Chooser,那么标记的时候为什么要对整个堆的所有分区逐一标记?实际上是为了正确性,如果并发标记不处理新生代,可能导致老生代的活跃对象被误标记。清理阶段结束状态如下图所示。

image.png image.png

到目前为止,我们介绍了Refine线程、YGC线程,在本章中还涉及并发线程。我们知道并发标记是依赖于YGC,即并发标记发生前一定有一次YGC。在并发标记结束之后,会更新CSet Chooser,此时如果在发生GC,则判断是否能够进行混合GC,混合GC的条件是上次发生的YGC不包含初始标记,并且CSet Chooser包含有效的分区。如果符合条件混合GC就会发生,注意混合GC不一定能在一次GC操作中完成所有的待收集的分区,所以混合GC可能发生多次,直到CSet Chooser中没有分区为止。图6-15是GC整体的活动图。活动图中仅涉及Refine线程、并发标记线程、GC线程和Mutator(应用线程)


image.png

其中黑色箭头表示Refine线程,可以看到这些线程会一直运行(除了被GC线程中断)。双线空心箭头是并发标记线程,只有在YGC发生,且并发条件IHOP满足之后,才会开始执行,并发标记中有两个STW阶段:再标记和清理子阶段。浅灰色箭头表示的是Mutator运行。

FGC

当对象分配失败,会进入到Evac失败过程,在GC日志详情中会打印相关信息。发生失败一般意味着不能继续分配,此时需要做两件事:
·处理失败。
·再次尝试分配,仍不成功,进行Full GC(FGC)。
Java 10之前是串行FGC;及Java 10引入的并行FGC。


image.png
上一篇下一篇

猜你喜欢

热点阅读