JVM经典垃圾回收器的运行机制和原理

1 JVM运行时内存划分

1.1 五大内存区域

JVM经典垃圾回收器的运行机制和原理_第1张图片
  • 方法区

    属于共享内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

    运行时常量池,属于方法区的一部分,用于存放编译期生成的各种字面量和符号引用。

    JDK1.8之前,Hotspot虚拟机对方法区的实现叫做永久代,1.8之后改为元空间。二者区别主要在于永久代是在JVM虚拟机中分配内存,而元空间则是在本地内存中分配的。很多类是在运行期间加载的,它们所占用的空间完全不可控,所以改为使用本地内存,避免对JVM内存的影响。

    根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

  • 线程共享,主要是存放对象实例和数组。

    如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

    PS:实际上写入时并不完全共享,JVM会为线程在堆上划分一块专属的分配缓冲区来提高对象分配效率。详见:TLAB

  • 虚拟机栈

    线程私有,方法执行的过程就是一个个栈帧从入栈到出栈的过程。

    每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

    如果线程入栈的栈帧超过限制就会抛出StackOverFlowError,如果支持动态扩展,那么扩展时申请内存失败则抛出OutOfMemoryError

  • 本地方法栈

    和虚拟机栈的功能类似,区别是作用于Native方法。

  • 程序计数器

    线程私有,记录着当前线程所执行的字节码的行号。其作用主要是多线程场景下,记录线程中指令的执行位置。以便被挂起的线程再次被激活时,CPU能从其挂起前执行的位置继续执行。

    唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

    注意:如果线程执行的是个java方法,那么计数器记录虚拟机字节码指令的地址。如果为native(底层方法),那么计数器为空。

1.2 对象的内存布局

在 HotSpot 虚拟机中,对象分为如下3块区域:

  • 对象头(Header)

    运行时数据:哈希码、GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳等。

    类型指针:对象的类型元数据的指针,如果对象是数据,还会记录数组长度。

  • 对象实例数据(Instance Data)

    包含对象真正的内容,即其包括父类所有字段的值。

  • 对齐填充(Padding)

    对象大小必须是是8字节的整数倍,所以对象大小不满足这个条件时,需要用对齐填充来补齐。

2 标记的方法和流程

2.1 判断对象是否需要被回收

要分辨一个对象是否可以被回收,有两种方式:引用计数法可达性算法

  • 引用计数法

    就是在对象被引用时,计数加1,引用断开时,计数减1。那么一个对象的引用计数为0时,说明这个对象可以被清除。

    这个算法的问题在于,如果A对象引用B的同时,B对象也引用A,即循环引用,那么虽然双方的引用计数都不为0,但如果仅仅被对方引用实际上没有存在的价值,应该被GC掉。

  • 可达性算法

    通过引用计数法的缺陷可以看出,从被引用一方去判定其是否应该被清理过于片面,所以我们可以通过相反的方向去定位对象的存活价值:一个存活对象引用的所有对象都是不应该被清除的(Java中软引用或弱引用在GC时有不同判定表现,不在此深究)。这些查找起点被称为GC Root

2.2 哪些对象可以作为GC Root呢?

  1. JAVA虚拟机栈中的本地变量引用对象

  2. 方法区中静态变量引用的对象

  3. 方法区中常量引用的对象

  4. 本地方法栈中JNI引用的对象

3.3 GC时如何快速找到GC Root呢?

Hotspot把所有GC Root对象都存放在OopMap中,当GC发生时,直接从这个map中寻找GC Root。

将GC Root存放到OopMap有两个触发时间点:

  1. 类加载完成后,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来
  2. 即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用

3.4 并发可达性分析

三色标记法

白色:表示垃圾回收过程中,尚未被垃圾收集器访问过的对象,在可达性分析开始阶段,所有对象都是白色的,即不可达

黑色:被垃圾收集器访问过的对象,且这个对象所有的引用均扫描过。黑色的对象是安全存活的,如果其他对象被访问时发现其引用了黑色对象,该黑色对象也不会再被扫描。

灰色:被垃圾收集器访问过的对象,但这个对象至少有一个引用的对象没有被扫描过。

那么标记阶段就是从GC Root的开始,沿着其引用链将每一个对象从白色标记为灰色最后标记为黑色的过程。

标记过程中不一致问题

由于这个阶段是层层递进的标记,所以过程中难免出现不一致的情况导致原本是黑色的对象被标记为白色,比如,当前扫描到B对象了,C对象尚未被访问时,标记情况如下:

GC Root
A
B
C

那么如果这时A对象取消了对B对象的引用,而GC Root增加了对C对象的引用,GC Root作为黑色标记不会再次被扫描,那么C对象在标记阶段结束后仍然会保持白色,就会被清除掉。

GC Root
A
B
C

为了解决上述问题有两种方式:

  • 增量更新

    当黑色对象增加了对白色对象的引用时,将其从黑色改为灰色,等并发标记阶段结束后,从GC Root开始顺着对象图再将灰色对象重新扫描一次,这个扫描过程会STW,不会再次产生不一致问题。CMS就采用了这种方式。

  • 原始快照(SATB)

    当灰色对象删除了白色对象的引用时,将其记录在线程独占的SATB Queue中,让其在标记阶段结束后被再次扫描。 G1、Shenandoah采用了这种方式。

我们通过一个例子来展示两种处理方式的不同,比如正常标记到对象A时,将其标记为灰色:

GC Root
A
B
C

用户线程发生如下行为:

  1. GC Root直接引用了C
  2. A取消了引用B

理论上,C仍然是可达对象,不应被清除,而B不可达,应当被清除。

GC Root
A
B
C

增量更新会记录行为1,将GC Root标记为灰色,B不能访问到被标记为可以回收

GC Root
A
B
C

等到重新标记阶段再次访问灰色的GC Root,顺序将GC Root和C标记为黑色:

GC Root
A
B
C

而原始快照会记录行为2,将发生引用变化的对象全部记录下来,等到重新标记阶段再次访问这些灰色,将其标记为黑色并顺着对象图扫描。

GC Root
A
B
C
GC Root
A
B
C

那么最终B作为浮动垃圾就被保存下来了,只能等到下一次GC时才能被回收。

3 内存分代模型

3.1 分代假说

  1. 弱分代假说(WeakGenerationalHypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(StrongGenerationalHypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
  3. 跨代引用假说(IntergenerationalReferenceHypothesis):跨代引用相对于同代引用来说仅占极少数。

上述假说是根据实际经验得来的,由此垃圾收集器通常分为“年轻代”和“年老代”:

  • 年轻代用来存放不断生成且生命周期短暂的对象,收集动作相对高频
  • 年老代用来存放经历多次GC仍然存活的对象,收集动作相对低频

3.2 空间分配担保

如果在GC后新生代存货对象过多,Survivor无法容纳,那么将会把这些对象直接送入年老代,这就叫年老代进行了“分配担保”。
为了保证年老代能够足够空间容纳这些直接晋升的对象,在发生Minor GC之前,虚拟机必须先检查年老代最大可用的连续空间,如果大于新生代所有对象总空间或者历次晋升的平均大小,就会进行MinorGC,否则将进行FullGC以同时清理年老代。

3.3 记忆集和卡表

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

记忆集的作用

新生代发生垃圾收集时(Minor GC),如果想确定这个新生代对象是否被年老代的对象引用,则需要扫描整个年老代,成本非常高。

如果我们能知道哪一部分年老代可能存在对新生代的引用,就可以降低扫描范围。

所以我们可以在新生代建立一个全局数据结构叫“记忆集(Remembered Set)”,这个结构把年老代分为若干个小块,标记了哪些小块内存中存在引用了新生代对象的情况,等到Minor GC时,只扫描这部分存在跨代引用的内存块即可。虽然在对象变化时增加了维护记忆集的成本,但相比垃圾收集时扫描整个年老代来说是值得的。

JVM通常在对象增加引用前设置写屏障判断是否发生跨代引用,如果有跨代情况,则更新记忆集。

卡表的定义

实现记忆集时,可以有不同精度的粒度:可以指向内存地址,也可以指向某个对象,或者指向某一块内存区域。精度越低,维护成本越低。指向某一块内存区域的实现方式就是“卡表”。卡表通常就是一个byte数组,数组中每一个元素代表某一块内存,其值是1或者0:当发生跨代引用时,就表示该元素“dirty”了,那么将将其设置为1,否则就是0。

JVM经典垃圾回收器的运行机制和原理_第2张图片

4 垃圾回收算法

4.1 标记-清除(Mark-Sweep)

GC分为两个阶段,标记和清除。首先标记所有可回收的对象,在标记完成后统一回收所有被标记的对象。

缺点是清除后会产生不连续的内存碎片。碎片过多会导致以后程序运行时需要分配较大对象时,无法找到足够的连续内存,而不得已再次触发GC。

JVM经典垃圾回收器的运行机制和原理_第3张图片

4.2 标记-复制(Mark-Copy)

将内存按容量划分为两块,每次只使用其中一块。当这一块内存用完了,就将存活的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。这样使得每次都是对半个内存区回收,也不用考虑内存碎片问题,简单高效。

JVM经典垃圾回收器的运行机制和原理_第4张图片

缺点需要两倍的内存空间。

一种优化方式是使用eden和survivior区,具体步骤如下:

eden和survivior区默认内存空间占比为8:1:1,同一时间只使用eden区和其中一个survivior区。标记完成后,将存活对象复制到另一个未使用的survivior区(部分年龄过大的对象将升级到年老代)。这样,相比普通的两块空间的标记复制算法来说,只有10%的内存空间浪费,而这样做的原因是:大部分情况下,一次young gc后剩余的存活对象非常少

heap

4.3 标记-整理(Mark-Compact)

标记-整理也分为两个阶段,首先标记可回收的对象,再将存活的对象都向一端移动,然后清理掉边界以外的内存。

JVM经典垃圾回收器的运行机制和原理_第5张图片

此方法避免标记-清除算法的碎片问题,同时也避免了复制算法的空间问题。 一般年轻代中执行GC后,会有少量的对象存活,就会选用复制算法,只要付出少量的存活对象复制成本就可以完成收集。

而年老代中因为对象存活率高,用标记复制算法时数据复制效率较低,且空间浪费较大。所以需要使用标记-清除或者标记-整理算法来进行回收。

所以通常可以先使用标记清除算法,当碎片率高时,再使用标记整理算法。

5 垃圾收集器

JVM经典垃圾回收器的运行机制和原理_第6张图片

5.1 串行收集器(Serial)

比较老的收集器,单线程,所收集时必须暂停应用的工作线程,直到收集结束。但和其他收集器的单线程相比更加简单、高效

作用于新生代的收集器叫Serial,采用标记复制算法;作用于年老代的收集器叫Serial Old,采用标记整理算法。

5.2 并行收集器(Parallel)

多条垃圾收集线程并行工作,在多核CPU下效率更高,但应用线程仍然处于等待状态。

并行收集器也分为ParNewParallel Old。可以理解为它们就是Serial和Serial Old的多线程并行版本,甚至部分代码进行了复用。

ParNew较为流行的原因是因为除了Serial只有它能和CMS搭配使用。但自JDK9开始,由于更先进的G1的出现,官方直接取消了单独指定ParNew的参数-XX:+UseParNewGC,使其并入了CMS收集器,成为它专门处理新生代的组成部分。

而Parallel Old则搭配新生代收集器ParallelScavenge成为名副其实的“吞吐量优先”的搭配组合。

5.3 ParallelScavenge

ParallelScavenge收集器是面向新生代的垃圾收集器,它和ParNew其实非常类似,使用标记复制算法并行收集。区别在于二者关注点不同,ParalletScavenge的目标是达到一个可控制的吞吐量(Throughput),更高的吞吐量意味着最大限度的使用处理器的资源来缩短整体的垃圾回收时间。ParalletScavenge有两个重要参数:

  • -XX:MaxGCPauseMillis

    收集器将尽力保证内存回收花费的时间不超过用户设定值。但这是以牺牲吞吐量为代价的,要求用更短的时间来完成垃圾收集,那么系统就需要降低新生代大小,新生代变小了自然垃圾回收会更加频繁,每次垃圾回收都有很多必要工作(比如等待所有线程到达安全点),那么更频繁的垃圾回收就导致了整体吞吐量的降低。

  • -XX:GCTimeRatio

    GCTimeRatio是垃圾收集时间占总时间的比率,换句话说:其表示运行用户代码时间是GC运行时间的X倍。比如默认为99,则垃圾收集时间占比应该1/(1+99)。这个数越低,运行用户代码时间占比越低。

ParallelScavenge收集器还可以通过参数(-XX:+UseAdaptiveSizePolicy)来激活自适应调节策略。激活后,就不需要人工指定新生代的大小(Xmn)、Eden与Survivor区的比例(XX:SurvivorRatio)、晋升年老代对象大小(XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

5.4 CMS收集器(Concurrent Mark Sweep)

CMS收集器是缩短暂停应用时间(Low Pause)为目标而设计的,最开始CMS仅仅是年老代收集器,后来将ParNew并入作为其年轻代收集器。

相较上述收集器,CMS是第一个无需全程STW而允许部分阶段并发执行的收集器。垃圾回收实际上主要是两个阶段:识别垃圾和回收垃圾,CMS在这两个阶段都做了努力来降低停顿:

  1. 首先是识别垃圾,CMS将标记过程打散,并将主要的染色标记过程和用户线程同步进行,并通过增量更新方式解决了引用切换带来的漏标的问题。
  2. 对于垃圾回收,CMS采用清除算法,相比复制和整理,清除算法由于仅处理死亡对象所以不需要任何停顿。

具体来说,CMS整个过程分为4个步骤:

  • 初始标记(Initial Mark)[STW]

    初始标记只是标记一下GC Roots能直接关联到的对象,速度很快。

  • 并发标记(Concurrent Marking)

    并发标记阶段是标记可回收对象。

  • 重新标记(Remark)[STW]

    重新标记阶段则是为了修正并发标记期间因用户程序继续运作导致标记产生变动的那一部分对象的标记记录,这个阶段暂停时间比初始标记阶段稍长一点,但远比并发标记时间短。

    CMS用增量更新来做并发标记,也就是说并发标记过程中,如果某个已经标记为存活的对象增加了对非存活对象的引用,那么将其标记为灰色,然后在重新标记阶段将这一部分对象重新扫描。

  • 并发清除(Concurrent Sweep)

    清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

优点:

由于整个过程中消耗最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,CMS收集器内存回收与用户一起并发执行的,大大减少了暂停时间。

缺点:

  1. 处理器资源敏感,垃圾回收的线程能够与用户线程同时执行,这样虽然不会导致STW,但是由于分摊了处理器的计算资源从而导致应用程序变慢,降低了总吞吐量。
  2. 内存敏感,当垃圾回收和用户线程在同步运行时产生的垃圾,由于已经过了标记阶段所以不会标记后清除,这部分垃圾只能等到下一次GC时才会被清除,这就是浮动垃圾问题。而且由于垃圾回收和用户线程同步运行,所以不能等堆满了再GC,而是需要预留一部分内存来保证GC过程中用户线程仍有可用内存。为了降低GC频率,只能等垃圾攒多一点再触发GC,那么GC时可供用户线程使用的内存就不多了。如果GC尚未结束用户线程分配内存失败,这个情况叫做“并发失败”,这时虚拟机会降级使用Serial Old来重新进行一次高吞吐的年老代收集,这样停顿时间就长了。线上环境应根据实际情况来调整触发GC的内存使用阈值,该参数为:-XX:CMSInitiatingOccupancyFraction
  3. CMS基于标记清除算法,所以内存碎片过多后,会频繁触发Full GC,且不可避免。CMS会在若干次触发后进行一次内存碎片的合并整理,内存整理过程涉及存活对象的移动,(在Shenandoah和ZGC出现前)无法并发。

5.5 G1收集器(Garbage First)

G1收集器相比上述垃圾收集器有了里程碑式的创新,它将堆内存划分多个大小相等的独立区域(Region),并且能建立“停顿时间模型”,使暂停时间可控,并尽量将-XX:MaxGCPauseMillis(默认200ms)作为停顿目标。根据Oracle官网的描述,G1是一个“软实时”的收集器,只是尽量保证在目标停顿时间内完成垃圾收集工作,但不能确保一定:

It is important to note that G1 is not a real-time collector. It meets the set pause time target with high probability but not absolute certainty.

能预测的原因是它能避免对整个堆进行全区收集,而是将整个堆分为若干个小的区域(Region),每个Region是单次垃圾回收的最小单元。在系统运行过程中,G1跟踪各个Region里的垃圾堆积价值大小(所获得空间大小以及回收所需时间),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,从而保证了再有限时间内获得更高的收集效率。这也是Garbage First名称的由来。

G1的分代模型

G1也分为年轻代和年老代,但不是固定划分,而是每个Region根据运行情况动态划分。

G1还有一个特殊的区域叫Humongous,G1将超过了一个Region容量一半的大对象,都存放在Humongous区域中,如果对象超过了Region大小,则存放在N个连续的Humongous Region中。G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

JVM经典垃圾回收器的运行机制和原理_第7张图片

TAMS(Top at mark start)

为了保证垃圾回收过程中的同时Region也能够被使用,G1为每一个Region设计了两个名为TAMS的指针,分别是Previous TAMS(PTAMS)、Next TAMS(NTAMS)。在并发标记阶段开始前,TAMS指针指向Region内占用内存的边界。在并发标记阶段中,G1默认指针之上的对象为存活对象不去进行标记,而对象分配时,用户线程直接在指针之上分配。这就保证了扫描行为和对象分配互不干扰。

JVM经典垃圾回收器的运行机制和原理_第8张图片

G1如何判定Region的“价值”

G1运行期间会收集每个Region的价值信息,比如回收耗时、记忆集的脏卡数量等,通过计算得出每个Region回收的性价比。G1的停顿预测模型就是通过这些信息,找出在用户预期时间内获得更高回收收益的Region组合。

Remembered Sets

G1堆中的每一个Region都有一份Rememberd Set,也叫RSet,它的作用就是为每一个Region记录哪些Region对其含有引用。

JVM经典垃圾回收器的运行机制和原理_第9张图片

RSet的更新需要线程同步处理,由于对象引用变更非常频繁,如果同步写卡表消耗非常大,所以通常会把更新信息存入队列中再异步更新RSet,这个队列就叫Dirty Card Queue

G1的垃圾回收过程

JVM经典垃圾回收器的运行机制和原理_第10张图片

当Eden中无法分配对象时,触发Young GC。

当年老代占比到达45%时,等待下一次Young GC时进行并发标记。

并发标记结束后马上执行Mixed GC。

当Mixed GC对内存的清理速度赶不上分配新对象的速度时触发Full GC,G1的Full GC将使用单线程(JDK11后改为多线程)执行标记整理算法,所以耗时巨大。

G1的Young GC

触发时机

当JVM无法在Eden区分配对象时。

回收范围

Eden区和Survivor区

运行过程

  1. 根扫描

    将所有Eden区中的GC Root和RSet记录的外部引用作为扫描存活对象的入口。

  2. 更新RSet

    通过Dirty Card Queue中的card更新RSet,保证RSet能准确反应老年代对该Region是否存在引用。

  3. 处理RSet

    将Eden区中被RSet指向的对象标记为存活对象。

  4. 对象复制

    判断存活对象的年龄,如果未达到“阈值”,则复制到一个Surviver区中,否则复制到Old区中。如果Surviver空间不够,则将部分对象直接复制到Old区中。

  5. 处理引用

    处理软引用、弱引用、虚引用等,最终清空全部Eden区。这时清理过的内存空间没有内存碎片。

G1的Mixed GC

触发时机

年老代占用空间超过整个堆的45%(可通过参数-XX:InitiatingHeapOccupancyPercent进行设置)

事实上,并不会立刻触发,而且等待下一次Young GC,同步进行初始标记步骤。

回收范围

被并发标记过的Region,这些Region是G1通过价值测算动态选中的。

运行过程

  1. 初始标记(Initial Marking)[STW]

    标记GC Roots直接关联的对象,并修改TAMS指针的值。

    值得注意的是,这一阶段并不单独执行,而是在Minor GC时同步完成。所以实际上这个阶段没有额外停顿。

  2. 并发标记(Concurrent Marking)

    与用户线程并发执行,顺着GC Root递归标记。

    标记完成后,重新扫描SATB记录的有引用变动的对象。

    如果这时发现空的Region则直接将其清空。

  3. 重新标记(Remark)[STW]

    由于并发标记是并发执行,并发标记结束后,仍然存在少量的引用变动的对象,所以在这个阶段可以STW来处理这部分遗留的对象。

    并且开始计算所有Region的活跃度。

  4. 清理(Clean Up)[STW]

    根据用户期望的停顿时间来制定回收计划,选择全部是非存活对象的Old区和回收收益较高的Region加入回收集。

    清空记忆集。

    重置已经被清理的空的Region(这一步是非STW的)。

  5. 拷贝(Coping)[STW]

    将回收集其中的存活对象复制到空的Region中,最后清空这些旧的Region。

    这个阶段的算法和Young GC完全一致,但默认分8次执行完成(可由参数-XX:G1MixedGCCountTarget设置)。所以每次清理的回收集包括Eden区、Survivor区和八分之一的Old区。低存活度(垃圾多)的Region清理的较快,所以会被G1优先回收。

    混合回收并不一定要进行8次。有一个阈值-XX :G1HeapWastePercent(默认值为10%),意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。

优点

G1相比较之前的垃圾收集器最大的变化是通过化整为零的思路,将堆分为若干个小的Region来减少GC的范围,从而达到“低延迟”的目的。

并且G1的垃圾回收过程采用标记复制的算法,避免了空间碎片化的问题。

缺点

  1. 内存占用较高,由于G1分区比CMS更多,每个Region都需要建立卡表。其中新生代对象变动频繁,又加大了卡表维护的成本。
  2. G1不仅需要通过写前屏障来更新卡表,还需要写后屏障来跟踪并发时的指针变化以实现快照搜索算法(SATB)。这样虽然相比增量更新算法能够减少并发标记和重新标记阶段的消耗,但是用户程序运行时的计算负载就高了。
  3. G1和CMS同样具有“并发回收”的能力,所以垃圾回收的速度如果跟不上用户创建新对象的速度,那么就会触发一个Full GC来获取更多内存。通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

最佳实践

  1. 不要设置年轻代大小

    年轻代大小应当由G1自行控制,设置为固定值将覆盖暂停时间目标

  2. 暂停时间目标不要过于严苛

    G1为了Young GC能够缩短时间需要减少Eden区的个数,那么Young GC就会更加频繁。

    Mixed GC想要达到停顿目标就需要减少回收的垃圾数量,如果回收速度低于新对象分配速度将引起Full GC。

  3. CMS和G1的选择

    目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间。

参考:

  1. 《深入理解Java虚拟机》by 周志明
  2. 《Getting Started with the G1 Garbage Collector》by Oracle

欢迎访问原文地址来阅读最新版本
转载请注明出处:https://kang.fun/gc
个人博客:kang.fun

你可能感兴趣的:(java,jvm,java)