暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Java 垃圾回收机制

阿东编程之路 2022-06-26
821


垃圾收集(Garbage Collection)诞生于第一门使用内存动态分配和垃圾收集技术的语言Lisp。C/C++语言的对象的内存分配与销毁都是交给程序员来操作的;而在Java中,内存回收是交给虚拟机自动化实现的。针对这点有个形象的梗:在软件学院的食堂里吃饭,吃完饭端盘子走的是 C++ 程序员,吃完饭直接走让阿姨收盘子的是 Java 程序员。


一. 如何判断对象的存活?

在Java中,几乎所有对象实例都在堆中(JVM运行时数据区相关可以看下阿东的《JVM 运行时数据区》这篇文章),而在垃圾收集时需要判断哪些对象“存活”,哪些对象“死去”,垃圾回执器需要做的就是“死去”的对象进行回收。

如何判断对象是否存活?

目前判断对象存活有两种主流算法:引用计数算法和可达性分析算法

引用计数算法

  • 引用计数算法的实现很简单,就是为每个对象维护一个引用计数器,当该对象被引用时,计数器会加1;当引用结束或失效时,计数器减1;如果计数器为 0 就代表对象不再存活可以被垃圾回收掉。

    // 引用后计数器加一 
    Object newObject = new Object();


      // 取消引用后计数器减一 
      Object newObject = null;


      • 虽然引用计数算法会占用一点额外空间,但是实现简单且判定的效率高,在很多情况下这是一种不错的算法。但是简单靠引用计数算法来判断对象存活也会有别的问题,比如循环引用

      我们看下如果在循环引用的情况下使用引用计数算法会有什么问题。

        public class CircularReference {


        public Object object;


        public void circularReference() {


        CircularReference a = new CircularReference();
        CircularReference b = new CircularReference();


        // 循环引用
        a.object = b;
        b.object = a;

        a = null;
        b = null;
        }
        }

        • 按照正常逻辑,我们将 a, b 引用置空后,a, b 两个引用指向的对象实例应该被判断为死亡的对象,但实际上,如果引用计数算法,两个实例还存在循环引用,所以需要先将 a,b 两个引用内的属性对象置空,然后将 a, b 两个引用置空,引用计数器才会归零;所以如果 Java 使用引用计数算法来判断对象存活,开发中稍有不慎就会造成内存泄漏

        可达性分析算法(Reachability Analysis)

        • 目前基本上所有的主流 JVM 虚拟机都是通过可达性分析算法来判断对象存活的,这个算法的基本思路就是遍历一系列根对象(GC  Roots),根据引用关系从 GC Root 往下搜索,搜索过程的路径称为“引用链”,如果对象到根对象上没有引用链相连(GC Root 到对象不可达),就会判断为可回收对象



        • 上图中灰色代表对象存活,白色代表可以回收的对象;可以看到,如果对象到根对象上没有引用链相连,不管这么循环引用都算作可回收对象,所以可达性分析算法就不会有循环引用导致的内存泄漏问题。

        哪些对象可以作为GC Roots对象呢?

        • 虚拟机栈(栈帧的局部变量表)里的引用的对象,比如方法中用到的参数,局部变量等;

        • 方法区中类静态属性引用的变量,比如类的引用类型静态变量;

        • 方法区中常量引用的对象,比如字符串常量池里的引用、类中final修饰的引用变量等;

        • 本地方法栈中本地方法引用的对象;

        • Java虚拟机内部的引用,比如的class对象(类在第一次加载时会生成一个class对象作为访问类信息的入口)及一些异常类对象;

        • 被同步锁(synchronized)持有的对象;


        Java 1.2之前,Java里的引用还是很传统的,只有被引用和未被引用两种类型。但其实我们希望对引用进行优先分级,等级弱一些的可以在垃圾收集完后内存还是很紧张的情况下不管是否存活直接被清理掉。Java 1.2 将引用进行了扩充和区分,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种。

        • 强引用就是最原始的引用,比如“Object = xxx”;只要有强引用存在,垃圾回收器就不会回收掉被引用的对象。

        • 软引用用来描述有些用处但是非必需的对象,级别次于强引用,只有在将要发生内存泄漏时,才会去将这些软引用回收掉;Java中提供了SoftReference包装类来实现软引用;可以来做本地缓存,不过现在都有功能强大的开源本地缓存框架:Caffeine和Guava等,所以SoftReference应用场景应该比较少了。

        • 弱应用级别低于软引用,这类引用会在发生垃圾收集时被清除不管内存是否够用;可以用来解决内存泄漏问题,比如 Jdk 中的 ThreadLocal 里 map 的key;Java 中提供了 WeakReference 包装类来实现弱引用。

        • 虚引用是级别最低的引用,基本上等于没有引用,因为通过虚引用无法获取到对象;可以用来管理堆外内存。


        对象自救

        • 但可达性分析算法判断一个对象不可达,去进行垃圾回收之前会做一些特殊操作。当一个对象被判断不可达后,会再次进行一次筛选判断,判断对象是否实现 finalized() 方法并该对象没有被虚拟机执行过 finalized() 方法,如果是,就该对象放进一个名为 F-Queue 的队列里,并在稍后由一个虚拟机创建,低调度优先级的 Finalizer 线程去执行他们的 finalized() 方法,但是这个执行只能保证开始执行,并不能保证执行完,因为不能因为一个对象的执行缓慢或者死循环而耽误主流程。finalized() 方法可以用作对象的自救(不过基本上没看到有人用过),实现 finalized() 方法并将当前对象再次引用,当前对象就能逃脱被回收的命运,但是一个对象只能被“自救”一次。

        刚刚说的都是针对 Java 堆的对象存活分析回收,那属于堆的逻辑部分-方法区里的内容会被垃圾回收吗?

        方法区的垃圾回收

        《Java 虚拟机规范》中有提到不要求虚拟机在方法区实现垃圾收集,并且方法区的垃圾收集性价比要比堆收集要低很多。方法区的垃圾回收主要在两个部分:废弃的常量和不再使用的类型

        • 常量的回收就和堆里的回收非常类似,比如常量池里的某个常量没有被外部引用,进行垃圾回收的时候就可能会将该常量回收掉。

        • 类的回收也可以叫做类的卸载,判断类型是否需要回收的条件需要满足以下三个:

        1. 在Java堆中已不存在该类和该类子类的实例;

        2. 加载该类的类加载器被回收(这个条件是最苛刻的);

        3. 该类对应的class对象不在任何地方被引用,无法在任何地方通过反射访问该类。

        当满足上述三个条件才可能被回收,但是不一定被回收,还需要虚拟机的参数(-Xnoclassgc)进行配置。像只有在大量使用反射,动态代理,字节码增强技术的场景才需要虚拟机具备类卸载的能力,来保证方法区不会有内存的压力。


        二.  垃圾收集算法

        标记 - 清除算法

        • 标记 - 清除是最早的垃圾收集算法,主要思想就是标记存活的对象或标记需要回收的对象进行清除。缺点主要是两点:第一点是效率随着对象数量的增长而下降;第二点是会产生内存碎片问题



        标记 - 复制算法

        • 标记 - 复制算法是为了解决 标记 - 清除 需要回收大量对象时候效率低下的问题,主要思想是将可用内存分为两个部分(只是举个例子分为两个部分,具体实现可以按照不同大小分为多个部门),每次只使用其中一块,当这块内存使用完了,会将存活的对象复制到另一个部分并把之前的部分一次性清空掉;如果存活的对象较多,就会有较大的复制开销,所以标记复制算法适用于朝生夕灭的对象。但是标记 - 复制还有个缺点是需要消耗一定的空间,如果把内存分为两个大小相同的部分进行复制算法,就类似阿东在杭州花了400w买了120平的房子却只能用60平



        标记 - 整理算法

        刚刚说了标记 - 复制算法不适合对象生命周期较长的场景并且会有空间浪费,有可以充分利用内存空间且没有内存碎片的算法呢?

        • 标记-整理算法它来了!标记 - 整理算法的思想是:和标记 - 清除算法一样先标记对象,然后将存活的对象移动到内存空间的一端,最后一次性清理掉边界以外的内存。标记-整理算法和标记-清除算法的唯一区别就是标记-整理会在标记后移动对象。而这个移动对象的操作也是一种重量级操作。


        为什么移动对象的操作很重?

        • 首先在堆中移动对象,需要改变实例对象的地址,而现在主流虚拟机采用的直接指针访问对象,所以在虚拟机栈的局部变量表中的引用类型存储的地址就要被更新;

        • 第二点是移动对象的过程需要暂停用户线程;因为如果不停顿,新对象在内存中进行分配时就会有并发问题,这类停顿就是大名鼎鼎的“Stop The World”。

        通过上面介绍,大家都知道的了各种垃圾收集算法的适用场景,而在HotSpot中就融合了上述三种的算法的思想,设计出了新的一种方法:分代收集算法,白话一些说分代收集算法就是以上三种算法的“组合拳”。


        分代收集算法

        Hot Spot虚拟机将堆区域分为新生代和老年代两个区域,根据不同区域选择最适合的垃圾收集算法。

        • 新生代默认按照 8:1:1 为 Eden,Survivor0,Survivor1 三个部分,为对象分配内存时大部分是分配在 Eden 区(大对象可能直接分配到 old 区减轻复制压力),当Eden区满了会发生Minor GC(新生代GC),将存活对象复制到一个空的 Survivor 区,并清空Eden区和另一个Survivor区,后面对象继续分配在Eden区,循环往复,当Survivor放不下存活对象时会直接复制到老年代,如果老年代也没有空间了就会触发Full GC。

        我们可以用命令观察下这个过程:

          # 找到当前java进程id 
          jps
          # 查看gc状态统计,每2000毫秒查询一次
          jstat -gcutil 1 2000


          s0和s1是Survivor0、Survivor1 区域使用占比,E是Eden使用占比,O是老年代使用占比,M是方法区使用占比,YGC是Minor GC次数,FGC是Full GC次数,最后两列是总耗时。

          • 我们看到红线处因为Eden区满了发生了一次Young GC,E 和 S0区域的对象复制到了S1,部分对象由新生代移动到了老年代O区,YGC次数由76变为77。

          这里有个问题:如果现在进行一次Minor GC,但是存在跨代引用(老年代对象引用新生代或者相反的情况),因为Minor GC是在新生代中枚举GC Roots,这点不考虑就会出现把跨代引用的对象清理掉,那JVM是怎么做的呢?

          • JVM在新生代上建立了一个全局的数据结构 - 记忆集,记忆集把老年代划分多个小块,并标识出哪块存在跨代引用,当发生 Minor  GC 时,将记忆集里存在跨代引用区域的对象加到 GC  Roots 进行扫描,就能保证判断对象存活的准确性。虽然说在改变对象引用时需要额外维护记忆集的内容,但是相比Minor GC收集时去扫描整个堆还是好很多的。

          Hot Spot 虚拟机利用了新生代对象朝生夕灭生命周期短的特点使用了标记复制算法。

          对象进入老年代有哪些情况呢?

          Survivor 区放不下存活对象进入老年代

          • 第一种情况就是上面说的,在新生代进行标记复制时 Survivor 区放不下存活对象;

          大对象直接进入老年代

          • 第二种情况是大对象会直接在老年代分配空间,因为大对象如果也要在新生代间来回复制,会有很高的复制开销;所以虚拟机提供了 -XX:PretenureSizeThreshold 参数,指定大于该值的对象直接在老年代分配空间。

          长期存活对象进入老年代

          • 之前我们讲过在对象头的 Mark Word里用4 bit来存储 GC年龄,而对象每经历过一次 Minor GC 都会将 GC 年龄加一,当年龄到达一定程度(默认是 15)就会进入老年代,这个年龄阈值可以通过参数 -XX:MaxTenuringThreshold 设置,默认是15。

          如果设置 20 可以不?

          不行的,上面我们讲了对象头里只分配了4 bit 的空间来存GC年龄,4bit 二进制数据最大是多少?15,所以这个GC年龄阈值最大也只能设置15。

          动态对象年龄判断进入老年代

          • 当 Survivor 区里所有相同年龄的对象的大小总和大于 Survivor 区空间大小的一半时,大于该年龄的对象就会直接进入老年代。


          三. 再谈可达性分析算法

          在第一节中讲到可达性分析算法是遍历一组 GC Roots 的引用链来判断对象存活的,对于耗时最长的查找引用链操作已经可以做到和用户线程并发执行(CMS,G1等,下篇文章详细讲),而寻找 GC Roots 的过程也是非常耗时的。针对 GC Roots 的寻找过程,Hot Spot 使用了一组 OopMap 的结构来存储对象引用的位置,典型的空间换时间思想,一旦类加载完成,就会将类中引用类型在类中的偏移量及引用类型存下。

          所以在 OopMap 的帮助下,可以快速完成 GC Roots 的寻找工作而不用从上到下遍历整个方法区进行寻找了。

          安全点和安全区域

          • 但是如果在字节码中每个导致引用变化的指令位置都存到 OopMap,那也会占用很大的空间,所以 Hot Spot 没有让每条指令都生成 OopMap,而是在特定的“安全点”才会生成,安全点的选取一般在程序执行时间较长并且不会发生引用变化的指令处,比如方法调用,循环跳转,异常跳转等,有了安全点,进行垃圾收集时所有线程就会在最近的安全点停下来(主动式中断)。

          • 针对运行中的线程这种方案已经没问题了,但是没有抢到 CPU 时间片的线程还处在阻塞状态,无法主动式执行到安全点,针对这种情况,Hot Spot又引入了安全区域,这个安全区域代表的是引用关系不会发生变化,只要线程执行到这个安全区域后,会标记自己进入安全区域,垃圾回收时不会去管理这些已经声明进入安全区域的线程;当线程要离开安全区域时,会去判断虚拟机是否完成GC,如果完成,线程会继续执行;如果没有完成会等待可以离开的信号。


          四. 总结

          本文介绍了JVM如何判断对象存活,一些主流的垃圾收集算法以及JVM中分代收集的过程(后面会单独搞一篇文章介绍垃圾收集器)。







          1.Java 


          文章转载自阿东编程之路,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

          评论