JVM系列之垃圾回收算法

1、JVM垃圾回收

1.1、垃圾回收概述

JVM系列之垃圾回收算法_第1张图片

Java和c++在内存方面的区别(内存动态分配、垃圾自动回收)

1.1.1、垃圾回收技术需要考虑的三个基本问题
  • 哪些内存需要回收?
  • 什么时候需要回收内存?
  • 如何回收内存?
1.1.2、什么是垃圾

垃圾是指运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾,如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占用的内存空间会一直保留直到应用程序结束,被保留的空间无法被其他对象使用,最终甚至可能导致内存溢出。

1.1.3、需要回收的内存区域

回收的区域(堆内存和方法区)
JVM系列之垃圾回收算法_第2张图片
总结:频繁回收新生代,较少回收老年代,基本不回收方法区

1.2、垃圾回收相关算法(重点)

1.2.1、标记阶段

如何判断对象已经死亡?
当一个对象已经不再被任何存活的对象引用时,认为该对象已经死亡

1.2.1.1、引用计数算法
1.2.1.1.1、概述

对每个对象保存一个整型的引用计数器属性,用于记录被对象引用的情况,每当有一个地方引用它时,计数器值就加1,当计数器次数过多时,就把这个对象放到老年代中,如果每次不被引用的时候,计数器的次数就减1,一直减到0,被标不可用了,说明这个对象可以被回收了,然后垃圾收集器将回收该对象使用的内存。

1.2.1.1.2、优点

实现简单,垃圾便于辨识,判断效率高,回收没有延迟性

1.2.1.1.3、缺点:
  • 需要单独的字段存储计数器,增加了存储空间的开销
  • 每次赋值需要更新计数器,伴随加减法操作,增加了时间开销
  • 无法处理循环引用的情况,致命缺陷,导致Java的垃圾回收器中没有使用这类算法
1.2.1.1.4、循环引用

对象A引用了对象B,对象B引用了对象A,但是除了它们互相引用,再也没有其他的对象去引用对象A,对象B

JVM系列之垃圾回收算法_第3张图片
Java没有采用引用计数法,而是采用可达性分析法,但是python,它是同时支持引用计数和垃圾回收机制,Python使用手动解除和弱引用,weakref,python提供的标准库,解决循环引用

1.2.1.2、可达性分析算法

JVM系列之垃圾回收算法_第4张图片

所有生成的对象,都是一个叫做“GC ROOTS”树的子树,是以根对象(GCRoots)为起始点,按照从上到下的方式搜索,搜索所经过的路径叫做引用链,内存中存活的对象都被根对象集合直接或间接连接着,如果一个对象到"GC ROOTS"没有一个可达的路径,那么就说明这个对象是不可达对象,可以被垃圾回收机制回收了。

在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活的对象。

1.2.1.2.1、GC Roots

GC Roots包括:

  • 虚拟机栈中引用的对象(局部变量表中的引用数据类型的变量):比如各个线程被调用的方法中使用到的参数、局部变量
  • 本地方法栈内JNI、引用的对象
  • 方法区中静态属性引用的对象,比如:Java类的引用类型静态变量
  • 方法区中常量引用的对象,比如字符串常量池里的引用
  • 所有被同步锁synchronized持有的对象
  • Java虚拟机内部的引用,基本数据类型对应的class对象,一些常驻的异常对象,如NullpointerException、OOMError、系统类加载器
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调,本地代码缓存等
  • 除了固定的GC Roots集合之外,根据用户选择的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性的加入,共同构成完整GCRoots集合,比如分代收集和局部回收,如果只针对Java堆中某一块内存区域进行垃圾回收,必须要考虑这个区域的对象可能被其他区域对象所引用,这是需要一并将关联的区域对象加入GC Roots集合中去考虑,才能保证可达性分析的准确性。(跨代引用)

小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那么它就是一个GC Root。

如果需要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话,分析结果的准确性就无法保证,这也是GC进行时必须STW的一个重要原因,即使是号称几乎不会发生停顿的CMS收集器中,枚举根节点也是必须要停顿的。

1.2.2、对象的finalization机制

Java语言提供了对象终止finaliztion机制来允许开发人员提供对象被销毁之前的自定义处理逻辑,当垃圾回收器发现没有引用指向一个对象,即垃圾回收此对象之前,总会先调用这个对象的finalize()方法,finalize()方法被定义在Object类中,允许在子类中被重写,用于在对象被回收时进行资源释放,通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件,套接字和数据库链接等

1.2.2.1、定义虚拟机的对象可能的三种状态
  • 可触及的:从根节点开始,可以到达这个对象
  • 可复活的:对象的所有引用都被释放了,但是对象有可能在finalize()中复活
  • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次

只有对象是不可触及时才可以被回收。

1.2.2.2、具体的回收过程

判断一个对象objA是否可以被回收,至少需要经历两次标记过程,如果对象到GCRoots没有引用链,则进行第一次标记,然后进行筛选,判断此对象是否有必要执行finalize()方法。

  • 如果对象A没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为没有必要执行,对象A被判定为不可触及的。
  • 如果对象A重写finalize()方法,且还未执行过,那么A会被插入到F-queue队列中,有一个虚拟机自动创建的,低优先级的Finalizer线程触发其finalize()方法执行
  • finalize方法是对象逃脱死亡的最后机会,稍后GC会对F-queue队列中的对象进行第二次标记,如果A在finalize方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,A会被移除即将回收集合。之后,对象会再次出现没有引用存在的情况下,finalize方法不会再被调用,对象直接变为不可触及状态,这时候A对象会被直接回收。
1.2.2.3、代码实现

JVM系列之垃圾回收算法_第5张图片
JVM系列之垃圾回收算法_第6张图片

1.2.2.4、Finalizer线程
  • 在Finalizer类中有静态内部类FinalizerThread继承自Thread,重写run()方法,并且在静态代码块中启动了Finalizer线程
  • 重写的run()方法中的逻辑就是从队列中获取对象,然后执行finalize()方法,当然了finalize()方法只会被执行一次
  • Finalizer线程的优先级低finalizer.setPriority(Thread.MAX_PRIORITY - 2)
  • Finalizer线程为后台守护线程finalizer.setDaemon(true)
1.2.3、MAT与JProfiler的GC Roots溯源
  • Eclipse MAT是Memory Analyzer的简称,是一款功能强大的Java堆内存分析器。用于查找内存泄露以及查看内存消耗情况,基于Eclipse开发的一款免费性能分析工具
  • JProfiler提供直观的用户界面帮助您解决性能瓶颈,确定内存泄漏并了解线程问题
1.2.4、清除阶段

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。

1.2.4.1、标记-清除算法(Mark-Sweep)
1.2.4.1.1、标记

从引用根节点开始遍历(需要停止所有用户线程Stop The World),标记所有被引用的对象,一般是在对象头中记录为可达对象,注意标记引用对象,不是垃圾对象,是存活的对象

1.2.4.1.2、清除

对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
JVM系列之垃圾回收算法_第7张图片

1.2.4.1.3、缺点
  • 效率不算高,当需要大量对象被回收时,进行大量的标记和清除,执行时间较长
  • 在GC的时候,需要停止整个应用程序(STW),导致用户体验差
  • 这种方式清理出来的空闲内存不连续,产生内存碎片,需要维护一个空闲列表
1.2.4.1.4、何为清除?

所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放。

1.2.4.2、复制算法(Mark-Copy)

将内存空间分为两块,每次使用其中一块。在垃圾回收时,将正在使用的内存中的存活的对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有的对象,交换两个内存的角色,最后完成垃圾回收
JVM系列之垃圾回收算法_第8张图片

1.2.4.2.1、优点
  • 没有标记和清除的过程,实现简单高效
  • 复制过去以后的保证空间的连续性,不会出现碎片的问题
1.2.4.2.2、缺点
  • 需要两倍的内存空间
  • 对于G1这种拆分为大量region的GC,复制而不是移动,意味着GC需要维护region之间的引用关系,不管是内存占用或者时间开销也不小
  • 如果系统中的垃圾对象很多,需要复制的存活对象数量并不会太大,或者非常低才行
1.2.4.2.2、应用场景

JVM系列之垃圾回收算法_第9张图片
新生代对常规应用的垃圾回收,一次通常可以回收70%-99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是使用这种收集算法回收新生代

1.2.4.3、标记-压缩算法(标记-整理算法)(Mark-Sweep-Compact)
1.2.4.3.1、标记阶段(Mark)

标记清除算法一样,从根节点开始标记所有被引用的对象

1.2.4.3.2、清除阶段(Sweep)

清除阶段(Sweep)第二阶段将所有的存活对象压缩在内存的一端,按照顺序排放,之后清理边界外所有的空间

1.2.4.3.3、整理阶段(Compact)

整理阶段(Compact)标记清除算法执行完成后,再进行一次内存碎片整理

JVM系列之垃圾回收算法_第10张图片
与标记清除算法本质区别,标记清除算法是非移动式的算法,标记压缩是移动式的,是否移动回收后的存活对象是一项优缺点并存的风险决策。

1.2.4.3.4、优缺点

优点:

  • 是消除了标记清除算法内存区域分散的缺点
  • 消除了复制算法中,内存减半代价

缺点:

  • 从效率上来讲,标记整理算法要低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动的过程中,需要全程暂停用户应用程序,即STW
1.2.5、各个垃圾回收算法优缺点

从效率上来说,复制算法是当之无愧的老大,但是却浪费了太多的内存。为了尽量兼顾上面提到的三个指标,标记-整理算法相对平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记阶段,比标记-清除多了一个整理内存的阶段。
在这里插入图片描述

1.2.6、分代收集算法

不同生命周期的对象可以采取不同的收集方式,以便提高回收效率,几乎所有的GC都采用分代收集算法执行垃圾回收。

1.2.6.1、HotSpot中分代收集算法
  • 新生代: 生命周期短,存活率低,回收频繁,适合使用复制算法,复制算法内存利用率不高的问题,通过两个survior的设计得到缓解
  • 老年代:区域较大,生命周期长,存活率高,回收不及年轻代频繁,这种大量存活率高的对象,复制算法明显不合适。一般由标记-清除或者是标记-整理的混合实现
1.2.7、增量收集算法、分区算法
1.2.7.1、增量收集算法思想(CMS、G1、ZGC)

每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成,通过对线程间冲突的妥善管理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作,缺点是线程和上下文切换导致系统吞吐量的下降。

1.2.7.2、分区算法(G1、ZGC)

采用了局部回收的设计思路和基于Region的内存布局来实现的垃圾回收算法。在G1垃圾回收算法中,将整个Java堆内存分成了多个Region,每个Region可以是Eden、Survivor或Old区。在进行垃圾回收的时候通过选择价值最大的Region开始执行回收操作,根据目标的停顿时间,每次合理的回收若干个小区间,从而减少一次GC所产生的时间,提高了回收效率,保证了性能的稳定性。

JVM系列之垃圾回收算法_第11张图片
为了控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的时间。

分代算法是将对象按照生命周期长短划分为两个部分,分区算法是将整个堆划分为连续的不同的小区间,每一个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个小区间

1.3、垃圾回收相关概念(重点)

1.3.1、System.gc()的理解

System.gc()或Runtime.getRuntime().gc()的调用,会显示触发FullGC,同时会对老年代和新生代进行回收,尝试释放垃圾对象占用的内存,然而System.gc()调用无法保证对垃圾收集器的调用,一些特殊情况下,比如编写性能基准,我们可以在运行之间调用System.gc()

1.3.2、内存溢出与内存泄露
1.3.2.1、内存溢出

Java虚拟机的堆内存设置不够,代码创建大量大对象,并且长时间不能被垃圾收集器收集(仍然有GC Roots引用这这些对象)

1.3.2.2、内存泄露

只有对象不再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄露,实际情况有一些疏忽导致对象的生命周期变的很长甚至OOM,宽泛意义上的内存泄露。

举例:单例的生命周期和程序是一样长,如果单例程序中,持有对外部对象的引用的话,那么这个外部对象是不能被回收的,导致内存泄露,一些提供close的资源未关闭导致内存泄露,如数据库链接、网络链接和IO,ThreadLocal不恰当地使用,set数据,get完数据后并没有及时remove数据。

1.3.3、Stop-The-World(重点)
1.3.3.1、简称

简称STW,指的是GC事件发生过程中,会发生应用程序的停顿,停顿产生式整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。

1.3.3.2、为什么需要STW
  • 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿,确认哪些对象存活,哪些对象死亡
  • 分析工作必须保证在一个能确保一致性的快照中进行
  • 一致性是指整个分析期间整个执行系统看起来像被冻结在某个时间点上
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
1.3.3.2、STW总结
  • STW事件和采用哪款GC无关,所有的GC都有这个事件
  • 哪怕是G1也不能完全避免STW情况的发生,只能说GC越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间
  • STW是JVM后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉
  • 开发中不要使用System.gc(),会导致STW事件的发生
1.3.4、垃圾回收的并行与并发(重点)
1.3.4.1、并发

并发是指一段时间内,同一个CPU处理运行多个线程的代码,CPU切换

1.3.4.2、并行

并行是指一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互相不抢占资源,可以同时进行,我们称之为并行,并行因素取决于CPU的核心数量。

1.3.4.3、并发与并行对比
  • 并发指的是多个事情,在同一时间段内交替发生了
  • 并行指的是多个事情,在同一个时间点上同时发生了
  • 并发的多个任务之间抢占资源
  • 并行多个任务之间不互相抢占资源
  • 只有在多CPU或者一个CPU多核的情况中,才会发生并行。否则看似同时发生的事情,其实都是并发执行的
1.3.4.4、垃圾回收的并发与并行(基于垃圾回收线程的数量)
  • 串行(Serial):垃圾收集线程单线程执行,例如Serial Old垃圾收集器
  • 并行(Parallel ):多条垃圾收集线程并行工作,用户线程处于等待状态,例如ParNew、Parallel Scavenge、Parallel Old垃圾收集器
    JVM系列之垃圾回收算法_第12张图片
  • 并发(Concurrent):用户线程与垃圾回收线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行,用户线程继续运行,而垃圾收集线程运行在另一个CPU上,例如CMS、G1垃圾收集器
    JVM系列之垃圾回收算法_第13张图片
1.3.5、四大引用(强、软、弱、虚引用)

JVM系列之垃圾回收算法_第14张图片
在JDK1.2之前,Java的引用还是很传统的,reference类型的数据就是另外一块内存的起始地址,如果我们想要描述这样一些对象,当内存空间足够时,保留在内存中。当内存空间在垃圾回收之后仍然非常紧张,那么就可以回收这些对象,在JDK1.2之后,Java对引用的概念进行了扩充,分为强引用、软引用、弱引用和虚引用,这4中引用强度依次减弱。

1.3.5.1、强引用(Strongly Reference)

最传统的引用定义,程序代码中普遍存在的引用赋值,类似Object obj = new Object()这种引用关系,无论任何情况下,强引用存在,垃圾收集器永远不会回收掉被引用的对象,强引用是造成Java内存泄露的主要原因之一,强引用可以直接访问目标对象。

1.3.5.2、软引用(Soft Reference)

用来描述一些还有用,但是非必须的对象。系统将要发生内存溢出之前,会将这些对象列入回收范围之中进行第二次回收,如果这次回收后还没有足够内存,才会抛出内存溢出异常。

  • 软引用通常用来实现内存敏感的缓存,高速缓存就有用到软引用
  • 垃圾回收器在某个时间决定回收软可达的对象的时候,会清理软引用,并可选的把引用存放到一个引用队列

JVM系列之垃圾回收算法_第15张图片
JVM系列之垃圾回收算法_第16张图片

1.3.5.3、弱引用(Weak Reference)

弱引用也是用来描述那些非必须的对象,只被弱引用关联的对象只能够生存到下一次垃圾收集器之前,当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象
JVM系列之垃圾回收算法_第17张图片

1.3.5.4、虚引用(Phantom Reference)

一个对象是否有虚引用存在,完全不会对其生存时间构成影响。唯一目的就是在这个对象被收集器回收时收到一个系统通知,他不能单独使用,也无法通过虚引用获取被引用的对象。
JVM系列之垃圾回收算法_第18张图片

JVM系列之垃圾回收算法_第19张图片
在JDK源码中DirectByteBuffer使用了虚引用

  • 当DirectByteBuffer对象被回收时,通过引用队列得到通知,回收申请的直接内存
  • DirectByteBuffer中有一个静态内部类Deallocator实现了Runnable接口,重写了run()方法,run()方法内部调用了unsafe.freeMemory(address),也就是释放申请的直接内存的逻辑
  • DirectByteBuffer的构造方法中139行cleaner = Cleaner.create(this, new Deallocator(base, size, cap));创建了Deallocator对象,同时添加到Clear类内部的链表中
  • Deallocator重写的run()方法什么时候被调用
    JVM系列之垃圾回收算法_第20张图片
  • Reference类内部静态内部类ReferenceHandler的静态代码块启动了Reference Handler线程,专门处理ReferenceQueue中额外的回收任务
  • ReferenceHandler类的run()方法内部有代码((Cleaner)r).clean()间接调用了Deallocator重写的run()方法,至此申请地直接内存被回收
1.3.5.4.5、终结器

用以实现对象的finalize()方法,所以被称为终结器引用,无需手动编码,其内部配合引用队列使用,GC时,终结器引用入队,由finalizer线程通过终结器引用找到被引用对象并调用他的finalize方法,第二次GC时才能回收被引用对象

1.3.5.4.6、对象可达性判断(GC引用链路存在多种引用,如何判断对象的引用类型)

当一个对象被一个或者多个对象同时引用,且引用还有强、软、弱、虚那么如何判定引用关系呢?
JVM系列之垃圾回收算法_第21张图片
由此带来了一个问题,某个对象的可达性如何判断

  • 单条引用路径可达性判断:在这条路径中,最弱的一个引用决定对象的可达性
  • 多条引用路径可达性判断:几条路径中,最强的一条引用决定对象的可达性

比如,假设①、③为强引用,⑤为软引用,⑦为弱引用。对于对象5按照这两个原则,路径① - ⑤取最弱的引用⑤,因此该路径对象5的引用为软引用。同样③ - ⑦为弱引用。在这两条路径之间取最强的引用,最终对象5是一个软引用

1.4、内存回收细节(重点)

1.4.1、概述

HotSpot虚拟机如何发起内存回收、如何加速内存回收、以及如何保证回收的正确性

1.4.2、枚举GC Roots(加速内存回收)

虚拟机并不是从GC Roots可能存在的位置(局部变量表、方法区静态变量、方法区常量等)开始查找,而是使用一组称为OopMap的数据结构记录GC Roots,一旦类加载动作完成时,HotSpot就会把对象内引用数据类型(普通对象指针 Ordinary Object pointer OOP)计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用

1.4.3、安全点(SafePoint)(解决如何停止用户线程)
  • 程序执行并非在所有地方都能停顿下来开始GC,只有特定的位置才能停顿下来开始GC,这些位置称为安全点
  • 安全点的选定既不能太少以至于让收集器等待时间太长,也不能太过于频繁以至于过分增加运行时的内存负荷
  • 大部分指令执行都比较短,通常会根据是否具有让程序长时间执行的特征为标准选择一些执行时间较长的指令作为安全点,比如方法调用,循环跳转和异常跳转等。

如何在垃圾收集发生时让所有的线程都跑到最近的安全点,然后停顿下来,这里有两种方案:抢先式中断和主动式中断

  • 抢先式中断:JVM中断所有用户线程,如果还有线程不在安全点,就恢复线程,让线程跑到最近的安全点,没有虚拟机采用
  • 主动式中断:设置一个中断标志,各个线程运行到安全点的时候,主动轮询这个标志,如果标志为真,则将自己进行中断挂起,轮询标志的地方和安全点是重合的,HotSpot虚拟机使用内存保护陷阱,把轮询操作精简到只有一条汇编指令test
1.4.4、安全区域(SafeRegion)(解决如何停止用户线程)

JVM系列之垃圾回收算法_第22张图片

  • 如果线程处于sleep或者blocked状态,这时候线程无法响应JVM中断请求,走到安全点去中断挂起。对于这种情况,就需要安全区域来解决
  • 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中任何位置开始GC都是安全的
  • 当线程运行到安全区域代码时,首先标志已经进入了安全区域,如果进行GC,JVM会忽略标识为安全区域状态的线程
  • 当线程即将离开安全区域时,会检查JVM是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,则继续运行。否则线程必须等待直到收到可以安全离开安全区域的信号为止(枚举GC Roots完成之后,所有的用户线程才可以运行)
1.4.5、记忆集与卡表(解决跨代引用问题)

在分代收集理论中,可能存在着跨代引用的问题,例如老年代引用新生代中的对象。因此在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加入GC Roots扫描范围。

1.4.5.1、跨代引用的产生

跨代引用的产生:老年代引用新生代的对象,对象从新生代晋升到老年代,新生代引用老年代,用户线程手动修改老年代对象的引用,使其指向新生代的对象,事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器(使用分区算法)G1、ZGC和Shenandoah收集器,都会存在着相同的问题

1.4.5.2、记忆集

记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构,在新生代中开辟一块内存记录老年代中哪些内存区域存在着对新生代对象的引用(GC Roots)

1.4.5.3、如何记录这种关系呢?

一般而言有三种方式:

  • 字段精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字段包含跨代指针
  • 对象精度:每个记录精确到一个对象,该对象里有字段包含跨代指针
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
1.4.5.4、卡表

第三种"卡精度"使用一种称为卡表(Card Table)的方式去实现记忆集。可以理解为记忆集是一种抽象的接口,卡表是记忆集的一种具体实现,卡表使用字节数组实现,字节数组的每一个元素都对应着一块内存,这个内存块称为"卡页"(Card Page)。一般来说卡页大小都是2的N次幂的字节数,HotSpot虚拟机中使用卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)

JVM系列之垃圾回收算法_第23张图片
记忆集合实际上就是内存空间的粗粒度的位图表示(BitMap)

1.4.5.5、写屏障(变量赋值前后进行记录,类似于AOP操作)

使用记忆集来缩减GC Roots扫描范围,但是没有解决卡表元素如何维护的问题。

例如它们何时变脏、谁来把他们变脏?

  • 卡表变脏的时机:跨代引用,本代对应区域的卡表变脏,时间点原则上是引用字段赋值的那一刻
  • HotSpot虚拟机通过写屏障(Write Barrier)技术来维护卡表状态
  • 写屏障可以看做是在虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是赋值的前后都在写屏障的覆盖范围内
  • 在赋值前的部分的写屏障叫做写前屏障,在赋值后的叫做写后屏障。HotSpot虚拟机大多数垃圾收集器只用了写后屏障,除了G1收集器
1.4.6、三色标记法(解决并发标记地正确性)(重点)

前面多次强调枚举GC Roots的时候需要保证在一个满足一致性的快照中才能进行正确地遍历对象图,但是这会停止所有用户线程(STW事件)。那么有没有办法让用户线程和垃圾收集器线程并发执行呢,降低GC停顿时间呢?

1.4.6.1、用户线程和收集器线程并发执行可能导致对象关系发生变化,导致两种结果
  • 把原本消亡的对象标记位存活,这不是好事,但其实是可以接受的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理就好了
  • 把原本存活的对象错误标记为已消亡,这就是非常致命的错误了。
1.4.6.2、三色标记法基本概念(白色、黑色、灰色):

现代垃圾收集器基本上都是基于三色标记法遍历对象图,来确定对象间的引用关系

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始阶段,所有的对象都是白色的,若分析结束阶段,仍然是白色的对象,即代表不可达
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象所有直接引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象至少存在一个引用还没有被扫描过
1.4.6.3、基本的运行过程

假设现在有黑、白、灰三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在白色集合
  2. 将GC Roots直接引用的对象挪到灰色对象中
  3. 从灰色对象集合中获取对象obj,将obj对象引用到的其他对象全部挪到灰色集合中,当obj对象的所有直接引用都已经遍历完成,将obj对象挪到黑色集合里面
  4. 重复步骤(3)直至灰色集合为空时结束
  5. 结束后,仍在白色集合的对象即为GC Roots不可达,可以进行内存回收

从上面的过程可以看出对象图的遍历方式是广度优先遍历的方式,当然没有考虑循环引用的情况

JVM系列之垃圾回收算法_第24张图片
当Stop The World(STW)时,对象间的引用是不会发生变化的,可以轻松完成标记。但是当需要支持并发标记时,即标记期间用户线程还在继续跑,对象间的引用关系可能发生变化,多标和漏标的情况可能发生。

1.4.6.4、多标 - 浮动垃圾

JVM系列之垃圾回收算法_第25张图片
假设已经遍历到E(变成了灰色),此时用户线程执行了objD.fieldE = null; 此刻之后,对象E、F、G是应该被回收的。然而因为E已经变成了灰色,其仍然会被当做存活的对象继续遍历下去。最终的结果:部分对象(F、G)仍然会被标记为存活,即本轮GC不会回收这部分内存

这也就导致了并发标记阶段不能等到内存不够,才来回收内存,而是当内存使用达到一定比例就需要提前开始回收内存,这部分本来应该回收但是没有回收到的内存,被称之为浮动垃圾。浮动垃圾不会影响程序的正确性,这是需要等待下一轮垃圾回收,另外,针对并发标记阶段生成的新对象,通常的做法是直接全部当成黑色的,本轮不会清除。这部分对象可能变成垃圾,这也算是浮动垃圾的一部分。

1.4.6.5、漏标 - 读写屏障

JVM系列之垃圾回收算法_第26张图片
假设GC线程已经遍历到了对象E(已经变成了灰色),此时用户线程执行了如下代码
在这里插入图片描述
此时切回GC线程继续跑,因为对象E已经没有对对象G的引用了,所以不会将对象G放到灰色集合;尽管对象D重新引用了对象G,但是因为对象D已经是黑色的,不会重新做遍历处理。

最终导致的结束是:对象G会一直停留在白色集合中,最后被当做垃圾进行清除。这直接影响到了程序的正确性,是不可接受的

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,才会产生漏标,所有灰色对象断开了对白色对象的直接或者间接引用,即灰色对象原来成员变量的引用发生了变化,有一个或者多个黑色对象引用白色对象,即黑色对象成员变量增加了新的引用

1.4.6.5.1、过程分析(添加读写屏障)

在这里插入图片描述

  1. 读取对象E的成员变量fieldG的引用值,即对象G
  2. 对象E往成员变量fieldG,写入null值
  3. 对象D往成员变量fieldG,写入对象G

我们可以将对象G记录起来,然后作为灰色对象再进行遍历。不管对象G是不是垃圾,都不能回收。比如放入到一个特定的集合,等待初始的GC Roots遍历完(并发标记),该集合的对象遍历即可(重新标记)。重新标记需要STW,因为程序如果一直跑,该集合可能会一直增加新的对象,导致永远跑不完

读屏障拦截第一步,写屏障拦截第二步和第三步。它们拦截的目的很简单:就是在读写前后,将对象G给记录下来

1.4.6.5.2、写屏障(Store Barrier)

给某个对象的成员变量赋值时,底层代码大概为
JVM系列之垃圾回收算法_第27张图片
所谓的写屏障就是在赋值前后,加入一些处理(可以参考AOP的环绕通知概念)
JVM系列之垃圾回收算法_第28张图片

1.4.6.5.3、写屏障 + SATB
  • 当对象E的成员变量的引用发生变化时(objE.fieldG = null),我们可以利用写屏障,将E原来成员变量的引用G记录下来
  • 当成员变量的引用发生变化之前,记录下原来的引用对象。这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning STAB),当某个时刻的GC Roots确定后,当时的对象图已经确定了
  • 比如当时D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots
  • 可以简单理解为无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索,防止产生漏标,将不是垃圾的对象进行了回收
  • SATB破坏了条件一:灰色对象断开了白色对象的引用,从而保证了不会漏标
  • 一点小优化:如果不是处于垃圾回收的并发标记阶段,或者已经被标记过了,其实是没必要再记录了,所以可以加个简单的判断
    JVM系列之垃圾回收算法_第29张图片
1.4.6.5.4、写屏障 + 增量更新

当对象D的成员变量的引用发生变化时(objD.fieldG = G),可以利用写屏障,将D新的成员变量引用对象G记录下来
JVM系列之垃圾回收算法_第30张图片
当有新引用插入进行时,记录下新的引用对象。这种做法的思路是:不保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update),可以简单地理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象,重新进行遍历
,增量更新破坏了条件二:黑色对象重新引用了该白色对象,从而保证了不会漏标

1.4.6.5.5、读屏障(Load Barrier)

在这里插入图片描述
读屏障是直接针对第一步:var G = objE.fieldG;,当读取成员变量时,一律记录下来
JVM系列之垃圾回收算法_第31张图片
这种做法是保守的,但也是安全的。因为条件二中黑色对象重新引用了白色对象,重新引用的前提条件是:得到该白色对象,此时读屏障就发挥作用了

1.4.6.5、三色标记法与现代垃圾回收器

现代追踪式(可达性分析法)的垃圾收集器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色、黑色集合一般不会出现,灰色集合可以是通过栈、队列、缓存日志的方式实现,遍历的方式可以是广度、深度遍历等等

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

你可能感兴趣的:(面试系列,JVM系列,jvm,算法,java)