深入浅出讲解JVM垃圾收集机制

102 阅读7分钟

深入浅出讲解JVM垃圾收集机制

Java与C++之间有一堵由内存动态分配和垃圾回收技术围城的高墙,墙外面的人想进去,墙里面的人想出来。

垃圾回收并非Java语言的产物,垃圾收集历史远比Java悠远,但这并非本文讨论的重点(我就提一嘴,有兴趣可以了解以下Lisp)。谈起垃圾收集,就得思考三件事:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

对象已死?

我们认为死掉的对象需要回收。如何界定一个对象的生死?如果一个对象没有任何一个地方引用,那么我们认为它已死。是不是有点像人的第三次死亡?

人的一生,要死去三次。第一次,当你的心跳停止,呼吸消逝,你在生物学上被宣告了死亡。第二次,当你下葬,人们穿着黑衣出席你的葬礼。他们宣告,你在这个社会上不复存在,你悄然离去。第三次死亡,是这个世界上最后一个记得你的人,把你忘记。

实际上判定对象是否已死并没有这么简单,比如如果AB两个对象互相持有引用,除此之外再无任何引用,那么它们理应被回收,但实际上根据上面的规则来设计垃圾收集器的话它们并不会被回收。

实际上,JVM使用可达性分析算法来判断对象是否“可达”。JVM会沿着GC Roots引用链,依次判断对象是否可达。能被固定为GC Roots的包括:

  • 栈帧中引用的对象
  • 方法区中静态/常量引用的对象
  • 本地方法引用的对象
  • JVM内部引用(Class对象,异常对象等)
  • 同步锁持有的对象

总之,就是“重要”的对象都会被作为GC Roots

再谈引用

也许有一些对象,我们即使持有它的引用,也希望它会被回收。比如ThreadLocal。我们知道,ThreadLocal的实际值是储存在线程内部的,那么将会出现线程不死,值就一直存在。即使销毁了ThreadLocal对象,它也会一直存在。这将会造成内存泄漏。

内存泄露:指某一块内存中的数据不再需要,但垃圾收集器无法将其回收。如果这种情况经常出现,那么系统可用内存将会越来越少。

public class ThreadLocalWeakReferenceExample {
    public static void main(String[] args) {
        // 创建一个ThreadLocal变量
        ThreadLocal<String> threadLocal = new ThreadLocal<>();

        // 为当前线程设置值
        threadLocal.set("ThreadLocal Value");

        // 读取值
        System.out.println("Value before GC: " + threadLocal.get());

        // 清除对ThreadLocal实例的强引用
        threadLocal = null;

        // 强制触发GC
        System.gc();

        // 模拟其他操作
        try {
            Thread.sleep(1000); // 给GC一些时间
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        // 尝试读取值
        // 若ThreadLocal被垃圾回收,key为null,但值(Value)仍在ThreadLocalMap中,造成潜在内存泄漏
        System.out.println("Value after GC: " + new ThreadLocal<>().get());
    }
}

所以我们需要一些即使持有引用,依然会被垃圾收集器回收的引用类型。Java1.2以后,引用由强到弱依次被分为:

  • 强引用:只要持有强引用,就一定不会被垃圾回收。例:Object obj = new Object(),这是一个强引用。
  • 软引用:如果一个对象被持有最强的引用为软引用,那么如果内存不足,软引用对象会被列入下一次垃圾回收的名单。
  • 弱引用:如果一个对象被持有最强的引用为软引用,那么弱引用对象一定会被列入下一次垃圾回收的名单。
  • 虚引用:它的存在不会对对象生存时间造成任何影响,它存在的唯一目的就是能在这个对象被垃圾回收时收到一个系统通知。

生存还是死亡?

即使在可达性算法中被标记为不可达,也不是非死不可的。因为真正宣告一个对象死亡需要经过两次标记。第一次判定为不可达后,JVM会执行对象的finalize()方法,类似于C++的析构函数。执行这些对象finalize()方法的任务会被塞入F-Queue中,随后,收集器会对该队列中的对象进行第二次标记,如果可达,那么这个对象将会被移出“即将回收”的集合。否则它真的要被回收了。那么对于对象来说,它逃脱回收命运的最后的办法就是在finalize()方法中给自己套上新的引用。

finalize()方法只会被执行一次,对象无法通过这个方法屡次逃脱。另外,不建议在这个方法中做关闭资源之类的清理性工作,因为它不太可靠,而且性能很差。它诞生的原因只是为了让C/C++程序员更容易接受Java而作的妥协(对标析构函数),这个机制正在被官方废弃,所以大伙可以防空脑袋忘记有这么个方法。

垃圾分代理论

目前几乎所有的JVM的垃圾收集器都基于两个“分代假说”进行设计。所谓两个假说就是:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的(死的快)。
  2. 强分代假说:熬过越多次垃圾收集过程的对象越难以消亡

这两个假说共同奠定了垃圾收集器的一致设计原则:收集器应该将Java堆划分为不同的区域,然后将回收对象依据其年龄(也就是熬过垃圾收集的次数),分配到不同的区域存储。为什么呢?

  • 如果一个区域中大多数对象朝生夕灭,那么我们只需要关注哪些对象保留而不是去标记那些大量需要回收的对象,就能以低代价回收大量空间。
  • 如果一个区域中大多数对象难以消亡,虚拟机就可以用较低的频率回收这个区域。

因而才有了不同种类的垃圾回收策略:

  • 部分收集(Partical GC):
    • 新生代收集(Minot GC/Young GC):指目标只是新生代的收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的收集。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

回收方法区

刚刚提到,Full GC也会收集方法区。即使是方法区(永久代),也是存在垃圾回收的。之所以以前的方法区叫永久代,是因为它很少被垃圾收集,但不是真的不收集。

方法区的收集主要分两部分:废弃的常量和不再使用的类型。回收废弃常量和回收Java堆中的对象非常相似,举个例子,字符串“abc”曾进入过常量池,但再后来再无任何地方引用这个常量,那么发生内存回收时,这个“abc”很有可能会被清理出常量池。

判断一个类是否“不再需要”的条件就比较苛刻了:

  • 该类的所有实例已被回收,当然也包括派生子类
  • 该类的所有类加载器已被回收,这通常很难达成
  • 该类对应的class对象没有被引用,也就是反射也找不到它。

满足以上条件后,它会被允许回收,当然还得看你的虚拟机设置是否支持了。

本文只对垃圾收集机制进行简要介绍,主打一个抛砖引玉,深入了不再讨论,因为我也不会了。