垃圾收集算法

image.png

分代收集理论

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

清除阶段的算法

当成功区分出内存中的存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收。目前JVM中比较常见的三种垃圾收集算法是:标记-清除,复制,标记-压缩

标记-清除算法

执行过程:当堆中的有效内存空间被耗尽的时候,就会停止整个程序(stop the world),然后进行两步工作,第一项是标记,第二项是清除。
标记:从引用根节点开始遍历,标记所有被引用的对象。(标记的是可达对象)
清除:对堆内存从头到尾进行线性遍历,若发现没标为可达对象,则将其回收
image.png
清除:并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里(清理出的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表)。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

复制算法(用于年轻代)

核心思想:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收(S0,S1)
image.png
特别:如果系统中的垃圾对象很少时,复制算法不会很理想,因为复制算法需复制的存活数量太大。

标记-整理算法

执行过程:第一阶段和标记-清除法一样
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放,之后清理边界外所有的空间
image.png
年轻代:复制算法效率最高(回收频繁)
老年代:标-清或者标-清与标-整混合

垃圾收集器

7款经典的垃圾收集器
串行:Serial,Serial old
并行:ParNew,Parallel Scavenge,Parallel old
并发:CMS,G1
image.png
jdk8之前:全部线都可以组合
jdk8:取消红色线组合
jdk9:彻底移除红线
jdk14:弃用绿线,删除CMS GC

Serial收集器:串行回收

-XX:+UseSerialGC -XX:+UseSerialOldGC
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程(“Stop The World” ),直到它收集结束。
新生代采用复制算法,老年代采用标记-整理算法。
image.png

Parallel Scavenge收集器:吞吐量优先

-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代)
Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。
新生代采用复制算法,老年代采用标记-整理算法。
image.png
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)

ParNew收集器:并行回收

-XX:+UseParNewGC
ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
新生代采用复制算法,老年代采用标记-整理算法。
image.png

CMS收集器:低延迟

-XX:+UseConcMarkSweepGC(old)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
采用标记-清除算法,并且也会STW
不幸的是CMS不能与Parallel Scavenge配合工作
初始标记->并发标记->重新标记->并发清理->重置线程
初始标记:STW短暂的暂停,仅仅只是标记出GC Roots能直接关联到的对象;
并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程。这个过程耗时长但不需要停顿用户线程,可以与GC线程一起并发运行。(漏标:原始快照和增量更新,多标-浮动垃圾:等下一个GC清除)
重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(主要是处理并发标记阶段漏标的情况),会STW;
并发清理:清理掉标记阶段判断的已经死亡的对象,释放空间(多标-浮动垃圾:等下一个GC清除)
image.png
主要优点:并发收集、低停顿
但是它有下面几个明显的缺点:
对CPU资源敏感(会和服务抢资源);
无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);
它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数-
XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理
执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是”concurrent mode failure“,此时会进入stop the world,用serial old垃圾收集器来回收

垃圾收集底层算法实现

三色标记

在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。 这里我们引入“三色标记”来给大家解释下,把Gc roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
灰色:表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
白色:表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。
image.png

多标-浮动垃圾

在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。

漏标-读写屏障

漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案:增量更新(
Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。
增量更新就是当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
原始快照就是当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)
以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。

注:只有新增引用,没有删除,这种不符合漏标的条件,因为不会孤零零的只有一个白色的对象在那里
。删除一个灰色到白色的,新增一个黑色到白色的,这两个条件全部满足,才会出现漏标的情况!!!
增量更新打破了新增黑色到白色引用这个条件,原始快照打破了删除一个灰色到白色引用的条件

写屏障

给某个对象的成员变量赋值时,其底层代码大概长这样:

  1. /**
  2. * @param field 某对象的成员变量,如 a.b.d
  3. * @param new_value 新值,如 null
  4. */
  5. void oop_field_store(oop* field, oop new_value) {
  6. *field = new_value; // 赋值操作
  7. }

所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):

  1. void oop_field_store(oop* field, oop new_value) {
  2. pre_write_barrier(field); // 写屏障‐写前操作
  3. *field = new_value;
  4. post_write_barrier(field, value); // 写屏障‐写后操作
  5. }

写屏障实现SATB

当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:

  1. void pre_write_barrier(oop* field) {
  2. oop old_value = *field; // 获取旧值
  3. remark_set.add(old_value); // 记录原来的引用对象
  4. }

写屏障实现增量更新

当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:

  1. void post_write_barrier(oop* field, oop new_value) {
  2. remark_set.add(new_value); // 记录新引用的对象
  3. }

读屏障

  1. oop oop_field_load(oop* field) {
  2. pre_load_barrier(field); // 读屏障‐读取前操作
  3. return *field;
  4. }

读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:

  1. void pre_load_barrier(oop* field) {
  2. oop old_value = *field;
  3. remark_set.add(old_value); // 记录读取到的对象
  4. }

对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
CMS:写屏障 + 增量更新
G1,Shenandoah:写屏障 + SATB
ZGC:读屏障

问题一:为什么G1用SATB?CMS用增量更新?

我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。

问题二:说一下JVM有哪些垃圾回收算法

标记-清除算法:标记无用对象,然后进行清除回收。
缺点:效率不高,无法清除垃圾碎片。
复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。
缺点:内存使用率不高,只有原来的一半。
标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

问题三:解释浮动垃圾是怎么回事

CMS收集器的浮动垃圾就是在并发标记和并发清理阶段又产生垃圾,这种垃圾只能等到下一次GC再清理