本文主要是介绍深入理解 JVM垃圾收集算法和垃圾收集器(一篇就够),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
一、概述
在Java中内存是由JVM虚拟机自动管理的,JVM在内存中划出一片区域,作为满足程序内存分配请求的空间。内存的创建仍然是由程序猿来显示指定的,但是对象的释放却对程序猿是透明的。就是解放了程序猿手动回收内存的工作,交给垃圾回收器来自动回收。
在JVM虚拟机中,释放哪些不再被使用的对象所占空间的过程称为垃圾收集(Garbage Collection,GC)。负责垃圾收集的程序模块被称为垃圾收集器(Garbage Collector)。
二、垃圾
1、什么是垃圾
在 JVM 进行垃圾回收之前,需要判断哪些对象是垃圾,也就是说,要判断哪些对象是可以被销毁的,其占有的空间是可以被回收的。根据 JVM 的内部架构划分,我们知道几乎所有的对象实例都在堆中存放,所以垃圾回收也主要是针对堆来进行的。
在 JVM 的眼中,垃圾就是指那些在堆中存在的,已经“死亡”的对象。而对于“死亡”的定义,我们可以简单的将其理解为“不可能再被任何途径使用的对象”。那怎样才能确定一个对象是存活还是死亡呢?这就涉及到了垃圾判断算法,其主要包括引用计数法和可达性分析法。
2、垃圾判断算法
1. 引用计数法
给对象(不是引用)中添加一个引用计数器,当一个对象被创建并且初始化赋值后,该对象的计数器的值就设置为 1,每当有一个地方引用它,计数器就加1,如:将对象 b 赋值给对象 a,那么 b 被引用,则将 b 引用对象的计数器累加 1;
当引用失效,计数器就减1;而那些引用计数器为0 的对象就是不再被使用的对象,称之为垃圾,可以被收集。
优点:引用计数法实现简单、效率高,对程序不被长时间打断的实时环境比较有利。
缺点:需要额外的空间来存储计数器,它很难解决对象之间相互循环引用的问题。
所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。
/*** 对象相互引用*/
public class ReferenceCountingGc {Object instance = null;public static void main(String[] args) {//ReferenceCountingGc对象被objA对象引用,此时objA对象的计数器为1ReferenceCountingGc objA = new ReferenceCountingGc();//ReferenceCountingGc对象被objB对象引用,此时objB对象的计数器为1ReferenceCountingGc objB = new ReferenceCountingGc();//objB对象被objA对象引用,此时objB对象的计数器为2objA.instance = objB;//objA对象被objB对象引用,此时objA对象的计数器为2objB.instance = objA;//将objA对象置为null,意味着与ReferenceCountingGc对象的引用就不存在了,那么objA对象的计数器变成了1objA = null;//将objB对象置为null,意味着与ReferenceCountingGc对象的引用就不存在了,那么objB对象的计数器变成了1objB = null;//因objA与objB这两个对象都为null,应该被回收,但因两个对象相互引用着,计数器的值不为0,导致无法进行回收}
}
2. 可达性分析法
可达性分析法也被称之为根搜索法,可达性是指如果一个对象会被至少一个在程序中的变量通过直接或间接的方式被其他可达的对象引用,则称该对象就是可达的(reachable)。更准确的说,一个对象只有满足下述两个条件之一,就会被判断为可达的:
a. 对象是属于根集中的对象
b. 对象被一个可达的对象引用
在这里,我们引出了一个专有名词,即根集,是指正在执行的 Java 程序可以访问的引用变量(注意,不是对象)的集合,程序可以使用引用变量访问对象的属性和调用对象的方法。在 JVM 中,会将以下对象标记为根集中的对象,具体包括:
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中的常量引用的对象
方法区中的类静态属性引用的对象
本地方法栈中 JNI(Native 方法)的引用对象
活跃线程(已启动且未停止的 Java 线程)
根集中的对象被称为GC Roots,也就是根对象。这个算法的基本思路:就是通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达),则证明此对象是不可用的,也称为不可达对象。
如上图所示,展示了可达对象与不可达对象。其中蓝色为可带对象,灰色的为不可达对象,灰色表示可以被垃圾收集的对象。在可达分析法中对象有两种状态,要么是可达的,要么是不可达的。判断对象可达性的过程也是对象被标记的过程。
在对象标记阶段有几点需要注意
a. 标记前,在开始标记前需要先暂停用户(应用)线程,否则若对象一直在变化的话是无法真正的遍历它。暂停用户(应用)线程以便JVM可以尽情的去遍历标记对象的这种情况被称为安全点(safe point),这会触发一次JVM的STW(Stop the world)暂停。触发安全点的原因有很多,垃圾回收是最常见的一种。
这里引入了一个名词安全点(safe point),安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。安全点就是指代码中一些特定的位置,当线程运行到这些位置是他的状态是确定的,这样JVM就可以安全的进行GC操作。所以GC不是想什么时候做就立即触发的,是需要等待线程运行到安全点后才能进行触发GC。特定的安全点位置有以下几种:
方法返回之前
调用某个方法之后
抛出异常的位置
循环的末尾
线程在GC时怎么找到最近的安全点上中断挂起,有两种方案:
抢先式中断:不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应 GC 事件。
主动式中断:当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的。
b. 暂停时间的长短跟存活对象的多少有关,
c. 在跟搜索算法中,要真正宣布一个对象的死亡,至少要经历两次标记过程:
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
i. 第一次标记并进行筛选
筛选的条件是此对象是否有必要执行 finalize()方法(可看作析构函数,类似于OC中的dealloc,Swift中的deinit)。
当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,对象将直接被回收。
ii. 第二次标记
若这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次)。如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那这个对象就会被回收了。
d. GC判断对象是否可达看的是强引用。
优点:可以解决循环引用的问题,不需要额外的内存空间。
缺点:多线程场景下,其他线程可能更新已访问过的对象引用,从而造成误报(将引用设置为null)或漏报(将引用设置为 未被访问过的对象)。
当标记阶段完成后,GC 开始进入下一阶段,回收删除不可达对象。
三、垃圾回收
通过上面的介绍,我们已经知道了什么是垃圾以及如何判断一个对象是否是垃圾。那么接下来,我们就来了解如何回收垃圾,这就是垃圾回收算法和垃圾回收器需要做的事情了。
1、垃圾收集算法
分代收集理论
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 java堆分为年轻代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
复制算法
复制(Copying Collector
)算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
复制算法执行过程如下
复制算法回收前后内存状态如下
优点:标记阶段和复制阶段可以同时进行;每次只对一块内存进行回收,收集效率高;只需移动栈顶指针,按顺序分配内存即可,实现简单;内存回收时不产生内存碎片。
缺点:内存空间使用率极低,需要一块额外的内存空间存放存活的对象。在对象存活率较高(老年代)时,需要进行较多的复制操作,效率会变得很低。
标记-清除算法
标记-清除(Tracing Collector
)算法是最基础的收集算法,是为了解决引用计数法的问题而提出。它使用了根集的概念,算法分为“标记”和“清除”阶段:标记存活的对象(就是可达性分析法判定垃圾对象的过程), 标记完成后统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象 。
标记清除执行过程如下
标记-清除算法回收前后内存状态如下
优点:不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。
缺点:标记和清除过程的效率都不高,这种方法需要使用一个空闲列表来记录所有的空闲区域以及大小,对空闲列表的管理会增加分配对象时的工作量;标记清除后会产生大量不连续的内存碎片。
标记-整理算法
标记-整理(Compacting Collector
)算法是根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可收集对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
标记-整理算法执行过程如下
标记-整理算法回收前后内存状态如下
优点:经过整理之后,新对象的分配只需要通过指针碰撞便能完成,比较简单;解决了内存碎片问题。
缺点:GC 暂停的时间会增长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。
2、垃圾收集器
垃圾收集算法是内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。垃圾回收(GC)线程与应用线程保持相对独立,当系统需要执行垃圾回收任务时,需先停止工作线程,然后命令 GC 线程工作。
如何选择垃圾收集器
1. 优先调整堆的大小让服务器自己来选择
2. 如果内存小于100M,使用串行收集器
3. 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
4. 如果允许停顿时间超过1秒,选择并行或者JVM自己选
5. 如果响应时间最重要,并且不能超过1秒,使用并发收集器
6. 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC
7. JDK 1.8默认使用 Parallel(年轻代和老年代都是) JDK 1.9默认使用 G1
下图有连线的可以搭配使用
不同的垃圾收集器有不同的收集机制,截止到目前还没有一种万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。
Serial收集器(串行收集器)
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结。
年轻代采用复制算法,老年代采用标记-整理算法。通过-XX:+UseSerialGC -XX:+UseSerialOldGC来配置年轻代和老年代垃圾收集器。
收集过程
当JVM发生GC时,应用(用户)线程找到最近的安全点中断挂起
若年轻代发生Minor GC(Young GC)时,Serial收集器采用复制算法对年轻代中Eden区和其中一个Survivor区中的不可达(死亡)对象进行回收。
若老年代发生Full GC(Monjor GC)时,Serial收集采用标记-整理算法对老年代中的不可达(死亡)对象进行回收。
优缺点
优点:与其他单线程收集器相比简单而高效,没有线程交互的开销,自然可以获得很高的单线程收集效率。
缺点:与其他多线程收集器相比收集时间长,毕竟是单线程进行垃圾收集,效率肯定没有多线程收集的快。
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。
Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代的多线程收集器,Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值,吞吐量= 用户线程时间/(用户线程时间+GC线程时间),如虚拟机总运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是99%
。
Parallel Scavenge收集器有两个版本:年轻代收集器和老年代收集器,年轻代采用复制算法,老年代采用标记-整理算法。可以通过-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代) 这两个参数来配置不同的版本。
收集过程
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。
ParNew收集器
ParNew收集器是新生代收集器。使用多个线程进行垃圾收集,在多核CPU环境下有着比Serial更好的表现。ParNew 是针对年轻代采用复制算法的垃圾回收器,可以看成是 Serial 的多线程版本。
ParNew收集器的设计初衷和算法优化都是为了高效地处理年轻代的垃圾收集,其并行处理和复制算法在老年代可能并不适用或效率不高。
通过-XX:+UseParNewGC 参数JVM年轻代配置为ParNew收集器。
收集过程
它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器)配合工作。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
收集过程
从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,CMS 收集器仅作用于老年代的收集。它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
初始标记: 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快。
并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变(据经验值并发标记时间大概占用收集总时间的80%)。
重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(主要是处理漏标问题),这个阶段需要停顿用户线程,停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记法中的增量更新算法(见下面详解)做重新标记。
并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。
并发重置:重置本次GC过程中的标记数据。
优缺点
优点:并发收集、低停顿,提高用户体验(牺牲垃圾收集总时间减少STW时间)。大内存(>4G)的一般推荐使用CMS。
缺点:
- 对CPU资源敏感(会和服务抢资源)。
- 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了)
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理。
- 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure"(并发模式失败),此时会将整个GC过程全部STW(stop the world)专心做垃圾回收,切换成serial old垃圾收集器单线程来回收(效率非常低)。
CMS核心参数
-XX:X越多代表这个命令(参数)越不稳定,后续版本可能弃用,X越少越稳定,如-version
1. -XX:+UseConcMarkSweepGC:启用cms收集器。
2. -XX:ConcGCThreads:并发的GC线程数
3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
5. -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比,避免concurrent mode failure)
6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,
JVM仅在第一次使用设定值,后续则会自动调整
7. -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记-跨代引用,如果在
minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在并发标记阶段
8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;
G1收集器
G1收集器(Garbage-First Garbage Collector,简称G1 GC)是Java虚拟机(JVM)中的一种垃圾收集器,专为服务器端应用设计,特别适用于具有多核处理器和大内存的机器。G1 GC在JDK 7u4版本中被正式推出,并且在JDK 9中成为默认的垃圾收集器。它的主要目标是在满足高吞吐量的同时,尽可能缩短垃圾收集造成的停顿时间。
主要特点
并行与并发:G1能够充分利用多核CPU优势,通过并行执行垃圾收集任务来提高效率。垃圾收集线程大部分时间都与应用(用户)线程并发执行的,减少了停顿(STW)时间(与CMS一样)。
分区域收集:G1将整个堆内存划分成了多个大小相等的独立区域(Region),这些区域在逻辑上是连续的,但在物理上可能不是连续的。每个Region都可以独立扮演Eden区、Survivor区或Old区等角色。这种设计使得G1能够更加灵活地进行内存管理和垃圾收集。
优先收集垃圾最多区域:G1通过跟踪每个Region中的垃圾堆积情况,并根据回收价值和成本进行排序,优先回收垃圾最多的Region。这种策略有助于最大限度地提高垃圾收集的效率。
可预测的停顿:G1通过建立一个可预测的停顿时间模型,允许用户明确指定在一个特定时间片段内,垃圾收集所造成的停顿时间不得超过某个阈值。这使得G1 GC非常适合需要严格控制停顿时间的应用场景。
使用标记-整理算法:在整体上,G1 GC使用标记-整理算法来回收内存,以减少内存碎片的产生。但在两个Region之间进行垃圾收集时,它则采用标记-复制算法。这种组合策略有助于兼顾内存利用率和垃圾收集效率。
分区划分
G1收集器的分区划分是其核心特性之一,它允许G1更灵活、高效地管理内存和执行垃圾回收。
1. 基本思想
G1收集器将整个Java堆划分为多个大小相等、独立的区域,这些区域被称为“Region”。每个Region的大小可以根据堆空间的实际大小而定,通常在1MB到32MB之间,且必须是2的N次幂,默认情况下,整个堆空间被划分为约2048个这样的Region。也可以用参数"- XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。 默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个 Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比。在系统运行中,JVM会不停的给年轻代增加更多 的Region,但是最多年轻代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和 Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100 个,s1对应100个。
2. 分区类型
G1的Region可以根据其用途和状态分为不同类型。主要包括:
自由区(Free Heap Region, FHR):这些Region当前没有包含任何对象,是空闲的,可以用于新的对象分配。
年轻代区(Young Heap Region, YHR):这些Region被划分为年轻代,包括Eden区和Survivor区,默认比例8:1:1。年轻代分区主要用于存储新创建的对象。
大对象区(Humongous Heap Region, HHR):专门用于存储大对象。大对象的判定规则就是一 个大对象超过了一个Region大小的50%,就会被放入Humongous中。不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。
老年代区(Old Heap Region, OHR):这些Region被划分为老年代,用于存储长时间存活的对象。
3. 分区管理和回收
G1收集器在后台通过维护一个优先列表来跟踪各个Region中的垃圾堆积情况和回收价值。在垃圾回收过程中,每次根据允许的收集时间,G1会根据这个列表优先回收价值最大的Region(这也就是它的名字 Garbage-First的由来)。比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回 收时间有限情况下,G1当然会优先选择后面这个Region回收。
这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。并最大限度地减少垃圾回收造成的停顿时间。
4. 分区划分优点
G1的分区划分带来了几个显著优点。首先,它允许更细粒度的内存管理,提高了内存的利用率。其次,通过优先回收垃圾最多的Region,G1能够保持较高的吞吐量并缩短停顿时间。最后,G1的分区策略使其能够很好地适应不同的内存大小和垃圾回收需求。
收集过程
G1收集器一次GC的运作过程大致分为以下几个步骤
初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
并发标记(Concurrent Marking):在初始标记完成后,G1会进入并发标记阶段。这个阶段与应用程序线程并发执行,通过递归地追踪所有可达的对象,并将它们标记为存活。这个过程是并发的,因此不会阻塞应用程序的执行(同CMS的并发标记)。
最终标记(Remark,STW):为了处理在并发标记过程中新产生的对象引用关系,G1会执行一次短暂的STW的最终标记。这个阶段确保所有在并发标记阶段漏掉的对象都被正确标记(同CMS的并发标记)。
筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个 Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得 知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集 合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但 是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老 年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样 回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字 Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回 收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收 方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
垃圾收集策略
G1垃圾收集器是Java虚拟机(JVM)中的一个重要组件,它提供了两种主要的垃圾回收策略:Young GC(年轻代回收)和Mix GC(混合回收)。这两种策略在回收对象和回收区域上有所不同,但都是为了提高垃圾回收的效率,减少停顿时间,从而提升应用程序的性能。
1. Young GC(年轻代回收)
Young GC主要负责回收年轻代中的对象。年轻代通常包含新创建的对象,这些对象更有可能在短时间内变成垃圾。Young GC的执行过程相对较快,因为它只涉及新生代中对象的扫描和回收。
在Young GC过程中,Eden区和Survivor区的存活对象会被复制到另一个Survivor区或者晋升到老年代。这个过程是Stop-The-World(STW)的,意味着在回收过程中,应用程序的所有线程都会被暂停。但是,由于新生代中的对象通常较少,因此这个暂停时间通常较短,对应用程序的性能影响也较小。
YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC。
2. Mixed GC(混合回收)
Mixed GC则是G1收集器特有的回收策略,不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值时触发Mixed GC。它不仅回收年轻代中的所有Region,还会回收部分老年代中的Region(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区。这种策略的目标是在保证停顿时间不超过预期的情况下,尽可能地回收更多的垃圾对象。
在Mixed GC过程中,首先会进行全局并发标记(global concurrent marking),这个过程是并发的,与应用程序线程同时执行,用于标记出所有存活的对象。然后,在回收阶段,G1会根据标记结果选择收益较高的部分老年代Region、年轻代Region以及大对象区一起进行回收。这个选择过程是基于对Region中垃圾对象的数量和回收价值的评估。
与Young GC不同,Mixed GC的停顿时间可能会更长,因为它涉及到对老年代中对象的扫描和回收。但是,由于Mixed GC能够回收更多的垃圾对象,因此它通常能够更有效地释放内存空间,减少垃圾堆积对应用程序性能的影响。
正常情况G1的垃圾收集是先做 MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
3. Full GC
停止系统应用(用户)线程程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
G1核心参数
在JDK9及以后的版本中,G1是默认的垃圾收集器,但在JDK8中,你需要显式地启用。以下是G1收集器的一些核心配置参数
1. -XX:+UseG1GC:使用G1收集器
2. -XX:ParallelGCThreads:指定GC工作的线程数量
3. -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
4. -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
5. -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)
6. -XX:G1MaxNewSizePercent:新生代内存最大空间
7. -XX:TargetSurvivorRatio:Survivor区的填充容量,默认50%。Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个 年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
8. -XX:MaxTenuringThreshold:最大年龄阈值(默认15)
9. -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值,默认45%。则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能 就要触发MixedGC了
10. -XX:G1MixedGCLiveThresholdPercent:region中的存活对象低于这个值时才会回收该region,默认85%。如果超过这个值,存活对象过多,回收的的意义不大。
11. -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
12. -XX:G1HeapWastePercent:gc过程中空出来的region是否充足阈值,默认5%。在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会 即停止混合回收,意味着本次混合回收就结束了。
性能优化建议
合理设置堆大小:根据应用程序的内存需求和硬件资源,合理设置JVM的堆大小。过大的堆可能会导致长时间的垃圾收集停顿,而过小的堆则可能导致频繁的垃圾收集。
调整停顿时间目标:通过调整G1的停顿时间目标(-XX:MaxGCPauseMillis参数),可以平衡垃圾收集的效率和应用程序的响应时间。在保证他的年轻代gc别太频繁的同时,还得考虑 每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发Mixed GC。在需要低延迟的场景中,可以设置较短的停顿时间目标。
启用并行垃圾收集线程:通过增加并行垃圾收集线程的数量(-XX:ParallelGCThreads参数),可以提高垃圾收集的效率。然而,过多的线程可能会导致系统资源的竞争和额外的开销,因此需要谨慎调整。
优化对象分配和晋升策略:通过优化对象的分配和晋升策略,可以减少新生代和老年代之间的对象流动,从而降低垃圾收集的开销。例如,可以考虑使用对象池、缓存等技术来减少临时对象的创建和销毁。
监控和分析GC日志:定期监控和分析GC日志可以帮助识别潜在的内存泄漏、性能瓶颈和优化机会。可以使用JVM自带的工具(如jstat、jvisualvm)或第三方工具(如GCViewer、YourKit)来进行日志分析和性能调优。
适合场景
1. 50%以上的堆被存活对象占用
2. 对象分配和晋升的速度变化非常大
3. 垃圾回收时间特别长,超过1秒
4. 8GB以上的堆内存(建议值)
5. 停顿时间是500ms以内
G1垃圾收集器以其可预测的停顿时间、灵活的内存管理和高效的并发标记等特点,在JVM中占据了重要的地位。通过深入理解G1的工作原理和关键特性,并根据实际应用场景进行性能优化,可以更好地利用G1来提升Java应用程序的性能和响应时间。
四、垃圾收集底层算法实现
1、三色标记算法
在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。漏标的问题主要引入了三色标记算法来解决。
三色标记算法是把Gc Roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:
黑色:表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
灰色:表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
白色:表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段,仍然是白色的对象, 即代表不可达。
通过代码示例来看下三色标记算法执行标记过程
/**
* 垃圾收集算法细节之三色标记
*
*/
public class ThreadColorRemark{public static void main(){//创建A对象实例,此时对象间引用关系是 A引用了B、B引用了C和DA a = new A();//开始做并发标记点D d = a.b.d; //写操作//将B对D对象的引用置为null(去掉B对D的引用)a.b.d = null; //读操作//将B之前引用的D对象,赋值给A对D的引用a.d = d; //读操作}}public class A{B b = new B();D d = null;}public class B{C c = new C();D d = new D();}public class C{}public class D{}
通过上面代码分析,进行并发标记时会将A、C对象置为黑色,由于还未对B和D的引用还未扫描,此时B为灰色,D为白色。正常标记完成时 B和D都应该为黑色。
当执行a.b.d = null时,此时B对D的引用就不存在了(引用消失),但并发标记已经完成,这是对于B和D的引用关系就属于多标情况。
当执行a.d = d时,由于标记完成,没有将A和D之间的引用标记,那么它们之间的关系就属于漏标情况。
多标-浮动垃圾
在并发标记过程中,如果由于方法运行结束导致部分局部变量(GC Root)被销毁,这个GC Root引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。
浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。 另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可 能也会变为垃圾,这也算是浮动垃圾的一部分。
漏标-读写屏障
漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决。有常用的解决方案有两种: 增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB) 。
增量更新:就是当黑色对象插入新的指向白色对象的引用关系时(如上图A和D), 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。
原始快照:就是当灰色对象要删除指向白色对象的引用关系时(如上如B和D), 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象 在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾) 以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。
写屏障
所谓的写屏障,其实就是指在赋值操作前后,加入一些处理操作(可以参考AOP概念)。
void oop_field_store(oop* field, oop new_value) {// 写屏障-写前操作pre_write_barrier(field); //写操作(赋值操作)*field = new_value;// 写屏障-写后操作post_write_barrier(field, value); }
1. 写屏障实现增量更新
当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来
void post_write_barrier(oop* field, oop new_value) {// 记录新引用的对象remark_set.add(new_value);//写操作(赋值操作)*field = new_value;}
2. 写屏障实现SATB
当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下 来:
void pre_write_barrier(oop* field, oop new_value) {// 获取旧值oop old_value = *field; // 记录原来的引用对象remark_set.add(old_value); //写操作*filed = new_value;}
读屏障
所谓的读屏障,其实就是指在读取操作前后,加入一些处理操作(可以参考AOP概念)。
读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来
void pre_load_barrier(oop* field) {oop old_value = *field;// 记录读取到的对象remark_set.add(old_value); }
现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。 对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
CMS:写屏障 + 增量更新
G1,Shenandoah:写屏障 + SATB
ZGC:读屏障
工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。
为什么G1用SATB?CMS用增量更新?
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
这篇关于深入理解 JVM垃圾收集算法和垃圾收集器(一篇就够)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!