程序gc卡顿?我换了G1就高枕无忧了(从时代剖析垃圾收集器原理)

经典案例:
一个堆内存 2G 的资源服务器,PV 50万,用户反馈网站速度比较慢。于是公司决定对服务器进行升级,于是将堆内存扩展为 16 个 G。
但是,用户反馈卡顿十分严重,反而效率更低了。

可能一些对 JVM 还不够熟悉的同学会不明所以然。其实目前来说,生产环境上对 JVM 的调优还是很重视的。虽然说 Java 这样的一个沙盒机制,帮我们屏蔽了各种操作系统的差异以及内存回收的工作。但是也正因如此,很多程序员会对底层的机制不了解,从而无法理解生产环境中遇见的问题。

所以,为了不只是做一个底层的 CRUD 小白程序员,我们也应该去了解 一部分(底层的知识是学不完的)相关的、重要的 较为底层的知识,来帮助我们提高一个整体的格局。从而能够在真正的生成环境中,让自己的项目高效而稳定。

(其实最开始我是想一篇文章写完 JVM 的,不过写了才发现知识点过于庞大,确实不能放在一篇博文中)

垃圾收集器发展原因

其实最初的时候,Java 程序是不会因为垃圾回收而产生严重卡顿的。而是随着技术的发展,Java 原始的垃圾收集器才慢慢出现了严重的问题。

一个很关键的原因,就是 内存大小 !!!

我这里举个形象的例子来帮助大家理解。

  • 垃圾收集器,就是去清理一块空间的垃圾。
    就比如,在你的家里,有一个小房间,你的小孩子在里面玩耍,而在不断玩耍的过程中,制造出越来越多的垃圾。
  • 而小孩子他是不用去自己清理垃圾的,这时候,就轮到你这个家长(相当于垃圾收集器),拿着个小扫帚,去清扫你的孩子屋里面的垃圾。
  • 这个过程有一个小细节,就是,你的孩子不能一边玩,一边产生垃圾,同时你一边清理。
    因为在清理垃圾之前,你需要先把地上面的物品先浏览一遍,看看哪些是垃圾,确认了才能将其清理。
    如果他一边生产垃圾,你一边清理,就无法分清哪些是垃圾,哪些不是垃圾。这时如果贸然清理,很容易清除一些本不该清理的东西,这样就会造成不可预估的错误(你的孩子可能会精神崩溃),程序异常终止。
    当然也可能会因此没有清理干净垃圾,导致垃圾残留。
  • 所以,你展露出为人父母的威严,让其停止一切活动(stop the world,简称 STW),你先清理垃圾,等到你的清理工作结束之后,他才能继续活动。
  • 这样,就能基本保证你的孩子在活动时产生的垃圾可以一直不断地会被回收,这样,房间就可以拥有空间,不至于你的孩子最终被垃圾埋起来。

这一切最初看起来都很和谐。但是,慢慢的,世界改变了。

由于世界上经济和技术的不断发展,我们已经能够购买到越来越高级的机器,一切看起来繁荣昌盛,但是相当于你家的房子越来越大。
这时候,你的清理工作可就越来越繁重了。

  • 曾经,你只要扫一间小小的屋子;
  • 而现如今,你要扫一个偌大的天安门广场。
  • 曾经,你只要花一会就能清理完你的小屋;
  • 而如今,你扫几天几夜也不一定扫的完这么大的广场。

当初的堆内存很肯能只有 几兆,而现在的堆内存,都可以有 几十、几百兆,甚至上 T。
所以,垃圾收集器会不断地发展,同时,也才有了 JVM 调优的工作。

而我在开头列举的案例的原因,也正是因为内存扩大,导致的 full gc 时间延长。

垃圾收集器的种类和原理

单线程垃圾收集器(Serial)

最初的垃圾收集器伴随着 Java 一起诞生(Java 1 版本的默认收集器),它是一个单线程的收集器,而且运行良好。
因为那时候的机器内存还很小,而且基本为单核 CPU。所以单线程的垃圾收集器非常合适。

这个时候的垃圾收集器为 Serial 垃圾收集器:
Serial(负责收集年轻代)和 Serial Old(负责老年代)

其中年轻代采用我们熟悉的复制算法,老年代采用标记-整理算法。
(太基础的我就不细讲了)
Serial
Serial 垃圾收集器由于过于简单,已经很难以适用于目前高性能、多 CPU、大内存 的服务器机器来使用。

  • 每一次 STW,由于只有一个线程去进行垃圾回收,会严重浪费多核 CPU 的并行特性优势
  • 再加上日益增大的内存,日常针对几十百兆小内存的 Serial 会显得力不从心,普通的垃圾收集算法会使得程序等待的时间让人无法忍受

但这也并不是说 Serial 就完全没有用武之地了

  • 首先对于一些比较老的机器,在只有一个核的情况下,单线程的垃圾收集器会比多线程拥有更高的效率(避免了多线程的上下文切换开销)
  • 不仅如此,对于一些小型程序,因为其算法实现简单,一个 Serial 完全可以应付得来,并且对系统的开销要比复杂的垃圾收集器低。
    因此,在 Client 端的小型程序,都仍然是默认采用 Serial 垃圾收集器。

多线程垃圾收集器(PS + PO)

Serial 单线程收集器在目前的市场,毕竟也只能去应用在小程序上了。
而对于我们的大型服务器,是绝对不可以用这么老的单线程垃圾收集器的(你要扫天安门,好歹也要一群年轻力壮的小伙一起去,而不能让一个老爷爷独自去吧)。

为了跟上时代的步伐,多线程的垃圾收集器也诞生了。(从此之后,垃圾回收再也不用一个人孤零零的了,众人拾柴火焰高)
其中 Parallel Scavenge 负责年轻代,Parallel Old 负责老年代
PS+PO
就拿我们目前最流行的 Java 1.8 来说,现在的 默认垃圾收集器仍然是 PS + PO,可见其确实很优秀。

但是,不得不承认,它虽然通过多线程提高了垃圾收集的效率,但是,面对日益增大的内存,即便是线程增多了,回收的 STW 可能仍然让人难以忍受
所以,在几百兆,到几个 G 的内存,PS + PO 的多线程组合仍然是非常不错的,但是,在内存继续扩大,几十个 G 的情况,PS + PO 多线程组合仍然会有较长的 STW 停顿时间。

不过,它仍然继承了上一代 单线程收集器 的优点,在垃圾收集时专心致志,停止用户线程一切操作,所以它的吞吐量非常可观(在追求吞吐量优先的情况下,仍推荐 PS + PO 组合)。

划时代的“并发”收集器(CMS)

不得不说到的 CMS 垃圾收集器,它是一个划时代的产品。
(因为它做到了 并发 收集)!!!

并发垃圾收集是指,垃圾收集器可以一边在用户线程工作的情况下,同时一边清理垃圾。这样,就不容易造成长时间的 STW 停顿。

回到我们之前的类比,假设你可以在你的小孩子一边玩的情况下,你一边清理垃圾,那你家小孩便不会因为 STW 而不能够继续玩耍而要大发雷霆了。

CMS 的收集过程分为 4 步:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清理

在对垃圾收集的时间做过统计分析之后发现,垃圾回收的最耗时间的部分是在(下图中并发标记)这段时间,于是,CMS 将最耗时间的这一部分 与用户线程并发执行。

  1. 初始标记很快,没有用户线程干扰,并且只标记了 gc root 相关的很少的一部分对象;
  2. 并发标记开始,它要一点一点找出所有的没有用的对象,同时,由于用户线程对垃圾回收线程的影响,不断在操作对象,也可能改变对象的状态,所以这段并发标记的过程最为复杂和耗时;
  3. 重新标记也很快,因为对象基本已经标记完成,并且用户线程没有干扰,只需要最后再保证以下完整性和正确性;
  4. 清理垃圾时继续和用户线程一起,减少 STW。
    CMS

其实并发垃圾收集很久之前就有设想,但是之前从来没有真正实现过,因为这个难度是巨大的。
假设你在扫地的同时,你的孩子在不断地丢垃圾,你怎么扫???
所以 CMS 的诞生历经波折,耗时许久,并且也被诟病有许多的问题(内存碎片、浮动垃圾)。

所以 CMS 在JDK 每一个版本都不是默认的垃圾收集器(尽管有了 CMS,但是 JVM 仍然拿 PS + PO 做默认收集器,要用 CMS 必须手动指定)。

CMS 是回收老年代的收集器,它采用的是 标记-清除 算法。
我们都清楚,标记-清除 算法是垃圾收集器最基础、简单高效的回收算法,CMS 通过这个算法来降低垃圾回收造成的 STW 延时。
但是我们也知道,这个收集算法有一致命的缺陷,就是内存碎片。
标记-清除
所以,随着垃圾收集的不断发生,内存的碎片化情况会越来越严重,内存都变为了一小块一小块,这时候,如果有大的对象需要进入,可能总内存是够的,但是却没有了连续的一块内存能够给它分配。

CMS 的第二个问题,就是:浮动垃圾
因为 CMS 允许在垃圾收集的部分过程中,用户线程也能继续执行任务。那么,
在垃圾回收时,也会有垃圾在不断产生,所以就会产生浮动垃圾。
所以可能出现这样一种情况,在垃圾收集器执行时,用户线程新产生的对象继续去占用内存,然后,突然就内存不够了。

这两种情况都是 CMS 致命的问题,没有了足够的内存空间:
1、内存碎片,没有足够的连续内存
2、回收时不断生产,导致内存不足

这时,CMS 无法解决,于是,为了不让程序挂掉,CMS 就会去请救兵,去让:
Serial Old(单线程老爷爷)进行垃圾回收

所以,曾经在 PS+PO 的时候,可能垃圾回收严重时要十几分钟,结果换了 CMS,有一天它突然卡了,一卡就是几十个小时。。。。。

Parallel Scavenge(PS) 的改造版(ParNew)

实际上,多线程的 Parallel Scavenge(PS)年轻代多线程收集器已经很不错了。它用于和 Parallel Old(PO)配合使用。

只不过,CMS 诞生之后,并没有一个年轻代垃圾收集器和它去搭配使用,于是,
Parallel Scavenge 垃圾收集器进行了改造,来专门用于 CMS 的配合使用。

垃圾收集器的巨大发展(G1)

G1 是一种运行在服务端的垃圾收集器,目的是用在 多核、大内存 的机器上,它在大多数情况下可以实现指定的 GC 暂停时间,并且保持较高的吞吐量。

可是为何 G1 能够解决 CMS 的问题,并且能够从容面对更大的内存???

逻辑分代,物理不分代

我们过去学习 JVM 内存空间的时候,都知道 堆分为 年轻代、老年代,并且年轻代还要分为 Eden 区 Survivor 区。
这都是为了年轻代的复制算法高效,并且常用对象放入老年代减少 标记-整理 频率。

但是,G1 开始打破格局(逻辑上分代,但是物理内存上不划分代)

通过将大的堆内存划分成小的一个一个的 Region,通过分治策略,分块回收,来降低响应延迟。
这时 G1 低延时的一大原因。
G1

Card Table

首先要提到一个概念 card set

我们在垃圾收集的时候,首先第一步就是先确定存活的对象。(我们都知道,要确定对象的存活,只要从根开始搜索,能够达到的都是存活对象)

在 Minor GC 时,我们只需要清理年轻代的垃圾。
但是,有一个点可能很多人没有去往那里想:

  • 就是,我们年轻代中,引用的对象可能在老年代
  • 而老年代引用的对象,可能在年轻代

这样就有个很头疼的问题,我们为了去清理年轻代的垃圾,但是,我们不仅要去扫描年轻代的对象,还要去扫描老年代的对象。(这样是非常不划算的)
card set
所以 JVM 在内部分成了 一个一个 card
当这个 card 中有对象,指回了年轻代之后,就把这个 card 标记出来;
这样,以后只需要去遍历被标记出来的 card 中的对象,其他没有被标记就不用去管,从而提高效率。

这个记录表则是 位图bitmap(不知道的去补基础)

打个比方,就好像你手里有一百套房,你要去查看哪些是被租出去的。
这时候,你手里有一张记录表,你可以确定是哪几栋有楼层去出租了。这样,你就只需要去那些被标记的楼去看一下,哪一层是否有人,从而提高了效率。
card table

Collection Set

提及一下 Collection Set 只是顺带,它和 Remember Set 要区分开来

Collection Set 里面存放的都是一些要被回收的对象,这些对象可以来自 Eden、Survivor、Old 各个分区,在 Collection Set 存活的数据会在 GC 从一个分区移动到另一个分区。

Remember Set

重点要提及的就是 Remember Set:
在 G1 的每一个 Region 中,都存放着一个 Remember Set,它记录着其它 Region 中的对象到本 Region 中的引用。
这样就使得,垃圾收集器不需要扫描整个堆栈来找到谁引用了当前分区的对象,只需要去扫描 Remember Set 即可。

要实现 Remember Set(RSet),就需要再每次给对象赋予引用时做一些额外的操作:
在 Remember Set 中做一条记录(在 GC 中称为 写屏障)
注意:这里的写屏障和内存屏障没有半毛钱关系 !!!(也不要拿去喷面试官)
remember set

MixedGC

首先,G1 的垃圾回收,不止 MinorGC、FullGC,它还有一个 垃圾回收机制:
MixedGC

MixedGC 回收过程和 CMS 大致相同(就相当于一个仿真版 CMS):

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收(解决内存碎片问题)

前 3 步几乎相同,但是最后一步有很大的差异。
它会筛选出垃圾最多的,最需要回收的 Region,然后,将这块 Region 用复制算法,直接复制到另一块 Region 上,并进行压缩,这样就解决了 CMS 的内存碎片的问题。
mixedgc

三色标记 + SATB

我们知道,不管是 CMS 还是 G1,里面最关键的就是并发标记(它使得垃圾收集线程和工作线程可以同时运行)

而并发标记的关键点就是 三色标记算法 !!!

  1. 黑色:表示自己被标记,并且自己所有的孩子也被标记过了(注意孙子不一定被标记)
  2. 灰色:表示自己被标记,但是自己的孩子还没有被标记
  3. 白色:表示自己还没有被标记

三色标记的概念理解起来很简单,就是记录自己以及孩子是否被标记。
三色标记
不过 三色标记的问题在于:
由于标记是和用户线程并发执行,因此可能出现漏标现象。

  • 假设,一个黑色对象,将自己一个引用指向了白色对象,(看图)
  • 然后,这个灰色对象,将自己指向白色对象的引用解除,
  • 那么,此时由于指向白色对象的对象已经是黑色(表示孩子也已经被扫描过,虽然实际上并没有)
  • 所以白色对象将被漏标
    三色标记
    为了去解决并发标记产生的漏标情况,那必须对 改变 的情况 进行记录

现在有两种选择:

  1. (incremental update)
    记录新增加的引用:
    把黑色重新改成灰色
  2. (SATB:snapshot at the beginning)
    记录丢失的引用:
    当引用取消时,不把引用直接丢弃,而是保存起来

这时候你可以自己想一想,用哪一种方式好。

——————————————— 华丽的分割线 ————————————————

虽然说两种方法可以达到同一种效果,但是,

  1. 我们看第一种方法,我们再最后最终标记的时候,我们要把那些由黑变灰的对象重新再次扫描一遍,
    这样就增加了扫描的次数,影响了效率
    (尤其是那个黑对象的孩子还特别多的时候)
  2. 而第二种,仅仅只是将引用额外保存,只需要多花费一点点地空间,
    但是不会产生重复扫描对象的情况,从而可以 节省 GC 时间

所以我们可以很确定地选择第二种,因为我们的 G1 就是为了解决 GC 的过长 STW 时间;
G1 在实际的运用这个方法的过程中,在对象引用被取消时,会将引用推入堆栈,
下次扫描时拿到这个引用,由于有 Remember Set(RSet)的存在,就不再需要去扫描整个堆去查找指向这个白色对象的引用
因此在 G1 中,Remember Set(RSet)和 SATB 完美融合。

不过在 CMS 之中所采用的是第一种方法,所以 G1 也改进了 CMS 的这一个不足。

其它垃圾收集器

ZGC 我这里没有给大家讲,因为 ZGC 出现的时间还不是特别长,我对它的了解并不够深入。所以我在这里暂时不去细讲,不过大家也可以去网上浏览一些其它的博文来对它有一定的了解。
Shenandoah 垃圾收集器同样如此。

不过我们可以知道的是,从 G1 开始,这些垃圾收集器都是针对大内存、低响应,它们的目标是一致的,只不过内存大小适用范围不一样,
G1 可以适用从几十 G,到上百 G 的内存,而 ZGC 可以用于上 T 的内存。

还有一个叫 Epsilon,这个垃圾收集器不是用来回收垃圾的。。。。它根本不去回收。
所以,它仅仅只是为了调试程序而使用,而不是放到生产环境中的。

垃圾收集器总结归纳图

其中上面的表示年轻代(Serial、ParNew、Parallel Scavenge),
下面的表示老年代(CMS、Serial Old、Parallel Old)
其中的实线连线表示他们是常用的垃圾收集器组合,虚线则一般不用。

G1 虽然逻辑分代、但是物理上已经不分代了,而是划分成一小块一小块的 Region
从 ZGC 开始完全不分代。
垃圾收集器的种类

作者的话

其实这篇文章比我预估的写作时间要短,大概 6 小时左右,(可能因为画图稍稍拖了点时间,不然会更短一些)。
一开始我再写完垃圾收集器之后想要进而跟进一步,写一写关于 JVM 的调优。(不过发现内容过于庞大,不适合挤在一块去写)

我们之所以要学习 JVM,就是为了去调优 !!!

现在 JVM 的知识也是面试的重灾区,一个程序员如果对 JVM 的底层有一定的了解(当然绝对不用你去看 Hotspot 虚拟机源码,这个深度的时间和收益并不合算),那么他在程序的编写过程中就会避免掉一系列的问题,以及对效率做一定的提高。
不仅如此,由于前几代的 垃圾收集器 的种种弊端,使得线上生产环境下出现的问题颇多,因此也需要有精通 JVM 的优秀人员能够去排查其中的问题,找到解决的思路与办法。

共勉。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章