垃圾回收算法(学习笔记)
垃圾回收算法概述
垃圾回收算法可以分为两个阶段:标记阶段和清除阶段。
标记阶段:区分内存中存活和死亡的对象,并对死亡的对象进行标记
清楚阶段:将标记的对象清除出内存
死亡的对象是指没有被任何存活的对象所引用的对象
标记阶段算法:
- 引用计数算法
- 可达性分析算法
清除阶段算法:
- 标记-清除算法
- 复制算法
- 标记-压缩算法
引用计数算法
对于每一个对象,都保留一个整形的引用计数器属性。用于记录对象被引用的情况。
例如:对于一个对象A,当有其他对象引用A时,A的引用计数器就加1,当引用失效时减1.只要当对象A的引用计数器为0,则表示A不可能再被使用,可以进行回收。
优点:
- 实现简单,垃圾对象易于辨识
- 判定效率高,回收没有延迟
缺点:
- 它需要单独的字段存储计数器,增加了存储空间开销
- 每次赋值都需要更新计数器,增加呢时间开销
- (重要)无法处理循环引用的情况,这种方法会导致内存泄漏
Java中并未采用此算法,但是此方案在Python中使用了。
Python的解决方案为 手动解除 与 使用弱引用weakref(Python提供的标准库)
可达性分析算法
可达性分析算法又名根搜索算法、追踪性垃圾收集。
Java、C#采用的是可达性分析算法。
相比于引用技术算法,可达性算法不仅同样具备实现简答和执行高效的特点,还可以有效地解决循环引用的问题,房子内存泄漏的发生。
基本思路
- 以根对象集合为起始点,按照从上至下的方式搜索被跟对象集合所连接的目标对象是否可达
- 使用可达性分析算法后,内存中的对象丢会被跟对象直接或间接地连接,搜索所走过的路径为引用链
- 如果目标对象没有被任何引用链相连,则他是不可达的,也就意味着该对象已经死亡,可以被回收
- 在可达性分析算法中,仅有被根对象集合直接或间接连接的对象才是存活对象
在可达性分析中,根对象集合被称为GC Roots
根对象集合
GC Roots通常包含以下几种元素:
- 虚拟机栈中引用的对象
- 比如:各个线程被调用方法中使用到的参数、局部变量
- 本地方法栈内JNI(本地方法)引用的对象
- 方法区中静态属性引用的对象
- 比如:Java类的引用类型静态变量
- 方法区中常量引用的对象
- 比如:字符串常量池里的引用
- 所有被同步锁synchronized持有的对象
- JVM内部的引用
- 基本数据类型对应的Class 对象,一些常驻异常对象,系统类加载器
- 反应JVM内部情况的JMXBean、JVMTI中注册的回调、本地缓存等
除了以上这些集合外,根据垃圾收集器以及回收区域的不同,还可以有其他对象“临时地”加入到GC Roots中,比如分代收集和局部回收。
比如只针对新生代回收时,有可能将老年代和元空间的数据也纳入到GC Roots中。
注意
可达性分析工作必须在一个能够保证一致性的快照中进行,否则分析结果的准确性无法保证。
这一点也导致了GC时必须STW(Stop The World)的一个重要原因。
即使是在号称几乎不会发生停顿的CMS收集器中,枚举根节点时也必须要停顿。
对象的finalization机制
Java提供了对象终止机制来允许开发人员在对象销毁前的自定义逻辑处理。
垃圾回收对象前,总会调用该对象的finalize()
方法。
finalize()
方法允许在之类中被重写,用于在对象被回收时进行资源释放(比如关闭文件、套接字或数据库连接等)。
永远不要主动调用某个对象的finalize()
方法,即使该方法被重写了,原因如下
finalize()
方法可能会导致对象地复活(准确地讲叫做免死)finalize()
方法的执行时间没有保障,他完全由GC 线程决定,极端情况下,如果不发生GC,则finalize()
方法没有执行机会- 一个糟糕的
finalize()
方法会严重影响GC的性能
对象在虚拟机中的状态
由于finalize()
方法的存在,虚拟机中的对象一般处于以下三种状态之一。
- 可触及的:从根节点开始,可以到达这个对象
- 可复活的:对象的所有引用都被释放,但是对象可能在
finalize()
方法中复活 - 不可触及的:对象的
finalize()
方法被调用,并没有复活。
以上三种状态中,只有对象不可触及时才可以被回收。
格外注意:finalize()
方法只会被调用一次,如果一个对象通过finalize()
方法复活了,一段时间后再次被GC判定为垃圾,则GC不会第二次执行该对象的finalize()
方法,而时直接将其判定为不可触及的对象。
可达性分析算法的两重标记
- 对象到GC Roots没有引用链,则进行第一次标记
- 判断对象是否有执行
finalize()
方法的必要
(1) 如果finalize()
方法没有被重写,或者finalize()
方法被执行过了,则标记为不可触及的
(2)finalize()
方法没有被重写了,但还没有被执行过,那么队形会被插入到F-Queue中,有虚拟机创建的一个低优先级线程触发其finalize()
方法执行;
(3) 如果对象在finalize()
方法中与引用链上的任何一个对象建立了联系,那么该对象会被移出“即将回收”集合;否则,该对象会被标记为不可触及的
对象复活的代码演示
public class CanReliveObj {
public static CanReliveObj obj;//类变量,属于 GC Root
//此方法只能被调用一次
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类重写的finalize()方法");
obj = this;//当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系
}
public static void main(String[] args) {
try {
obj = new CanReliveObj();
// 对象第一次成功拯救自己
obj = null;
System.gc();//调用垃圾回收器
System.out.println("第1次 gc");
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
System.out.println("第2次 gc");
// 下面这段代码与上面的完全相同,但是这次自救却失败了
obj = null;
System.gc();
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
finalize()方法在JDK9中已经过时
由于在jdk9中, finalize()
方法被标记为过时的,因此,不再推荐使用finalize()
方法进行资源释放,而是推荐使用try-with-resource的形式进行资源释放或重用。
不推荐使用的原因:
- finalize()方法不能保证执行。
- 还有另外一个方法能够回收对象,Runtime.getRuntime().runFinalization(); 但是这只能保证GC做出最大的努力,但是我们也不能finalize方法都能执行。
- 我们还有一种方式能够保证执行finalize()方法,Runtime.runFinalizersOnExit(true),这个方法已经被JDK弃用,因为这种方法本质上是不安全的,可能导致finalizers方法被活对象调用而其他线程正在并行操作这个对象,从而导致不正确的行为或者死锁。
- finalize()方法不像构造方法在链中工作,意味着像当你调用构造方法的时候,超类中的构造方法也会被隐含的调用,但是在finalize()方法的这种情况中,这种隐含的调用不会发生。超类中的finalize()方法需要显示的调用。
- 假设,你创建了一个类并且小心翼翼的写了finalize()方法。一些人来extend了你的类,但是在子类中的finalize()块中没有调用super.finalize()方法。然后超类中finalize()方法将永远都不会被调用。
- 任何有finalize()方法抛出的异常都会被GC线程忽略而且不会被进一步传播,事实上也不会在日志文件上记录下来。
标记-清除(Mark-Sweep)算法
执行过程
当进行GC时,会停止整个程序,然后进行两项工作,第一项时标记,第二项是清除。
- 标记:Collector会从引用根节点开始遍历,标记所有被引用的对象(可达对象)。
- 清除:Collector对堆内所有对象进行线性遍历(也就是全部遍历),如果发现某一个对象在堆中但没有被标记,则将其回收。
存在的问题
- 效率不高:需要进行两次全遍历
- GC时需要暂停整个程序,用户体验不好
- 这样的方式请理出的空闲内存不连续,会产生内存的碎片。需要维护一个空闲列表。
这里所谓的清除并非真的置空,而是把对象地址保存在空闲地址列表中。下此新对象需要加载时,判断位置空间是否足够,如果够就覆盖存放。
复制算法
核心思想
将活着的内存空间分成两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中存活的对象复制到未被使用的内存块中,交换两个内存的角色,之后清除原来使用的内存块中的所有对象,完成垃圾回收。
(内存空间中的幸存者区S0、S1就采用这种算法)
优缺点
优点:
- 没有标记和清除的过程,实现简单、运行高效
- 没有内存碎片的问题
缺点:
- 需要两倍的内存空间
- 对于G1这种将内存拆分为大量region的GC,意味着GC需要维护region之间的引用关系,内存占用和时间开销并不小
特别的:
- 如果系统中垃圾对象很少,复制需要复制的存活对象数量很多,复制算法的效率就会很低。因此Java中复制算法用于新生代中(新生代中绝大多数对象的存活时间不长)。
标记-压缩(Mark-Compact)算法
标记-压缩算法又叫标记-整理算法,主要是解决标记-压缩算法的内存碎片问题。
标记-清除算法是非移动式的。
但是是否移动回收后的存活对象是一项优缺点并存的风险决策。
执行过程
标记:与标记-清除算法的第一过程一致。
压缩:将所有存活对象压缩到内存的一端,按顺序排放,之后请理边界外的所有空间。
优缺点
优点:
- 解决了标记-清除算法中内存分散的缺点,JVM只需要持有一个内存起始地址即可
- 解决了压缩算法中,内存减半的高额代价
缺点:
- 效率低于标记-清除算法
- 移动对象时,如果对象被引用,还需要调整引用地址
- 移动过程中需要暂停用户程序
三种清除算法的对比
标记-清除算法 | 标记-压缩算法 | 复制算法 | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(会有内存碎片) | 少(无内存碎片) | 2倍开销(无内存碎片) |
移动对象 | 否 | 是 | 是 |
效率上,复制算法最好,但是浪费太多内存。
如果兼顾以上三个指标,标记-整理算法更平滑一些,但是效率上不尽人意。
分代收集算法
不同对象生命周期是不一样的。因此,不同生命周期的对象可以采用不同的收集方式,以提高回收效率。一般是把Java堆分为新生代和老年代,并根据链代的特点使用不同的垃圾回收算法。
目前几乎所有的GC都是采用的分代收集算法。
新生代
特点:区域相对于老年代较小,对象生命周期短、存活率低、回收频繁。
适用:复制算法
对于复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得以缓解。
老年代
特点:区域比较大,对象生命周期长、存活率高,回收不频繁。
适用:标记-压缩或/和标记-清除算法
开销问题:
- Mark环节:开销与存活对象的数量正相关
- Compact环节:开销与存活对象的占用空间正相关
- Sweep环节:开销与管理区域的空间正相关
具体操作方法(CMS回收器):
基于标记-清除算法。而对于碎片问题,则采用标记-压缩算法的Serial Old回收器作为补偿,即执行Full GC对老年代进行整理。
增量收集算法
上述现有的算法,在垃圾回收过程中,程序处于一种暂停状态。如果垃圾回收时间过长,则会影响用户体验或系统的稳定性。为解决这一问题,有人提出了增量收集算法。
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用线程。依次反复,知道垃圾收集完成。
缺点:线程切换和上下文转换的消耗,会是的垃圾回收的总体成本上升,造成系统吞吐量下降。
分区算法
一般来说,相同条件下,堆空间越大,一次GC的时间越长,产生的停顿也越长。
为了更好地控制GC的停顿时间,通常会将一块的内存区域分割为若干个小块,根据目标的停顿时间,每次合理地回收若干个小区间而非整个对空间。每一个小区间独立使用,独立回收。