深入理解Java虚拟机(三)HotSpot算法和垃圾收集器

前面介绍了对象存活判定算法和垃圾收集算法,在HotSpot虚拟机上实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。

1. 可达性分析算法的实现(枚举根节点)

1.1 GC Roots根节点的选择

可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中。

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

1.2 可达性分析

该算法的基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。这个过程会出现GC停顿,意思就是在GC的时候Java的执行线程都被停顿,好像被冻结在某一个时间点,也叫“Stop the world”。

《深入理解Java虚拟机(三)HotSpot算法和垃圾收集器》

  • 准确式GC:目前主流的Java虚拟机都是用准确式GC(准确式GC,就是让虚拟机知道内存中的某个位置的数据是什么类型),当“Stop the world”的时候并不需要检查所有的引用位置,虚拟机通过使用OopMap这个数据结构知道哪些地方存放着对象的引用。

1.3 OopMap

垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference 类型。如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。但问题是,栈上的本地变量表里面只有一部分数据是 Reference 类型的(它们是我们所需要的),那些非 Reference 类型的数据对我们而言毫无用处,但我们还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。

一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。 gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap ,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。参考这里

2. 安全点

  • 现在虚拟机通过OopMap已经可以快速的知道对象存储在哪个位置,但是并没有为每一个指令都创建了一个OopMap(可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高)。
  • HotSpot只是在“特定的位置”记录了OopMap这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。

安全点的位置经常被设置在:

  • 循环的末尾
  • 方法临返回前 / 调用方法的call指令后
  • 可能抛异常的位置

3. 让程序在安全点停下来

如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来,有两种方法。

  • 抢先式中断(Preemptive Suspension):抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
  • 主动式中断(Voluntary Suspension):当GC需要中断线程的时候,不直接对线程操作,仅仅简单地在安全点设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。(例如,现在有一条线程正在执行,自动去轮询安全点,如果一旦发现当前的安全点是可进入GC的Safepoint(标志位为真),那么就立即自动挂起让当前所在的安全点进行GC)

4. 安全区域

线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。

  • 安全点的使用似乎解决了OopMap计算的效率的问题,但是这里还有一个问题。安全点需要程序自己跑过去,那么对于那些已经停在路边休息或者看风景的程序(比如那些处在Sleep或者Blocked状态的线程),他们可能并不会在很短的时间内跑到安全点去。所以这里为了解决这个问题,又引入了安全区域的概念。

  • 安全区域很好理解,就是在程序的一段代码片段中并不会导致引用关系发生变化,也就不用去更新OopMap表了,那么在这段代码区域内任何地方进行GC都是没有问题的。这段区域就称之为安全区域。线程执行的过程中,如果进入到安全区域内,就会标志自己已经进行到安全区域了。那么虚拟机要进行GC的时候,发现该线程已经运行到安全区域,就不会管该线程的死活了。所以,该线程在脱离安全区域的时候,要自己检查系统是否已经完成了GC或者根节点枚举(这个跟GC的算法有关系),如果完成了就继续执行,如果未完成,它就必须等待收到可以安全离开安全区域的Safe Region的信号为止。

5. 垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。HotSpot虚拟机的垃圾收集器如下:
《深入理解Java虚拟机(三)HotSpot算法和垃圾收集器》

作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。

5.1 Serial收集器

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。该收集器的原理如下:

《深入理解Java虚拟机(三)HotSpot算法和垃圾收集器》

Serial收集器并不是一个“老而无用、食之无味弃之可惜”的鸡肋,但实际上到现在为止,它依然是虚拟机运行在Client模式下的默认新生代收集器。它也有着优于其他收集器的地方:**简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。**在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。

5.2 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。ParNew收集器的工作过程如下:
《深入理解Java虚拟机(三)HotSpot算法和垃圾收集器》

主要在于除了Serial收集器,目前只有ParNew收集器能够与CMS收集器配合工作。

5.3 Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代垃圾收集器,其使用的算法是复制算法,也是并行的多线程收集器。

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

Parallel Scavenge收集器更关注可控制的吞吐量,吞吐量等于运行用户代码的时间/(运行用户代码的时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge收集器使用两个参数控制吞吐量:-XX:MaxGCPauseMillis控制最大的垃圾收集停顿时间,-XX:GCRatio直接设置吞吐量的大小。

直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的。比如原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间下降的同时,吞吐量也下降了。

除此之外,Parallel Scavenge收集器还可以设置参数-XX:+UseAdaptiveSizePocily来动态调整停顿时间或者最大的吞吐量,这种方式称为GC自适应调节策略,这点是ParNew收集器所没有的。

5.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用 ,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

5.5 Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法进行垃圾回收。使用多线程和“标记-整理”算法。

这个收集器是在JDK 1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外别无选择(还记得上面说过Parallel Scavenge收集器无法与CMS收集器配合工作吗?)。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合“给力”。直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old 收集器。

Parallel Scavenge/Parallel Old收集器的工作过程如下:
《深入理解Java虚拟机(三)HotSpot算法和垃圾收集器》

5.5 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。CMS主要分为4个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

初始标记和重新标记这两个步骤仍然需要暂停Java执行线程,初始标记只是标记GC Roots能够关联到的对象,并发标记就是执行GC Roots Tracing的过程,而重新标记就是为了修正并发标记期间因用户程序执行而导致标记发生变动使得标记错误的记录。其执行过程如下:

《深入理解Java虚拟机(三)HotSpot算法和垃圾收集器》

CMS的优点很明显:并发收集、低停顿(由于进行垃圾收集的时间主要耗在并发标记与并发清除这两个过程,虽然初始标记和重新标记仍然需要暂停用户线程,但是从总体上看,这部分占用的时间相比其他两个步骤很小,所以可以认为是低停顿的)。

尽管如此,CMS收集器的缺点也是很明显的:

  • 对CPU资源太敏感,这点可以这么理解,虽然在并发标记阶段用户线程没有暂停,但是由于收集器占用了一部分CPU资源,导致程序的响应速度变慢
  • CMS收集器无法处理浮动垃圾。所谓的“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们(为什么?原因在于CMS是以获取最短停顿时间为目标的,自然不可能在一次垃圾处理过程中花费太多时间),只好在下一次GC的时候处理。这部分未处理的垃圾就称为“浮动垃圾”
  • 由于CMS收集器是基于“标记-清除”算法的,前面说过这个算法会导致大量的空间碎片的产生,一旦空间碎片过多,大对象就没办法给其分配内存,那么即使内存还有剩余空间容纳这个大对象,但是却没有连续的足够大的空间放下这个对象,所以虚拟机就会触发一次Full GC(这个后面还会提到)这个问题的解决是通过控制参数-XX:+UseCMSCompactAtFullCollection,用于在CMS垃圾收集器顶不住要进行FullGC的时候开启空间碎片的合并整理过程。

5.6 G1收集器

G1(Garbage-First)收集器是现今收集器技术的最新成果之一,之前一直处于实验阶段,直到jdk7u4之后,才正式作为商用的收集器。

G1收集器有以下特点:

  • 并行与并发
  • 分代收集(仍然保留了分代的概念)
  • 空间整合(整体上属于“标记-整理”算法,不会导致空间碎片)
  • 可预测的停顿(比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒)

G1收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

初始标记阶段仅仅只是标记一下GC Roots能够直接关联的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段的用户程序并发运行的时候,能在正确可用的Region中创建对象,这个阶段需要暂停线程。并发标记阶段从GC Roots进行可达性分析,找出存活的对象,这个阶段食欲用户线程并发执行的。最终标记阶段则是修正在并发标记阶段因为用户程序的并发执行而导致标记产生变动的那一部分记录,这部分记录被保存在Remembered Set Logs中,最终标记阶段再把Logs中的记录合并到Remembered Set中,这个阶段是并行执行的,仍然需要暂停用户线程。最后在筛选阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划。整个执行过成功如下:

《深入理解Java虚拟机(三)HotSpot算法和垃圾收集器》

垃圾收集器常用参数总结

《深入理解Java虚拟机(三)HotSpot算法和垃圾收集器》

    原文作者:java虚拟机
    原文地址: https://blog.csdn.net/xyh930929/article/details/81281471
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞