JVM垃圾回收机制

发布时间 2023-12-21 11:26:15作者: 肖德子裕

JVM垃圾回收机制

JVM垃圾回收机制术语

回收机制:在Java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫描那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

回收时机:当对象对当前使用这个对象的应用程序变得不可触及的时候,这个对象就可以被回收了。垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。

GC:GC是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java提供的GC功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java语言没有提供释放已分配内存的显示操作方法。对于GC来说,当程序员创建对象时,GC就开始监控这个对象的地址、大小以及使用情况。通常,GC采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是可达的,哪些对象是不可达的。当GC确定一些对象为不可达时,GC就有责任回收这些内存空间。程序员可以手动执行System.gc()通知GC运行,但是Java语言规范并不保证GC一定会执行。

Minor GC(ˈmaɪnər):是新生代GC,指的是发生在新生代的垃圾收集动作。由于Java对象大都是朝生夕死的,所以Minor GC非常频繁,一般回收速度也比较快,一般采用复制算法回收垃圾。Eden区满时,触发MinorGC,即申请一个对象时,发现Eden区不够用,则触发一次MinorGC。

Major GC(ˈmeɪdʒər):是老年代GC,指的是发生在老年代的GC,通常执行Major GC会连着Minor GC一起执行。Major GC的速度要比Minor GC慢的多,可采用标记清除法和标记整理法。Major GC触发通常是跟full GC是等价的,如永久代空间不足或执行System.gc()。

Full GC:清理整个堆空间,包括年轻代和老年代和永久代。因为Full GC是清理整个堆空间,所以Full GC执行速度非常慢,在Java开发中最好保证少触发Full GC。

GC Roots:虚拟机栈的栈帧的局部变量表所引用的对象、本地方法栈的JNI所引用的对象、方法区的静态变量和常量所引用的对象。

可达对象:能与GC Roots构成连通图的对象,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可达的。

JNI:JNI是Java Native Interface的缩写,它提供了若干的API实现了Java和其他语言的通信(主要C&C++)

JVM垃圾回收算法

引用计数法(已淘汰)

1)原理:假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计数器的值为0,就说明对象A没有引用了,可以被回收。

2)优点:实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报outofmember错误。区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。

3)缺点:每次对象被引用时,都需要去更新计数器,有一点时间开销。浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。最大的缺点是无法解决循环引用问题。

/**
 * 循环引用案例(引用计数算法最大缺陷)
 * a、b计数都是为1,即使为空无法回收
 */
@Test
public void jvmTest() {
    TestA a = new TestA();
    TestB b = new TestB();
    a.b = b;
    b.a = a;
    a = null;
    b = null;
}

class TestA {
    public TestB b;
}

class TestB {
    public TestA a;
}

标记清除法(广泛使用)

程序运行期间所有对象的状态:

image-20221008143305652

标记完以后对象的状态:

image-20221008143451889

方法说明:

1)原理:标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
标记:从根节点GC Roots开始标记引用的对象。
清除:未被标记引用的对象就是垃圾对象,可以被清理。

上面这张图代表的是程序运行期间所有对象的状态,它们的标志位全部是0(也就是未标记,以下默认0就是未标记,1为已标记),假设这会儿有效内存空间耗尽了,JVM将会停止应用程序的运行并开启GC线程(不停止,引用会动态变化),然后开始进行标记工作,按照根搜索算法,标记完以后,对象的状态如下图。可以看到,按照算法,所有从root对象可达的对象就被标记为了存活的对象,此时已经完成了第一阶段标记。接下来,就要执行第二阶段清除了,那么清除完以后,可以看到,没有被标记的对象将会回收清除掉,而被标记的对象将会留下,并且会将标记位重新归0。接下来就会唤醒停止的程序线程,让程序继续运行即可。

2)优点:可以看到,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。 

3)缺点:效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。通过标记清除算法清理出来的内存,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。

标记压缩法(标记整理法)

image-20221008154114337

方法说明:

1)原理:标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的清理未标记的对象,而是将存活的对象压缩到内存的一端,然后清理边界以外的垃圾,从而解决了碎片化的问题。

2)优缺点:优缺点同标记清除算法,解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。

复制算法

image-20221008153620156

方法说明:

1)原理:复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。

2)优点:在垃圾对象多的情况下,效率较高,清理后,内存无碎片。

3)缺点:在垃圾对象少的情况下,不适用,如:老年代内存。分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低。

分代算法

分代算法是根据回收对象的特点进行选择,在JVM中,年轻代适合使用复制算法,老年代适合使用标记清除或标记压缩算法。在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片,所以一般在老年代使用标记压缩算法。

分代处理流程:
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的1/3,老生代的默认占比是2/3。新生代使用的是复制算法,新生代里有3个分区:Eden、To Survivor、From Survivor,它们的默认占比是8:1:1,它的执行流程如下:
1)把Eden+From Survivor存活的对象放入To Survivor区;
2)清空Eden和From Survivor分区;
3)From Survivor和To Survivor分区交换,From Survivor变To Survivor,To Survivor变From Survivor。
4)每次在From Survivor到To Survivor移动时都存活的对象,年龄就+1,当年龄到达 15(默认配置是15)时,升级为老生代。大对象也会直接进入老生代。
5)老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。

JVM垃圾回收器

垃圾回收器类别

image-20221010103246779

垃圾回收器类别说明:

垃圾回收器是垃圾回收算法(标记清除法、标记整理法、复制算法、分代算法)的具体实现,不同垃圾收集器、不同版本的JVM所提供的垃圾收集器可能会有很在差别。

1)Serial
工作区域:新生代
回收算法:复制算法
工作线程:单线程
用户线程并行:否
描述:Client模式下默认新生代收集器,简单高效。新生代单线程收集器,标记和清理都是单线程,优点是简单高效。

2)ParNew
工作区域:新生代
回收算法:复制算法
工作线程:多线程
用户线程并行:否
描述:Serial的多线程版本,Server模式下首选,可搭配CMS的新生代收集器。新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。

3)Parallel Scavenge
工作区域:新生代
回收算法:复制算法
工作线程:多线程
用户线程并行:否
描述:目标是达到可控制的吞吐量。新生代并行收集器,追求高吞吐量,高效利用CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景。

4)Serial Old
工作区域:老年代
回收算法:标记整理
工作线程:单线程
用户线程并行:否
描述:Serial老年代版本,给Client模式下的虚拟机使用。

5)Parallel Old
工作区域:老年代
回收算法:标记整理
工作线程:多线程
用户线程并行:否
描述:Parallel Scavenge老年代版本,吞吐量优先。

6)CMS(Concurrent Mark Sweep)
工作区域:老年代
回收算法:标记清除
工作线程:多线程
用户线程并行:否
描述:老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。

7)G1(Garbage First)
工作区域:新生代、老年代
回收算法:复制算法、标记整理
工作线程:多线程
用户线程并行:是
描述:JDK1.9默认垃圾收集器。Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于标记整理算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。

串行垃圾回收器

串行垃圾收集器,是指使用单线程进行垃圾回收,垃圾回收时,只有一个线程在工作,并且Java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为STW(Stop-The-World)。对于交互性较强的应用而言,这种垃圾收集器是不能够接受的,一般在JavaWeb应用中是不会采用该收集器的。在IDEA中可以配置当前项目堆内存大小,如指定年轻代和老年代都使用串行垃圾收集器,并且打印垃圾回收的详细信息(堆的初始和最大内存都设置为16M)。

配置示例图:

image-20221010114335526

GC日志信息解读:

DefNew:表示使用的是串行垃圾收集器。 
4416K->512K(4928K):年轻代GC前,占有4416K内存,GC后,占有512K内存,总大小4928K。
0.0046102 secs:表示,GC所用的时间,单位为毫秒。 
4416K->1973K(15872K):表示,GC前,堆内存占有4416K,GC后,占有1973K,总大小为15872K。
Full GC:表示,内存空间全部进行GC。

[GC (Allocation Failure) [DefNew: 4416K‐>512K(4928K), 0.0046102 secs] 4416K‐>1973K(15872K), 0.0046533 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [Tenured: 10944K‐>3107K(10944K), 0.0085637 secs] 15871K‐>3107K(15872K), [Metaspace: 3496K‐>3496K(1056768K)], 0.0085974 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]

并行垃圾回收器

并行垃圾收集器在串行垃圾收集器的基础之上做了改进,将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间。这里是指,并行能力较强的机器,当然了,并行垃圾收集器在收集的过程中也会暂停应用程序,这个和串行垃圾回收器是一样的,只是并行执行,速度更快些,暂停的时间更短一些。如ParNewGC:
# 参数 
‐XX:+UseParNewGC ‐XX:+PrintGCDetails ‐Xms16m ‐Xmx16m

# 打印出的信息 
[GC (Allocation Failure) [ParNew: 4416K‐>512K(4928K), 0.0032106 secs] 4416K‐>1988K(15872K), 0.0032697 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

ParallelGC收集器工作机制和ParNewGC收集器一样,只是在此基础之上,新增了两个和系统吞吐量相关的参数,使得其使用起来更加的灵活和高效。如:
-XX:+UseParallelGC:年轻代使用ParallelGC垃圾回收器,老年代使用串行回收器。 
-XX:+UseParallelOldGC:年轻代使用ParallelGC垃圾回收器,老年代使用ParallelOldGC垃圾回收器。 
-XX:MaxGCPauseMillis:设置最大的垃圾收集时的停顿时间,单位为毫秒。需要注意的是ParallelGC为了达到设置的停顿时间,可能会调整堆大小或其他的参数,如果堆的大小设置的较小,就会导致GC工作变得很频繁,反而可能会影响到性能。该参数使用需谨慎。
-XX:GCTimeRatio:设置垃圾回收时间占程序运行时间的百分比,公式为1/(1+n)。 它的值为0~100之间的数字,默认值为99,也就是垃圾回收时间不能超过1%。
-XX:UseAdaptiveSizePolicy:自适应GC模式,垃圾回收器将自动调整年轻代、老年代等参数,达到吞吐量、 堆大小、停顿时间之间的平衡。一般用于手动调整参数比较困难的场景,让收集器自动进行调整。

# 参数
‐XX:+UseParallelGC ‐XX:+UseParallelOldGC ‐XX:MaxGCPauseMillis=100 ‐XX:+PrintGCDetails ‐Xms16m ‐Xmx16m 

# 打印的信息
[GC (Allocation Failure) [PSYoungGen: 4096K‐>480K(4608K)] 4096K‐ >1840K(15872K), 0.0034307 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

[Full GC (Ergonomics) [PSYoungGen: 505K‐>0K(4608K)] [ParOldGen: 10332K‐ >10751K(11264K)] 10837K‐>10751K(15872K), [Metaspace: 3491K‐ >3491K(1056768K)], 0.0793622 secs] [Times: user=0.13 sys=0.00, real=0.08 secs]

CMS垃圾回收器

image-20221010141514658

CMS垃圾回收器的执行过程:

CMS全称Concurrent Mark Sweep,是一款并发的、使用标记清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,通过参数-XX:+UseConcMarkSweepGC进行设置。

CMS垃圾回收器的执行过程如下: 
1)初始化标记(CMS-initial-mark),标记root,会导致stw。
2)并发标记(CMS-concurrent-mark),与用户线程同时运行。
3)预清理(CMS-concurrent-preclean),与用户线程同时运行。
4)重新标记(CMS-remark),会导致stw。
5)并发清除(CMS-concurrent-sweep),与用户线程同时运行。
6)调整堆大小,设置CMS在清理之后进行内存压缩,目的是清理内存中的碎片。
7)并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行。

G1垃圾回收器(重点、默认)

image-20221010150122936

G1说明:

1)G1垃圾收集器是在jdk1.7中正式使用的全新的垃圾收集器,oracle官方计划在jdk9中将G1变成默认的垃圾收集器,以替代CMS。G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:开启G1垃圾收集器、设置堆的最大内存、设置最大的停顿时间。G1中提供了三种模式垃圾回收模式,Young GC、Mixed GC和Full GC,在不同的条件下被触发。G1垃圾收集器相对比其他收集器而言,最大的区别在于它取消了年轻代、老年代的物理划分,取而代之的是将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的年轻代、老年代区域。这样做的好处就是,我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

2)在G1划分的区域中,年轻代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有CMS内存碎片问题的存在了。在G1中,有一种特殊的区域,叫Humongous区域。如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

3)Young GC
Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。每个区(Region)初始化时,会初始化一个RSet,该集合用来记录并跟踪其它Region指向该Region中对象的引用,每个Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是xx Region的xx Card。

4)Mixed GC
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region,这里需要注意的是,是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。MixedGC触发由参数-XX:InitiatingHeapOccupancyPercent=n决定。默认:45%,该参数的意思是:当老年代大小占整个堆大小百分比达到该阀值时触发。

G1垃圾回收器相关参数

使用G1垃圾收集器:-XX:+UseG1GC

设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),默认值是200毫秒:-XX:MaxGCPauseMillis 

设置的G1区域的大小。值是2的幂,范围是1MB到32MB之间。目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000:-XX:G1HeapRegionSize=n 

设置STW工作线程数的值。将n的值设置为逻辑处理器的数量。n的值与逻辑处理器的数量相同,最多为8:-XX:ParallelGCThreads=n 

设置并行标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右:-XX:ConcGCThreads=n

设置触发标记周期的Java堆占用率阈值。默认占用率是整个Java堆的45%:-XX:InitiatingHeapOccupancyPercent=n 

JVM内存分配机制

对象的内存分配通常是在Java堆上分配(随着虚拟机优化技术的诞生,某些场景下也会在栈上分配),对象主要分配在新生代的Eden区,如果启动了本地线程缓冲,将按照线程优先在TLAB上分配。少数情况下也会直接在老年代上分配。总的来说分配规则不是百分百固定的,其细节取决于哪一种垃圾收集器组合以及虚拟机相关参数有关,但是虚拟机对于内存的分配还是会遵循以下几种普遍规则:

1)对象优先在Eden区分配
多数情况,对象都在新生代Eden区分配。当Eden区分配没有足够的空间进行分配时,虚拟机将会发起一次Minor GC。如果本次GC后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。这里我们提到Minor GC,如果你仔细观察过GC日常,通常我们还能从日志中发现Major GC/Full GC。Minor GC是指发生在新生代的GC,因为Java对象大多都是朝生夕死,所有Minor GC非常频繁,一般回收速度也非常快;Major GC/Full GC是指发生在老年代的GC,出现了Major GC通常会伴随至少一次Minor GC。Major GC的速度通常会比Minor GC慢10倍以上。

2)大对象直接进入老年代
所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发GC以获取足够的连续空间来安置新对象。新生代使用的是标记清除算法来处理垃圾回收的,如果大对象直接在新生代分配就会导致Eden区和两个Survivor区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。

3)长期存活对象将进入老年代
虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。因此虚拟机给每个对象定义了一个对象年龄的计数器,如果对象在Eden区出生,并且能够被Survivor容纳,将被移动到Survivor空间中,这时设置对象年龄为1。对象在Survivor区中每熬过一次Minor GC,年龄就加1,当年龄达到一定程度(默认15)就会被晋升到老年代。

在线GC日志分析工具(GC Easy)

GC Easy是一款在线的可视化工具,易用、功能强大,官方地址:https://gceasy.io/。

‐XX:+PrintGC:输出GC日志 
‐XX:+PrintGCDetails:输出GC的详细日志 
‐XX:+PrintGCTimeStamps:输出GC的时间戳(以基准时间的形式) 
‐XX:+PrintGCDateStamps:输出GC的时间戳(以日期的形式,如 2013‐05-04T21:53:59.234+0800) 
‐XX:+PrintHeapAtGC:在进行GC的前后打印出堆的信息 
‐Xloggc:../logs/gc.log:日志文件的输出路径

配置案例:
‐XX:+UseG1GC ‐XX:MaxGCPauseMillis=100 ‐Xmx256m ‐XX:+PrintGCDetails ‐ XX:+PrintGCTimeStamps ‐XX:+PrintGCDateStamps ‐XX:+PrintHeapAtGC ‐ Xloggc:F://test//gc.log

导出GC日志,通过GC Easy打开即可进行分析:

image-20221011150920706