十一、垃圾回收概述与相关算法

一、垃圾回收概述

1、前言

1、Java和C++语言的区别,就在于垃圾收集技术和内存动态分配上,C没有垃圾收集技术,需要手动收集。垃圾收集机制是Java的招牌能力,极大的提高了开发效率

十一、垃圾回收概述与相关算法_第1张图片

2、什么是垃圾(Garbage)

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

3、为什么需要GC

1、对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而不进行打扫一样。
2、除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象
3、随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。

4、早期垃圾回收

1、在C/C++时代,垃圾回收基本上是手工进行的,通过new关键字进行内存申请,通过delete关键字进行内存释放。
2、这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃
/**
 * 没有垃圾回收
 */
MibBridge *pBridge = new cmBaseGroupBridge();
//如果注册失败,使用Delete释放该对象所占内存区域
if(pBridge->Register(kDestroy) != NO ERROR)
	delete pBridge;

/**
 * 有垃圾回收
 */
MibBridge *pBridge = new cmBaseGroupBridge();
pBridge->Register(kDestroy);

5、垃圾回收优点

1、自动内存管理,无需开发人员手动管理内存分配与回收,降低了内存泄漏和内存溢出的风险
2、自动内存管理,让开发人员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
3、Oracle官网关于垃圾回收介绍

6、垃圾回收担忧

1、对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力
2、此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OutOfMemoryError时,快速地根据错误异常日志定位问题和解决问题。
3、当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节

7、GC主要区域

1、GC主要关注于方法区中的垃圾收集。
2、垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收,其中Java堆是垃圾收集器的工作重点
3、垃圾收集次数:
  • 频繁收集Young区
  • 较少收集Old区
  • 基本不收集Perm区(或元空间)

十一、垃圾回收概述与相关算法_第2张图片

二、标记阶段之引用计数算法

1、标记阶段作用

1、作用:判断对象是否存活,已经死亡的对象就会被垃圾回收器回收。
2、对象存活判断:
  • 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些对象是存活状态,哪些是死亡状态。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放其所占用的内存空间,因此这个过程称为垃圾标记阶段
  • JVM中当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡了。
  • 判断对象存活的两种方式:引用计数算法可达性分析算法

2、引用计数算法

1、引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况
2、对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就会减1。只要对象A的引用计数器的值为0,即表示A对象不可能再被使用,可进行回收。
3、优点:实现简单,垃圾对象便于识别,判定效率高,回收没有延迟性
4、缺点:
  • 需要单独的字段存储计数器,增加了存储空间的开销
  • 每次赋值都需要更新计数器,并伴随着加法减法操作,增加了时间开销
  • 引用计数器无法处理循环引用的情况,这是致命缺陷,导致在Java的垃圾回收器中没有使用这类算法

3、循环引用

当p的指针断开的时候,内部的引用形成一个循环,这就是循环引用,从而造成内存泄漏

十一、垃圾回收概述与相关算法_第3张图片

/**
 * 证明java使用的不是引用计数算法
 * 设置jvm启动参数:-XX:+PrintGCDetails
 */
public class RefCountGC {
    //这个成员属性的唯一作用就是占用一点内存
    private byte[] bigSize = new byte[5 * 1024 * 1024];
    //引用
    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;
        //显示的执行垃圾收集行为,判断obj1和obj2是否被回收?
        // System.gc();
    }
}
1、注释System.gc时的GC日志

十一、垃圾回收概述与相关算法_第4张图片

2、没有注释时候的GC日志,可以看到内存回收日志中包含了14141K->840K意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明了Java虚拟机并不是通过引用计数算法来判断对象是否存活的

十一、垃圾回收概述与相关算法_第5张图片

3、如果使用引用计数算法,那么这两个对象将会无法回收。而现在两个对象被回收了,说明Java使用的不是引用计数算法来进行标记的。

十一、垃圾回收概述与相关算法_第6张图片

4、总结

1、引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
2、具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
3、Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。Python如何解决循环引用?
  • 手动解除:很好理解,就是在合适的时机,解除引用关系。
  • 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。

三、标记阶段之可达性分析算法

1、概述

1、可达性分析算法:也可以称为根搜索算法追踪性垃圾收集
2、当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
3、相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生

2、基本思路

1、这个算法的基本思路就是通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。内存中的存活对象都会被根对象集合直接或间接连接着,如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象
2、GC Roots:根集合就是一组必须活跃的引用。
3、如图,对象object5、object6、object7虽然互有关联,但是它们到GC Roots是不可达的, 因此它们将会被判定为可回收的对象。

十一、垃圾回收概述与相关算法_第7张图片

3、可以作为GC Roots的对象

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  • 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)。如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引 用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。
小技巧:
  • 由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。

4、注意事项

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

四、对象的finalization机制

1、概述

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

十一、垃圾回收概述与相关算法_第8张图片

2、finalize()使用的注意事项

1、永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由如下:
  • 在finalize()时可能会导致对象复活
  • finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。可以调用System.gc()来触发GC。
  • finalize()方法优先级比较低,即使主动调用该方法也不能保证能马上执行。
  • 一个糟糕的finalize()会严重影响GC的性能。
2、从功能上来说,finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。

3、虚拟机中对象的几种状态

1、由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态
2、如果从所有的根节点(GCRoots)都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收
3、在可达性分析算法中判定为不可达的对象,也并非是“非死不可”的,这时候它们暂时还处于“缓刑”阶段。一个无法触及的对象有可能在某一条件下复活自己,如果这样,那么对它的回收就是不合理的,因此,定义虚拟机中的对象可能有三种状态:
  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
  • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
4、以上3种状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收

4、判断一个对象是否可回收的过程

要真正宣告一个对象死亡,至少要经历两次标记过程:
1、如果对象在进行可达性分析后发现没有与GCRoots相连接的引用链,则进行第一次标记
2、随后进行第一次筛选,筛选的条件是此对象是否有必要执行finalize()方法
  • 如果对象没有重写finalize()方法或者finalize()方法已经被虚拟机调用过,则虚拟机将这两种情况都视为“没有必要执行”,对象被判定为不可触及的
  • 如果对象重写finalize()方法,且还未执行过,那么该对象会被放置在一个名为F-Queue的队列中,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
3、finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合(对象复活了)如果对象这时候还没有逃脱,那基本上它就真的要被回收了
4、之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()方法只会被调用一次

十一、垃圾回收概述与相关算法_第9张图片

/**
 * 演示对象复活
 * 1、对象可以在被GC时自我拯救。
 * 2、这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 */
public class CanReliveObj {
    //类变量,属于GCRoots的一部分
    public static CanReliveObj canReliveObj;

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

    public static void main(String[] args) throws InterruptedException {
        canReliveObj = new CanReliveObj();
        canReliveObj = null;
        //调用垃圾回收器
        System.gc();
        System.out.println("-----------------第一次gc操作------------");
        //因为Finalizer线程的优先级比较低,暂停2秒,以等待它
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
        System.out.println("-----------------第二次gc操作------------");
        //下面代码和上面代码是一样的,但是canReliveObj却自救失败了
        canReliveObj = null;
        System.gc();
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
    }
}

/**
 * 运行结果
 * 调用当前类重写的finalize()方法
 * -----------------第一次gc操作------------
 * obj is still alive
 * -----------------第二次gc操作------------
 * obj is dead
 */

五、MAT与JProfiler的GCRoots溯源

1、MAT概述

1、MAT是Memory Analyzer的简称,它是一款功能强大的Java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况
2、MAT是基于Eclipse开发的,是一款免费的性能分析工具。下载并使用MAT

2、命令行获取Dump文件

1、命令行使用jmap输出Dump文件

十一、垃圾回收概述与相关算法_第10张图片

3、使用JVisualVM获取Dump文件

1、捕获的heap dump文件是一个临时文件,关闭JVisualVM后自动删除,若要保留,需要将其另存为文件
public class GCRootsTest {
    public static void main(String[] args) {
        List<Object> numList = new ArrayList<>();
        Date birth = new Date();

        for (int i = 0; i < 100; i++) {
            numList.add(String.valueOf(i));
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("数据添加完毕,请操作:");
        new Scanner(System.in).next();
        numList = null;
        birth = null;

        System.out.println("numList、birth已置空,请操作:");
        new Scanner(System.in).next();

        System.out.println("结束");
    }
}
1、生成堆内存快照:在左侧应用程序子窗口中点击相应的应用程序,点击【监视】,再点击【堆Dump】

十一、垃圾回收概述与相关算法_第11张图片

2、选中heapdump文件之后,右键将其另存为到桌面文件

十一、垃圾回收概述与相关算法_第12张图片

3、按照上面两步接着生成第二张堆内存快照

十一、垃圾回收概述与相关算法_第13张图片

4、使用MAT查看堆内存快照

1、打开 MAT ,选择【File --> Open Heap Dump】打开第一个Dump文件

十一、垃圾回收概述与相关算法_第14张图片

2、选择【Java Basics --> GC Roots】

十一、垃圾回收概述与相关算法_第15张图片

3、第一次捕捉堆内存快照时,GCRoots中包含我们定义的两个局部变量,类型分别为ArrayList和Date,Total Entries = 21

十一、垃圾回收概述与相关算法_第16张图片

4、重复上面步骤,打开第二个Dump文件,并查看GCRoots。由于两个局部变量引用的对象被释放,所以这两个局部变量不再作为 GCRoots,从Total Entries=19也可以看出(少了两个GCRoots)

十一、垃圾回收概述与相关算法_第17张图片

5、JProfiler的GCRoots溯源

1、选择【Live Memory --> All Object】,可以看到当前程序中所有对象的个数

十一、垃圾回收概述与相关算法_第18张图片

2、选择【View --> Mark Current Values】,动态的展示对象个数的变化可以找到那些对象的变化特别大,这些变化大的对象是应该关注的对象

十一、垃圾回收概述与相关算法_第19张图片

3、当发现对象个数变化比较大或者某些对象占用空间比较大,可点击【Run GC】手动进行垃圾回收,对比之前的对象个数发现有一些对象已经回收了,如果始终回收不了,就要关注这些对象了,防止增多导致内存溢出问题。

十一、垃圾回收概述与相关算法_第20张图片

4、右击对象,选择【Show Selection In Heap Walker】单独查看某一对象

十一、垃圾回收概述与相关算法_第21张图片

5、点击【References】查看引用,这是重点关注的。

十一、垃圾回收概述与相关算法_第22张图片

十一、垃圾回收概述与相关算法_第23张图片

6、选择【Incoming References】,点击【Show Paths To FC Roots】使用默认配置进行溯源

十一、垃圾回收概述与相关算法_第24张图片

6、JProfiler分析OOM

/**
 * 设置JVM启动参数:-Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    //创建1M的文件
    byte [] buffer = new byte[1 * 1024 * 1024];

    public static void main(String[] args) {
        ArrayList<HeapOOM> list = new ArrayList<>();
        int count = 0;
        try {
            while (true) {
                list.add(new HeapOOM());
                count ++;
            }
        } catch (Exception e) {
            e.getStackTrace();
            System.out.println("count:" + count);
        }
    }
}
1、上述代码不断的创建一个1M大小字节数组,然后让内存溢出,同时通过JVM启动参数控制内存大小,并使用-XX:+HeapDumpOnOutOfMemoryError将出错的dump文件输出。
2、将生成的dump文件打开,然后点击【Biggest Objects】能够看到超大对象

十一、垃圾回收概述与相关算法_第25张图片

3、通过线程定位到哪里出现OOM

十一、垃圾回收概述与相关算法_第26张图片

六、清除阶段之标记清除算法

1、垃圾清除算法概述

1、当成功分出内存中存活对象死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的内存空间为新对象分配内存。目前在JVM中比较常见的三种垃圾收集算法是:
  • 标记——清除算法(Mark-Sweep)
  • 复制算法(copying)
  • 标记——压缩算法(Mark-Compact)
2、标记清除算法(Mark-Sweep)分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程(即上面的标记阶段的算法)。

2、标记清除算法执行过程

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作标记和清除
  • 标记:垃圾回收器(Collector)从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的对象头(Header)中记录为可达对象。标记的引用的对象,不是垃圾对象
  • 清除:垃圾回收器(Collector)对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

十一、垃圾回收概述与相关算法_第27张图片

3、什么是清除

1、清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。
2、关于空闲列表是在为新生对象分配内存空间的时候提到过
  • 如果Java堆中内存是绝对规整的:采用指针碰撞(Bump The Pointer)方式分配内存。
  • 如果Java堆中内存并不是规整的:虚拟机需要维护一个空闲列表(Free List)这种方式分配内存。

4、标记清除算法缺点

1、执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
2、内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

七、清除阶段之标记复制算法

1、出现背景与核心思想

1、出现背景
  • 标记——复制算法常被简称为复制算法。为了解决标记——清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法。
2、核心思想
  • 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
  • ######当这一块的内存用完了,就将还存活着的(可达的)对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉

十一、垃圾回收概述与相关算法_第28张图片

3、其实新生代里面就使用到了复制算法(幸存者0,幸存者1)

十一、垃圾回收概述与相关算法_第29张图片

2、复制算法优缺点

1、优点:
  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现碎片问题
2、缺点:
  • 需要两倍的内存空间。
  • 对于G1这种分拆称为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系。不管是内存占用或者时间开销也不小。(因为对象从一个内存区域复制到另一个内存区域,对象的地址发生了改变,所以对象的引用也需要做出对应的改变)。

3、复制算法应用场景

1、如果系统中的垃圾对象很多,需要复制的存活对象数量很少的情况下,效率很高(因为垃圾对象多的话,复制到另一个内存块中的可达对象就少,效率自然就高)。
2、在老年代中有大量的对象存活,那么复制的对象将会有很多,效率就低。
3、在新生代,对常规应用的垃圾回收,一次通常可以回收70% - 99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

十一、垃圾回收概述与相关算法_第30张图片

4、扩展

1、现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研 究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间
2、在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为Appel式回收。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。
3、Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间
4、当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)
5、内存的分配担保,如果另外一块 Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

八、清除阶段之标记整理(压缩)算法

1、出现背景

1、标记——复制算法的高效性是建立在存活对象少、垃圾对象多的情况下(新生代中)。针对老年代,大部分对象都是存活的,如果依然使用复制算法,由于存活的对象较多,复制的成本也就极高。
2、标记——清除算法也可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完成内存回收后还会产生内存碎片。因此针对老年代对象的死亡特征,1974年Edward Lueders提出了另外一种有针对性的标记——整理(Mark-Compact)算法

2、执行过程

第一阶段:和标记清除算法一样,从根节点开始标记所有被引用对象 (在对象的对象头标记可达标记)
第二阶段:将所有的存活对象都向内存空间的一端移动,按顺序排放。之后,然后直接清理边界外所有的内存空间

十一、垃圾回收概述与相关算法_第31张图片

3、标记清除与标记整理的区别

1、标记整理算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记—清除—压缩(Mark-Sweep-Compact)算法
2、二者本质差异在于标记清除算法是一种非移动式的回收算法,而标记压缩算法是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
3、标记整理算法中标记的存活对象会被整理,按照内存地址一次排列,而未被标记的内存会被清理掉。如此一来,当需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,比维护一个空闲列表少了许多开销

4、标记整理算法优缺点

1、优点:
  • 消除了标记清除算法中,内存区域分散的问题,当需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
  • 消除了标记复制算法中,内存减半的问题
2、缺点:
  • 标记整理算法的效率低于标记复制算法
  • 将存活对象进行移动的时候,如果对象被其他的对象引用,则还需要调整的引用的地址
  • 移动过程中,需要全程暂停用户应用程序(即STW)。

5、不同指标上对比三种算法

Mark-Sweep Mark-Compact Copying
速度 中等 最慢 最快
空间开销 少(会堆积碎片) 少(不堆积碎片) 通常需要活对象的2倍大小(不堆积碎片)
移动对象
1、复制算法虽然效率快,但是浪费了太多内存。
2、标记整理算法相对来说更平滑一些,但是效率不是很好,比复制算法多了标记阶段,比清除算法多了整理内存阶段。

九、分代收集算法

1、出现背景

1、前面的算法中,并没有一种算法能够完全替代其他算法,它们都具有自己独特的优势和特点,因此分代收集算法应运而生。
2、分代收集算法是基于不同对象的生命周期是不一样的事实。不同生命周期的对象可以采取不同的收集方式,以便提高回收效率
3、一般把Java堆分为老年代和新生代,这样就可以根据各个年代的特点使用不同的收集算法,以提高垃圾回收的效率。
4、在Java程序运行过程中,会产生大量的对象,其中有些对象是与业务信息相关的:
  • 如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。
  • 还有一些对象是程序运行过程中生成的临时变量,这些对象生命周期会比较短,如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

2、分代依据

1、目前几乎所有的GC都是采用分代收集(Generational Collecting)算法进行垃圾回收的
2、在HotSpot,基于分代的思想,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点
3、年轻代(Young Gen)特点:
  • 内存区域相对老年代较小,对象生命周期短、存活率低、回收频繁
  • 这种情况使用复制算法进行回收,效率是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收,而复制算法内存利用率不高的问题,通过HotSpot中的两个Survivor的设计得到缓解。
4、老年代(Old Gen)特点:
  • 内存区域较大,对象生命周期长、存活率高、回收不及年轻代频繁
  • 因此会存在大量存活率高的对象,复制算法明显不适用了。一般是由标记—清除算法或者是标记-清除,标记-整理算法的混合实现。
  • 标记阶段的开销与存活对象的数量成正比。
  • 清除阶段的开销与所管理区域的大小成正比。
  • 压缩阶段的开销与存活对象的数据成正比。

3、举例

1、以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep(标记清除)实现的,对于对象的回收效率很高。
2、对于碎片问题,CMS采用基于Mark-Compact(标记压缩)算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。
3、分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代

十、增量收集算法和分区算法

1、增量收集算法出现背景

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

2、增量算法的基本思想

1、如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。反复执行,直到垃圾收集完成
2、总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作

3、增量算法的优缺点

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

4、分区算法

1、一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
2、分代算法将按照对象的生命周期长短划分成两个部分(新生代和老年代)
3、分区算法将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间

十一、垃圾回收概述与相关算法_第32张图片

你可能感兴趣的:(Java虚拟机,算法,jvm,java)