JVM(8)--垃圾回收算法与垃圾回收器

一、概述

深入理解java虚拟机中写到”Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。”

Java在动态内存分配与回收上已经是自动化的,但是当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

1.1 什么是垃圾

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

1.2 内存溢出or内存泄漏

  • 内存溢出:指程序在申请空间的时候,内存不足了,出现oom异常,例如一个盘子只能装下5个苹果,强行给他6个苹果将会导致内存溢出
  • 内存泄漏:指程序在申请内存空间的时候,无法释放已经申请的空间,多次内存泄漏后会产生严重的内存问题,例如将一个柜子的钥匙锁在柜子里,那么无法访问柜子里的内容

1.3 垃圾回收的区域

堆区时进行垃圾回收的重点区域,从次数上讲:

  • 频繁收集Young区
  • 较少收集old区
  • 基本不动Perm区

二、判定对象是否垃圾

2.1 引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1,当引用失效时,计数器的值就减1

在Java中不使用这种方法,原因是使用引用计数法会出现循环引用的问题,例如看下面代码:

public class ReferenceCountingGC {
    
    
    public ReferenceCountingGC instance = null;
    private Byte[] bytes = new Byte[1024 * 1024];

    public static void main(String[] args) {
    
    
        ReferenceCountingGC A = new ReferenceCountingGC();
        ReferenceCountingGC B = new ReferenceCountingGC();
        A.instance = B;
        B.instance = A;
        A = null;
        B = null;
        System.gc();    //手动进行GC
    }
}

添加-XX:+PrintGCDetails参数,打印GC信息

image-20201225115237310

可见,即使有循环引用,还是能进行GC,所以java中并没有使用这种标记方法

2.2 根搜索算法

这个算法的基本思路就是通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

image-20201225115850736

例如上图,object5、object6、object7就被判定为不可达对象,被认为是垃圾。

那么,被判定为GC Root的对象包括下面:

  • 虚拟机栈中的引用对象
  • 方法区中的类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中的引用的对象

2.3 finalization机制

Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,如果有finalize()方法,并且没有被调用过,总会先调用这个对象的finalize()方法。

如果这个对象需要被执行finalize方法。那么如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程中去执行,这就有可能造成对象复活,因为在执行finalize方法的时候可以让对象重新回到GC Root引用链上

public class FinalizeEscapeGC {
    
    
    private static FinalizeEscapeGC finalizeEscapeGC = null;

    @Override
    protected void finalize() throws Throwable {
    
    
        super.finalize();
        System.out.println("finalize method invoke");
        //重新获得引用 对象复活
        FinalizeEscapeGC.finalizeEscapeGC = this;
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        finalizeEscapeGC = new FinalizeEscapeGC();
        finalizeEscapeGC = null;
        System.gc();
        //线程休眠 让步给finalize线程
        Thread.sleep(500);
        if(finalizeEscapeGC == null){
    
    
            System.out.println("finalizeEscapeGC is not alive");
        }else{
    
    
            System.out.println("finalizeEscapeGC is still alive");
        }
        //只调用一次finalize方法
        finalizeEscapeGC = null;
        System.gc();
        Thread.sleep(500);
        if(finalizeEscapeGC == null){
    
    
            System.out.println("finalizeEscapeGC is not alive");
        }else{
    
    
            System.out.println("finalizeEscapeGC is still alive");
        }

    }
}

image-20201225134425587

注意:任何一个对象的finalization方法都只调用一次!

三、垃圾收集算法

3.1 标记-清除算法

算法分为两个阶段:标记和清除。标记阶段使用前面的判定对象是否属于垃圾的算法,在标记出垃圾之后,进行统一回收所有被标记的对象,如下图

image-20201225135314771

优点:是后序算法基础,算法实现比较简单

缺点:

  • 时间问题:效率不是很高
  • 空间问题:标记清除后会产生大量的的不连续的内存碎片,而空间碎片太多会导致如果需要分配大对象会没有空间

3.2 复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况

image-20201225155317466

优点:

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

缺点:

  • 将内存空间缩小到了内存的一半
  • 如果存活对象多的话,需要复制大量的对象

复制算法一般应用于回收新生代

3.3 标记-整理

前序步骤和标记-清除算法一样都要进行标记,但是后续步骤不是直接对可回收对象进行清理,而是让存活的对象都向一端移动,然后直接清理掉端边界以外的内存

image-20201225155903016

优点:

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

缺点:

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

3.4 分代收集算法

没有最好的算法只有更好的算法,前面几种算法都有各自的优点和缺点所以出现了分代收集算法

首先,不同对象的生命周期是不一样的,所以可以对不同时期的对象采取不同的收集算法,Java堆中分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以便提高回收的效率

年轻代,年轻代的特点是区域相对老年代较小,对象生命周期短,存活率低,回收频繁。所以在年轻代中采取的是复制算法,而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

老年代,老年代的特点是内存区域大,对象生命周期长,存活率高。一般都是采用标记-清除算法或者标记-整理算法的混合实现,不同的GC有不同的实现

四、垃圾回收器

垃圾回收算法是理念,而具体的垃圾回收器便是这些理念的实践者。在jdk版本不断更新的过程中,垃圾收集器的技术不断更新迭代,这里只介绍jdk8前经典的垃圾回收器,而后续版本出现的垃圾回收器会在相应版本的新特性中介绍

4.1 4种方式

垃圾回收器在jdk8(后续不再说明,版本都是jdk1.8)中一共有7种,而这7种可以分为4种方式

  • 串行垃圾回收器(Serial)
  • 并行垃圾回收器(Parallel)
  • 并发垃圾回收器(CMS)
  • G1垃圾回收器
4.1.1 串行垃圾回收器(Serial)
  • 使用单线程进行垃圾回收
  • 独占式的垃圾回收。

在串行收集器进行垃圾回收时,Java 应用程序中的线程都需要暂停(STW),等待垃圾回收的完成,这样给用户体验造成较差效果

4.1.2 并行垃圾回收器(Parallel)
  • 使用多线程进行垃圾回收
  • 独占式的垃圾回收

并行垃圾回收器(Parallel)多用于多核cpu上,并发能力较强,和串行垃圾回收器一样,当它工作时也必须要停止Java应用程序,但是它的停顿时间要比串行垃圾回收器更短。但是如果在单线程单核的CPU中,它的表现效果可能会比串行垃圾回收器更差劲

4.1.3 并发垃圾回收器(CMS)

CMS 是 Concurrent Mark Sweep 的缩写,意为并发标记清除,从名称上可以得知,它使用的是标记-清除算法,在CMS收集器工作时,允许java应用程序运行,但是在CMS的具体步骤中,有些步骤还是不允许Java应用程序运行。但是

整体上说,CMS不是独占式的

4.1.4 G1垃圾回收器

G1 收集器的目标是作为一款服务器的垃圾收集器,因此,它在吞吐量和停顿控制上,预期要优于 CMS 收集器。与 CMS 收集器相比,G1 收集器是基于标记-压缩算法的。因此,它不会产生空间碎片,也没有必要在收集完成后,进行一次独占式的碎片整理工作。G1 收集器还可以进行非常精确的停顿控制

4.2 经典的垃圾回收器

七种垃圾回收器按照作用域的分类可以分为在新生代垃圾收集器和老年代垃圾收集器和G1收集器

  • 新生代垃圾收集器:

    • Serial收集器
    • ParNew收集器
    • Paraller Scavenge收集器
  • 老年代垃圾收集器:

    • CMS收集器
    • Serial Old收集器
    • Paraller Old收集器
  • G1收集器

image-20210101200625872

图展示了七种作用于不同分代的收集器, 如果两个收集器之间存在连线, 就说明它们可以搭配使用, 图中收集器所处的区域, 则表示它是属于新生代收集器抑或是老年代收集器。

4.2.1 Serial收集器(新生代)

Serial收集器是一个单线程的垃圾收集器,在进行垃圾回收时,必须暂停其他所有的工作线程知道它收集结束,也就是STW,例如下图

image-20210101201740761

Serial收集器是最基础的,历史最悠久的垃圾回收器,也是最稳定的、效率最高的收集器。Serial收集器还是JVM虚拟机运行在客户端模式下的默认新生代垃圾收集器。

这里注意:是客户端模式下的,而现在的机器大部分都是服务器模式

image-20210101202150970

也可以使用相应的参数强制开启 Serial收集器,对于的JVM参数是:

  • -XX:+UseSerialGC

但是要注意,因为JVM采用的是分代的收集算法,使用-XX:+UseSerialGC开启了新生代是Serial收集器,则老年代会默认使用Serial Old收集器的组合

//-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags -XX:+UseSerialGC
public static void main(String[] args) throws InterruptedException {
    
    
    System.out.println("*************");
    Byte[] bytes = new Byte[50 * 1024 * 1024];
}

image-20210101203745282

4.2.2 ParNew收集器(新生代)

ParNew收集器实质上是Serial收集器的多线程并行版本 ,最常见的应用场景是配合老年代的CMS 的GC工作,其余和Serial收集器一样, ParNew收集器在垃圾收集的过程中同样也要暂停所有其他的工作线程。它是很多java虚拟机运行在服务模式下的新生代的默认垃圾收集器

image-20210101204306911

开启参数:

  • -XX:+UseParNewGC

image-20210101205405651

当使用如上参数时,新生代会使用ParNew收集器,老年代会使用Serial Old收集器,但是在输出控制台也打印了如上图所示的一段飘红日志。大致意思就是说在将来的版本内ParNew + Serial Old这种组合将会被取消,也对应于最开始的那张连线图,ParNew + Serial Old的组合在jdk9版本已经不在被推荐了,而且已经直接取消-XX:+UseParNewGC参数

image-20210101210154229

4.2.3 Paraller Scavenge收集器(新生代)

Paraller Scavenge收集器类似于ParNew也是一个新生代的垃圾收集器,使用复制算法,是一个多线程的并行垃圾收集器,但是 Paraller Scavenge与ParNew不同的是Paraller Scavenge的目标是达到一个可控制的吞吐量,吞吐量的定义如下:

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)

image-20210102102122700

Paraller Scavenge收集器的自适应调节策略也是于ParNew收集器的一个重要区别,就是说:虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供合适的停顿时间或者最大吞吐量

开启参数:

  • -XX:+UseParallerGC

在jdk1.8服务器的环境下,默认的垃圾收集器就是新生代使用Paraller Scavenge收集器,老年代使用Paraller Old收集器

image-20210102103230141

上图是没有使用-XX:+UseParallerGC参数打印出来的信息,可见默认使用的垃圾收集器就是Paraller Scavenge + Paraller Old

Paraller Scavenge收集器有一些自适应调节的参数:

  • -XX: MaxGCPauseMillis :控制最大垃圾收集停顿时间 。这个参数的值需要大于0ms,但是不是说吧这个参数的时间调整下,就就让系统的垃圾回收速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的

  • -XX: GCTimeRatio :设置吞吐量大小 ,是一个大于0小于100的整数, 也就是垃圾收集时间占总时间的比率, 相当于吞吐量的倒数,譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)) , 默认值为99, 即允许最大1%(即1/(1+99)) 的垃圾收集时间

4.2.4 Serial Old收集器 (老年代)

Serial Old收集器是Serial收集器的老年代版本, 它同样是一个单线程收集器, 使用标记-整理算法。 这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用 。

如果在服务端模式下, 它也可能有两种用途: 一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用, 另外一种就是作为CMS收集器发生失败时的后备预案, 在并发收集发生Concurrent Mode Failure时使用。

image-20210102110113135

开启参数和Serial收集器一样

4.2.5 Paraller Old收集器 (老年代)

Parallel Old是Parallel Scavenge收集器的老年代版本, 支持多线程并发收集,基于标记-整理算法实现

image-20210102110628693

4.2.6 CMS收集器 (老年代)

CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。 目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上, 这类应用通常都会较为关注服务的响应速度, 希望系统停顿时间尽可能短, 以给用户带来良好的交互体验。 CMS收集器就非常符合这类应用的需求。

CMS收集器一共分为4个阶段:

  • 初始标记:初始标记这个阶段仍然是STW的,但是初始标记只是标记一下GC Root能直接关联到的对象(可达性分析算法,)
  • 并发标记:并发标记是指从初始标记的对象开始遍历整个对象图查找垃圾的过程,这个阶段耗时较长,但是不需要停顿用户线程
  • 重新标记:重新标记是标记上一阶段因为并发过程中,用户线程再次产生的对象,这个阶段仍然是STW的
  • 并发清除:清理在标记阶段已经被判定为死亡的垃圾对象,这个过程也是可以和用户线程同时并发的

image-20210102115404020

从上面4个阶段可以看出,CMS收集器在需要比较耗时的阶段都是可以和用户线程并行的,所以从整体上说CMS的内存垃圾回收过程是与用户线程并发的

开启参数:

  • -XX:+UseConcMarkSweepGC

image-20210102120526147

可见如果设置了-XX:+UseConcMarkSweepGC,那么JVM会使用ParNew + CMS组合

缺点:

  • CMS收集器对CPU资源非常敏感 ,因为在并发阶段,需要占用一部分用户线程会使应用程序变慢,总吞吐量降低,为了缓解这种情况, 虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS) 的CMS收集器变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、 清理的时候让收集器线程、 用户线程交替运行, 尽量减少垃圾收集线程的独占资源的时间, 这样整个垃圾收集的过程会更长, 但对用户程序的影响就会显得较少一些, 直观感受是速度变慢的时间更多了, 但速度下降幅度就没有那么明显

  • CMS收集器无法处理浮动垃圾,因为在CMS收集器工作在并发阶段,用户线程可能再次产生垃圾,导致这次产生的垃圾只能在下次清理了

  • CMS收集器时基于标记-清除算法的收集器,这会产生大量的空间碎片

4.2.7 G1垃圾收集器

G1垃圾收集器对于前面6款收集器来说,是一个里程碑式的变化,之前的垃圾收集器:

  • 年轻代和老年代是各自独立且连续的内存块
  • 年轻代收集使用复制算法
  • 老年代收集必须扫描整个老年代区域

G1垃圾收集器是一款面向服务端应用的收集器,它的设计目标就是可以替换CMS收集器,所以CMS收集器的优点,G1垃圾收集器都有,但是相比CMS收集器,在以下方面表现的更出色:

  • G1收集器不会产生很多的内存碎片
  • G1的STW更可控,在停顿时间上添加了预测机制,用户可以指定期望停顿时间

之前的垃圾收集器将连续的内存空间划分为新生代和老年代,元空间,这种划分的特点是分配的内存时连续的

image-20210103192314051

但是G1收集器的各代存储空间都是不连续的,每一代都使用了n个不连续的大小都相同的Region,每个Region占有连续的内存地址,也就是说G1将内存空间化为了每一个大小相同的Region,然后这些Region可以存储新生代或者老年代

image-20210103192601750

另外,上图还有一些Region标明了H,它代表Humongous,这表示这些Region存储的是巨大对象。

G1垃圾收集器分为4个步骤:

  • 初始标记:只标记GC Root能直接关联的对象,需要停顿用户线程

  • 并发标记:从GC Root开始对堆中对象进行可达性分析, 递归扫描整个堆里的对象图, 找出要回收的对象, 这阶段耗时较长, 但可与用户程序并发执行。 当对象图扫描完成以后, 还要重新处理SATB记录下的在并发时有引用变动的对象

  • 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象,需要停顿用户线程

  • 筛选回收:根据时间来进行价值最大化的回收

image-20210103193616808

开启参数:

  • -XX:+UseG1GC

616632107059)]

另外,上图还有一些Region标明了H,它代表Humongous,这表示这些Region存储的是巨大对象。

G1垃圾收集器分为4个步骤:

  • 初始标记:只标记GC Root能直接关联的对象,需要停顿用户线程

  • 并发标记:从GC Root开始对堆中对象进行可达性分析, 递归扫描整个堆里的对象图, 找出要回收的对象, 这阶段耗时较长, 但可与用户程序并发执行。 当对象图扫描完成以后, 还要重新处理SATB记录下的在并发时有引用变动的对象

  • 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象,需要停顿用户线程

  • 筛选回收:根据时间来进行价值最大化的回收

[外链图片转存中…(img-1YDMKNeH-1616632107060)]

开启参数:

  • -XX:+UseG1GC

image-20210103194024541

猜你喜欢

转载自blog.csdn.net/weixin_44706647/article/details/115193435