JVM(三)----垃圾收集算法及Safe Point介绍

JVM(一)---- 总结与专题目录
JVM(二)----Java运行时数据区域
JVM(三)----垃圾收集算法及Safe Point介绍
JVM(四)----HotSpot的垃圾收集器与内存分配回收策略
JVM(五)----虚拟机类加载机制

本文的内容如下:

  • 如何判断对象是否存活
  • 强软弱虚引用
  • 垃圾收集算法
  • HotSpot的算法实现
  • safe point 和safe region介绍

一、判断对象是否存活(Which?)

垃圾收集器在对堆进行回收之前,第一件事情就是要确定这些对象之中哪些还存活着,哪些已经死去。

1.1.引用计数算法

每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。

1.2 可达性分析算法

此算法可以解决循环引用问题。从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,是不可达对象。

在Java语言中,GC Roots包括:
虚拟机栈中引用的对象。
方法区中类静态属性实体引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI引用的对象。

JVM(三)----垃圾收集算法及Safe Point介绍_第1张图片
image.png

1.3 强、软、弱、虚引用

在JDK1.2以前的版本中,当一个对象不被任何变量引用,那么程序就无法再使用这个对象。也就是说,只有对象处于可触及状态,程序才能使用它。这就像在日常生活中,从商店购买了某样物品后,如果有用,就一直保留它,否则就把它扔到垃圾箱,由清洁工人收走。一般说来,如果物品已经被扔到垃圾箱,想再把它捡回来使用就不可能了。
但有时候情况并不这么简单,你可能会遇到类似鸡肋一样的物品,食之无味,弃之可惜。这种物品现在已经无用了,保留它会占空间,但是立刻扔掉它也不划算,因 为也许将来还会派用场。对于这样的可有可无的物品,一种折衷的处理办法是:如果家里空间足够,就先把它保留在家里,如果家里空间不够,即使把家里所有的垃圾清除,还是无法容纳那些必不可少的生活用品,那么再扔掉这些可有可无的物品。
从JDK1.2版本开始,把对象的引用分为四种级别,从而使程序能更加灵活的控制对象的生命周期。这四种级别由高到低依次为:强引用、软引用、弱引用和虚引用。

强引用:
平时我们编程的时候例如:Object object=new Object();那object就是一个强引用了。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

软引用:
如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

弱引用:
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

虚引用:
"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。 虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

二、垃圾收集算法(How?)

知道了要收集那些垃圾对象后,怎么收集呢?这就需要一些垃圾收集算法了。

2.1 标记清除算法

最基本的收集算法“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,之所以说它是最基本的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。
它的主要不足有两个:一是效率问题,标记和清除效率都不高,二是空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序在运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。执行过程如下:


JVM(三)----垃圾收集算法及Safe Point介绍_第2张图片
image.png

2.2 复制算法

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,他将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活这的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。现代的商业虚拟机都采用这种算法进行垃圾回收,但是不是分为大小相等的两块,而是分为一块较大的Eden和两块较小的Survivor区域,每次使用一块Eden和Survivor区域,把存活的复制到另外一块Survivor区域,然后清理刚才使用的Eden和Survivor。HotSpot虚拟机默认Eden和Survivor的的大小比例是8:1.

JVM(三)----垃圾收集算法及Safe Point介绍_第3张图片
image.png

2.3 标记整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是如果不想浪费50%的空间就要使用额外的空间进行分配担保(Handle Promotion当空间不够时,需要依赖其他内存),以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
对于“标记-整理”算法,标记过程仍与“标记-清除”算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有的存活对象都向一端移动,然后直接清理掉端边界以外的内存,”标记-整理“算法示意图如下:


JVM(三)----垃圾收集算法及Safe Point介绍_第4张图片
image.png

2.4 分代收集算法

当前的商业虚拟机的垃圾收集都是采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把堆划分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

三、HotSpot的算法实现

3.1 枚举根节点

通过前面的介绍,我们知道,在分析一个对象是否是存活的时候有两种方法,一个是引用计数法,引用计数法虽然实现简单并且效率较高,但是很难解决循环引用。所以目前主流的虚拟机都是使用的是:可达性分析法。在可达性分析法中对象能被回收的条件是没有引用来引用它,要做到这点就需要得到所有的GC Roots节点,来从GC Root来遍历。可作为GC Root的主要是全局性引用(例如常量和静态变量),与执行上下文(栈帧中的本地变量表)中。那么如何在这么多的全局变量和栈中的局部变量表中找到栈上的根节点呢?

在栈中只有一部分数据是Reference(引用)类型,那些非Reference的类型的数据对于找到根节点没有什么用处,如果我们对栈全部扫描一遍这是相当浪费时间和资源的事情。

那怎么做可以减少回收时间呢?我们很自然的想到可以用空间来换取时间,我们可以在某个位置把栈上代表引用的位置记录下来,这样在gc发生的时候就不用全部扫描了,在HotSpot中使用的是一种叫做OopMap的数据结构来记录的。对于OopMap可以简单的理解成:它记录着对象内什么偏移量上是什么类型的数据。

3.2 安全点(Safe point)

在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但我们也不能为每一条指令都生成OopMap,那样一方面会需要更多的空间来存放这些对象,另一方面效率也会低。所以,只会在特定的位置记录这些信息,这些特定位置称为安全点(Safe point),即程序执行时并非在所有地方都能停顿下来GC,只有在到达安全点时才能暂停。

从线程角度看,safepoint可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这个位置暂停,比如发生GC时,需要暂停暂停所以活动线程,但是线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,等待GC结束。

什么地方可以放safepoint?

  1. 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)
  2. 方法返回前
  3. 调用方法的call之后
  4. 抛出异常的位置

之所以选择这些位置作为safepoint的插入点,主要的考虑是“避免程序长时间运行而不进入safepoint”,比如GC的时候必须要等到Java线程都进入到safepoint的时候VMThread才能开始执行GC,如果程序长时间运行而没有进入safepoint,那么GC也无法开始,JVM可能进入到Freezen假死状态。

知道了safe point的概念后,怎么使线程都“跑”到最近的安全点上停下来呢。这里有两种方式:抢先式中断和主动式中断。

  • 抢先式中断
    在GC发生时先中断所有线程,如果线程不在安全点上,则启动该线程使其执行到安全点后挂起。几乎已没有虚拟机只用此种方式

  • 主动式中断
    不需要直接对线程进行操作,仅仅简单设置一个标识,在线程执行时主动轮询这个标识,若中断标识为真,线程自己中断挂起。这个标识和安全点是重合的。

3.3安全区域(safe region)

上面的安全点检查仿佛完全解决了如何进入GC的问题,但只有安全点还是不够的,安全点只解决了那些在运行的程序,保证了他们可以运行到安全点并挂起,但如果有些线程此时并未执行,例如处于sleep或blocked状态的线程,就无法响应JVM的中断请求,这是就用到了安全区域。

定义:
安全区域是指在此区域内,对象的引用关系不会发生变化(即不会影响枚举根节点)

原理:
当线程运行到安全区域时会将自己标识,在JVM准备进行GC时将视这些线程为安全的,不影响GC,当线程运行完毕要离开安全区域时,线程会检查JVM是否在枚举根节点,若是,则等待完成后再离开安全区域继续执行。

补充:引自占小狼的文章

Safe Point对JVM性能有什么影响?
通过设置JVM参数 -XX:+PrintGCApplicationStoppedTime, 可以打出系统停止的时间,大概如下:

Total time for which application threads were stopped: 0.0051000 seconds  
Total time for which application threads were stopped: 0.0041930 seconds  
Total time for which application threads were stopped: 0.0051210 seconds  
Total time for which application threads were stopped: 0.0050940 seconds  
Total time for which application threads were stopped: 0.0058720 seconds  
Total time for which application threads were stopped: 5.1298200 seconds
Total time for which application threads were stopped: 0.0197290 seconds  
Total time for which application threads were stopped: 0.0087590 seconds

从上面数据可以发现,有一次暂停时间特别长,达到了5秒多,这在线上环境肯定是无法忍受的,那么是什么原因导致的呢?

一个大概率的原因是当发生GC时,有线程迟迟进入不到safepoint进行阻塞,导致其他已经停止的线程也一直等待,VM Thread也在等待所有的Java线程挂起才能开始GC,这里需要分析业务代码中是否存在有界的大循环逻辑,可能在JIT优化时,这些循环操作没有插入safepoint检查。

参考资料:https://www.jianshu.com/p/c79c5e02ebe6

你可能感兴趣的:(JVM(三)----垃圾收集算法及Safe Point介绍)