JVM学习笔记(四)———垃圾收集概述与对象存亡判定

本文详细阐述了垃圾收集在Java中的作用,介绍了垃圾收集主要在堆和方法区进行,涉及引用计数算法和可达性分析算法的原理与示例,以及对象存活判定、自我拯救机制和方法区垃圾回收的复杂性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

什么是垃圾收集

垃圾收集(Garbage Collection) 简称GC,在Java程序运行过程中,会出现许多在为对象分配内存后却不再使用的情况,释放这部分对象所占用的空间后以便为其他对象分配空间的过程就是垃圾收集。想要进行垃圾收集,一定避不开如下三个问题

  • 哪些内存需要回收?(即判断哪些对象已经不会再被使用)
  • 什么时候回收?
  • 如何回收?

垃圾收集主要在哪个内存区域发生?

  • 编者在笔记(一)中说明了JVM所管理的几个内存分区,其中程序计数器、虚拟机栈和本地方法栈三个内存区域的生命周期都与其所属线程相同,当方法与线程结束时,对应的内存区域自然也就被回收了,加之这三块内存区域的内存分配都在编译时期就已经基本确定,可以认定这三个区域的内存分配与垃圾收集都是确定的,垃圾收集自然也就不需要过多考虑和讨论这几块内存区域。
  • 只有程序运行期间,我们才能知道程序究竟会创建哪些对象,才会知道哪些对象在什么时期已经不可能再被使用,因此方法区与Java堆这两个内存区域有着很显著的不确定性,这部分的内存分配与回收是动态的,垃圾收集器所关注的重点自然就是这两部分内存。

判断哪些对象存亡的算法

了解完了垃圾收集器关注的主要内存区域,下一个问题就是如何判断哪些对象还“存活”,哪些对象以及“死去”(死去泛指已经不可能再被任何途径使用)。判断对象生死的算法有两种,如下

引用计数算法

引用计数的实现非常简单,只需要在对象中添加一个引用计数器,每当有一个地方对该对象进行引用时,计数器的值就加一,当引用失效时,计数器的值就减一,当计数器的值归零时,则认为该对象已经不可能再通过任何途径被使用。引用计数算法占用了一部分额外的内存,但实现简单,判断十分高效,许多情况下它都是一个不错的算法。但在部分情况下,使用引用计数算法将导致部分死亡的对象无法被正常回收,例子如下

public class Test01 {

    public Test01 instance = null;
    
	//占据部分内存以便在GC日志中查看是否有过垃圾回收
	public int placeholder = 0;

    public static void main(String[] args){
        Test01 t1 = new Test01();
        Test01 t2 = new Test01();

        t1.instance = t2;
        t2.instance = t1;

        t1 = null;
        t2 = null;
        System.gc();
    }
}

/*
运行结果如下:
[GC (System.gc())  5263K->1024K(502784K), 0.0010858 secs]
[Full GC (System.gc())  1024K->845K(502784K), 0.0044673 secs]
*/

上述代码若使用引用技术算法来判断对象的存亡,即使t1和t2最开始申请的两块内存空间已经不可能再被访问,但这两块空间中仍存放着对方的引用,即这两个对象的引用计数器都为1,判定为存活,不能正常对这两块内存空间进行垃圾收集。但根据运行结果可见,虚拟机并没有放弃回收这两块内存空间,这是因为JVM并未采用引用计数算法来判断对象的存活,JVM所采用的是可达性分析算法。

可达性分析算法

当前主流的商用程序语言的内存管理子系统都是通过可达性分析算法来判断对象是否存活。可达性分析算法将若干被称为“GC Roots”的根对象作为起始节点集合,从这些初始节点开始根据引用关系主播享下搜索,搜索所走过的路径被称为引用链,如果某个对象与GC Roots直接没有任何引用链关联,那么判定此对象不可能再被通过任何途径使用。

例如下图中,虽然object5、6、7还存在引用关系,即在引用计数算法中值不为0的情况,但这些对象与GC Roots间已经不存在引用链,所以此部分对象将被判定为死亡。
在这里插入图片描述
Java技术体系内,固定作为GC Roots的对象包括以下几种:

  • 在虚拟机栈中(栈帧中的本地变量表)中引用的对象,譬如各个线程中被调用的方法堆栈中使用的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池中的引用。
  • 在本地方法栈中JNI(本地Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象和系统类加载器等。
  • 所有被同步锁(被synchronized关键字修饰)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除固定的GC Roots 集合外,根据不同的垃圾收集器以及不同的回收区域,可能还有其他的对象被加入到GC Roots集合,共同构成完整的GC Roots集合。

什么是引用(reference)

从上文中不难看出,对象存亡的判定与引用脱不了干系。JDK1.2前的定义为,如果某reference类型的数据存储了另一块内存的起始地址,就称该reference数据是某块内存、每个对象的引用。

在这种定义下仅有被引用与未被引用两种状态,当有一类对象具有在内存空间足够时允许保留在内存中,如果内存空间紧张时可以回收的特性,这种定义显然不能准确的描述这一类对象的状态。

因此在JDK1.2后Java对引用的概念进行了扩展,将引用分为四种类型,引用强度逐级递减,分别是

  • 强引用(Strongly Reference):该类引用状态与传统定义的“被引用”等价,程序中普遍进行的直接赋值操作都属于强引用,任何情况下,只要存在强引用关系,垃圾收集器就永远不会收集被引用的对象。
  • 软引用(Soft Reference):该类引用状态用于描述一些还可能被使用但非必须的对象,是垃圾收集最后考虑收集的一系列对象,被软引用的对象,在内存空间足够时不会被收集,当内存溢出发生前,会将被弱引用的对象划入回收范围进行回收,若收集完被软引用的对象后仍不能为为申请提供足够的内存,才会抛出内存溢出异常。
  • 弱引用(Weak Reference):该类引用状态也用于描述非必须对象,程度较软引用弱一个等级,被弱引用的对象只能存活到下一次GC发生,这与传统定义中的“未被引用”相似,无论内存是否足够,被弱引用关联的对象总是会被回收。
  • 虚引用(Phantom Reference):该类引用也被称为“幽灵引用”或“幻影引用”,顾名思义,此类引用关系不会对对象的生存时间构成任何印象,同样也不能通过此类引用关系来获取对象实例。虚引用的唯一目的是,在被虚引用关联的对象被垃圾收集器回收时收到一个系统通知。

对象被回收前的自我拯救

一个对象在真正被垃圾收集器回收之前至少要经历两次标记过程。

  • 第一次即为上文中提及的,若经过可达性分析算法后判定为某对象与GC Roots无引用链关联,那么该对象会被第一次标记。
  • 在被第一次标记后,JVM会进行一次筛选,用于判断该对象是否有必要执行finalize()方法,有两种情况JVM会认为没有必要执行finalize()方法,一种是若该对象没有覆盖重写finalize()方法,另一种是此前以及被JVM调用过。

被认为没有必要执行finalize()方法的对象将被第二次标记,随后会被垃圾收集器回收。

如果被第一次标记的对象被JVM认定为有必要执行finalize()方法,那么该对象会被放置在一个名为F-Queue的队列中,并在稍后由一条JVM自动建立的、低调度优先级的Finalizer线程区执行他们的finalize()方法。

注意上段中加粗的执行二字,他代表的是字面意思,JVM只会保证执行对象的finalize()方法,但不保证一定会等待该方法执行完毕,这样做主要是为了避免finalzie()方法执行过于缓慢甚至出现死循环而导致线程无法继续执行,进而导致垃圾回收子系统崩溃的问题。

成功执行了finalize()并不代表该对象一定可以成功自救免于被垃圾收集器回收,只有在对象的finalize()方法中,成功将该对象与引用链上的任一对象建立引用关联,才会被移出F-Queue队列,免于回收。

对象自救演示代码如下

public class Test01 {

    public static Object obj = null;

    @Override
    protected void finalize() throws Throwable{
        super.finalize();
        System.out.println("finalize方法被调用!");
        Test01.obj = this;
    }

    public static void test() throws InterruptedException {
        obj = null;//此时上一行的匿名对象已经不可能再被访问

        System.gc();
        //由于Finalizer线程的调度优先级很低 暂停0.5秒等待执行
        Thread.sleep(500);

        if(obj != null){//obj不为null时 说明在finalize方法中 即将被回收的匿名对象成功将自己重新与obj建立了引用关系
            System.out.println("匿名对象存活");
        }else{
            System.out.println("匿名对象死亡");
        }
    }


    public static void main(String[] args) throws InterruptedException {
        obj = new Test01();
        for (int i = 0; i < 3; i++) {
            test();
        }
    }
    
}
/*
运行结果如下
[GC (System.gc())  5263K->1088K(502784K), 0.0008759 secs]
[Full GC (System.gc())  1088K->845K(502784K), 0.0057240 secs]
finalize方法被调用!
匿名对象存活
[GC (System.gc())  11372K->1173K(502784K), 0.0006338 secs]
[Full GC (System.gc())  1173K->781K(502784K), 0.0065473 secs]
匿名对象死亡
[GC (System.gc())  6044K->813K(502784K), 0.0004179 secs]
[Full GC (System.gc())  813K->780K(502784K), 0.0057531 secs]
匿名对象死亡
*/

由上面的代码运行结果可以印证此前提及的一些结论,比如finalize()方法只会被JVM自动调用一次,被调用过后不会再次被调用,同时第一次垃圾收集后匿名对象的存活也说明了,在finalize()方法中重新与引用链建立关联的确可以实现对象的自救。

虽然finalize()方法可以实现对象的自我拯救,但事实上官方明确声明不推荐这种做法,也不推荐使用finalize()方法,不推荐使用finalize()方法实现对即将被回收的对象进行回收前的一系列处理,因为try-finally或其他实现方式可以完成的比finalize更好且更及时。

回收方法区

在《Java虚拟机规范中》提到过可以不要求虚拟机在方法区实现垃圾收集,因为方法区垃圾收集的性价比较低,在Java堆中,尤其在新生代,通常一次垃圾收集可以回收70%-99%的内存空间,反观方法区,回收判断条件十分苛刻,收效甚微。

方法区垃圾收集的主要目标有二

1)废弃的常量:当一个某一类型的常量满足,该常量曾进入过常量池,且当前系统中没有任何一个该类型变量的值是该常量,且虚拟机中也没有其他地方引用这个常量,那么该常量被判定为废弃的常量,在GC时,若垃圾收集器(不同垃圾收集器实现不同)认为有必要的话,会将废弃的常量清理出常量池,即进行回收。
2)不再使用的类型:判断一个常量是否废弃还相对简单,而要判断一个类型是否不会再使用条件就比较苛刻了。需要一个类型同时满足以下三个条件:

  • 该类的所有实例都已经被回收,即Java堆中不存在该类及其子类的实例。
  • 加载该类的类加载器已经被回收。(需要精心设计可替换类加载器的场景,否则通常很难达成)
  • 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法。

满足上述三种条件的类型被JVM允许回收,此处仅是允许被回收,并非必然被回收,是否回收可以通过一系列虚拟机参数控制。

虽方法区的垃圾回收困难,但在大量使用反射、动态代理、GCLib等字节码框架、动态生成JSP等需要频繁自定义类加载器的场景中,通常需要JVM具备类卸载能力,进而保证方法区不会承受过大的内存压力。


参考书籍 《深入理解Java虚拟机》第三版 ——周志明
本篇内容主要用于作者自身学习总结记录,才疏学浅,如文中出现纰漏,还望指正

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

7rulyL1ar

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值