【jvm】JVM(三)JVM 垃圾回收算法详解(CMS、三色标记)
文章目录
- 一、常见的垃圾回收算法
- 分代收集理论
- 标记复制算法
- 标记清除算法
- 标记整理算法
- 二、垃圾收集器
- Serial收集器 (-XX:+UseSerialGC)
- Serial Old 收集器(-XX:+UseSerialOldGC)
- Parallel 收集器(-XX:+UseParallelGC)— jdk 1.8新生代默认垃圾回收器
- Parallel Old 收集器(-XX:+UseParallelOldGC) — jdk 1.8老年代默认垃圾回收器
- ParNew 收集器(-XX:+UseParNewGC)
- CMS(Concurrent Mark Sweep)收集器 (-XX:+UseConcMarkSweepGC)
- 三、垃圾回收器底层算法实现
- 三色标记
- 多标-浮动垃圾
- 漏标-读写屏障
- 四、总结
JVM 垃圾回收算法详解(CMS、三色标记)
一、常见的垃圾回收算法
分代收集理论
分代收集算法(generational garbage collection)是一种用于自动内存管理的算法,用于识别和回收无用的内存对象。这种算法是现代编程语言中常用的垃圾回收机制之一。
分代收集算法基于一个简单的观察:大部分对象在内存中的生命周期都非常短暂,而只有少数对象会存活很长时间。因此,分代收集算法将内存对象分成几个代,每一代代表一个对象存活的时间段。通常,新创建的对象被放在第一代,如果它们存活了一段时间,就会被转移到下一代。
分代收集算法通常使用两个主要的阈值:年龄阈值和大小阈值。年龄阈值指的是一个对象在第一代中存活的次数,而大小阈值则是一个对象的大小,超过这个大小的对象将被认为是大对象。
在分代收集算法中,一般会经常进行轻量级的垃圾回收操作,主要是针对第一代内存对象的回收。这是因为第一代中的对象很少存活很长时间,因此可以快速地回收它们并释放内存空间。如果一个对象在第一代存活了足够长的时间,就会被转移到下一代,这样就可以减少垃圾回收操作的频率。较大的对象通常被放在老年代,因为它们更倾向于存活很长时间。
总的来说,分代收集算法可以提高垃圾回收的效率,因为它可以减少对整个堆内存的扫描次数。此外,分代收集算法还可以通过将内存对象划分为不同的代,使垃圾回收操作更具有可预测性和可控性。
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几 块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。 比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可 以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选 择“标记-清除”或“标记-整理”算法进行垃圾收集。注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。
标记复制算法
复制算法将内存分成大小相同的两块,每次使用一块内存区域,当一块内存区域满了之后,标记出存活的对象,并将存活的对象复制到另一块空的区域,然后将空间全部清空。
标记清除算法
算法分为两个阶段,标记和清除阶段,一般采用标记出存活对象,清除未被标记的对象(一般采用这种),反之亦可以。算法比较简单但会存在如下问题
- 效率问题 (如果被标记对象过多,效率比较慢)
- 空间问题 (清除会出现不连续的空间,碎片化)
标记整理算法
标记过程跟标记清楚算法一样,但是在清除阶段不会直接清除垃圾对象,而是把存活的对象向一端移动然后清楚掉边界以外的内存
二、垃圾收集器
如图所示,我们常见的垃圾收集器有以上几种,新生代跟老年代垃圾收集器上下连线代表可以组合只用,相同颜色是推荐组合,下面我就分别介绍下不同的垃圾收集器
Serial收集器 (-XX:+UseSerialGC)
Serial收集器是一种单线程的垃圾回收器,适用于小型或简单应用程序。下面是使用Serial收集器的一般步骤
- 在应用程序启动时,使用以下命令行参数启用Serial收集器 这将使JVM使用Serial收集器进行垃圾回收:
java -XX:+UseSerialGC MyApp
- 如果需要调整垃圾回收的参数,可以使用以下命令行参数进行配置:
-XX:NewSize=<size> -XX:MaxNewSize=<size> -XX:SurvivorRatio=<ratio>
其中,NewSize指定新生代的大小,MaxNewSize指定新生代的最大大小,SurvivorRatio指定Eden区和Survivor区的大小比例。例如,下面的命令将新生代的大小设置为256MB,最大大小设置为512MB,Eden区和Survivor区的比例设置为8:1:1
例如,下面的命令将新生代的大小设置为256MB,最大大小设置为512MB,Eden区和Survivor区的比例设置为8:1:1
java -XX:+UseSerialGC -XX:NewSize=256m -XX:MaxNewSize=512m -XX:SurvivorRatio=8 MyApp
- 运行应用程序,Serial收集器将在单个线程中进行垃圾回收操作。当垃圾回收操作进行时,应用程序将被暂停(stop the word)。在垃圾回收完成后,应用程序将恢复运行。
Serial Old 收集器(-XX:+UseSerialOldGC)
Serial Old收集器是Serial收集器的老年代版本,也是一种单线程的垃圾回收器,用于回收老年代的对象, 需要注意的是,Serial Old收集器仍然是一种单线程的垃圾回收器,可能会导致长时间的应用程序暂停时间
使用Serial Old收集器的步骤与使用Serial收集器类似,不同之处在于需要使用以下命令行参数启用Serial Old收集器:
java -XX:+UseSerialGC -XX:+UseSerialOldGC MyApp
其中,-XX:+UseSerialGC
用于启用Serial收集器,-XX:+UseSerialOldGC
用于启用Serial Old收集器。
Parallel 收集器(-XX:+UseParallelGC)— jdk 1.8新生代默认垃圾回收器
Parallel收集器是一种多线程的垃圾回收器,用于在多核处理器上并行回收垃圾。它是JDK 1.4引入的一种垃圾回收器,用于代替早期版本中的Serial收集器。与Serial收集器不同,Parallel收集器能够在多个线程上同时执行垃圾回收操作,因此可以显著缩短垃圾回收时间。运行应用程序,Parallel收集器将在多个线程中并行执行垃圾回收操作。当垃圾回收操作进行时,应用程序将被暂停。在垃圾回收完成后,应用程序将恢复运行
使用Parallel收集器的步骤如下:
- 在应用程序启动时,使用以下命令行参数启用Parallel收集器:
java -XX:+UseParallelGC MyApp
- 如果需要调整垃圾回收的参数,可以使用以下命令行参数进行配置
-XX:ParallelGCThreads=<threads> -XX:MaxGCPauseMillis=<pause time> -XX:GCTimeRatio=<ratio>
其中,ParallelGCThreads
指定用于垃圾回收的线程数,MaxGCPauseMillis
指定最大垃圾回收暂停时间,GCTimeRatio
指定垃圾回收时间与应用程序运行时间的比率
Parallel Old 收集器(-XX:+UseParallelOldGC) — jdk 1.8老年代默认垃圾回收器
Parallel Old收集器是Parallel收集器的老年代版本,用于回收老年代的对象。它与Parallel收集器类似,也是一种多线程的垃圾回收器,可以利用多个CPU来并行处理垃圾回收操作,从而提高垃圾回收的效率。
使用Parallel Old收集器的步骤与使用Parallel收集器类似,不同之处在于需要使用以下命令行参数启用Parallel Old收集器:
java -XX:+UseParallelGC -XX:+UseParallelOldGC MyApp
ParNew 收集器(-XX:+UseParNewGC)
ParNew收集器是一种新生代垃圾回收器,它使用多线程并行回收新生代中的对象。它是一种与Serial收集器类似的垃圾回收器,但是可以利用多个CPU来并行处理垃圾回收操作,从而提高垃圾回收的效率。ParNew收集器通常与CMS收集器一起使用,用于回收新生代对象。
java -XX:+UseParNewGC MyApp
CMS(Concurrent Mark Sweep)收集器 (-XX:+UseConcMarkSweepGC)
收集器是一种用于回收老年代对象的垃圾回收器,它的特点是并发收集和低停顿时间。与其他垃圾回收器不同,CMS收集器采用了一种并发标记-清除算法,可以在应用程序运行期间与垃圾回收线程并发执行,从而减少应用程序的停顿时间。
java -XX:+UseConcMarkSweepGC MyApp
CMS 运行过程
- 初始标记(Initial Mark) — 在这个阶段,CMS收集器会暂停所有应用程序线程,标记所有老年代中的对象,并记录下根对象的引用信息,以便在下一个阶段中继续执行垃圾回收操作。 速度很快 (STW)
- 并发标记阶段(Concurrent Mark)— 在这个阶段,CMS收集器会并发执行,不会停止应用程序线程。它会遍历老年代中所有对象,并标记所有可达的对象,将它们标记为“存活”对象,因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变
- 重新标记 — 此阶段主要标记 由于并发标记阶段用户线程运行导致对象引用产生变动的那一部分对象(主要是处理漏标问题),这个阶段停顿时间会初始标记长,并发标记短。这个过程主要是用三色标记中的增量跟新算法来进行标记(下面介绍)
- 并发清理 — 并发清理垃圾对象
- 并发重置 — 重置本次GC过程中的标记数据
CMS 收集器的优缺点
- 优点
- 并发收集、停顿时间短
- 缺点
- 对CPU 资源敏感和用户线程抢占CPU
- 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了)
- 使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。 通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理。
- 执行过程不确定,会存在上一次垃圾收集器没执行完,再次出发垃圾收集。 特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收
CMS相关参数设置
- -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;
三、垃圾回收器底层算法实现
三色标记
在并发标记过程中由于用户线程和GC 线程同时在运行,会造成已经标记过的对象引用发生变化,会产生多标、漏标问题,漏标问题采用三色标记来解决
三色标记算法 把Gc Roots 可达性分析算法过程中遍历的对象,按照“是否访问过”这个条件标记成以下三种颜色:
黑色:表示对象已经被垃圾收集器访问过并且从这个对象出发的多有引用都已经标记过。 如果一个新的标量指向一个黑色对象是不需要重新扫描一遍。黑色对象不可能直接指向白色对象(跳过灰色对象)
灰色:表示对象已经被垃圾收集器访问过 但是至少存在1条以上的引用未被扫描过
白色:表示未被扫描的对象。显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达
在并发标记过程中,当我们扫描过 B→ C 这条引用,将C 标记为黑色对象,准备扫表B→D 之前,此时用户线程 删除了B-D 的引用,增加了A-D 的引用,此时根据可达性分析是无法到达D, 显然D又不是垃圾对象,如果将D 按垃圾对象清除,就是JVM一个非常严重的bug ,显然JVM不可能让这样的事情发生,那么JVM是如何处理的呢? 此时会引入几个概念 增量更新(Incremental Update),原始快照(Snapshot At The Beginning,SATB),写屏障,读屏障。
写屏障
给某个对象的成员变量赋值时,其底层代码大概长这样:
void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 赋值操作
}
所谓写屏障就是在复制前后增加一些操作(类似以AOP概念)
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障-写前操作
*field = new_value;
post_write_barrier(field, value); // 写屏障-写后操作
}
写屏障实现SATB
当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录原来的引用对象 记录B-D 应用,当并发标记扫描结束,重新标记阶段我们可以从remark_set 取出B-D 应用直接将D对象标记为黑色 (G1垃圾收集器采用次算法)
}
写屏障实现增量更新
当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:
void post_write_barrier(oop* field, oop new_value) {
remark_set.add(new_value); // 记录新引用的对象 记录A->D 的引用
}
读屏障
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障-读取前操作
return *field;
}
读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:
void pre_load_barrier(oop* field) {
oop old_value = *field;
remark_set.add(old_value); // 记录读取到的对象
}
多标-浮动垃圾
在并发标记过程中,由于用户线程运行结束,直接标记过有Gc Roots 引用的对象 标记为了黑色对象,这部分对象其实已经无用,但是咱本次垃圾回收是不是回收的,这就产生了浮动垃圾,浮动垃圾在下次Gc 时会被回收掉
另外,针对与并发标记(还有并发清理)过程中新产生的对象,直接标记为黑色,本次垃圾回收直接不处理这部分对象
漏标-读写屏障
漏标是指并发标记过程的对象,引用发生变化,造成扫描对象不可达。 JVM处理这种问题有两种方式,增量更新、原始快照
增量更新:当一个黑色对象插入指向白色对象引用时,就把这个关系记下拉,并发标记结束,重新标记过程中从新标记这部分对象(相当于把黑色对象变成灰色对象,重新从灰色对象扫描,因为重新标记STW ,对象引用不会再次发生变化)
原始快照:当一个灰色对象断开指向白色对象引用时,把这个引用记录下来,当并发标记结束后,重新标记过程中 直接把记录下来的引用中的对象标记为黑色,本次垃圾收集回收这部分对象(这些对象可能是浮动垃圾),下次收集重新扫描回收。
备注:增量更新、原始快照 牵扯到对象引用的插入还是删除JVM都是通过写屏障实现的。
四、总结
-
垃圾收集算法分为
- 分代收集理论
- 复制算法
- 标记-清除算法
- 标记-整理算法
-
常见的垃圾回收器介绍
- Serial
- Serial Old
- ParNew
- CMS
- Parallel
- Parallel Old
-
垃圾收集底层算法 - 三色标记
-
漏标、多标问题
- 增量更新
- 原始快照
- 读/写屏障