【JVM 系列 5】深入分析 Java 垃圾收集算法和常用垃圾收集器
===================================================================
上面分析了如何确定一个对象属于可回收对象的两种算法,那么当一个对象被确定为垃圾之后,就需要对其进行回收,回收也有不同的算法,下面就来看一下常用的垃圾收集算法
标记-清除算法主要分为两步,标记(Mark)和清除(Sweep)。
比如说有下面一块内存区域(白色-未使用,灰色-无引用,蓝色-有引用):
然后标记-清除算法会进行如下两个步骤:
1、将堆内存扫描一遍,然后会把灰色的区域(无引用对象,可悲回收)对象标记一下。
2、继续扫描,扫描的同时将被标记的对象进行统一回收。
标记清除之后得到如下图所示:
可以很明显看到,回收之后内存空间是不连续的,产生了大量的内存空间碎片。过多内存碎片最直接的就是可以导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记-清除算法的缺点
1、标记和清除两个过程都比较耗时,效率不高
2、会产生大量不连续的内存碎片。
为了解决这两个问题,所以就有了复制算法。
复制算法的思想就是把内存区域一分为二,两块内存保持一样的大小,每次只使用其中的一块,当其中一块内存使用完了之后,将仍然存活的对象复制到另一块内存区域,然后把已使用的一半内存全部一次性清理掉。
如下图(绿色表示暂时不放对象的一半空间):
回收之后:
复制算法的缺点
复制算法的缺点就是牺牲了一半的内存空间,有点过于浪费。
复制算法在 Java 虚拟机的落地形式
Java 堆内存中做了好几次划分,最后是将 Survivor 区分成了 2 个区域 S0 和 S1 来进行复制算法,这种做法就是为了弥补原始复制算法直接将一半的空间作为空闲空间方式的弥补。想要详细了解 Java 堆内存划分及原因的可以点击这里。
IBM 公司的研究表明,Young 区(新生代)中 98%的对象都是“朝生夕死”的,生命周期极短,所以说在一次 GC 之后能存活下来的对象很少,完全没必要划分一半的区间来进行复制算法。Hot Spot 虚拟机中 Eden 区和 Survivor 区域的比例为:Eden:S0:S1=8:1:1,也就是说其实只有 10%的空间被浪费掉,完全是可以接受的。
我们想一下,假如 Young 区(新生代)的对象在一次 GC 之后,基本所有对象都存活下来了,那就需要复制大量的对象,效率也会变低。而堆中的 old 区(老年代)的特点就是对象生命周期极为顽强,因为默认要进行第 16 次垃圾回收的时候还能存活下来的对象才会放到老年代,所以对老年代中对象的回收一般不会选择标记-复制算法。
标记-整理算法就是为了老年代而设计的一种算法,标记-整理算法和标记清除算法的区别就是在最后一步,标记-整理算法不会对对象进行清理,而是进行移动,将存活的对象全部向一端移动,然后清理掉端边界以外的对象。如下图所示:
回收前:
回收后:
分代收集算法(Generational Collection)
目前主流的商业虚拟机都是采用的分代收集算法,这种算法本质上就是上面介绍的算法的结合体。新生代采用标记-清除算法,老年代采用标记-清除或者标记-整理算法。
==================================================================
上面介绍了确定对象的算法以及回收对象的算法,然后具体要怎么落地却并没有一个规定,而垃圾收集器就是实现了对算法的落地,而因为落地形式不同,自然也产生了很多不同的收集器。下面是一张收集器的汇总图:
上面一半表示新生代收集器,下面一半表示老年代收集器,横跨中间的表示都可以用。
根据这个图形有了整体认知之后,我们再来一个个看看这些垃圾收集器的工作原理吧。
Serial 收集器是基本、发展历史悠久的收集器,在 JDK1.3.1 之前是虚拟机新生代收集的唯 一选择。
Serial 收集器是一种单线程收集器,而且是在进行垃圾收集的时候需要暂停所有其他线程,也就是说触发了 GC 的时候,用户线程是暂停的,如果 GC 时间过长,用户是可以明显感知到卡顿的。
Serial Old 是 Serial 的一个老年代版本,也是一种单线程收集器。
可以用下面一个图形来表示一下 Serial 和 Serial Old 收集器的工作原理:
优点:简单高效,拥有很高的单线程收集效率
缺点:收集过程需要暂停所有线程
算法:Serial 采用复制算法,Serial Old 采用标记-整理算法
适用范围:Serial 用于新生代,Serial Old 用于老年代
应用:Client 模式下的默认的收集器
ParNew 收集器是 Serial 收集器的多线程版本,实现了并行执行,其余工作原理都和 Serial 一致。可以使用参数:-XX:+UseParNewGC 来指定使用。
注意:这里的并行指的是多个 GC 线程并行,但是其他线程还是暂停,而并发指的是用户线程和 GC 线程同时执行。
ParNew 收集器默认开启和 CPU 个数相同的线程数来进行回收,可以使用参数:-XX:ParallelGCThreads 来限制线程数
ParNew 收集器工作原理如下图:
![在这里插入图片描述](https://img-blog.csdnimg.cn/20200815214732379.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nL
mNzZG4ubmV0L3p3eDkwMDEwMg==,size_16,color_FFFFFF,t_70#pic_center)
优点:在多 CPU 时,比 Serial 效率高。
缺点:收集过程暂停所有应用程序线程,单 CPU 时比 Serial 效率差
算法:复制算法
适用范围:新生代
应用:运行在 Server 模式下的虚拟机中首选的新生代收集器
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 收集器是 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 组合
这是一种以实现 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 也是以实现 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 应用。
评论