Java虚拟机 -- 垃圾回收算法


1. 概述

1.1 什么是垃圾?

垃圾是指运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。如果不及时的对内存中的垃圾进行处理,那么这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他的对象所使用,甚至可能导致内存溢出。垃圾回收重点的关注区域有:

  • 方法区
1.2 为什么要GC?

对于运行的程序来说,内存空间是最为宝贵的资源。如果不进行垃圾回收,即时可用的内存空间再大,总有一天还是会被用尽。垃圾回收除了最为重要的释放没用的对象外,也可以进行内存碎片的整理和清除,以便将整理后的内存空间分配给新的对象使用。而且随着程序越来越大,如果不进行即时的垃圾回收,可能会导致程度都无法正常运行。

1.3 回收机制

Java中的垃圾回收机制提供了自动内存管理的功能,无需开发人员参与内存的分配和回收,有效的降低了内存泄漏和内存溢出发生的概率,而且将开发人员从繁重的内存管理工作中释放出来,使他们可以更加专注于具体的业务。

垃圾回收的次数特点:

  • 频繁收集年轻代
  • 较少收集老年代
  • 基本不动永久代(元空间)

2. 标记阶段

标记阶段的主要完成的工作是判断对象是否存活,对象是否可以被标记为垃圾。几乎所有的对象实例都存在于堆中,因此,在GC前需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象才能被回收,GC阶段会将那些标记为垃圾的对象回收,同时释放它所占用的内存空间。

通常来说,当一个对象不再被任何的存活对象继续应用时,就可以被标记为已经死亡的对象。判断对象存活一般有两种方式:

  • 引用计数算法
  • 可达性分析算法
2.1 引用计数算法

引用计数(Reference Counting)算法对每个对象都持有一个整型的引用计数器属性,它用于记录对象被引用的情况。对于一个对象来说,只要有一个其他的对象引用了该对象,它的引用计数值就增加1;当引用失效时,对应的引用计数值就减1。

引用计数法的优点在于实现简单、垃圾对象便于辨识、判定效率高,以及回收操作没有延迟性。它的缺点有:

  • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
  • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法

循环引用
在这里插入图片描述

但循环引用并不是无法解决的,例如Python就采用了两种方法来解决循环引用问题:

  • 手动解除:在合适的时机解除引用关系
  • 使用弱引用weakref
2.2 可达性分析算法

可达性分析算法也被称为根搜索算法、追踪性垃圾收集算法。它相对于引用计数算法而言,不仅同样具备实现简单和执行高效的优点,更为重要的特点是它可以有效的解决循环引用问题,防止内存泄漏的发生。

可达性分析算法的基本思路为:

  • 以根对象集合(GC Roots) 为起始点, 按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着, 搜索所走过的路径称为引用链(Reference Chain)
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象
  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象

例如在下图中,object1~4都直接或间接可达GC Roots,因此它们是存活对象;而object5~7找不到GC Roots指向它们,所以被标记为可回收对象。


在这里插入图片描述

2.3 GC Roots

上图中所提到的GC Roots是一组必须活跃的引用集合,Java中GC Roots包括以下几类元素:

  • 虚拟机栈中引用的对象,比如各线程组别调用的方法中使用到的参数、局部变量等
  • 本地方法栈JNI(本地方法)引用的对象
  • 方法区中类静态属性引用的对象,比如Java类的引用类型静态变量
  • 方法区中常量引用的对象,比如字符串常量池中的引用
  • 所有被同步锁synchronized持有的对象
  • Java虚拟机内部的引用,例如基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、
    OutOfMemoryError) ,系统类加载器

除了这些固定的GC Roots以外, 根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots。比如:分代收集和局部回收(Partial GC)。

如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots中去考虑, 才能保证可达性分析的准确性。

可以简单的认为堆周边的运行时数据区的对象引用都可以作为GC Root。同时当出现局部回收,即对新生代的垃圾回收,那么老年代和永久代中的对象引用也需要作为GC Root。

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证,这点也是导致GC进行时必须Stop TheWorld的一个重要原因。即使是号称(几乎) 不会发生停顿的CMS收集器中, 枚举根节点时也是必须要停顿的。

2.4 对象的finalization机制

Java语言提供了对象终止(finalization) 机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()finalize()允许在子类中被重写, 用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

但是永远不要主动调用某个对象的finalize() 方法, 应该交给垃圾回收机制调用。主要原因有:

  • finalize()时可能会导致对象复活
  • finalize()的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC, 则finalize() 方法将没有执行机会
  • 一个糟糕的finalize()会严重影响GC的性能

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的。由于finalize()的存在, 虚拟机中的对象一般处于三种可能的状态:

  • 可触及的:从根节点开始,可以到达这个对象
  • 可复活的:对象的所有引用都被释放, 但是对象有可能在finalize()中复活
  • 不可触及的:对象的finalize()被调用, 并且没有复活, 那么就会进入不可触及状态。不可触及的对象不可能被复活, 因为finalize()只会被调用一次,此时的对象才可以被回收
2.5 判断对象是否可回收

判定一个对象obj A是否可回收至少要经历两次标记过程:

  • 如果对象obj A到GC Roots没有引用链, 则进行第一次标记。

  • 进行筛选, 判断此对象是否有必要执行finalize()方法

    • 如果对象obj A没有重写finalize()方法, 或者finalize()已经被虚拟机调用过,则虚拟机视为“没有必要执行”, obj A被判定为不可触及
    • 如果对象obj A重写了finalize(), 且还未执行过, 那么obj A会被插入到FQueue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()执行
    • finalize()方法是对象逃脱死亡的最后机会, 稍后GC会对FQueue队列中的对象进行第二次标记。如果obj Afinalize()中与引用链上的任何一个对象建立了联系,那么在第二次标记时, obj A会被移出“即将回收”集合。之后, 对象会再次出现没有引用存在的情况。在这个情况下, finalize()不会被再次调用, 对象会直接变成不可触及的状态;也就是说,一个对象的finalize()只会被调用一次

示例程序:

public class CanReliveObj {
    public static CanReliveObj obj;//类变量,属于 GC Root

    //此方法只能被调用一次
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("调用当前类重写的finalize()方法");
        obj = this; //当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系
    }

    public static void main(String[] args) {
        try {
            obj = new CanReliveObj();
            // 对象第一次成功拯救自己
            obj = null;
            System.gc();//调用垃圾回收器
            System.out.println("第1次 gc");
            // 因为Finalizer线程优先级很低,暂停2秒,以等待它
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is still alive");
            }
            System.out.println("第2次 gc");
            // 下面这段代码与上面的完全相同,但是这次自救却失败了
            obj = null;
            System.gc();
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is still alive");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
调用当前类重写的finalize()方法
第1次 gc
obj is still alive
第2次 gc
obj is dead
2.6 GC Roots溯源

要对程序中涉及的GC Roots进行溯源,就需要首相获取到程序运行时某时刻的快照,即dump文件。获取dump文件的方式有:

  • 命令行指令jmap:首先使用jps获取当前程序的线程ID,然后使用jmap -dump:format=b,live,file=test.bin ID

  • 使用JVisual VM导出,点击绿色标记的按钮就会生成当前时刻的dump文件,在左侧状态栏生成的dump文件上右击选择另存为,就可以保存生成的dump文件
    在这里插入图片描述

使用MAT工具来查看程序中的GC Roots:
在这里插入图片描述


3. 清除阶段

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在JVM中比较常见的三种垃圾收集算法是

  • 标记 - 清除算法(MarkSweep)
  • 复制算法(Copying)
  • 标记 - 压缩算法(MarkCompact)
标记-清除算法标记-整理算法复制算法
速度中等最慢最快
空间开销少(但会堆积碎片)少(不堆积碎片)通常需要存活对象的2倍大小(不堆积碎片)
移动对象

没有最优的算法,最有最合适的算法!

3.1 标记-清除(Mark-Sweep)算法

在这里插入图片描述

当堆中的有效内存空间被耗尽时,程序就会被停止(Stop The World)。然后垃圾收集器就会执行两项操作:

  • 标记:Collector从引用根节点开始遍历, 标记所有被引用的对象。一般是在对象的对象头(Header)中记录为可达对象。

    注意:这里的标记操作标记的是存活对象,而不是前面标记阶段关注的垃圾。

  • 清除:Collector对堆内存从头到尾进行线性的遍历, 如果发现某个对象在其Header中没有标记为可达对象, 则将其回收

    这里的清除并不是置空,而是把需要清除的对象地址保存在空闲地址列表中,等到下次有新对象需要分配内存时,判断垃圾的空间是否足够,如果够,则直接覆盖掉即可

标记-清除算法的不足之处有:

  • 效率较低,因为在标记的过程中需要遍历空间中的所有区域,清除阶段又会使用到所有的区域
  • GC时需要暂停所有的用户线程,影响用户体验
  • 清除后的内存空间存在内存碎片,需要额外的维护一个空闲列表
3.2 复制(Copying)算法

在这里插入图片描述

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

复制算法的优点有:

  • 没有标记和清除过程,实现简单,运行高效
  • 复制后保证空间的连续性,不会出现“碎片”问题

缺点

  • 需要两倍的内存空间。
  • 对于G1这种分拆成为大量region的GC, 复制而不是移动, 意味着GC需要维护region之间对象引用关系,内存占用或者时间开销都较大
  • 复制算法较适合于需要复制的存活对象数量非常低的情况

例如,对于新生代垃圾回收就是使用复制算法,一次通常可以回收70%~99%的内存空间,而且回收性价比很高。

3.3 标记-整理(Mark-Cpmpact)算法

在这里插入图片描述

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,更多的是使用标记-整理算法。

标记-整理算法的执行过程为:

  • 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象
  • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间

标记-整理算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片整理。因此, 也可以把它称为标记-清除-整理(Mark Sweep Compact)算法。二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-整理是移动式的。

是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可, 比维护一个空闲列表显然少了许多开销。

优点

  • 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
  • 消除了复制算法当中内存减半的高额代价

缺点

  • 从效率上来说,标记整理算法要低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序

4. 分代收集算法

不管是上面三种算法的哪一种,都无法做到完全替代其他算法,每种算法都有自己的优势和特点。而分代收集算法并不是一种具体的算法实现,它更像是提供了一种已有算法使用的指导思想,指导虚拟机根据不同的内存区域的特点来决定使用哪一种算法。

JVM中不同的对象的生命周期是不同的,不同的对象存放于内存的不同区域中。因此,不同生命周期的对象理应也可以采取不同的收集方式,从而提高整体的垃圾收集效率。一般把Java堆分为新生代和老年代, 这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

目前几乎所有的GC都是采用分代收集(Generational Collecting) 算法执行垃圾回收的。在HotSpot中, 基于分代的概念, GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • 年轻代:内存区域较小,存放对象的生命周期较短(朝生夕死),回收频繁。此时使用复制算法速度是最快的,它的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题, 通过hotspot中的两个survivor区的设计得到缓解
  • 老年代:内存区域较大,存放对象的生命周期较长,回收没那么频繁。此时,一般是由标记-清除或者标记-清除与标记-整理的混合实现。标记阶段的开销与存活对象的数量成正比;清除阶段的开销和所管理的区域大小成正比;整理阶段的开销与存活对象的数据成正比

以HotSpot中的CMS回收器为例, CMS是基于标记清除实现的, 对于对象的回收效率很高。而对于碎片问题, CMS采用基于标记整理算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时) ,将采用Serial Old执行Full GC以达到对老年代内存的整理。

运行时数据区之堆


5. 增量收集算法

上述现有的算法, 在垃圾回收过程中, 应用软件将处于一种Stop the World的状态。在Stop the World状态下, 应用程序所有的线程都会挂起, 暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(IncrementalCollecting) 算法的诞生。

增量收集算法的基本思想是:如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。

总的来说,增量收集算法的基础仍是传统的标记清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降


6. 分区算法

在这里插入图片描述

一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间,每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。


7. 参考

JVM垃圾回收算法
JVM之垃圾回收-垃圾收集算法

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值