15、JVM实战:垃圾回收相关算法

1. 标记阶段算法

标记阶段主要用于标记垃圾对象. 标记阶段的算法就是区分存活对象和垃圾对象的过程.

1.1 引用计数算法

简单的说,就是为每一个对象都保存一个整形的引用计数器属性.
每当此对象被引用,引用计数器+1,当一个引用失效,引用计数器-1,当计数器=0时,说明此对象无任何引用.

优点:

1、 实现简单,对垃圾对象的表示高效快速就是一个计数器,使用加法减法计算;
2、 效率高,无延迟当引用=0时直接回收;

缺点:

1、 单独维护计数器,增加内存开销;
2、 多次加法减法,增加时间开销;
3、 无法处理循环引用问题

循环引用问题:
有A,B,C
A引用了B和C,B引用了C,C引用了B 如下图

*

当A被回收的时候,B和C按理说已经没有任何引用了,可以被回收了
但是B和C出现了**循环引用,**就不会被引用计数器算法回收,因为B和C的计数器一直是1

java中并没有使用引用计数器算法,就是因为该算法无法处理循环引用问题

package com.zy.study13;

/**
 * @Author: Zy
 * @Date: 2021/12/20 17:12
 * 测试java是否能处理循环引引用问题
 * -XX:+PrintGCDetails 打印gc
 * 
 */
public class RefCountAlgorithm {
   
     
    // 占用空间
    private byte[] data = new byte[5*1024*1024];

    //用来引用另外的变量
    Object reference = null;

    public static void main(String[] args) {
   
     
        RefCountAlgorithm ref1 = new RefCountAlgorithm();
        RefCountAlgorithm ref2 = new RefCountAlgorithm();

        // 循环引用
        ref1.reference = ref2;
        ref2.reference = ref1;
        // 切断自身引用,只保留循环引用
        ref1 = null;
        ref2 = null;

        // System.gc() 观察是否回收上面两个对象,如果回收,就证明没有使用引用计数算法.
        System.gc();
    }
}

没有System.gc时
*
System.gc后
*
可以看到已经被回收了

1.2 可达性分析算法

又叫根搜索算法/追踪性垃圾收集

可达性分析算法相较于引用计数算法来说,主要就是解决了循环引用的问题,同时也比较简单高效(还是比引用计数算法复杂一点的)
java/c#选择的就是可达性分析算法
以GCRoots为根节点,向下延伸,所有能被GC Roots引用的对象都称为可达的对象,反之则不可达
延伸的路径称之为引用链

经过可达性分析算法分析之后没有被引用链相连接的对象就是垃圾对象

1.2.1 GC Roots

GCRoots 是可达性分析算法中的重要概念
GCRoots 指的是一组活跃的对象.

GCRoots一般包括如下几个区域的对象:

1、 虚拟机栈中的对象引用,例如局部变量表中的变量;
2、 方法区中的静态属性,例如静态变量;
3、 方法区中的常量引用,例如字符串常量池中的引用;
4、 本地方法栈中的对象引用;
5、 被同步锁持有的对象(Synchronized);
6、 jvm中的内部引用,例如基本数据类型的Class对象;
7、 反映jvm内部情况的JMXBean,JVMTI中注册的回调,代码缓存等.;

但是特殊情况下,GC Roots也可能包含一些特殊情况:
当针对局部回收,分代收集这种情况的时候,GC Roots并不只包含上述的几种情况
例如:分代收集的时候,YGC针对年轻代收集的时候,这个时候老年代也可以被认为是GC Roots的对象

1.2.2 finalization机制

Object类中的finalize方法,会在对象被回收前调用**一次,**可以用作连接的关闭,流的关闭等.

/**
 *Called by the garbage collector on an object when garbage collection determines that there are no more references to the            
 *object. A subclass overrides the finalize method to dispose of system resources or to perform other cleanup
 * 当垃圾收集确定不再有对对象的引用时,由垃圾收集器在对象上调用。子类覆盖 finalize 方法以处理系统资源或执行其他清理
 */
protected void finalize() throws Throwable {
   
      }

简单的说,该方法可以被重写,并再此方法中处理一些逻辑.

这个机导致了如下情况:
对象在经过一次可达性分析后,没有被引用链引用,被标记为垃圾对象,正常情况下会被回收,但是finalization机制可能会使此对象复活.
复活:此对象不再被标记为垃圾对象.

由于finalization机制,所以垃圾对象回收必须经过两次标记:

1、 如果对象到GCRoots没有引用链,则进行第一次标记;
2、 执行finalization机制筛选;

1、 如果对象没有重写finalize方法,或者已经执行过finalize方法,那么该对象就会被第二次标记,确定死亡;
2、 如果对象重写了finalize方法,且还未执行,那么就将该对象就会被插入一个低优先级的F-Quene队列中,被另外的线程触发finalize方法;
3、 finalize()方法中如果可以与GCRoots建立联系,那么这个对象就会被复活;

1.  注意: **finalize()方法只会执行一次,即对象只可能通过finalize()方法复活一次.**

由此可以得出对象的三种状态:

1、 可触及的即没有被标记为垃圾对象;
2、 可复活的被第一次标记为垃圾对象,但是没有执行过finalize()方法,可能会复活的对象;
3、 不可触及的没有重写finalize()方法或者已经执行过finalize()方法,但是并没有复活的对象.;

代码测试对象复活

package com.zy.study13;

/**
 * @Author: Zy
 * @Date: 2021/12/22 16:37
 *  可达性分析算法 验证finalization机制 可复活的对象
 *  验证目标:
 *      一个被第一次标记为垃圾的对象经过finalize()方法复活的情况
 */
public class ReachabilityAnalysisAlgorithm {
   
     
    private static ReachabilityAnalysisAlgorithm obj;

    @Override
    /**
     *重写finalize方法,并在此方法中复活此对象
     * @author Zy
     * @date 2021/12/22
     * @param
     * @return void
     */
    protected void finalize() throws Throwable {
   
     
        super.finalize();
        System.out.println("调用finalize方法复活对象");
        obj = this;
    }

    public static void main(String[] args) {
   
     
        // 初始化obj
        obj = new ReachabilityAnalysisAlgorithm();

        // 置为垃圾对象
        obj = null;
        // 第一次gc
        System.gc();

        try {
   
     
            // 线程休眠2s,finalize线程优先级比较低
            Thread.sleep(2000);
        } catch (InterruptedException e) {
   
     
            e.printStackTrace();
        }

        if(obj == null){
   
     
            System.out.println("obj 死亡");
        }else{
   
     
            System.out.println("obj 存活");
        }

        // 第二次置为垃圾对象
        obj = null;
        // 第二次gc
        System.gc();
        try {
   
     
            // 线程休眠2s,finalize线程优先级比较低
            Thread.sleep(2000);
        } catch (InterruptedException e) {
   
     
            e.printStackTrace();
        }

        if(obj == null){
   
     
            System.out.println("obj 死亡");
        }else{
   
     
            System.out.println("obj 存活");
        }
    }
}

1.2.3 GC Roots溯源

溯源指的是在发生了内存泄露问题时,通过泄露对象的引用链,找到该对象的GC Roots,从而便于切断引用链,解决内存泄露问题
GCRoots溯源可以通过工具来辅助

1.2.3.1 MAT

略需要dump文件,在安装MAT

1.2.3.2 JProfiler

与idea插件配合,便于调试
JProfiler安装包(11版本)和注册机 https://pan.baidu.com/s/15vitCCdxl20WZEgGZZkLXw
idea插件在idea插件市场安装

*

1.2.3.2.1 JProfiler分析OOM

测试代码:

package com.zy.study13;

import java.util.ArrayList;
import java.util.List;

/**
 * @Author: Zy
 * @Date: 2021/12/22 22:18
 * 使用JProfiler分析OOm
 * -Xms10M -Xmx10M -XX:+HeapDumpOnOutOfMemoryError
 */
public class OomTest {
   
     
    byte[] bytes = new byte[5 * 1024 * 1024];

    public static void main(String[] args) {
   
     
        List<OomTest> list = new ArrayList<OomTest>();
        int count = 0;
        try {
   
     
            while (true) {
   
     
                list.add(new OomTest());
                count++;
            }
        } catch (Throwable e) {
   
     
            System.out.println("count =" + count);
            e.printStackTrace();
        }
    }
}
-XX:+HeapDumpOnOutOfMemoryError 当发生OOM时生成一个dump文件

生成文件后用JProfiler打开*
可以看到OOM的大对象.进而分析OOM

2. 清除阶段算法

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

当内存空间不足时触发STW,然后开始执行标记清除算法,标记清除算法执行过程:

1、 标记此时的标记为标记可达对象,并非垃圾对象,将所有的可达对象打上标记;
2、 清除迭代所有对象,把所有没有标记的对象都回收掉;

1、 此时的清除只是把对象的地址维护到空闲列表里,并不是真正的置空该对象,类似于电脑文件的删除,只是把目录清空了

标记-清除算法是比较基础且广泛使用的算法

缺点:

1、 效率较低标记阶段需要进行一次堆空间的全遍历清除阶段也要进行一次全遍历;
2、 gc时会存在stw问题;
3、 使用该方式清理的空间是不连续的,需要维护一个空闲列表(对象分配内存空间时有两种方式,这里就是空闲列表);

2.2 复制算法 (Copying)

复制算法就是为了弥补标记-清除算法的两次遍历问题
复制算法的核心思想是将内存空间分为两份,每次只使用一份,回收时将可达对象复制到另外一半未使用的空间中,然后对当前使用的空间全部回收.(类似新生代的S0/S1区)
复制算法的执行过程:

1、 将内存空间分为两部分,每次只使用一部分.;
2、 遍历堆空间对象,将可达对象复制到另外一部分空间中;

1、 此处的复制是完整的对象复制,并不是只复制引用;
3、 将原来使用的空间全部回收;

优点:

1、 效率高,没有两次遍历;
2、 复制过去的对象重新整理空间,解决了空间碎片问题;

缺点:

1、 需要双倍内存空间;
2、 对于当前jdk的G1垃圾回收器来说,因为维护了大量的分区,当堆空间的对象被复制之后,对象地址变化需要同步修改栈中的引用,此时需要的内存开销/cpu开销也会比较大;

总结:复制算法适合垃圾对象比较多,存活对象较少的情况. 如果存活对象较多,需要将这些存活对象都复制到另外一个区域,再同步修改栈中对象的引用地址,是不太合适的.

因此,新生代中对象基本生命周期比较短,适合使用复制算法(s0/s1)
老年代就不行了.

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

标记-压缩算法是整合了标记-清除算法和复制算法的优点
也可以称之为 标记-清除-压缩算法.
执行过程:

1、 标记与标记-清除算法一样,将可达对象标记出来;
2、 清除此时的清除与清除算法不一样,而是类似复制算法,将所有标记对象整理到内存空间的一端,按顺序存放,然后再清除边界外的垃圾对象.;

优点:

1、 解决了标记-清除算法中空间碎片问题,不用再维护一个空闲列表了,经过标记-压缩算法后,回收的内存空间应该是规整的,此时使用指针碰撞方式分配内存空间即可;
2、 解决了复制算法中需要分为两部分内存空间(内存减半)的问题.;

缺点:

1、 效率低,低于复制算法,某种情况下可能比标记-清除算法还要低;
2、 STW问题,;
3、 与复制算法一样的问题,当内存对象复制完后,栈中对象的引用也需要同步修改.;

2.4 对比三种算法

标记-清除算法 复制算法 标记-压缩算法
效率
空间开销 少(有碎片) 多(无碎片) 少(无碎片)
移动对象(需要更新引用) 不移动 移动 移动

3. 分代收集算法(Generational Collecting)

通过上面的对比可以看出,不同的算法有不同的优势,并没有一种完美的算法
因此,针对不同的情况使用不同的算法才是最优选择,这就是分代收集算法
**分代收集算法: **针对不同生命周期的对象采用不同的算法.
新生代由于对象生命周期较短,就可以使用复制算法,比较划算(S0/S1)
老年代对象生命周期较长,此时就可以考虑标记-清除算法或者标记压缩算法或者混合实现.

例如:jvm中的cms垃圾回收期就是基于标记-清除算法来实现的,针对标记-清除算法的碎片问题,cms采用基于标记-压缩算法的Serial Old回收器作为补偿,当碎片过多导致空间不足以分配时(Concurrent Mode Failure)时,执行FullGC整理碎片.

现代虚拟机中基本都使用了分代算法,区分老年代和新生代.

4. 增量收集算法

增量收集算法是为了解决STW问题的(在标记算法中,都会出现stw问题)
核心思想: 增加收集算法每次只回收一部分的区域,然后让应用程序继续执行, 如此循环往复直至垃圾回收完毕

优点:
原来的标记算法必须要所有的应用程序线程挂起,等待gc完成后,才能进行接下来的操作,但是当gc时间过长的时候,可能就会影响应用的稳定和用户的体验,增量收集算法就是解决这个问题的

缺点:
增加收集算法本质是垃圾回收线程和应用程序线程的切换,线程的切换是需要开销的,频繁的切换垃圾回收线程和应用程序线程就会导致程序整体的性能/吞吐量下降

5. 分区算法

与分代算法不同,
分代算法根据对象的生命周期划分新生代和老年代
分区算法根据堆空间的大小划分为不同的region(G1垃圾回收器)

分区算法核心思想: 根据每个region的大小及回收所需时间,每次仅回收堆空间中的一部分region,从而减少gc的停顿时间

分区算法与增量收集算法的目的是一样的,都是为了降低gc的延迟.

6. 总结

这几种算法都是基本算法,而在jvm的实际运用中,大多数都是复合算法,多种算法并行来满足jvm垃圾回收的需要.

版权声明:本文不是「本站」原创文章,版权归原作者所有 | 原文地址: