Step 3.1:垃圾收集器与内存分配策略

本文详细介绍了Java的垃圾收集机制,包括对象存活判断的引用计数法和可达性分析算法,以及四种垃圾收集算法:标记-清除、标记-复制、标记-整理。此外,还探讨了分代收集理论和不同代之间的引用处理,强调了在方法区的垃圾回收。文章深入讲解了 finalize() 方法的作用和限制,并讨论了如何通过参数控制方法区的垃圾收集。最后,讨论了不同垃圾收集算法的优缺点和适用场景。
摘要由CSDN通过智能技术生成

1. 概述

垃圾回收机制最早出现于麻省理工学院的Lisp语言,它是第一个使用内存动态分配和垃圾收集技术的语言。为了排查各种内存溢出、内存泄露问题时,当垃圾收成为系统达到更高并发量的瓶颈时,我们就必须对这些技术实施必要的监控和调节。垃圾回收主要时用在Java堆和方法区这两个区域,因为这两个区域有着很显著的不确定性:一个接口的多个实现类需要的内存可能不太一样,一个方法所执行的不同条件分支需要的内存也不一样,只有处于运行期间,我们才知道程序究竟会创建哪些对象,创建多少对象,这部分内存的分配和回收时动态的。垃圾收集器所关注的正是这部分内存该如何管理。

2. 对象是否存活

在堆里面存放着Java中所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中哪些还存活着,哪些已经死去了。

2.1 引用计数法

引用计数法是在对象中添加一个引用计数器,每当一个地方引用它时,计数器就加1,当引用失效时计数器就减一。计数器为0就表示该对象已经死亡。

虽然引用计数器占用了一些额外的内存来进行计数,但它的原理简单,效率高,大多数情况下是一个不错的选择。很多领域在对内存进行管理时都用到的引用计数器,但java没有,因为这个看似简单的算法有很多例外情况要考虑,必须配合大量额外处理才能保证正常工作,例如单纯的引用计数器就很难解决对象之间的互相循环引用。例如下面的例子:

-verbose:gc -Xms20M -Xms10M -XX:+PrintGCDetails
  • verbose:gc:这个选项启用了GC(垃圾回收)的详细输出,使得在控制台上可以看到垃圾回收事件的详细信息,包括何时发生垃圾回收以及回收了多少内存等。
  • XX:+PrintGCDetails:这个选项启用了GC的详细输出,类似于-verbose:gc,但提供更多关于垃圾回收的详细信息,包括不同种类的垃圾回收事件以及堆内存的使用情况。

在这里插入图片描述

public class Main {
    public Object instance=null;
    private static final int _1MB=1024*1024;
    /**
     * 这个成员属性的唯一意义就说占点内存,以便在GC日志中看清楚是否回收过
     */
    private byte[] bigSize=new byte[2* _1MB];
    public static void main(String[] args) throws Exception {
        Main objA=new Main();
        Main objB=new Main();
        objA.instance=objB;
        objB.instance=objA;
        objA=null;
        objB=null;
        System.gc();
    }
}

在这里插入图片描述

从运行结果可以看出包含5438K->4608K,意味着虚拟机并没有因为这两个对象互相引用而放弃回收它们,这也说明了java不是通过互相引用计数器来判断一个对象是否存活的。

2.2 可达性分析算法

这个算法的基本思路就是通过一系列“GC Roots”的根对象作为起点节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中走过的路径称为引用链,如果某个对象到GC Roots间没有任何引用链相连,则证明该对象不可能再被使用。

在这里插入图片描述

上图Obj5、Obj6,Obj7之间有关联,但它们到GC Roots没有引用链,所以它们也会被判定为可回收对象

在java技术体系里面,固定可作为GC Roots的对象包括以下几种:

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

2.3 引用

可以看到无论是引用技术器还是可达性分析算法,都与对象之间的引用分离不开,传统的引用的定义是:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称该reference数据代表的是某块内存、某个对象的引用。JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,引用强度依次递减:

  • 强引用:即上面定义的传统的引用方式,是指程序代码之中普遍存在的引用赋值(Object obj=new Object();),这种引用关系,无论任何情况下,只要强引用关系还存在,垃圾收集器就不会回收掉被引用的对象
  • 软引用:软引用表示一些还有用,但非必须的对象。只被软引用关联的对象,在系统要发生内存溢出异常前,会把这些对象列进回收范围之中进行二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。Jdk 1.2 之后提供了SoftReference类实现软引用
  • 弱引用:弱引用也是描述那些非必须对象的,强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作时,无论当前内存是否足够,都会回收被弱引用引用的对象。JDK1.2之后使用WeakReference类实现弱引用
  • 虚引用:它也被称为“幽灵引用”或者“幻影引用”,是最弱的引用关系。一个对象是否有虚引用存在,不会对该对象的生存时间构成任何影响(可以当做它没有引用),也无法通过虚引用获取对象实例。虚引用存在的目的是能在这个对象被收集器回收时受到一个系统通知。JDK1.2后使用PhantomReference类实现虚引用。
  public static void main(String[] args) throws Exception {
            // 创建一个强引用
            Object strongReference = new Object();
            // 创建一个软引用
            SoftReference<Object> softReference = new SoftReference<>(new Object());
            // 创建一个弱引用
            WeakReference<Object> weakReference = new WeakReference<>(new Object());
            // 创建一个虚引用
            ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
            PhantomReference<Object> phantomReference = new PhantomReference<>(new Object(), referenceQueue);
            // 输出引用类型
            System.out.println("Strong Reference: " + strongReference);
            System.out.println("Soft Reference: " + softReference.get());
            System.out.println("Weak Reference: " + weakReference.get());
            System.out.println("Phantom Reference: " + phantomReference.get());
            // 释放强引用
            strongReference = null;
            // 手动触发垃圾回收
            System.gc();
            // 输出引用类型
            System.out.println("Strong Reference after GC: " + strongReference);
            System.out.println("Soft Reference after GC: " + softReference.get());
            System.out.println("Weak Reference after GC: " + weakReference.get());
            // 查看虚引用是否被加入引用队列
            Reference<?> polledReference = referenceQueue.poll();
            System.out.println("Phantom Reference from Queue: " + polledReference);
    }

在这里插入图片描述

可以看出首先从虚引用获取对象,获取的对象为空,然后一次垃圾回收后弱引用就被回收了

2.4 生存还是死亡?

前面说到在可达性分析算法中,如果一个对象对GC Roots之间没有引用链,那么这个对象就会被回收,其实本质上该对象并不会直接被垃圾收集器回收,要真正宣告一个对象的死亡,至少要经历两次标记过程

  1. 如果对象在进行可达性分析后发现和GC Roots之间没有引用链,那么它将会被第一次标记
  2. 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过了,那么虚拟机将这两种情况都视为“没有必要执行”。

finalize() 方法是Java中的一种特殊方法,它属于所有对象的默认方法。在对象被垃圾回收器回收之前,finalize() 方法会被自动调用。它的主要作用是允许对象在被销毁之前执行一些清理操作或释放资源的工作

如果对象被判定有必要执行finalize()方法,那没该对象会被放置在一个名为F-Queue的对列中,虚拟机随后会自动建立一个低调度优先级的Finalizer线程去执行finalize()方法。(注意虚拟机只是承诺会触发这个方法的执行,但不会确保这个方法一定会执行成功,因为如果某个对象的finalize方法执行很缓慢,甚至出现了死循环,将会使得F-Queue中的对象永久处于等待,甚至会导致内存回收子系统崩溃)。finalize方法是对象逃脱死亡命运的最后一次机会,随后收集器对F-Queue中的对象进行第二次小规模标记,如果对象在finalize中重新与引用链上的某个对象建立了关联,那么它就不会被垃圾收集器回收。下面演示一个逃脱案例:

public static void main(String[] args) throws Exception {
        SAVE_HOOK=new Main();
        //对象第一次拯救自己
        SAVE_HOOK=null;
        System.gc();
        //因为Fianlizer线程的优先级比较低,暂停0.5s等待它执行
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else {
            System.out.println("No, i am dead:");
        }
        //下面这段代码与上面完全相同,但自救失败了
        SAVE_HOOK=null;
        //一次垃圾回收,开始第一次标记
        System.gc();
        //因为Fianlizer线程的优先级比较低,暂停0.5s等待它执行
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else {
            System.out.println("No, i am dead:");
        }

    }

在这里插入图片描述

从结果可以看出来,第一次拯救确实执行了finalize方法实现了自救

为什么第二次自救失败了,这是因为任何一个finalize方法都只会被系统自动调用一次,如果对象面临下一次垃圾回收,他的fianlize方法就不会再次执行。在实际开发中,不建议使用finalize进行,使用try-finally可以做的更好。

2.5 回收方法区

方法区垃圾收集的性价比通常是比较低的,在Java堆中,尤其是新生代中,对常规应用使用一次垃圾收集通常可以回收70%到99%的内存空间,但方法区因为其苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。方法区的垃圾收集主要回收废弃的常量和不再使用的类型。回收废弃常量与回收对象十分类型,例如常量池中有一个"jackiechai"的字符常量,且当前系统中没有任何一个字符串对象是"jackiechai",且没有任何对这个常量的引用,则该常量会被回收。判断常量是否可以回收相对比较简单,但判定一个类型是否可以回收要满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射来访问该类的方法
    在这里插入图片描述

关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading-XX:TraceClassUnLoading查看类加载和卸载信息。

在这里插入图片描述

3. 垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为:

  • 引用计数式垃圾收集(Reference Counting GC)
  • 追逐式垃圾收集(Tracing GC)

上面这两种垃圾收集算法也被称为直接垃圾收集间接垃圾收集

3.1 分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论建立在两个分代假说之上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难消完

这两个分代假说共同奠定了多款垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域中存储。在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分区域,一般把这种区域分为新生代(Young Generation)老年代(Old Generation)两个区域。在新生代中,每次垃圾收集都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

假如现在进行一次局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全可能被老年代所引用的,为了找出该区域中存活的对象,不得不在固定GC Roots之外,再额外便利整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代中所有对象的方案理论上可行,但无疑为内存回收带来了很大的性能负担。为了解决这个问题,就需要对分代理论添加第三条经验法则:

  • 跨代引用假说:跨代引用相对于同代引用来说占少数

这其实是可以根据前两条假说逻辑推理出的隐含理论:在两个相互引用关系的两个对象,是应该倾向于同时生存或同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长后晋升到老年代中,这事跨代引用也随机消除了。依据上面这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代了,也不必浪费空间专门记录每一个对象是否存在哪些跨代引用,只需再新生代上建立一个全局数据结构(记忆集),这个结构将老年代划分为若干小块,标识出老年代的哪一块内存会存在跨代引用。此后发生Minor GC时,只有包含一跨代引用的小内存里的对象才会被加入到GC Roots进行扫描。

3.2 标记-清除算法

  • 算法概念

该算法时最早的垃圾收集算法,是由垃圾收集之父John McCarthy所提出。该算法被分为标记清除两个部分:首先标记出所有需要回收的对象,在标记完成之后,统一回收掉所有被标记的对象,也可以标记存活的对象,统一回收没有被标记的对象。标记的过程就是判定对象是否属于垃圾的过程。(使用GC Roots判定)

  • 算法优缺点

缺点:执行效率不稳定,如果java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除动作,导致标记和清除两个过程的执行效率随着对象的增加而降低。第二个是内存碎片化,标记清除后产生大量不连续的内存碎片,空间碎片太多导致需要分配大对象时找不到连续空间,所以不得不提前触发垃圾收集。

优点:算法思路十分简单

在这里插入图片描述

3.3 标记-复制算法

  • 算法概念

标记-复制算法出现是为了解决标记清除算法面对大量可回收对象执行效率低的问题。1969年Fenichel提出一种“半区复制”的算法,它将可用内存分为两个相等的部分,每次使用其中一块,当一块内存用完后,将还存活的对象复制到另一块上面,然后将之前那一块内存空间一次性清除掉,而对于多数对象是可回收的情况,算法只需要复制的就是占少数的存货对象,而且每次都是针对整个半区进行内存回收,分配内存时不用考虑有空间碎片的问题,只需要在另一块内存移动指针按序进行复制即可。

  • 算法优缺点

缺点:这种复制回收算法将可用的内存缩小为了原来的一半,空间浪费太大
优点:实现简单,运行高效

在这里插入图片描述

现在的商业虚拟机大多采用上面算法来回收新生代。1989年,Andrew Appel针对新生区“朝生夕灭”的特点,提出了一种优化的半分区复制分代策略,称为“Appel”回收。Appel回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor空间上。发生垃圾收集时,将Eden和已用过的那块Survivor空间中仍然存活的对象复制到另一块Survivor空间上,然后清除原来的Eden和Survivor空间。HotSpot虚拟机的Eden和Survivor空间大小比例时8:1(有研究表明新生代中有98%对象熬不过一轮,只需要回收2%空间即可,这也是这个比例的由来),即每次新生代中可用内存空间占90%。但极端情况下是无法保证每次回收都只有不多于10%的对象存活,为了应对留下来的一块Survivor空间无法保存存活下来对象的情况,Appel回收提供了一个“逃生门”安全设计,当Survivor空间不足容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上是老年代)进行分配担保—类似于银行借款担保。

在这里插入图片描述

3.4 标记-整理算法

前面介绍的标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是如果不想浪费额外的空间用于复制,就需要额外的空间作为非配担保(Appel算法),以应对被使用的内存中所有对象都100%存活的极端即可,所以老年区不能直接使用该算法。针对老年代对象不易死亡的情况,1974年Edward Lueders提出了另外一种有针对性的“标记-整理算法”,其中标记过程和前面一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清除掉边界以外的内存。

在这里插入图片描述
标记-整理算法与标记-清除算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动性的。但移动对象是否真的有利,我们知道老年代这种每次回收都会存在大量存货对象的区域,移动存活对象将是一个很负重的操作,它会全程暂停应用程序才能进行(Stop The World)。而如果不移动,标记-清除算法的内存碎片问题同样也是一个很重的负担。基于上面两点,是否移动对象都是存在弊端的,移动则内存回收时会更复杂,不移动则内存分配更复杂。从垃圾收集停顿时间来看,不移动对象停顿时间更短,甚至不需要停顿时间,但从整个程序的吞吐量来看,移动对象会更划算。

另外一种方式时是,让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,知道内存空间碎片化程度很大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。当然有收集器采用的是这种方法,例如CMS垃圾收集器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值