【JVM系列5】深入分析Java垃圾收集算法和常用垃圾收集器

前言

上一篇我们介绍了对象在堆内的内存布局已经占用空间的大小,同时分析了堆内可以分为Young区和Old区,而且Young区可以分为Eden区和Survivor区,Survivor区又拆分成了两个大小一样的区S0和S1区域,其实这么拆分的理由和GC是密切相关的,那么这一篇文章就让我们深入了解一下Java中的垃圾收集机制。

如何确定无效对象

在垃圾收集的时候第一件事就是怎么确定一个对象是垃圾,那么该如何确定一个对象已经可以被回收了呢?主流的算法有两种:引用计数法可达性分析算法

引用计数法(Reference Counting)

这个算法很简单,效率也非常高。就是给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1,当引用失效时,计数器的值就减1,当计数器的值减为0时就表名这个对象不会再被使用,成为了无用对象,可以被回收。

这种算法虽然实现简单,效率也高,但是存在一个问题,我们看下面一个场景:

在这里插入图片描述
上图中4个对象相互引用,但是并没有其他对象去引用他们,这种对象实际上也是无效对象,但是他们的引用计数器都是1而不是0,所以引用计数法没办法解决这种“一坨垃圾”的场景。

可达性分析算法(Reachability Analysis)

可达性分析算法就是选择一些对象作为起始点,这些对象称之为:GC Root。然后从GC Root开始向下搜索,搜索路径称之为引用链(Reference Chain),当一个对象不在任何一条引用链上时,就说明此对象是无效对象,可以被回收。
比如说下面这幅图,右边那一串互相引用的对象因为没有不在GC Root的引用链上,所以就是无效对象,可达性分析算法有效的解决了互相引用对象无法回收问题。
在这里插入图片描述

GC Root

在Java中,可以作为GC Root对象的包括下面几种:

  • Java虚拟机栈内栈帧中的局部变量表中的变量
  • 方法区中类静态属性
  • 方法区中常量
  • 本地方法栈中JNI(即Native方法)中的变量

注意:在分析对象的过程中,为了确保结果的准确性,需要保证分析过程中对象引用关系不会发生变化,而为了达到这个目的,就需要暂停用户线程,这种操作也叫:Stop The World(STW)。

引用的分类

上面两种算法其实都是一个目的,判断对象有没有被引用,而引用也不仅仅都是一样的引用,JDK1.2开始,Java中将引用进行了分类,划分成了四种引用,分别是:强引用,软引用,弱引用,虚引用。这四种引用关系的强度为:强引用>软引用>弱引用>虚引用。

强引用(Strong Reference)

我们写的代码中一般都是用的强引用,如:Object obj = new Object()这种就属于强引用,强引用只要还存在,一定不会被回收,空间不够就直接抛出OOM异常

软引用(Soft Reference)

软引用是通过SoftReference类来实现的。软引用可以用来表示一些还有用但又是非必需的对象,系统在即将溢出之前,如果发现有软引用的对象存在,会对其进行二次回收,回收之后内存还是不够,就会抛出OOM异常。

弱引用(Weak Reference)

弱引用是通过WeakReference类来实现的。弱引用也是用来表示非必需对象的,但是相比较软引用,弱引用的对象会在第一次垃圾回收的时候就被回收掉。

虚引用(Phantom Reference)

虚引用是通过PhantomReference类来实现的,也被成为幽灵引用或者幻影引用。这是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不对其生存时间构成影响。也无法通过虚引用来取得一个对象实例。设置为虚引用的唯一用处可能就是当这个对象被回收的时候可以收到一个系统通知。

垃圾收集算法

上面分析了如何确定一个对象属于可回收对象的两种算法,那么当一个对象被确定为垃圾之后,就需要对其进行回收,回收也有不同的算法,下面就来看一下常用的垃圾收集算法

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

标记-清除算法主要分为两步,标记(Mark)和清除(Sweep)。
比如说有下面一块内存区域(白色-未使用,灰色-无引用,蓝色-有引用):
在这里插入图片描述
然后标记-清除算法会进行如下两个步骤:

  • 1、将堆内存扫描一遍,然后会把灰色的区域(无引用对象,可悲回收)对象标记一下。
  • 2、继续扫描,扫描的同时将被标记的对象进行统一回收。

标记清除之后得到如下图所示:
在这里插入图片描述
可以很明显看到,回收之后内存空间是不连续的,产生了大量的内存空间碎片。过多内存碎片最直接的就是可以导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法的缺点

1、标记和清除两个过程都比较耗时,效率不高
2、会产生大量不连续的内存碎片。

为了解决这两个问题,所以就有了复制算法。

复制(Copying)算法

复制算法的思想就是把内存区域一分为二,两块内存保持一样的大小,每次只使用其中的一块,当其中一块内存使用完了之后,将仍然存活的对象复制到另一块内存区域,然后把已使用的一半内存全部一次性清理掉。
如下图(绿色表示暂时不放对象的一半空间):
在这里插入图片描述
回收之后:
在这里插入图片描述

复制算法的缺点

复制算法的缺点就是牺牲了一半的内存空间,有点过于浪费。

复制算法在Java虚拟机的落地形式

Java堆内存中做了好几次划分,最后是将Survivor区分成了2个区域S0和S1来进行复制算法,这种做法就是为了弥补原始复制算法直接将一半的空间作为空闲空间方式的弥补。想要详细了解Java堆内存划分及原因的可以点击这里

IBM公司的研究表明,Young区(新生代)中98%的对象都是“朝生夕死”的,生命周期极短,所以说在一次GC之后能存活下来的对象很少,完全没必要划分一半的区间来进行复制算法。Hot Spot虚拟机中Eden区和Survivor区域的比例为:Eden:S0:S1=8:1:1,也就是说其实只有10%的空间被浪费掉,完全是可以接受的。

标记-整理(Mark-Compact)算法

我们想一下,假如Young区(新生代)的对象在一次GC之后,基本所有对象都存活下来了,那就需要复制大量的对象,效率也会变低。而堆中的old区(老年代)的特点就是对象生命周期极为顽强,因为默认要进行第16次垃圾回收的时候还能存活下来的对象才会放到老年代,所以对老年代中对象的回收一般不会选择标记-复制算法。

标记-整理算法就是为了老年代而设计的一种算法,标记-整理算法和标记清除算法的区别就是在最后一步,标记-整理算法不会对对象进行清理,而是进行移动,将存活的对象全部向一端移动,然后清理掉端边界以外的对象。如下图所示:
回收前:
在这里插入图片描述
回收后:
在这里插入图片描述

分代收集算法(Generational Collection)

目前主流的商业虚拟机都是采用的分代收集算法,这种算法本质上就是上面介绍的算法的结合体。新生代采用标记-清除算法,老年代采用标记-清除或者标记-整理算法。

垃圾收集器

上面介绍了确定对象的算法以及回收对象的算法,然后具体要怎么落地却并没有一个规定,而垃圾收集器就是实现了对算法的落地,而因为落地形式不同,自然也产生了很多不同的收集器。下面是一张收集器的汇总图:
在这里插入图片描述
上面一半表示新生代收集器,下面一半表示老年代收集器,横跨中间的表示都可以用。

根据这个图形有了整体认知之后,我们再来一个个看看这些垃圾收集器的工作原理吧。

Serial和Serial Old收集器

Serial收集器是基本、发展历史悠久的收集器,在JDK1.3.1之前是虚拟机新生代收集的唯 一选择。
Serial收集器是一种单线程收集器,而且是在进行垃圾收集的时候需要暂停所有其他线程,也就是说触发了GC的时候,用户线程是暂停的,如果GC时间过长,用户是可以明显感知到卡顿的。
Serial Old是Serial的一个老年代版本,也是一种单线程收集器。
可以用下面一个图形来表示一下Serial和Serial Old收集器的工作原理:
在这里插入图片描述
优点:简单高效,拥有很高的单线程收集效率
缺点:收集过程需要暂停所有线程
算法:Serial采用复制算法,Serial Old采用标记-整理算法
适用范围:Serial用于新生代,Serial Old用于老年代
应用:Client模式下的默认的收集器

ParNew收集器

ParNew收集器是Serial收集器的多线程版本,实现了并行执行,其余工作原理都和Serial一致。可以使用参数:-XX:+UseParNewGC来指定使用。

注意:这里的并行指的是多个GC线程并行,但是其他线程还是暂停,而并发指的是用户线程和GC线程同时执行。

ParNew收集器默认开启和CPU个数相同的线程数来进行回收,可以使用参数:-XX:ParallelGCThreads来限制线程数
ParNew收集器工作原理如下图:
在这里插入图片描述
优点:在多CPU时,比Serial效率高。
缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差
算法:复制算法
适用范围:新生代
应用:运行在Server模式下的虚拟机中首选的新生代收集器

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,和ParNew一样也是一个并行的多线程收集器,Parallel Scanvenge收集器相比较于ParNew收集器,更关注与提升系统的吞吐量。

吞吐量指的是CPU用于运行用户代码的而时间于CPU总消耗时间的比值。
即:吞吐量=运行用户代码时间/(运行用户代码时间+GC时间)

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量:

-XX:MaxGCPauseMillis//GC最大停顿毫秒数,必须大于0
-XX:GCTimeRatio//设置吞吐量大小,大于0小于100,默认值为99

我们思考一个问题,假如我们通过参数把允许最大停顿毫秒数设置的相对较小会怎么样?是不是GC速度就会变快了

答案是否定的。如果设置的时间过短,Parallel Scavenge收集器会牺牲吞吐量和新生代空间来交换。
比如新生代400Mb需要GC时间为100ms,然后手动设置为50ms,那么就会把新生代调小为200Mb,这样肯定时间就降下来了,然而这种操作可能会降低吞吐量,假如说原先是10s触发一次GC,每次100ms,修改时间后编程5s触发一次GC,每次70ms,那么10s触发两次GC时间就变成了140ms,吞吐量反而降低。

如果不知道如何设置,那么还可以通过参数:-XX:+UseAdaptiveSizePolicy开启自适应策略(GC Ergonomics),这样我们就不需要手动设置吞吐量和GC停顿时间了,虚拟机会根据运行情况手机监控信息来动态调整。

Paralled Old收集器

Paralled Old收集器是Parallel Scavenge收集器的老年代版本,但是这个收集器是jdk1.6之后才出现的,所以导致了在Paralled Old收集器出现之前Parallel Scavenge收集器一直找不到合适的“搭档”。因为Parallel Scavenge收集器没办法和CMS收集器配合使用(后面会介绍原因),所以在Paralled Old收集器出现之前,如果新生代选择了Parallel Scavenge收集器,那么老年代就只能选择Serial Old收集器,而Serial Old收集器是单线程的,所以单单只是新生代替换成了多线程的吞吐量收集器Parallel Scavenge,在性能上并不一定有多少提升。

在注重吞吐量的业务系统中,可以考虑Parallel Scavenge+Paralled Old收集器配合使用,结合使用后的工作原理如下图所示:
在这里插入图片描述
PS:在jdk1.8中,默认收集器就是Parallel Scavenge+Parallel Old组合

CMS(Concurrent Mark Sweep)收集器

这是一种以实现GC时最短停顿时间为目标的收集器,也是一款真正实现了并发回收的收集器。当然,虽然是并发的,但是仍然需要Stop The World,只是尽可能将这个时间缩到最短。

对于任何暂停时间要求较低的应用程序,都应该考虑使用此收集器。CMS收集器可以通过参数:-XX:+UseConcMarkSweepGC启用。

CMS收集器是基于算法标记-清除来实现的,整个过程分为4步:

  • 1、初始标记(inital mark)
    需要Stop The World。标记GC Roots对象,因为GC Root对象并不会很多,所以这个过程非常快。
  • 2、并发标记(concurrent mark)
    这个阶段可以和用户线程同时进行,也可以分为三步:
    (1)并发标记(CMS-concurrent-mark):主要是进行GC Roots Tracing。就是说根据第1步中找到的GC Root对象,开始搜索,这个过程相比阶段1是比较慢的。
    (2)预清理(CMS-concurrent-preclean),这个阶段是为了处理并发标记之后发生了变化的对象
    (3)可被终止的预清理(CMS-concurrent-abortable-preclean),这个预清理差不多,但是是可以被终止的,主要是分了尽可能分担下面第3步的工作,这个阶段会有一个abort触发条件,该阶段存在的目的是希望能发生一次Young GC,这样就可以减少Young区对象的数量,降低重新标记的工作量,因为重新标记会扫描整个堆内空间。可以通过参数-XX:+CMSScavengeBeforeRemark参数控制在重新标记前发生一次Young GC,默认为false。这个阶段发生的最大时间由-XX:CMSMaxAbortablePrecleanTime控制,默认5s
  • 3、重新标记(remark)
    需要Stop The World,这个阶段是为了修正在阶段2标记之后产生了变化的对象
  • 4、并发清除(concurrent sweep)
    和用户线程同时进行,开始正式清除垃圾,在此阶段也会产生垃圾,产生垃圾后无法清除,只能留待下一次GC。

CMS收集过程如下图所示:
在这里插入图片描述

CMS优缺点

  • 优点:并发收集、低停顿。
    其实最主要的是CMS把收集过程中步骤拆分了,而最耗时的操作都是并发执行,自然就会低停顿了。
  • 缺点:产生大量空间碎片、并发阶段会降低吞吐量。
    CMS采用的是标记-清除算法,所以会产生大量的空间碎片。在阶段2和阶段4并发执行的时候,会占用CPU资源,就会导致应用程序变慢,降低了吞吐量。

Floating Garbage(浮动垃圾)

上面的步骤中,步骤2是并发标记,所以在标记过程中,可能会有新的垃圾产生而没有被标记到。比如说对象A,刚扫描的时候是有效对象,然后继续扫描的时候,对象A又变成不可用了,然后还有并发清除的阶段,也可能会有新的垃圾产生,这种就称之为浮动垃圾(Floating Garbage)。CMS并不能收集浮动垃圾,只能等到下一次GC时再回收。

Concurrent Mode Failure(并发模式失败)

CMS收集器不能和其他收集器一样等到空间满了才开始触发GC,因为CMS收集的时候是并发的,并发的过程肯定会持续产生对象,如果因为在垃圾收集期间内存不足而导致了GC失败,就称之为Concurrent Mode Failure。出现这种情况之后,Java虚拟机就会启动预备方案,启用Serial Old收集器替换CMS收集器,这时候整个GC过程都会Stop The World。

CMS收集器的触发阈值可以通过参数:-XX:CMSInitiatingOccupancyFraction=来进行设置,N为(0,100)之间,在jdk1.6中默认是92,即老年代空间使用率达到92%就会触发CMS收集器开始进行垃圾回收。

G1(Garbage-First)收集器

G1也是以实现GC时最短停顿时间为目标并发回收的收集器,它尝试以高概率满足垃圾收集(GC)暂停时间目标,同时实现高吞吐量。

在G1之前的其他收集器都是属于分代收集器,也就是说一个收集器要不然用于新生代,要不然就是用于老年代,而G1中,将堆的整个内存布局做了很大的修改,在G1中,将整个Java堆划分为多个大小相等的独立区域(Region),虽然在逻辑上还保留了新生代和老年代的概念,但是物理上已经没有隔离了。

G1收集器中堆内布局如下图所示:
在这里插入图片描述
上图中堆被划分为一组大小相同的Region,每个Region都是连续的虚拟内存范围。
G1可以知道哪个Region区域内大部分都是空的,这样就可以在每次允许的收集时间内去优先回收价值最大的Region区域(根据回收所获得的空间大小以及回收所需要的时间综合考虑),所以这也就是G1为什么叫做Garbage-First的原因。

PS:G1是JDK1.9的默认垃圾收集器

G1特点

经过上面的简单介绍,可以得出G1主要有以下特点:

  • 1、实现了并行与并发,尽可能的缩短了Stop The World时间。
  • 2、分代收集:逻辑上依然保留了分代概念
  • 3、空间整合:整体来看是基于“标记-整理”算法来实现的(如果冲Region来看,是基于“复制”算法),所以不会产生大量内存空间碎片。
  • 4、支持可预测的停顿时间:可以通过参数来设置每次GC最大时间
  • 5、非实时收集:因为可以人为设置停顿时间,所以在指定时间范围内会进行优先选择收集,而不会收集所有被标记好的垃圾。

G1工作流程

G1收集器在工作流程上和CMS比较相似,只是在最后的步骤有所区别,主要经过了如下4个步骤:

  • 1、初始标记(Initial Marking):需要Stop The World。标记一下GC Roots能够关联的对象,并且修改TAMS(Next Top at Mark Start)的值,使得下一阶段并发运行时,能在正确可用的Region中创建对象。
  • 2、并发标记(Concurrent Marking):和CMS一样,主要是进行GC Roots Tracing,找出存活对象进行标记。
  • 3、最终标记(Final Marking):需要Stop The World。和CMS一样,这个阶段主要是为了修正在并发标记期间因用户程序继续运行而导致产生变动的对象。
  • 4、筛选回收(Live Data Counting and Evacuation):对各个Region的回收价值和成本进行排序,根据 用户所期望的GC停顿时间制定回收计划进行回收。

工作流程图如下所示:
在这里插入图片描述

G1应用场景

G1的第一个重点是为运行需要大堆且GC延迟有限的应用程序的用户提供解决方案。这意味着堆大小大约为6GB或更大,并且稳定且可预测的暂停时间低于0.5秒。

如果我们的应用程序具有以下一个或多个特性,那么可以考虑切换到G1收集器。

  • 1、超过50%的Java堆被实时数据占用。
  • 2、对象分配率或提升率差异很大。
  • 3、当前应用程序GC停顿时间超过0.5到1秒,而又想缩短停顿时间的应用。

其他收集器

  • ZGC收集器:是Java11中提供的一种垃圾收集器。
  • Shenandoah:OpenJDK中包含的收集器,最开始是由RedHat公司开发,后来贡献给了OpenJDK。
  • Epsilon(A No-Op Garbage Collector):一款控制内存分配,但是不执行任何垃圾回收工作的收集器。一旦java的堆被耗尽,jvm就直接关闭。

如何选择垃圾回收器

垃圾收集器主要可以分为如下三大类:

  • 串行收集器:Serial和Serial Old
    只能有一个垃圾回收线程执行,用户线程暂停。 适用于内存比较小的嵌入式设备 。
  • 并行收集器[吞吐量优先]:Parallel Scanvenge和Parallel Old
    多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。 适用于科学计算、后台处理等若交互场景 。
  • 并发收集器[停顿时间优先]:CMS和G1。
    用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的时候不会停顿用户线程的运行。 适用于对时间有要求的场景,比如Web应用。

总结

本文主要介绍了确定无效对象的两种算法,并且结合垃圾收集算法介绍了不同类型的落地形式而产生的不同垃圾收集器,本文将对比较偏向于理论,下一篇开始,JVM系列文章将会结合JVM系列前5篇文章来进一步结合实际场景以及相关监控工具的使用来进行实际场景分析。
请关注我,和孤狼一起学习进步

猜你喜欢

转载自blog.csdn.net/zwx900102/article/details/108180739
今日推荐