JVM垃圾回收算法
1.垃圾回收需要做什么
Java垃圾回收器需要做的三件事:
1、哪些内存需要回收?即如何判断对象已经死亡;
2、什么时候回收?即GC发生在什么时候?需要了解GC策略,与垃圾回收器实现有关;
3、如何回收?即需要了解垃圾回收算法,及算法的实现--垃圾回收器;
2.如何判断对象可被回收
垃圾收集器对堆进行回收前,首先要确定堆中的对象哪些还"存活",哪些已经"死去"。有两种算法,分别是引用计数算法(Recference Counting)和可达性分析算法(Reachability Analysis)。
2.1 引用计数算法
2.1.1 算法思路
给对象添加一个引用计数器,每当有一个地方引用它,计数器加1;当引用失效,计数器值减1;任何时刻计数器值为0,则认为对象是不再被使用的;
2.1.2 优点
-
实现简单、垃圾便于辨识;
-
判定效率高,回收没有延迟。
2.1.2 缺点
- 需要单独的字段存储计数器,额外的存储空间开销
- 需要更新计数器,伴随着加法和减法操作,带来时间开销
- 无法处理循环引用的情况
循环引用的例子:
public class RefCountGC {
//这个成员属性唯一的作用就是占用一点内存
private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB
Object reference = null;
public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();
obj1.reference = obj2;
obj2.reference = obj1;
obj1 = null;
obj2 = null;
//显式的执行垃圾回收行为
//这里发生GC,obj1和obj2能否被回收? 能被回收,是因为JVM采用的不是引用计数算法。所以obj1和obj2能被回收。这里反向证明了JVM没有采用引用计数算法。
System.gc();
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2.2 可达性分析算法
2.2.1 算法思路
通过一系列的GC Roots的对象作为起始点,从这些根节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
2.2.2 GC Roots对象(两栈两方法)
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
2.2.3 优点
更加精确和严谨,可以分析出循环数据结构相互引用的情况;主流的编程语言Java c#的选择。
2.2.4 缺点
-
消耗大量时间
从前面可达性分析知道,GC Roots主要在全局性的引用(常量或静态属性)和执行上下文中(栈帧中的本地变量表);
要在这些大量的数据中,逐个检查引用,会消耗很多时间; -
GC停顿
可达性分析期间需要保证整个执行系统的一致性,对象的引用关系不能发生变化;
导致GC进行时必须停顿所有Java执行线程(称为"Stop The World");
(几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的) Stop The World: 是JVM在后台自动发起和自动完成的; 在用户不可见的情况下,把用户正常的工作线程全部停掉;
3.判断对象生存还是死亡
3.1 两次标记过程
要真正宣告一个对象死亡,至少要经历两次标记过程。
1、第一次标记
在可达性分析后发现到GC Roots没有任何引用链相连时,被第一次标记;并且进行一次筛选:此对象是否必要执行finalize()方法;
(A)、没有必要执行
没有必要执行的情况:(1)、对象没有覆盖finalize()方法;(2)、finalize()方法已经被JVM调用过;这两种情况就可以认为对象已死,可以回收;
(B)、有必要执行
对有必要执行finalize()方法的对象,被放入F-Queue队列中;稍后在JVM自动建立、低优先级的Finalizer线程(可能多个线程)中触发这个方法;
2、第二次标记
GC将对F-Queue队列中的对象进行第二次小规模标记;finalize()方法是对象逃脱死亡的最后一次机会: (A)、如果对象在其finalize()方法中重新与引用链上任何一个对象建立关联,第二次标记时会将其移出"即将回收"的集合;(B)、如果对象没有,也可以认为对象已死,可以回收了;
一个对象的finalize()方法只会被系统自动调用一次,经过finalize()方法逃脱死亡的对象,第二次不会再调用;
3.2 finalize()方法
finalize()是Object类的一个方法,是Java刚诞生时为了使C/C++程序员容易接受它所做出的一个妥协,但不要当作类似C/C++的析构函数;因为它执行的时间不确定,甚至是否被执行也不确定(Java程序的不正常退出),而且运行代价高昂,无法保证各个对象的调用顺序(甚至有不同线程中调用);如果需要"释放资源",可以定义显式的终止方法,并在"try-catch-finally"的finally{}块中保证及时调用,如File相关类的close()方法; 此外,finalize()方法主要有两种用途:
1、充当"安全网"
当显式的终止方法没有调用时,在finalize()方法中发现后发出警告; 但要考虑是否值得付出这样的代价; 如FileInputStream、FileOutputStream、Timer和Connection类中都有这种应用;
2、与对象的本地对等体有关
本地对等体:普通对象调用本地方法(JNI)委托的本地对象;
本地对等体不会被GC回收;
如果本地对等体不拥有关键资源,finalize()方法里可以回收它(如C/C++中malloc(),需要调用free());
如果有关键资源,必须显式的终止方法;
一般情况下,应尽量避免使用它,甚至可以忘掉它。
4.HotSpot虚拟机中对象可达性分析的实现
4.1 枚举根节点
枚举根节点也就是查找GC Roots;
目前主流JVM都是准确式GC,可以直接得知哪些地方存放着对象引用,所以执行系统停顿下来后,并不需要全部、逐个检查完全局性的和执行上下文中的引用位置;
在HotSpot中,是使用一组称为OopMap的数据结构来达到这个目的的;在类加载时,计算对象内什么偏移量上是什么类型的数据;在JIT编译时,也会记录栈和寄存器中的哪些位置是引用;这样GC扫描时就可以直接得知这些信息;
4.2 安全点
4.2.1 安全点是什么,为什么需要安全点
HotSpot在OopMap的帮助下可以快速且准确的完成GC Roots枚举,但是这有一个问题:
运行中,非常多的指令都会导致引用关系变化;如果为这些指令都生成对应的OopMap,需要的空间成本太高;问题解决:
只在特定的位置记录OopMap引用关系,这些位置称为安全点(Safepoint);即程序执行时并非所有地方都能停顿下来开始GC;
4.2.2 安全点的选定
不能太少,否则GC等待时间太长;也不能太多,否则GC过于频繁,增大运行时负荷;所以,基本上是以程序"是否具有让程序长时间执行的特征"为标准选定;"长时间执行"最明显的特征就是指令序列复用,如:方法调用、循环跳转、循环的末尾、异常跳转等; 只有具有这些功能的指令才会产生Safepoint;
4.2.3 如何在安全点上停顿
对于Safepoint,如何在GC发生时让所有线程(不包括JNI线程)运行到其所在最近的Safepoint上再停顿下来? 主要有两种方案可选:
(A)、抢先式中断(Preemptive Suspension)
不需要线程主动配合,实现如下: (1)、在GC发生时,首先中断所有线程; (2)、如果发现不在Safepoint上的线程,就恢复让其运行到Safepoint上; 现在几乎没有JVM实现采用这种方式;
(B)、主动式中断(Voluntary Suspension)
(1)、在GC发生时,不直接操作线程中断,而是仅简单设置一个标志; (2)、让各线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起
而轮询标志的地方和Safepoint是重合的; 在JIT执行方式下:test指令是HotSpot生成的轮询指令; 一条test汇编指令便完成Safepoint轮询和触发线程中断;
4.3 安全区域
4.3.1 为什么需要安全区域
对于上面的Safepoint还有一个问题: 程序不执行时没有CPU时间(Sleep或Blocked状态),无法运行到Safepoint上再中断挂起;
这就需要安全区域来解决;
4.3.2 什么是安全区域(Safe Region)
指一段代码片段中,引用关系不会发生变化; 在这个区域中的任意地方开始GC都是安全的;
4.3.3 如何用安全区域解决问题
安全区域解决问题的思路:
(1)、线程执行进入Safe Region,首先标识自己已经进入Safe Region;
(2)、线程被唤醒离开Safe Region时,其需要检查系统是否已经完成根节点枚举(或整个GC);如果已经完成,就继续执行;否则必须等待,直到收到可以安全离开Safe Region的信号通知;
这样就不会影响标记结果;
5.垃圾回收算法
5.1 标记清除
标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。
标记: Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
清除: Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
适用场合:
- 存活对象较多的情况下比较高效
- 适用于年老代(即旧生代)
缺点:
- 容易产生内存碎片,再来一个比较大的对象时(典型情况:该对象的大小大于空闲表中的每一块儿大小但是小于其中两块儿的和),会提前触发垃圾回收而且需要维护一个空闲列表
- 效率不算高(第一次:标记存活对象;第二次:清除没有标记的对象)
- 在进行GC的时候,需要停止整个应用程序,导致用户体验差
5.2 复制算法
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾收。
在新生代,对常规应用的垃圾回收,一 次通常可以回收70%-99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
适用场合:
- 存活对象较少的情况下比较高效
- 扫描了整个空间一次(标记存活对象并复制移动)
- 适用于年轻代(即新生代):基本上98%的对象是”朝生夕死”的,存活下来的会很少
优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点:
- 需要两倍的内存空间
- 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小
5.3 标记整理
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。
这种情况在新生代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。
执行过程:
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark- Sweep-Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一-项优缺点并存的风险决策。
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一-来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
- 优点:
消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只 需要持有一个内存的起始地址即可。消除了复制算法当中,内存减半的高额代价。 - 缺点:
从效率上来说,标记-整理算法要低于复制算法。移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。移动过程中,需要全程暂停用户应用程序。即: STW
对比三种算法
5.4 分代收集算法
把堆内存分为新生代和老年代,新生代又分为 Eden 区、From Survivor 和 To Survivor。一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收。
在这些区域的垃圾回收大概有如下几种情况:
在这些区域的垃圾回收大概有如下几种情况:
大多数情况下,新的对象都分配在Eden区,当 Eden 区没有空间进行分配时,将进行一次 Minor GC,清理 Eden 区中的无用对象。清理后,Eden 和 From Survivor 中的存活对象如果小于To Survivor 的可用空间则进入To Survivor,否则直接进入老年代);Eden 和 From Survivor 中还存活且能够进入 To Survivor 的对象年龄增加 1 岁(虚拟机为每个对象定义了一个年龄计数器,每执行一次 Minor GC 年龄加 1),当存活对象的年龄到达一定程度(默认 15 岁)后进入老年代,可以通过 -XX:MaxTenuringThreshold 来设置年龄的值。
当进行了 Minor GC 后,Eden 还不足以为新对象分配空间(那这个新对象肯定很大),新对象直接进入老年代。
占 To Survivor 空间一半以上且年龄相等的对象,大于等于该年龄的对象直接进入老年代,比如 Survivor 空间是 10M,有几个年龄为 4 的对象占用总空间已经超过 5M,则年龄大于等于 4 的对象都直接进入老年代,不需要等到 MaxTenuringThreshold 指定的岁数。
在进行 Minor GC 之前,会判断老年代最大连续可用空间是否大于新生代所有对象总空间,如果大于,说明 Minor GC 是安全的,否则会判断是否允许担保失败,如果允许,判断老年代最大连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则执行 Minor GC,否则执行 Full GC。
当在 java 代码里直接调用 System.gc() 时,会建议 JVM 进行 Full GC,但一般情况下都会触发 Full GC,一般不建议使用,尽量让虚拟机自己管理 GC 的策略。
永久代(方法区)中用于存放类信息,jdk1.6 及之前的版本永久代中还存储常量、静态变量等,当永久代的空间不足时,也会触发 Full GC,如果经过 Full GC 还无法满足永久代存放新数据的需求,就会抛出永久代的内存溢出异常。
大对象(需要大量连续内存的对象)例如很长的数组,会直接进入老年代,如果老年代没有足够的连续大空间来存放,则会进行 Full GC。
6.参考
1.JVM:引用计数算法和可达性分析算法(https://www.zhifou.net/blogdetail/183)
2.4种JVM垃圾回收算法详解(https://mikechen.cc/7102.html)
3.深入理解Java虚拟机(四)之垃圾回收算法(https://www.codenong.com/cs106639755/)