JVM(六)垃圾回收机制及算法

2021-08-17  本文已影响0人  干天慈雨

1. 垃圾回收概述

什么是垃圾回收?
垃圾收集(Garbage Collection,也就是GC)需要完成的三件事件:
哪些内存需要回收?
什么时候回收?
如何回收?
java垃圾回收的优缺点:
优点:

缺点:
java开发人员不了解自动内存管理, 内存管理就像一个黑匣子,过度依赖就会降低我们解决内存溢出/内存泄漏等问题的能力。

2. 垃圾回收-对象是否已死?

2.1 判断对象是否存活-引用计数算法

引用计数算法可以这样实现:给每个创建的对象添加一个引用计数器,每当这个对象被某个地方引用时,计数值+1,引用失效-1,所以当计数器为0时表示对象已经不能被使用。引用计数并不是一个很好的选择,因为它很难解决对象直接相互循环引用的问题。
优点:
实现简单,执行效率高,很好的和程序交织。
缺点:
无法检测出循环引用。

譬如有A和B两个对象,他们都互相引用,除此之外都没有任何对外的引用,那么理论上A和B都可以被作为垃圾回收掉,但实际如果采用引用计数算法,则A、B的引用计数都是1,并不满足被回收的条件,如果A和B之间的引用一直存在,那么就永远无法被回收了

public class App {
    public static void main(String[] args) {
        Test object1 = new Test();
        Test object2 = new Test();
        object1.object = object2;
        object2.object = object1;
        object1 = null;
        object2 = null;
    }
}

class Test {
    public Test object = null;
}

这两个对象再无任何引用, 实际上这两个对象已 经不可能再被访问, 但是它们因为互相引用着对方, 导致它们的引用计数都不为零, 引用计数算法也 就无法回收它们 。但是在java程序中这两个对象仍然会被回收,因为java中并没有使用引用计数算法。

2.2 判断对象是否存活-可达性分析算法

2.2.1 可达性分析算法

此算法的基本思路就是通过一系列的“GC Roots”的对象作为起始点,从起始点开始向下搜索到对象的路径。搜索所经过的路径称为引用链(Reference Chain),当一个对象到任何GC Roots都没有引用链时,则表明对象“不可达”,即该对象是不可用的。

可达性分析
在Java语言中,可作为GC Roots的对象包括下面几种:

2.2.2 JVM之判断对象是否存活

finalize()方法最终判定对象是否存活:
即使在可达性分析算法中判定为不可达的对象, 也不是“非死不可”的, 这时候它们暂时还处于“缓 刑”阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程:
第一次标记:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记, 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法。
没有必要:
假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为“没有必要执行”。
有必要:
如果这个对象被判定为确有必要执行finalize()方法, 那么该对象将会被放置在一个名为F-Queue的 队列之中, 并在稍后由一条由虚拟机自动建立的、 低调度优先级的Finalizer线程去执行它们的finalize() 方法。 finalize()方法是对 象逃脱死亡命运的最后一次机会, 稍后收集器将对F-Queue中的对象进行第二次小规模的标记, 如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己 (this关键字) 赋值给某个类变量或者对象的成员变量, 那在第二次标记时它将被移出“即将回收”的集 合; 如果对象这时候还没有逃脱, 那基本上它就真的要被回收了。

image.png
演示:
/**
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次, 因为一个对象的finalize()方法最多只会被系统自动调用一次
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();
        // 对象第一次拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低, 暂停0.5秒, 以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低, 暂停0.5秒, 以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead  :(");
        }

    }
}

注意:
Finalizer线程去执行它们的finalize() 方法, 这里所说的“执行”是指虚拟机会触发这个方法开始运行, 但并不承诺一定会等待它运行结束。 这样做的原因是, 如果某个对象的finalize()方法执行缓慢, 或者更极端地发生了死循环, 将很可能导 致F-Queue队列中的其他对象永久处于等待, 甚至导致整个内存回收子系统的崩溃。

2.2.3 再谈引用

在JDK1.2以前,Java中引用的定义很传统: 如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。 我们希望能描述这一类对象: 当内存空间还足够时,则能保存在内存中;如果内存空间在进行垃圾回收后还是非常紧张,则可以抛弃这些对象。很多系统中的缓存对象都符合这样的场景。 在JDK1.2之后,Java对引用的概念做了扩充,将引用分为 强引用(Strong Reference) 、 软引用(Soft Reference) 、 弱引用(Weak Reference) 和 虚引 用(Phantom Reference) 四种,这四种引用的强度依次递减。
强引用(Strong Reference)
强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。 ps:强引用其实也就是我们平时A a = new A()这个意思。
软引用(Soft Reference)
如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中
弱引用(Weak Reference)
用来描述那些非必须对象, 但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。 当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只 被弱引用关联的对象。 在JDK 1.2版之后提供了WeakReference类来实现弱引用。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

弱引用与软引用的区别在于:
①更短暂的生命周期;
②一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

虚引用(PhantomReference)
“虚引用”顾名思义,它是最弱的一种引用关系。如果一个对象仅持有虚引用,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。

虚引用与软引用和弱引用的一个区别在于:
①虚引用必须和引用队列 (ReferenceQueue)联合使用。
②当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。

3. 垃圾收集算法

3.1 分代收集理论

根据对象的生命周期将内存划分,然后进行分区管理。 当前商业虚拟机的垃圾收集器, 大多数都遵循了“分代收集”(Generational Collection)的理论进 行设计, 分代收集名为理论, 实质是一套符合大多数程序运行实际情况的经验法则, 它建立在两个分代假说之上:

3.2 标记-清除算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep) 算法, 在1960年由Lisp之父 John McCarthy所
提出。 如它的名字一样, 算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回 收的对象, 在标记完成后,
统一回收掉所有被标记的对象, 也可以反过来, 标记存活的对象, 统一回 收所有未被标记的对象。
标记过程就是对象是否属于垃圾的判定过程, 之所以说它是最基础的收集算法, 是因为后续的收集算法大多都是以标记-清除算法为基础, 对其 缺点进行改进而得到的。


标记清除算法

标记-清除算法有两个不足之处:
第一个是执行效率不稳定, 如果Java堆中包含大量对 象, 而且其中大部分是需要被回收的, 这时必须进行大量标记和清除的动作, 导致标记和清除两个过 程的执行效率都随对象数量增长而降低;
第二个是内存空间的碎片化问题, 标记、 清除之后会产生大 量不连续的内存碎片, 空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3.3 标记-复制算法

标记-复制算法常被简称为复制算法。
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题, 1969年Fenichel提出了一种称为“半区复制”(Semispace Copying) 的垃圾收集算法, 它将可用 内存按容量划分为大小相等的两块, 每次只使用其中的一块。 当这一块的内存用完了, 就将还存活着 的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉。 如果内存中多数对象都是存活的, 这种算法将会产生大量的内存间复制的开销, 但对于多数对象都是可回收的情况, 算法需要复制的就是占少数的存活对象, 而且每次都是针对整个半区进行内存回收, 分配内存时也就不用考虑有空间碎片的复杂情况, 只要移动堆顶指针, 按顺序分配即可.


标记复制算法

但是这种算法也有缺点:
1)需要提前预留一半的内存区域用来存放存活的对象(经过垃圾收集后还存活的对象),这样导致可用的对象区域减小一半,总体的GC更加频繁了
2)如果出现存活对象数量比较多的时候,需要复制较多的对象,成本上升,效率降低
3)如果99%的对象都是存活的(老年代),那么老年代是无法使用这种算法的

注意事项:
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代, IBM公司曾有一项专门研 究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。 因此 并不需要按照1∶1的比例来划分新生代的内存空间。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时, 将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空 间。 HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1, 也即每次新生代中可用内存空间为整个新 生代容量的90%(Eden的80%加上一个Survivor的 10%) , 只有一个Survivor空间, 即10%的新生代是会 被“浪费”的。

3.4 标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作, 效率将会降低。 更关键的是, 如果不想浪费50%的空间, 就需要有额外的空间进行分配担保, 以应对被使用的内存中所有对象都100%存活的极端情况, 所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征, 1974年Edward Lueders提出了另外一种有针对性的“标记-整 理”(Mark-Compact) 算法, 其中的标记过程仍然与“标记-清除”算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法, 而后者是移动式的。 是否移动回收后的存活对象是一项优缺点并存的风险决策:


标记整理算法

是否移动对象都存在弊端, 移动则内存回收时会更复杂, 不移动则内存分配时会更复杂。 从垃圾收集的停顿时间来看, 不移动对象停顿时间会更短, 甚至可以不需要停顿, 但是从整个程序的吞吐量来看, 移动对象会更划算。

上一篇下一篇

猜你喜欢

热点阅读