经典案例:
一个堆内存 2G 的资源服务器,PV 50万,用户反馈网站速度比较慢。于是公司决定对服务器进行升级,于是将堆内存扩展为 16 个 G。
但是,用户反馈卡顿十分严重,反而效率更低了。
可能一些对 JVM 还不够熟悉的同学会不明所以然。其实目前来说,生产环境上对 JVM 的调优还是很重视的。虽然说 Java 这样的一个沙盒机制,帮我们屏蔽了各种操作系统的差异以及内存回收的工作。但是也正因如此,很多程序员会对底层的机制不了解,从而无法理解生产环境中遇见的问题。
所以,为了不只是做一个底层的 CRUD 小白程序员,我们也应该去了解 一部分(底层的知识是学不完的)相关的、重要的 较为底层的知识,来帮助我们提高一个整体的格局。从而能够在真正的生成环境中,让自己的项目高效而稳定。
(其实最开始我是想一篇文章写完 JVM 的,不过写了才发现知识点过于庞大,确实不能放在一篇博文中)
其实最初的时候,Java 程序是不会因为垃圾回收而产生严重卡顿的。而是随着技术的发展,Java 原始的垃圾收集器才慢慢出现了严重的问题。
一个很关键的原因,就是 内存大小 !!!
我这里举个形象的例子来帮助大家理解。
这一切最初看起来都很和谐。但是,慢慢的,世界改变了。
由于世界上经济和技术的不断发展,我们已经能够购买到越来越高级的机器,一切看起来繁荣昌盛,但是相当于你家的房子越来越大。
这时候,你的清理工作可就越来越繁重了。
当初的堆内存很肯能只有 几兆,而现在的堆内存,都可以有 几十、几百兆,甚至上 T。
所以,垃圾收集器会不断地发展,同时,也才有了 JVM 调优的工作。
而我在开头列举的案例的原因,也正是因为内存扩大,导致的 full gc 时间延长。
最初的垃圾收集器伴随着 Java 一起诞生(Java 1 版本的默认收集器),它是一个单线程的收集器,而且运行良好。
因为那时候的机器内存还很小,而且基本为单核 CPU。所以单线程的垃圾收集器非常合适。
这个时候的垃圾收集器为 Serial 垃圾收集器:
Serial(负责收集年轻代)和 Serial Old(负责老年代)
其中年轻代采用我们熟悉的复制算法,老年代采用标记-整理算法。
(太基础的我就不细讲了)
Serial 垃圾收集器由于过于简单,已经很难以适用于目前高性能、多 CPU、大内存 的服务器机器来使用。
但这也并不是说 Serial 就完全没有用武之地了
Serial 单线程收集器在目前的市场,毕竟也只能去应用在小程序上了。
而对于我们的大型服务器,是绝对不可以用这么老的单线程垃圾收集器的(你要扫天安门,好歹也要一群年轻力壮的小伙一起去,而不能让一个老爷爷独自去吧)。
为了跟上时代的步伐,多线程的垃圾收集器也诞生了。(从此之后,垃圾回收再也不用一个人孤零零的了,众人拾柴火焰高)
其中 Parallel Scavenge 负责年轻代,Parallel Old 负责老年代
就拿我们目前最流行的 Java 1.8 来说,现在的 默认垃圾收集器仍然是 PS + PO,可见其确实很优秀。
但是,不得不承认,它虽然通过多线程提高了垃圾收集的效率,但是,面对日益增大的内存,即便是线程增多了,回收的 STW 可能仍然让人难以忍受
所以,在几百兆,到几个 G 的内存,PS + PO 的多线程组合仍然是非常不错的,但是,在内存继续扩大,几十个 G 的情况,PS + PO 多线程组合仍然会有较长的 STW 停顿时间。
不过,它仍然继承了上一代 单线程收集器 的优点,在垃圾收集时专心致志,停止用户线程一切操作,所以它的吞吐量非常可观(在追求吞吐量优先的情况下,仍推荐 PS + PO 组合)。
不得不说到的 CMS 垃圾收集器,它是一个划时代的产品。
(因为它做到了 并发 收集)!!!
并发垃圾收集是指,垃圾收集器可以一边在用户线程工作的情况下,同时一边清理垃圾。这样,就不容易造成长时间的 STW 停顿。
回到我们之前的类比,假设你可以在你的小孩子一边玩的情况下,你一边清理垃圾,那你家小孩便不会因为 STW 而不能够继续玩耍而要大发雷霆了。
CMS 的收集过程分为 4 步:
在对垃圾收集的时间做过统计分析之后发现,垃圾回收的最耗时间的部分是在(下图中并发标记)这段时间,于是,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)年轻代多线程收集器已经很不错了。它用于和 Parallel Old(PO)配合使用。
只不过,CMS 诞生之后,并没有一个年轻代垃圾收集器和它去搭配使用,于是,
Parallel Scavenge 垃圾收集器进行了改造,来专门用于 CMS 的配合使用。
G1 是一种运行在服务端的垃圾收集器,目的是用在 多核、大内存 的机器上,它在大多数情况下可以实现指定的 GC 暂停时间,并且保持较高的吞吐量。
可是为何 G1 能够解决 CMS 的问题,并且能够从容面对更大的内存???
我们过去学习 JVM 内存空间的时候,都知道 堆分为 年轻代、老年代,并且年轻代还要分为 Eden 区 Survivor 区。
这都是为了年轻代的复制算法高效,并且常用对象放入老年代减少 标记-整理 频率。
但是,G1 开始打破格局(逻辑上分代,但是物理内存上不划分代)
通过将大的堆内存划分成小的一个一个的 Region,通过分治策略,分块回收,来降低响应延迟。
这时 G1 低延时的一大原因。
首先要提到一个概念 card set
我们在垃圾收集的时候,首先第一步就是先确定存活的对象。(我们都知道,要确定对象的存活,只要从根开始搜索,能够达到的都是存活对象)
在 Minor GC 时,我们只需要清理年轻代的垃圾。
但是,有一个点可能很多人没有去往那里想:
这样就有个很头疼的问题,我们为了去清理年轻代的垃圾,但是,我们不仅要去扫描年轻代的对象,还要去扫描老年代的对象。(这样是非常不划算的)
所以 JVM 在内部分成了 一个一个 card
当这个 card 中有对象,指回了年轻代之后,就把这个 card 标记出来;
这样,以后只需要去遍历被标记出来的 card 中的对象,其他没有被标记就不用去管,从而提高效率。
这个记录表则是 位图bitmap(不知道的去补基础)
打个比方,就好像你手里有一百套房,你要去查看哪些是被租出去的。
这时候,你手里有一张记录表,你可以确定是哪几栋有楼层去出租了。这样,你就只需要去那些被标记的楼去看一下,哪一层是否有人,从而提高了效率。
提及一下 Collection Set 只是顺带,它和 Remember Set 要区分开来
Collection Set 里面存放的都是一些要被回收的对象,这些对象可以来自 Eden、Survivor、Old 各个分区,在 Collection Set 存活的数据会在 GC 从一个分区移动到另一个分区。
重点要提及的就是 Remember Set:
在 G1 的每一个 Region 中,都存放着一个 Remember Set,它记录着其它 Region 中的对象到本 Region 中的引用。
这样就使得,垃圾收集器不需要扫描整个堆栈来找到谁引用了当前分区的对象,只需要去扫描 Remember Set 即可。
要实现 Remember Set(RSet),就需要再每次给对象赋予引用时做一些额外的操作:
在 Remember Set 中做一条记录(在 GC 中称为 写屏障)
注意:这里的写屏障和内存屏障没有半毛钱关系 !!!(也不要拿去喷面试官)
首先,G1 的垃圾回收,不止 MinorGC、FullGC,它还有一个 垃圾回收机制:
MixedGC
MixedGC 回收过程和 CMS 大致相同(就相当于一个仿真版 CMS):
前 3 步几乎相同,但是最后一步有很大的差异。
它会筛选出垃圾最多的,最需要回收的 Region,然后,将这块 Region 用复制算法,直接复制到另一块 Region 上,并进行压缩,这样就解决了 CMS 的内存碎片的问题。
我们知道,不管是 CMS 还是 G1,里面最关键的就是并发标记(它使得垃圾收集线程和工作线程可以同时运行)
而并发标记的关键点就是 三色标记算法 !!!
三色标记的概念理解起来很简单,就是记录自己以及孩子是否被标记。
不过 三色标记的问题在于:
由于标记是和用户线程并发执行,因此可能出现漏标现象。
现在有两种选择:
这时候你可以自己想一想,用哪一种方式好。
——————————————— 华丽的分割线 ————————————————
虽然说两种方法可以达到同一种效果,但是,
所以我们可以很确定地选择第二种,因为我们的 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 的优秀人员能够去排查其中的问题,找到解决的思路与办法。
共勉。