JVM——垃圾回收算法保姆式详解

什么垃圾?

垃圾就是指在程序运行过程中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

在程序的执行过程中,栈帧中的局部变量表中的引用指向具体的对象,当栈帧随着方法的执行结束弹出栈之后,这些对象就处于没有任何指针指向的状态(原文是不可达的对象),此时就变成了需要被回收的垃圾。

如果不及时堆内存中的垃圾进行清理,那么这下垃圾对象所占用的内存空间会一直保存到程序的执行结束,被保留的空间无法再次被分配,可能导致内存溢出。

所以GC的范围只有方法区和堆,堆是GC的重点区域(JVM官方规范中没有明确说明方法区必须要有GC)

为什么要进行GC?

好像是个憨憨问题一样QaQ

  • 对于一门高级语言来说,当然是为了避免过多的垃圾导致的内存被消耗完。
  • 垃圾回收可以清除内存里的记录碎片,碎片整理将非垃圾对象整理到堆的一端,可以整理出一块较为整体的、连续的内存空间用于分配给新的对象使用。
  • 随着应用程序越来越庞大,使用场景也是越来越复杂,所以就要通过GC不断的对程序运行环境就行优化。

垃圾回收的相关算法

Garbage Collection,很明显,垃圾的收集包括了两个阶段:判断哪些是垃圾(标记垃圾)、清理垃圾。

  • 标记阶段算法
    • 引用计数算法
    • 可达性分析算法
  • 清除阶段算法
    • 标记清除算法
    • 复制算法
    • 标记压缩算法
  • 分代收集算法
  • 增量收集算法
  • 分区算法

垃圾标记阶段

在垃圾回收前,需要判断哪些对象是已经死亡的对象(不再使用的对象,不可达的对象,不再被引用的对象),将这些对象标记为死亡。

引用计数算法(Reference Counting)

简单的来讲,就是为每一个对象保存一个整形的引用计数属性,用于记录对象被引用的情况。对于一个对象O来说,当有任何一个对象引用O的时候,O的引用计数器就+1,当某个引用失效的时候就-1,当对象O的引用计数为0时,就代表该对象不再被使用,可以被回收。

优点:

  1. 实现简单
  2. 垃圾对象便于识别,判定效率高
  3. 垃圾回收没有延迟性

缺点:

  • 需要单独的字段储存计数器,增加了空间上的开销
  • 每次更新计数器,都伴随着+1和-1的操作,增加了时间上的开销
  • 引用计数器无法处理循环引用问题,所以Java的垃圾回收器中没有使用该类型的算法(python用了该算法,并使用手动接触和weakref弱引用来解决该问题)

可达性分析算法

也被称为 根搜索算法、追踪性垃圾收集算法。相对于引用计数算法而言,其同样具备了实现简单和执行高效的特点,并且解决了循环引用的问题,防止内存泄漏问题的发生。Java和C#都使用了该垃圾标记算法。

基本思路:

  • 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上层到下层的方式搜索被根对象集合所连接的目标对象是否可达
  • 能直接或者间接被连接到的对象就是存活的对象,搜索所经过的路径为该对象的引用链。没有被GC Roots直接或者间接引用到的就是可回收的对象。

根对象集合(GC Roots):一组必须活跃的引用

在Java语言中GC Roots是什么?

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

除了上述比较固定的作为GC Roots的成员之外,还可以有其他对象临时性的加入GC Roots,比如:分代收集和局部回收(Partial GC)。

比如只回收新生代,那么老年代和永久代就有可能存在对象引用了新生代对象的情况,那么这时,新生代之外的这些老年代、永久代等区域的对象引用者也可以看作为是GC Roots。

特别注意:

  • 可达性分析算法的使用条件必须满足:分析工作在一个能保障一致性的快照中进行。正是这个要求导致了GC过程中需要“stop the world”,将用户线程都停下来。

垃圾回收与对象的finalization机制

Java提供了finalization机制允许开发者提供对象销毁之前的自定义处理逻辑:当垃圾收集器发现没有引用指向某个对象的时候,在回收该对象之前,会主动调用这个对象的finalize()方法

通常情况下finalize方法在子类中被重写,用于在对象被垃圾回收之前释放资源。

不建议主动调用finalize方法

  • finalize方法可能会导致对象的复活
  • finalize方法执行时间是没有办法保证的,完全由GC线程决定,极端情况下,如果没有GC触发,那么finalize方法将没有执行的机会。
  • 重写finalize方法不当将严重影响GC的性能

由于finalize()方法,对象的状态分为三种。当可达性分析之后,某些对象已经没有被引用的时候,需要对这些死亡对象进行回收,但事实上,这些对象也并非是非死不可,他们这时候处于“死亡暂定阶段”。一个无法被触及的对象在某些情况下可能会“复活”自己,对这样的对象进行回收其实是不合理的,由于finalize()机制的存在所以虚拟机中的对象可以分为三种

  • 可触及的
  • 可复活的:对象的所有引用都被释放了,但是对象有可能在finalize()中复活
  • 不可触及的:对象的finalize()方法被调用,并且没有复活,那么就会进入不可达的状态,不可达的对象不能被复活,因此finalize()只会被调用一次。

具体过程如下:

判断一个对象Obj是否可以被回收,至少要经历两次标记过程。

  1. 如果对象Obj到GC Roots没有直接或者间接的引用(没有引用链),则进行一次标记
  2. 进行筛选,看看次被标记的对象是否需要执行finalize()方法
    1. 如果对象Obj对应的类没有重写finalize()方法,或者finalize方法已经被虚拟机调用过,则被虚拟机视为“没有必要执行”,Obj将被判定为死亡、不可达,将会被回收
    2. 如果对象Obj对应的类重写了finalize()方法,并且还没有执行过,那么Obj会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法的执行。因为Finalizer线程的优先级很低,所以才说主动调用finalize方法并不能得到及时的执行。
    3. finalize()方法是对象逃离死亡状态的最后机会,之后GC会对F-Queue队列中的对象进行第二次标记,如果Obj在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,Obj将会被移除待回收的集合。之后对象会再次出现没有引用存在的情况,这个情况下,finalize方法将不会被再次执行,对象会直接变成不可达的状态,也就是说一个对象的finalize方法只会被调用一次。
public class Test {

    public static Test obj;//类变量

    //此方法只会被执行一次
    @Override
    protected void finalize() throws Throwable {

        System.out.println("GC判断对象死亡之前,Finalizer线程执行F-Queue队列中对象的finalize()方法");
        obj = this;//此时obj和调用该finalize方法的对象(GC Routs)搭上关系,构成引用链
    }

    public static void main(String[] args) {

        try {
            obj = new Test();
            //对象通过finalize方法第一次成功拯救自己
            obj = null;//在此之后对象将失去引用,会被GC标记
            System.out.println("第一次GC");
            System.gc();
            //因为Finalizer线程的优先级很低,暂停一会,等等它
            Thread.sleep(3 * 1000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is alive");
            }

            //第二次gc

            obj = null;//在此之后对象将失去引用
            System.out.println("第二次GC");
            System.gc();
            //因为Finalizer线程的优先级很低,暂停一会,等等它
            Thread.sleep(3 * 1000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is alive");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}


执行结果:
第一次GC
GC判断对象死亡之前,Finalizer线程执行F-Queue队列中对象的finalize()方法
obj is alive
第二次GC
obj is dead

清除阶段算法

在成功标记垃圾对象之后,接下来GC需要做的就是清除这些垃圾,释放内存供之后的对象使用

标记-清除算法(Mark Sweep)

当堆中的内存空间被耗尽的时候,就会停止整个程序(stop the world),然后进行标记、清除工作

  • 标记:Collector从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的header中标记为可达对象。
  • 清除:Collector对堆内存从头到尾进行线性的的遍历,如果发现某个对象在其Header中没有可达对象的信息,将其回收。
    • 这里所谓的清除并不是真的进行了内产释放,而是把需要清楚的对象地址保存在空闲的地址列表中,下次由新的对象需要分配内存空间的时候,判断垃圾所在的内存空间是否够用,如果够就存放

算法缺点:

  • 效率不高,存在两次遍历操作
  • 进行GC的时候需要停掉所有用户线程(stop the world)
  • 清理出来的空闲内存是不连续的,需要维护一个空闲内存的列表

复制算法

为了解决标记-清除算法在垃圾收集效率上的问题被M.LMinsky在1963年提出,最后应用到了Lisp语言的一个版本中。

核心思想是将活着的内存分为两部分,每次只是用其中的一块内存空间,在垃圾回收时将正在使用的内存中的活对象复制到未被使用的内存块中,之后清除正在使用的那一块内存中剩下的所有对象(剩下的就是不可达的死亡对象),在此之后交换两个内存块的角色,完成垃圾收集,之后每次收集重复上述过程。

每一次的可达的对象复制到另一个未被使用的内存块中的时候,这些对象被连续存放,解决内存碎片化问题

优点:

  • 没有标记和清除过程,实现简单,运行高效
  • 复制到另一块内存空间的过程,解决了内存碎片化的问题

缺点:

  • 需要两倍的内存空间,或者说是少了一半实际可用的内存空间
  • 对于G1这种拆分成大量region的GC来说,进行对象的复制,需要维护region之间对象的引用关系,不管是内存占用还时间开销也不小。
    • Java中站内通过直接引用(非句柄引用)指向堆内对象,对象的复制导致栈中的引用也要改变,也是不小的开销

但是:

  • 如果可达的对象数量很多,垃圾很少的情况下,复制算法就不是那么的“划算”。反之,复制算法较为适用于垃圾对象较多、存活对象较少的情况

应用场景:

由于复制算法鲜明的优缺点,以及新生代中70%-80%的对象都是“照生夕死”的,所以很多虚拟机都是使用复制算法来收回新生代。也就是Survivor0和Survivor1(也叫from和to区)

标记-压缩算法(Mark-Compact)

由于复制算法不适用于老年代,标记-清除算法效率较低并且会造成内存碎片化的问题,在标记-清除算法基础之上产生了标记-压缩算法。

原理:

  • 标记:从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的header中标记为可达对象。
  • 压缩:将所有标记为存活的对象压缩到内存的一段,按顺序存放。这些对象占用的内存空间是连续的
  • 清除:清理所有存活对象所占用内存空间之外的内存区域

由于该算法在清理的过程中已经整理了可用的对象内存存放顺序,不存在内存碎片,也就不需要维护内存空闲列表

优点:

  • 不存在内存碎片化的问题,只需要JVM持有一个内存的起始地址即可(指针碰撞)
  • 没有内存减半的浪费问题

缺点:

  • 效率上低于复制算法和标记-清除算法
  • 移动对象的同时,还需要改变对象的引用地址
  • 在移动过程中需要停止用户线程(stop the world)

分代收集算法

上面阐述的三种算法都是各有各的优缺点,所以没有说是存在一种完美的算法能解决所有问题,具体使用哪种算法应该具体问题具体分析。

分代收集算法基于的事实基础如下:

不同的对象的生命周期是不一样的,因此可以根据不同生命周期的对象使用不同的垃圾收集算法,提高垃圾回收的执行效率。JVM中的划分的新生代、老年代,就可以根据各自的特点使用合适的回收算法,提高垃圾回收效率。目前几乎所有的GC都是采用分代收集算法。

在Hotspot VM中,GC所使用的内存回收算法也是结合新生代和老年代的特点

新生代(Young Gen):区域较小,对象生命周期短、存活率低、回收频繁,所以使用复制算法是最合适的。复制算法的效率由于新生代中对象存活率低显得更为优异,在新生代回收次数多的情况下,复制算法满足了效率上的要求,内存浪费的缺点由于两个Survivor区较小的原因显得也不严重。默认情况下年轻代和老年代比例为1:2, Eden:Survivor0:Survivor1 = 8:1:1,所以浪费的也就只有新生代大小的1/30

老年代(Tenured Gen):区域较大,对象生命周期长,存活率高,回收频率低于新生代,所以标记清除算法和标记压缩算法在老年代使用较多。

  • 标记(Mark)阶段的开销和存活对象的数量成正比
  • 清除(Sweep)阶段的开销和所管理的内存区域的大小成正比(对堆内存从头到尾进行线性的的遍历)
  • 压缩(Compact)阶段的开销和存活对象的数量成正比

增量收集算法

上述算法都会存在STW问题,用户线程会被暂停,实时垃圾收集算法——增量收集算法就是为了解决这个问题而诞生的。

基本思路:

如果想要一次性的处理所有现存的垃圾,那必将造成用户线程的停顿,基于这样的原因,那就让垃圾收集线程和用户线程交替执行,每次垃圾收集线程都用较短的时间处理一小块内存空间的垃圾,之后就交给用户线程执行,如此反复,知道垃圾收集全部收集完成

总体来讲,增量收集算法的基础仍然是标记清除算法和复制算法,只不过着重处理的是垃圾收集线程和用户线程之间的冲突问题,阶段性的使用标记-清除算法或者复制算法完成所有的垃圾收集工作。

缺点

由于垃圾回收线程和用户线程交替执行的原因,会造成大量在线程间切换和上下文切换的消耗,是的垃圾回收的总成本变高,造成系统的吞吐量下降

分区算法

一般来讲,堆空间越大,完成一次GC的时间就越长,GC的操作就越长。为了更好的控制GC产生的停顿时间,将一块大的内存分割为若干个小快内存区域(连续的小内存区间region),根据目标的停顿时间(STW的限制时间),每次合理的回收若干个小区域,而不是整个堆空间,从而减少一次GC所产生的时间。每个小区间region都独立使用,独立回收,给的目标停顿时间长一次垃圾收集就多处理若干个region,给的时间短就少处理若干个region

总结

以上都是一些基础的垃圾回收算法或者思路,实际GC的实现要复杂的多,基本都是使用符合算法,力求兼顾效率和减小副作用。

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值