万字总结常见JVM垃圾回收面试知识点(附带实际项目案例分析)

前言

最近公司上了Prometheus监控之后,暴露了一些与JVM相关的问题,通过沟通与分析,我发现大多数同学对于JVM这一块还是比较陌生的,所以我觉得很有必要普及一下这方面内容,同时又因为问题大多数与垃圾回收相关,所以本文主要也就围绕垃圾回收话题进行展开,希望能够对那些不了解的人有所帮助,尤其是平时缺少实际经验以及可观测环境的同学。

一、JVM内存模型

我不想太多的介绍关于JVM内存模型这方面,并不是说它不重要,实际上如果你了解操作系统组成原理,那么你再看JVM的内存模型应该也是很轻松的,而本文要介绍的垃圾回收,问题主要集中在堆内存,所以接下来我们重点了解堆内存即可,当然关于内存模型如果你有兴趣也可以参考我之前写过的一篇文章 JVM内存结构设计详解,这里详细介绍了这方面内容。

堆空间

关于堆这块空间,主要就是用来存放创建出来的对象,几乎所有的对象都存放在堆空间。为什么是几乎呢?当然有些特殊的场景存在,比如:堆外分配、对象逃逸、栈上分配等一些为了提高性能的优化手段,了解即可。

二、什么是垃圾回收

了解完前面这些基础之后,我们开始正式进入垃圾回收本身,既然讲垃圾回收,那当然要先搞搞清楚什么是垃圾回收。

在JAVA的世界里,即使你不懂GC,也能写出很不错的代码,你不用太去关注对象创建前该如何分配,以及用完后的又要如何回收

去食堂吃饭,吃完要收盘子的那是C系的,吃完起身就走的,那是搞JAVA的😎

玩笑归玩笑,最终盘子还是要有人收的,那就交给JVM中的垃圾回收器去完成吧。

如何判断一个对象是垃圾(可回收)

灵魂拷问,垃圾回收器要如何判断一个对象可回收呢?

这里一般有两种判断方式:1、引用计数法,2、可达性分析。

引用计数法

这是一种非常简单的方法,为每一个对象中添加一个计数器,当对象被引用时,计数器就加1,当引用被释放时,计数器就减1,最后如果计数器为0,则表示该对象没有任何引用关系了,即为垃圾对象。

这是一种实现简单,判定效率较高的方法,也有一些著名的应用案例,比如Python语言中就使用了这种方式,但是在JVM中并没有使用这种算法,因为它存在一个明显的问题:循环引用。

如下图所示,A引用B,B引用A,除此之外再无其他任何对象引用了这个两个对象,所以这两个对象应当为垃圾对象,但却因为计数器都不为0,所以不能被回收。

在这里插入图片描述

可达性分析

为了避免上述的问题,在JVM中采用的是另一种可达性分析法来判断对象是否存活,这个算法的思想就是通过判断一系列被称为GCRoots的根对象,并作为起点,根据引用关系向下查找,查找过的路径称为引用链,如果某个对象到GCRoots对象没有任何一条引用链,则判断此对象为可回收对象。

如下图所示,D、G对象与GCRoots没有任何引用链关系所以为可回收对象。

在这里插入图片描述

哪些对象可以做为GCROOT?

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。

  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  • 所有被同步锁(synchronized关键字)持有的对象。

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除此之外根据某些垃圾回收器的选用,还有可能会存在临时性的GCRoot对象,因为垃圾划代收集的方式,比如扫描新生代对象的时候,还需要考虑被老年代中对象引用的情况,此时老年代中的对象也可视为GCRoot对象。

三、垃圾回收算法

你已经知道什么样的对象是可回收的了,现在就要想想该如何回收了😯

一般考虑如下三种回收算法:标记-清除,标记-复制,标记-整理,下面我们分别对这三种算法进行介绍。

标记-清除

我们首先介绍标记清除算法,因为这是一种最基础的垃圾回收算法,它的回收过程主要分为两个阶段:1、根据可达性分析算法,标记每一个存活的对象,2、将没有被标记的对象作为垃圾对象进行回收。当然也可以反过来标记。

回收之前内存状态

在这里插入图片描述
回收之后内存状态

在这里插入图片描述

优点:

1、整个回收过程只需要简单的设置一个标记位,相对而言开销较小,能实现较高的吞吐量。
2、整个回收过程不会移动存活的对象。
3、相比复制算法,内存利用率高。

缺点:

1、 执行效率不稳定,如果标记的是存活对象,那么存活对象较多时就需要大量的标记和清除,如果标记的是可回收对象,那么可回收对象较多时就需要大量的标记和清除。
2、内存碎片问题,从上图中也可以看出,再一次回收完成后内存未使用空间看起来依然很零碎,这样将导致大对象因为没有连续的内存空间而无法被分配。

标记-复制

使用标记复制算法当存活对象较少时的可以得到不错的收益,基本的算法思想是将内存分为两个大小相等的区域,分配对象时每次只使用其中的一块区域,当这块区域用完时,就把还存活的对象复制到另一块区域上,然后再把这块区域已使用的空间直接清除。

回收之前内存状态

在这里插入图片描述
回收之后内存状态

在这里插入图片描述

优点:

1、如果大部分对象都是可回收的,那么只需要复制少量存活的对象,效率较高。
2、不存在内存碎片的问题。
3、分配对象简单,只需移动堆顶指针,按顺序分配即可。

缺点:

1、内存利用率较低,需要空出一半的内存空间用来确保容得下存活的对象。
2、存活对象在内存中的位置会发生变化,需要移动对象的引用地址。
3、同样只适合存活对象较少的场景,如果存活对象较多就会复制大量的存活对象。

标记-整理

标记整理算法同时解决了标记清除的内存碎片问题和标记复制的内存浪费的问题,相比标记清除算法,标记整理多个一步整理阶段,即移动存活对象,让存活对象向堆的一端移动,然后再清理掉其余的内存空间。

回收之前内存状态

在这里插入图片描述
回收之后内存状态

在这里插入图片描述

优点:

1、解决了内存碎片的问题。
2、解决复制算法的内存浪费的问题。

缺点:

1、同样如果存活对象较多,每次移动存活对象又会带来不小的开销。

JVM中的具体应用

通过上面的分析我们了解到,每种垃圾收集器都有一定的适用范围,所以在JVM中并没有完全的采用其中任意一种垃圾回收算法,而是根据不同的场景选择合适的算法。

分代收集

为了满足选择合适的垃圾回收算法,JVM中采用了分代收集的理论进行设计,它建立在两个分代假设之上:

弱分代假说:绝大多数对象都是朝生夕死的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

基于这两个假设的基础上,JVM对堆空间进行了划分,并根据对象的年龄(熬过多次的对象)划分到不同的空间上,所以就划分出了:新生代、老年代。

新生代:基于弱分代假说理论,大多数对象都是可回收的,所以可以采用标记复制算法。
老年代:基于强分代假说理论,把在新生代经历过多次回收都存活的对象,放入老年代,那么老年代中的对象大部分就都是不可回收的,所以可以采用标记清除或者标记整理算法,只需要少量的标记和清除可回收对象即可。

同时新生代中的对象因为死的快,而老年代中的对象大多数都是难以消亡的,所以把这个两部分区域划分开来,就又能以不同回收频率去进行回收,老年代的回收频率往往要远低于新生代的回收频率。

标记复制算法的改进

现在我们知道新生代可以采用标记复制算法进行回收,而标记复制算法的缺点就是需要空出一半的内存空间,那么在JVM中实际上并没有这样做,IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。

依据这一项研究,HotSpot虚拟机又把新生代划分为了3个部分:Eden、Survivor0、Survivor1,他们的比例默认为8:1:1,也就是说两个Survivor区采用完全复制的算法,这样一来仅仅浪费了新生代的10%的内存空间。

当然理论之下总有意外,如果存活的对象就是超过了10%怎么办?当然这时候一般就会依靠老年代来进行担保了。

标记清除还是标记整理

HotSpot中关注吞吐量的Parallel Scavenge收集器是基于标记整理算法的,而关注延迟的CMS收集器则是基于标记清除算法的。

为什么Parallel Scavenge选择标记整理,CMS选择标记清除?

标记清除和标记整理的主要区别就在于对象的移动整理,如果移动则内存回收时会更加复杂,如果不移动则对象分配时会更加复杂,所以标记清除算法在垃圾回收时速度更快、停顿时间更短,而标记整理算法虽然停顿时间稍长,但是对象的分配和访问则相比标记清除更加快,又因为分配和访问的频率要远远高于垃圾回收的频率,所以从总比来看吞吐量是要高于标记清除算法的。

CMS中还有一种特殊的做法,就是一般情况下采用标记清除算法,直到碎片程度太严重的时候可以再采用标记整理算法。

四、垃圾回收器

算法的基础有了之后,就剩下具体实现了。

百度随便找了一张图,一般大家认知比较多的垃圾回收器都在下图中了,当然图上的最新回收器是G1,而在JDK11时发布的ZGC也是话题度很高的一款新型垃圾回收器,虽然有这么多种垃圾回收器,不过就当下来看,目前用的最多的还以parallel、cms、g1这三种为代表。
在这里插入图片描述
下面我们就分别来谈谈,这三种垃圾回收器的区别。

Parallel

首先是Parallel,见名知意,这一款能够并行执行的垃圾回收器,其主要的关注点在于如果保证系统的吞吐量,有人可能会觉得这款垃圾回收器太老了,也不是并发回收的,为什么还要拿出来说呢?原因很简单,因为它是JDK7、8中默认的垃圾回收器,而这一点我发现很多人都不知道。

不信,查查你们公司目前正在用的垃圾回收器是不是它咯😏

Parallel Scavenge、Parallel Old回收算法

从上图中可以看到Parallel Scavenge是针对新生代的垃圾回收器,而Parallel Old是针对老年代的垃圾回收器,对于新生代的回收算法,参考前面相关的理论知识,应该选择标记-复制算法,而老年代,可以用标记-清除或者标记-整理,Parallel Old选择了标记-整理。

吞吐量

这里有必要说明一下什么是吞吐量?在垃圾回收中,吞吐量指的就是运行用户线程时间占系统总运行时间的比值。

举个例子:运行用户代码时间为99分钟,垃圾收集器进行垃圾回收运行了1分钟,那么吞吐量就是:99 / (1+99) = 99%

追求高吞吐量可以最大程度的利用CPU资源完成运算的任务,这就比较适合关注后台运算,而与用户交互较少的场景。

Parallel Scavenge中提供了两个调整吞吐量相关的参数:

-XX:MaxGCPauseMillis

设置最大GC暂停时间的目标(以毫秒为单位)。这是一个软目标,并且JVM将尽最大的努力来实现它。
默认情况下,没有最大暂停时间值,这需要额外注意,之后的案例中,就会遇到这个问题。
下面的示例显示如何将最大目标暂停时间设置为500ms:
-XX:MaxGCPauseMillis = 500

当然你不能想当然的认为这个值设置的越小越好,你要知道Parallel Scavenge是如何做到控制停顿时间的?实际上就是简单的增加垃圾回收频率而已,也就是说你设置的停顿时间越短,垃圾回收的频率就会越频繁,比如:原来30秒一次垃圾回收,一次停顿2秒,现在由于设置的停顿时间为1秒,所以必须10秒执行一次垃圾回收,虽然停顿时间短了,但是吞吐量也低了。

-XX:GCTimeRatio

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

CMS

CMS(Concurrent Mark Sweep)是HotSpot虚拟机中第一款实现并发收集的垃圾回收器,是为那些希望使用较短的垃圾收集暂停时间并且可以在应用程序运行时与垃圾收集器共享处理器资源的应用程序而设计的,简单来说,CMS就是追求最短停顿时间的垃圾收集器。

CMS的热度一直都很高,也算是具有重要意义的一款垃圾回收器,不过遗憾的是,它并没有成为任何一版JDK中的默认垃圾回收器,我想应该也是因为它缺点明显,后面又有了更出色的G1的原因吧,尽管如此,CMS的设计理念还是很值得我们学习的,所以让我们一起看看它到底是如何做到同时兼顾垃圾回收与对象产生的。

回收策略

CMS主要针对老年代进行垃圾回收,可以配合Serial或者ParNew新生代垃圾收集器进行回收,并且从名字上包含“Mark Sweep”就可以看出CMS收集器是基于标记-清除算法实现的,相对之前的垃圾收集器CMS整个回收过程要稍微复杂一些,大致分为4步:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

1、初始标记(CMS initial mark)

首先初始标记,需要暂停用户线程,不过这一步仅仅标记GCRoots能直接关联到的对象,因此暂停时间很短。

只标记GCRoots直接可达对象
在这里插入图片描述

2、并发标记

并发标记就是接着初始标记的根对象继续往下标记,这个阶段是最耗时的,但是好在是与用户线程并发执行的。

考虑一种情况,老年代对象被新生代对象引用,如果此时只扫描老年代的GCRoots对象,A对象就会被遗漏,所以并发标记时实际上也会扫描新生代对象。

在这里插入图片描述

3、重新标记

重新标记阶段是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

4、并发清除

清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

并发预清理阶段

实际上除了上述的主要流程之外,CMS还有一步并发预清理阶段,这个阶段主要是发生在重新标记之前,此阶段工作与重新标记类似,目的主要是为了希望能够在重新标记前触发一次新生代的GC,这样就可以减少重新标记的停顿时间,此阶段主要标记新生代晋升到老年代的对象,直接分配到老年代的对象,并发过程中引用发生修改的对象,默认情况下当eden区达到了2M,则会开启并发预清理阶段,当eden区使用达到50%时停止预清理,或者预清理阶段超过默认时间5秒时也会停止预清理,配置CMSScavengeBeforeRemark参数,也可强制使每次重新标记前都触发一次YGC,但是这样的做法,虽然减少了重新标记的任务,但如果刚好已经执行过一次YGC,重新标记又执行一次,也会造成STW时间变长。

如何解决并发标记时引用关系改变问题?

由于第二阶段垃圾标记是与用户线程并发执行的,那就有可能产生错误标记的问题,比如一个对象我们刚刚标记完,结果用户线程又把其他对象引用到这个刚刚标记完的对象上。

如下图,当垃圾线程标记时,A的这条引用链走到B就已经走完了,但是如果之后用户线程让B对象又引用了C对象,那么C对象就会被漏标,最终会被当做垃圾对象被清理掉,显然C对象是不能被回收的。
在这里插入图片描述
为了解决这样的问题,CMS首先将老年代等份划分成了好多小块,这些小块的集合可以叫做card table(byte数组,数组中每一元素对应一个块),当某一个对象的引用发生变化时(只记录黑色对象引用发生变化),就改变这个对象所在的块的标识,比如标记为:脏card,这样我们在最终标记时只要在遍历一次所有的脏card即可。

这里涉及到三色标记算法,不理解建议参考JVM垃圾回收算法—三色标记法分析这篇文章。

如果确定新生代对象是否存活?

1、GC可达性分析
2、老年代引用新生代对象

GC可达性分析不用多说,主要分析一下老年代引用新生代对象的问题,刚才分析初始标记时就已经了解到,在分代收集中只是扫描GCRoots肯定是不够的,要确认老年代对象是否存活就必须扫描所有新生代对象,所以刚才介绍了CMS并发预清理阶段就是为了来一次新生代的垃圾回收,这样新生代中大多数对象就被回收了。

现在问题是新生代要判断哪些对象被老年代引用了,老年代的对象的都是长期存活的,一次垃圾回收可没用,那就只能全量扫描老年代了?显示CMS不会这样做,这时候card table又派上用场了,当有新生代引用老年代对象时,只需要把老年代所在的card标记新增一个标识即可,就像上面标记为“脏”一样,这样新生代只需要扫描所有有相关标识的card即可。

cardtable是一个byte数组,一个byte有8个位,只要约定好每一位的含义就可以区分标识是对象在并发期间修改了,还是老年代引用新生代对象!

CMS缺点

  1. 因为是并发执行,所以会占用用户线程,CPU核心数小于4的服务器不推荐使用。
  2. 浮动垃圾问题,因为CMS是与用户线程并发执行的,所以并不能等待内存占用达到100%了再回收,jdk6以后默认是92%,就会开启CMS垃圾回收,如果过程中产生Concurrent Mode Failure,则会切换成serial old进行回收。
  3. 垃圾碎片:CMS采用标记-清除算法,因此会存在碎片问题,CMS默认情况下每一次FullGC都会进行一次压缩整理,通过参数可以配置UseCMSCompactAtFullCollection 默认为true, CMSFullGCsBeforeCompaction就是表示配置每多少次CMS的FullGC执行一次压缩,但是如果用户调用system.gc或者担保失败,那也会触发压缩的FullGC。

CMS常见问题解决思路

并发模式失败和晋升失败都会导致长时间的停顿,常见解决思路如下:

1、降低触发 CMS GC 的阈值。即参数 -XX:CMSInitiatingOccupancyFraction 的值,让 CMS GC 尽早执行,以保证有足够的空间。
2、增加 CMS 线程数,即参数 -XX:ConcGCThreads。
3、增大老年代空间。
4、让对象尽量在新生代回收,避免进入老年代。

通常 CMS 的 GC 过程基于标记清除算法,不带压缩动作,导致越来越多的内存碎片需要压缩。
常见以下场景会触发内存碎片压缩:

1、新生代 Young GC 出现新生代晋升担保失败(promotion failed))
2、程序主动执行System.gc()

可通过参数 CMSFullGCsBeforeCompaction 的值,设置多少次 Full GC 触发一次压缩。

默认值为 0,代表每次进入 Full GC 都会触发压缩,带压缩动作的算法为单线程 Serial Old 算法,暂停时间(STW)时间非常长,需要尽可能减少压缩时间。

G1

G1是一款非常具有特殊意义垃圾收集器的技术发展体现,因为相比G1之前的垃圾收集器,G1首次打破了基于老年代或者新生代一整块内存进行收集的设计思想,G1设计上依然有分代的思想,但是在内存上不在进行分代上的物理划分,也就是在一块大的内存区域中,既有年轻代也有老年代。

G1与CMS一样都是追求低停顿时间的垃圾收集器,但是由于G1在设计上的突破,使其能在更大的内存空间回收时,保持优秀的垃圾回收效率,这是G1之前的所有垃圾收集器所不能做到的。

G1中的分代设计

G1与其他的垃圾收集器相比不再有物理上的区域划分,而是直接使用一整块内存空间,并且划分为多个大小相等的独立区域(Region),每一个Region可以在逻辑上被划分为Eden区、Suvivor区、Old区、Humongous区,并且每一个类型的Region也没有固定的数量、大小与地址。

Humongous区是G1中新增的区域,专门用来存放大对象的,G1中定义一个对象如果超过Region大小的50%就属于大对象。

每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。

在这里插入图片描述
每一个Region表示的含义是不固定的,Eden区可能会变成Old区,G1可以根据优化策略自行调整它们之间的比例,所以一般使用G1时,不建议手动配置新生代与老年代大小。

可预测的停顿时间

在G1中使用参数-XX:MaxGCPauseMillis,可以控制最大的停顿时间,这依然是一个软目标,但相比Parallel Scavenge设置而言,这要更加可控一些,因为现在的内存已经被划分为许多小的Region区,G1就可以根据每个Region区回收时大小和数量的一些经验值来进行选择性的回收,优先回收那些收益较高的Region,这一切都归功于Region的设计思想,当然如果你把这个值设置的太小,那么G1最终也只能牺牲每次回收的垃圾量而导致垃圾回收变得更频繁,这反而降低了总体性能。

G1中的回收步骤

初始标记
通过可达性分析,找到GCRoot根节点直接关联的对象,这个阶段依然是需要STW的,但是时间很短,而且是通过MinorGC时完成的,所以可以说这个阶段不需要额外的停顿时间。

并发标记
顺着GCRoots跟节点继续往下扫描,这个阶段耗时比较长,不过是与用户线程并发执行的。

最终标记
暂停用户线程,处理并发阶段时引用产生变化的一些对象。

筛选回收
依然需要暂停用户线程,统计Region中的数据,根据期望的停顿指标进行回收。

三色标记漏标问题

关于三色标记不理解的可以参考JVM垃圾回收算法—三色标记法分析。文章中讲述了CMS和G1下分别是如何处理漏标问题的。

RSet集合

之前在介绍CMS时提到了一个问题如何确定新生代对象是否存活?对于G1同样存在这个问题,就是那些在老年代的对象引用了新生代的对象,与CMS一样,G1也是把每一个Region划分为一些cardtable块,不同的是因为CMS的老年代只有一个,所以只需要维护一个对应的cardtable集合,而G1中的老年代会有很多个,这就需要维护很多个cardtable集合,所以G1在外面又加了一层集合,直接用来记录当前新生代被哪些老年代引用了,这个集合就是RSet,RSet可以理解为是一个Map集合,Key就是Region分区的起始地址,Value又是一个集合,集合中的元素就是这个Region分区中cardtable的脏下标。

如果cardtable不太了解,可以参考JVM垃圾收集器详解之CMS这篇文章,关于在CMS中是如何使用cardtable进行优化的,这与G1类似。

G1与CMS

G1并不是HotSpot中第一款并发垃圾收集器,在这之前已经有CMS了,CMS的回收步骤也与G1相似,并且也都是追求低停顿时间,不过由于CMS过于明显的缺点,注定是要被G1所取代的,JDK9中如果你配置使用CMS垃圾收集器,在项目启动时还会收到一条警告,提示CMS未来将会被抛弃。

G1如何解决CMS中垃圾碎片的问题

由于CMS中采用的是垃圾-清除算法,所以会产生内存碎片的问题,而在G1中回收的是Region区域,并且每个Region区域所代表的含义并不固定,所以G1在对多个Region进行回收的同时又能完成多个Region的整理,这从局部来看就是标记-复制的算法(一个Region中存活的对象复制到另一个Region中),从整理来看这又是标记-整理的算法,所以G1中不存在内存碎片的问题。

G1更适合大内存的服务器

G1相比之前的垃圾收集器的优势就在于并不会因为内存的变大而带来垃圾回收时间的增加,无论是CMS还是之前的垃圾收集器由于都是对整个内存空间进行回收,所以内存越大那么回收的对象就越多,同时回收的时间就越长,而G1不一样,G1在大内存上的优势会更加明显,因为G1并不会对整个内存进行回收,所以回收时间也不会随着内存增长而增长。

内存占用

由于G1中每个Region都需要维护一份RSet集合,而CMS只需要老年代中维护一份,这就导致G1中的RSet(和 其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间。

CPU负载消耗

CMS和G1都有因为并发标记过程用户线程改变对象引用关系的问题,二者都需要进行cardtable的维护,CMS和G1中都通过写后屏障进行维护,不过G1中为了实现原始快照的算法还需要写前屏障来跟踪指针的变化情况,所以在用户程序运行过程中会产生由跟踪引用变化带来的额外负担。

五、实际项目中遇到的问题

线上环境

在我们项目中,JDK使用的1.8版本,那么默认使用的是Parallel组合的垃圾回收器,服务器内存32G(阿里云ECS服务器,部署在K8S上,一个节点部署了多个应用)

线上也没有设置任何相关的参数,所以默认配置为:

Non-default VM flags: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=null -XX:InitialHeapSize=526385152 -XX:MaxHeapSize=8392802304 -XX:MaxNewSize=2797600768 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=1572864 -XX:OldSize=524812288 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC

初始化内存大小:500M(总内存的1/64),最大内存:8G(总内存的1/4)。

问题现象

服务器运行一段时间后,可以看出FGC次数频繁且耗时长,104次FGC,耗时达到258秒,平均每次FGC持续时间2秒多。

在这里插入图片描述

当前内存使用情况

Eden区2G多,老年代5G多,内存被大量消耗,实际上根本不需要如此大的内存,初步判定老年代中存在大量本应该在年轻代就消亡的对象,过早的进入老年代,就会导致FGC频繁。
在这里插入图片描述

问题分析

通过前面的理论学习,我们了解到默认情况下Eden和Survivor区的分配占比应该是8:1:1,但从实际情况来看,明显不是这样的,一个Survivor区空间只有63M左右,怀疑应该是垃圾回收器自身动态调配导致的,于是查阅官网文档,找到了关于ParallelGC的说明.

在这里插入图片描述
根据描述,可以大致了解到,首先ParallelGC会使用一种自动调节的方式,来满足对高吞吐量的追求,这种自动调节的方式,主要针对用户指定的一些特定行为来进行评估,包含:最大垃圾回收暂停时间、吞吐量、尽可能小的占用内存,在这些目标中又会按照优先级来以此满足,首先达到最大暂停时间目标。只有在达到目标之后,才能实现吞吐量目标。同样,只有在达到前两个目标后,才会考虑使用最小内存。

现在我们了解到,由于我们没有设置期望的暂定时间,所以默认值就是没有这个约束,其次我们也没有指定吞吐量,所以默认值为99,也就是允许1%的垃圾回收时间,在达到这个目标之后,又会考虑使用最小内存。所以经过多次不断的调节之后,最终就变成了那样。

解决问题

现在已经了解到问题的原因了,解决方式也很简单,你可以指定比例Eden区和Survivor的比例,不要让AdaptiveSizePolicy动态调整,并设置期望的暂停时间,使得每一次回收时间变得尽量可控。

最后的选择

然而,考虑到ParallelGC还是以吞吐量为目标而设计的,并不适合我们的使用场景,所以最后我们并没有选择调整参数,而是直接换成了G1,并设置期望的最大暂停时间,更换后持续观察了几天,没有出现任何异常,并且FGC的次数以及时间都有明显的改善。

总结

垃圾回收本身还是比较偏理论性质的,如果你只去看,而不去自己动手实验,你很难体会到其中的乐趣,所以我还是建议大家能够亲自实践一下,打印一些堆栈日志分析分析,玩一玩JDK自身提供的一些故障处理、分析工具,比如:jstack、jstat、jmap等等,如果能接触到线上真实环境,则更好。

实践是检验真理的唯一标准,也是掌握原理的良好方式。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码拉松

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值