JVM入门-垃圾回收概述与算法

Posted by 小拳头 on Saturday, January 30, 2021

垃圾是指在运行程序中没有任何指针指向的对象, 这个对象就是需要被回收的垃圾. 如果不及时对内存中的垃圾进行清理, 这些垃圾对象所占的内存空间会一直保留到应用程序结束, 被保留的空间无法被其他对象使用, 导致内存溢出.

  • 对于高级语言来说, 一个基本认知是如果不进行垃圾回收, 内存迟早都会被消耗完.
  • 除了释放没用的对象, 垃圾回收也可以清除内存里的记录碎片. 碎片整理将所占用的堆内存移到堆的一端, 以便JVM将整理出的内存分配给新的对象.
  • 业务越来越复杂, 没有GC就不能保证应用程序的正常进行. 而经常造成STW(Stop-The-World)的GC又跟不上实际的需求, 所以才会不断地尝试对GC进行优化.

在早期的C/Cpp时代, 垃圾回收基本是手工进行的. 开发人员可以使用new关键字进行内存申请, 用delete关键字进行内存释放. 这种方式可以灵活控制内存释放的时间, 但是会给开发人员带来频繁申请和释放内存的管理负担. Java的自动化解决了这个问题, 降低内存泄漏和内存溢出的风险. JVM的垃圾回收主要在方法区(元空间)和堆区, 频繁收集Young区, 较少收集Old区, 基本不动Perm/Metaspace区.

标记阶段

垃圾标记阶段主要是判断对象是否存活. 而当一个对象已经不再被任何的存活对象继续引用时, 就可以认为已经死亡. 判断对象存活一般有引用计数算法可达性分析算法两种方式.

标记阶段-引用计数算法

**引用计数算法(Reference Counting)**对每个对象保存一个整型的引用计数器属性, 用于记录对象被引用的情况. python使用了引用计数算法, 但是Java没有. 对于一个对象A, 只要有任何一个对象引用了A, 则A的引用计数器就加1. 当引用失效时, 引用计数器就减1. 只要对象A的引用计数器的值为0就表示对象A不能再被使用.

  • 优点: 实现简单, 垃圾对象便于辨识; 判定效率高, 回收没有延迟性, 不需要等内存不够了再回收.
  • 缺点: 需要单独的字段存储计数器, 增加空间的开销. 每次赋值都需要更新计数器, 伴随着加法和减法操作, 增加时间的开销. 最致命的缺陷是: 无法处理循环引用的情况, 这也是Java不用引用计数算法的原因. 如下图, 当p不指向右边的循环引用, 那么右边的对象都无法被收集.

也可以通过下面代码测试Java没有用引用计数算法.

/**
 * -XX:+PrintGCDetails
 */
public class RefCountGC {

    private byte[] bigSize = new byte[5 * 1024 * 1024]; //5MB
    Object reference = null;

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();

        //循环引用
        obj1.reference = obj2;
        obj2.reference = obj1;

        //断开指向循环引用的部分
        obj1 = null;
        obj2 = null;

        //显式的执行垃圾回收行为
        //PSYoungGen中used的空间变少了, 说明发生了GC, obj1和obj2可以被回收, 
        System.gc();

        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

python解决循环引用的方法是手动解除(在合适的时机, 解除引用关系), 使用弱引用weakref.

标记阶段-可达性分析算法

也叫根搜索算法, 追踪性垃圾收集. Java与C#都用到了, 和可达性分析算法一样具备实现简单和执行高效等特点, 而且解决了在引用计数算法中循环引用的问题. 基本思路是:

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

GC Roots主要包括:

  • 虚拟机栈中引用的对象(主要就是局部变量表内东西)
  • 本地方法栈内引用的对象
  • 静态属性引用的对象(Java类的引用类型静态变量)
  • 常量引用的对象(字符串常量池里的引用)
  • 所有被同步锁synchroni zed持有的对象
  • Java虚拟机内部的引用(基本数据类型对应的Class对象, 一些常驻的异常对象: NullPointerException/OutOfMemoryError, 系统类加载器)
  • 反映java虚拟机内部情况的JMXBean, JVMTI中注册的回调, 本地代码缓存等

除了这些固定的GCRoots集合以外, 根据用户所选用的垃圾收集器以及当前回收的内存区域不同, 还可以有其他对象临时地加入, 共同构成完整的GC Roots集合. 如分代收集局部回收(Partial GC). 如果只针对Java堆中的某一块区域进行垃圾回收, 比如只针对新生代, 新生代的对象有可能被其他区域的对象(老年代等)所引用, 这时候就需要把关联的区域对象也加入GC Roots集合中去考虑.

由于Root采用栈方式存放变量和指针,所以如果一个指针保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root

如果要使用可达性分析算法来判断内存是否可回收, 那么分析工作必须在一个能保障一致性的快照中进行, 这点不满足的话分析结果的准确性就无法保证. 导致STW, 因为要把线程停下来, 所以说几乎不会停顿的CMS收集器中, 枚举根节点时也是必须要停顿的.

对象的终止机制

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

但是要注意永远不用主动调用finalize方法, 当然这个方法在没重写的情况下只是"protected void finalize() throws Throwable { };", 直接调用也没有用, 更主要的还有以下的原因:

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

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

  • 可触及的: 从根节点开始, 可以到达这个对象
  • 可复活的: 对象的所有引用都被释放, 但是对象有可能在finalize中复活
  • 不可触及的: 对象的finalize被调用, 并且没有复活, 就会进入不可触及状态. 不可触及的对象不可能被复活, 因为finalize只会被调用一次

也就是说对象不可触及的时候才会被回收, 判定一个对象是否可回收, 至少要经历两次标记过程:

  1. 如果对象到GC Roots没有引用链, 则进行第一次标记
  2. 进行筛选, 判断此对象是否有必要执行finalize方法:
  • 如果对象没有重写finalize()方法(也就是方法体为空), 或finalize()已经被调用过,则虚拟机视为没有必要执行, 对象被判定为不可触及的
  • 如果对象objA重写了finalize(), 但还未执行过, 那么对象会被插入到F-Queue队列中, 由一个虚拟机自动创建的低优先级Finalizer线程触发其finalize方法执行
  • finalize()是对象逃脱死亡的最后机会, 稍后GC会对F-Queue队列中的对象进行第二次标记. 如果对象在finalize方法中与引用链上的任何一个对象建立了联系(和GC Roots搭上关系), 那么在第二次标记时对象会被移出即将回收集合, 对象就会再次出现没有引用存在的情况, 而finalize也不会被再次调用, 对象会直接变成不可触及的状态.

测试finalization机制:

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建立了联系(和GC Roots搭上关系)
    }

    public static void main(String[] args) {
        try {
            obj = new CanReliveObj();
            obj = null; //没有链接
            System.gc(); //调用垃圾回收器
            System.out.println("first 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("second GC");

            obj = null;
            System.gc();
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead"); //finalize不能再执行了, 自救失败
            } else {
                System.out.println("obj is still alive");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

用Jprofiler查看GC Roots

在Live memory中的感兴趣的东西右键到Heap Walker, 就能监控内存的使用, 可以用来查看是否内存泄漏, 超大对象等. 还可以看是哪个线程出现了问题.

清除阶段

当成功区分出内存中存活对象和死亡对象后, GC接下来的任务就是执行垃圾回收, 释放掉无用对象所占用的内存空间, 以便有足够的可用内存空间为新对象分配内存. 目前在JVM中比较常见的三种垃圾收集算法是标记清除算法(Mark-Sweep), 复制算法(Copying), 标记一压缩算法(Mark-Compact).

清除阶段-标记清除算法

当堆中的有效内存空间被耗尽的时候, 就会停止整个程序(STW), 然后进行标记和清除. 过程如下:

  • 标记: Collector从引用根节点开始遍历, 标记所有被引用的对象. 一般是在对象的Header中记录为可达对象(非垃圾对象).
  • 清除: Collector对堆内存从头到尾进行线性的遍历, 遍历所有对象, 如果发现某个对象在其Header中没有标记为可达对象,则将其回收. 这里所谓的清除并不是真的置空, 而是把需要清除的对象地址保存在空闲的地址列表里. 有新对象需要加载时, 判断垃圾位置的空间是否足够, 够就放在这个空间里面.

缺点是:

  • 效率不高
  • 在进行GC的时候, 需要停止整个应用程序, 线程就停止了, 导致用户体验差
  • 清理出来的空闲内存是不连续的(如上图清除过后的白色框框是不连续的), 产生内存碎片, 需要维护一个空闲列表

清除阶段-复制算法

为了解决标记-清除算法在垃圾收集效率方面的缺陷, 论文CALISP Garbage Collector Algorithm Using Serial Secondary Storage解决这个问题, 被人称作复制算法. 如下图中的上下两个内存空间互相复制, 使用的内存中的存活对象复制到未被使用的内存块中, 之后清除正在使用的内存块中的所有对象. 保证了空闲内存空间的连续, 堆空间的S0, S1的对象移动就是用了复制算法.

优点是:

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

缺点是:

  • 需要两倍的内存空间
  • 对于G1这种分拆成为大量region的GC, 复制意味着GC需要维护region之间对象引用关系, 浪费内存和时间. 如果系统中的垃圾对象很多, 复制算法就不会很理想. 通常复制算法需要复制的存活对象数量不能不会太大, 或者说非常低才行(进一步说明了survivor区比较适合复制算法, 而老年代就不适合).

清除阶段-标记压缩算法

过程如下, 对比标记清除算法多了碎片整理, 也就是说标记压缩算法是移动式的. 当我们需要给新对象分配内存时, JVM只需要持有一个内存的起始地址, 对比复制算法显然少了许多开销.

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

指针碰撞(Bump the Pointer): 如果内存空间以规整和有序的方式分布, 即已用和未用的内存都各自一边, 彼此之间维系着一个记录下一次分配起始点的标记指针. 当为新对象分配内存时, 只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上.

但是依然有缺点:

  • 标记整理算法的效率要低于复制算法, 因为有整理碎片的过程
  • 移动对象的同时, 如果该对象被其他对象引用, 则还需要调整引用的地址. 移动过程中会STW.

清除阶段算法小结

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

不同代根据特点采用不同的GC算法:

  • 年轻代: 区域相对老年代较小, 对象生命周期短/存活率低/回收频繁. 那么就适合用复制算法, 因为速度最快. 复制算法内存利用率不高的问题, 通过hotspot中的两个survivor的设计得到缓解.
  • 老年代: 特点是区域较大, 对象生命周期长/存活率高/回收不及年轻代频繁. 那么就适合用标记清除算法和标记压缩算法. Mark阶段的开销与存活对象的数量成正比, Compact阶段的开销与存活对象的数据成正比.

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

增量收集算法

上述的算法在垃圾回收过程中, 应用软件将STW, 所有的线程都会挂起, 暂停一切正常的工作. 如果垃圾回收时间过长,应用程序会被挂起很久, 将严重影响用户体验或者系统的稳定性. 为了解决这个问题, 即对实时垃圾收集算法的研究导致了增量收集(Incremental Collecting)算法的诞生.

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

缺点是线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升, 造成系统吞吐量的下降.

分区算法

一般在相同条件下, 堆空间越大一次GC时所需要的时间就越长, GC产生的停顿也越长. 为了降低延迟,可以将一块大的内存区域分割成多个小块, 根据目标的停顿时间, 每次合理地回收若干个小区间. 分代算法将按照对象的生命周期长短划分成两个部分, 而分区算法将整个堆空间划分成连续的不同小区间,每一个小区间都独立使用/回收. 优点是可以控制一次回收多少个小区间.

参考

  1. 尚硅谷最新版宋红康JVM教程
  2. The Java® Virtual Machine Specification
  3. JVM垃圾回收

comments powered by Disqus