深入浅出 Java 虚拟机之垃圾回收
第05讲:大厂面试题:得心应手应对OOM的疑难杂症
在前面几个课时中,我们不止一次提到了堆(heap),堆是一个巨大的对象池。在这个对象池中管理着数量巨大的对象实例。
而池中对象的引用层次,有的是很深的。一个被频繁调用的接口,每秒生成对象的速度,也是非常可观的。对象之间的关系,形成了一张巨大的网。虽然 Java 一直在营造一种无限内存的氛围,但对象不能只增不减,所以需要垃圾回收。
那 JVM 是如何判断哪些对象应该被回收?哪些应该被保持呢?
在古代,刑罚中有诛九族一说。指的是有些人犯大事时,皇上杀一人不足以平复内心的愤怒时,会对亲朋好友产生连带责任。诛九族时首先需要追溯到一个共同的祖先,再往下细数连坐。堆上的垃圾回收也有同样的思路。我们接下来就具体分析 JVM 中是如何进行垃圾回收的。
JVM 的 GC 动作,是不受程序控制的,它会在满足条件的时候,自动触发。
在发生 GC 的时候,一个对象,JVM 总能够找到引用它的祖先。找到最后,如果发现这个祖先已经名存实亡了,它们都会被清理掉。而能够躲过垃圾回收的那些祖先,比较特殊,它们的名字就叫作 GC Roots。
从 GC Roots 向下追溯、搜索,会产生一个叫作 Reference Chain 的链条。当一个对象不能和任何一个 GC Root 产生关系时,就会被无情的诛杀掉。
如图所示,Obj5、Obj6、Obj7,由于不能和 GC Root 产生关联,发生 GC 时,就会被摧毁。

垃圾回收就是围绕着 GC Roots 去做的。同时,它也是很多内存泄露的根源,因为其他引用根本没有这样的权利。
那么,什么样的对象,才会是 GC Root 呢?这不在于它是什么样的对象,而在于它所处的位置。
GC Roots 有哪些
GC Roots 是一组必须活跃的引用。用通俗的话来说,就是程序接下来通过直接引用或者间接引用,能够访问到的潜在被使用的对象。
GC Roots 包括:
- Java 线程中,当前所有正在被调用的方法的引用类型参数、局部变量、临时值等。也就是与我们栈帧相关的各种引用。
 - 所有当前被加载的 Java 类。
 - Java 类的引用类型静态变量。
 - 运行时常量池里的引用类型常量(String 或 Class 类型)。
 - JVM 内部数据结构的一些引用,比如 sun.jvm.hotspot.memory.Universe 类。
 - 用于同步的监控对象,比如调用了对象的 wait() 方法。
 - JNI handles,包括 global handles 和 local handles。
 
这些 GC Roots 大体可以分为三大类,下面这种说法更加好记一些:
- 活动线程相关的各种引用。
 - 类的静态变量的引用。
 - JNI 引用。
 

有两个注意点:
- 我们这里说的是活跃的引用,而不是对象,对象是不能作为 GC Roots 的。
 - GC 过程是找出所有活对象,并把其余空间认定为“无用”;而不是找出所有死掉的对象,并回收它们占用的空间。所以,哪怕 JVM 的堆非常的大,基于 tracing 的 GC 方式,回收速度也会非常快。
 
引用级别
接下来的一道面试题就有意思多了:能够找到 Reference Chain 的对象,就一定会存活么?
我在面试的时候,经常会问这些问题,比如“弱引用有什么用处”?令我感到奇怪的是,即使是一些工作多年的 Java 工程师,对待这个问题也是一知半解,错失了很多机会。
对象对于另外一个对象的引用,要看关系牢靠不牢靠,可能在链条的其中一环,就断掉了。

根据发生 GC 时,这条链条的表现,可以对这个引用关系进行更加细致的划分。
它们的关系,可以分为强引用、软引用、弱引用、虚引用等。
强引用 Strong references
当内存空间不足,系统撑不住了,JVM 就会抛出 OutOfMemoryError 错误。即使程序会异常终止,这种对象也不会被回收。这种引用属于最普通最强硬的一种存在,只有在和 GC Roots 断绝关系时,才会被消灭掉。
这种引用,你每天的编码都在用。例如:new 一个普通的对象。
Object obj = new Object()
这种方式可能是有问题的。假如你的系统被大量用户(User)访问,你需要记录这个 User 访问的时间。可惜的是,User 对象里并没有这个字段,所以我们决定将这些信息额外开辟一个空间进行存放。
static Map<User,Long> userVisitMap = new HashMap<>();
...
userVisitMap.put(user, time);
当你用完了 User 对象,其实你是期望它被回收掉的。但是,由于它被 userVisitMap 引用,我们没有其他手段 remove 掉它。这个时候,就发生了内存泄漏(memory leak)。
这种情况还通常发生在一个没有设定上限的 Cache 系统,由于设置了不正确的引用方式,加上不正确的容量,很容易造成 OOM。
软引用 Soft references
软引用用于维护一些可有可无的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。
可以看到,这种特性非常适合用在缓存技术上。比如网页缓存、图片缓存等。
Guava 的 CacheBuilder,就提供了软引用和弱引用的设置方式。在这种场景中,软引用比强引用安全的多。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java 虚拟机就会把这个软引用加入到与之关联的引用队列中。
我们可以看一下它的代码。软引用需要显式的声明,使用泛型来实现。
// 伪代码
Object object = new Object();
SoftReference<Object> softRef = new SoftReference(object);
这里有一个相关的 JVM 参数。它的意思是:每 MB 堆空闲空间中 SoftReference 的存活时间。这个值的默认时间是1秒(1000)。
-XX:SoftRefLRUPolicyMSPerMB=<N>
这里要特别说明的是,网络上一些流传的优化方法,即把这个值设置成 0,其实是错误的,这样容易引发故障,感兴趣的话你可以自行搜索一下。
这种比较偏门的优化手段,除非在你对其原理相当了解的情况下,才能设置一些比较特殊的值。比如 0 值,无限大等,这种值在 JVM 的设置中,最好不要发生。
弱引用 Weak references
弱引用对象相比较软引用,要更加无用一些,它拥有更短的生命周期。
当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。弱引用拥有更短的生命周期,在 Java 中,用 java.lang.ref.WeakReference 类来表示。
它的应用场景和软引用类似,可以在一些对内存更加敏感的系统里采用。它的使用方式类似于这段的代码:
// 伪代码
Object object = new Object();
WeakReference<Object> softRef = new WeakReference(object);
虚引用 Phantom References
这是一种形同虚设的引用,在现实场景中用的不是很多。虚引用必须和引用队列(ReferenceQueue)联合使用。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
实际上,虚引用的 get,总是返回 null。
Object  object = new Object();
ReferenceQueue queue = new ReferenceQueue();
// 虚引用,必须与一个引用队列关联
PhantomReference pr = new PhantomReference(object, queue);
虚引用主要用来跟踪对象被垃圾回收的活动。
当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象之前,把这个虚引用加入到与之关联的引用队列中。
程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
下面的方法,就是一个用于监控 GC 发生的例子。
private static void startMonitoring(ReferenceQueue<MyObject> referenceQueue, Reference<MyObject> ref) {ExecutorService ex = Executors.newSingleThreadExecutor();ex.execute(() -> {while (referenceQueue.poll()!=ref) {//don't hang foreverif(finishFlag){break;}}System.out.println("-- ref gc'ed --");});ex.shutdown();
}
基于虚引用,有一个更加优雅的实现方式,那就是 Java 9 以后新加入的 Cleaner,用来替代 Object 类的 finalizer 方法。
典型 OOM 场景
OOM 的全称是 Out Of Memory,那我们的内存区域有哪些会发生 OOM 呢?我们可以从内存区域划分图上,看一下彩色部分。
可以看到除了程序计数器,其他区域都有OOM溢出的可能。但是最常见的还是发生在堆上。
所以 OOM 到底是什么引起的呢?有几个原因:
- 内存的容量太小了,需要扩容,或者需要调整堆的空间。
 - 错误的引用方式,发生了内存泄漏。没有及时的切断与 GC Roots 的关系。比如线程池里的线程,在复用的情况下忘记清理 ThreadLocal 的内容。
 - 接口没有进行范围校验,外部传参超出范围。比如数据库查询时的每页条数等。
 - 对堆外内存无限制的使用。这种情况一旦发生更加严重,会造成操作系统内存耗尽。
 
典型的内存泄漏场景,原因在于对象没有及时的释放自己的引用。比如一个局部变量,被外部的静态集合引用。

你在平常写代码时,一定要注意这种情况,千万不要为了方便把对象到处引用。即使引用了,也要在合适时机进行手动清理。关于这部分的问题根源排查,我们将在实践课程中详细介绍。
小结
你可以注意到 GC Roots 的专业叫法,就是可达性分析法。另外,还有一种叫作引用计数法的方式,在判断对象的存活问题上,经常被提及。
因为有循环依赖的硬伤,现在主流的 JVM,没有一个是采用引用计数法来实现 GC 的,所以我们大体了解一下就可以。引用计数法是在对象头里维护一个 counter 计数器,被引用一次数量 +1,引用失效记数 -1。计数器为 0 时,就被认为无效。你现在可以忘掉引用计数的方式了。
本课时,我们详细介绍了 GC Roots 都包含哪些内容。HostSpot 采用 tracing 的方式进行 GC,内存回收的速度与处于 living 状态的对象数量有关。
这部分涉及的内容较多,如果面试被问到,你可以采用白话版的方式进行介绍,然后举例深入。
接下来,我们了解到四种不同强度的引用类型,尤其是软引用和虚引用,在平常工作中使用还是比较多的。这里面最不常用的就是虚引用,但是它引申出来的 Cleaner 类,是用来替代 finalizer 方法的,这是一个比较重要的知识点。
本课时最后讨论了几种典型的 OOM 场景,你可能现在对其概念比较模糊。接下来的课时,我们将详细介绍几个常见的垃圾回收算法,然后对这些 OOM 的场景逐个击破。
第06讲:深入剖析:垃圾回收你真的了解吗?(上)
本课时我们重点剖析 JVM 的垃圾回收机制。关于 JVM 垃圾回收机制面试中主要涉及这三个考题:
- JVM 中有哪些垃圾回收算法?它们各自有什么优劣?
 - CMS 垃圾回收器是怎么工作的?有哪些阶段?
 - 服务卡顿的元凶到底是谁?
 
虽然 Java 不用“手动管理”内存回收,代码写起来很顺畅。但是你有没有想过,这些内存是怎么被回收的?
其实,JVM 是有专门的线程在做这件事情。当我们的内存空间达到一定条件时,会自动触发。这个过程就叫作 GC,负责 GC 的组件,就叫作垃圾回收器。
JVM 规范并没有规定垃圾回收器怎么实现,它只需要保证不要把正在使用的对象给回收掉就可以。在现在的服务器环境中,经常被使用的垃圾回收器有 CMS 和 G1,但 JVM 还有其他几个常见的垃圾回收器。
按照语义上的意思,垃圾回收,首先就需要找到这些垃圾,然后回收掉。但是 GC 过程正好相反,它是先找到活跃的对象,然后把其他不活跃的对象判定为垃圾,然后删除。所以垃圾回收只与活跃的对象有关,和堆的大小无关。这个概念是我们一直在强调的,你一定要牢记。
本课时将首先介绍几种非常重要的回收算法,然后着重介绍分代垃圾回收的内存划分和 GC 过程,最后介绍当前 JVM 中的几种常见垃圾回收器。
这部分内容比较多,也比较细。为了知识的连贯性,这里我直接将它们放在一个课时。篇幅有点长,你一定要有耐心学完,也希望你可以对 JVM 的了解上一个档次。
为什么这部分这么重要呢?是因为几乎所有的垃圾回收器,都是在这些基本思想上演化出来的,如果你对此不熟悉,那么我们后面讲解 CMS、G1、ZGC 的时候,就会有诸多障碍。这将直接影响到我们对实践课的理解。
标记(Mark)
垃圾回收的第一步,就是找出活跃的对象。我们反复强调 GC 过程是逆向的。
我们在前面的课时谈到 GC Roots。根据 GC Roots 遍历所有的可达对象,这个过程,就叫作标记。

如图所示,圆圈代表的是对象。绿色的代表 GC Roots,红色的代表可以追溯到的对象。可以看到标记之后,仍然有多个灰色的圆圈,它们都是被回收的对象。
清除(Sweep)
清除阶段就是把未被标记的对象回收掉。

但是这种简单的清除方式,有一个明显的弊端,那就是碎片问题。
比如我申请了 1k、2k、3k、4k、5k 的内存。

由于某种原因 ,2k 和 4k 的内存,我不再使用,就需要交给垃圾回收器回收。

这个时候,我应该有足足 6k 的空闲空间。接下来,我打算申请另外一个 5k 的空间,结果系统告诉我内存不足了。系统运行时间越长,这种碎片就越多。
在很久之前使用 Windows 系统时,有一个非常有用的功能,就是内存整理和磁盘整理,运行之后有可能会显著提高系统性能。这个出发点是一样的。
复制(Copy)
解决碎片问题没有银弹,只有老老实实的进行内存整理。
有一个比较好的思路可以完成这个整理过程,就是提供一个对等的内存空间,将存活的对象复制过去,然后清除原内存空间。
在程序设计中,一般遇到扩缩容或者碎片整理问题时,复制算法都是非常有效的。比如:HashMap 的扩容也是使用同样的思路,Redis 的 rehash 也是类似的。
整个过程如图所示:

这种方式看似非常完美的,解决了碎片问题。但是,它的弊端也非常明显。它浪费了几乎一半的内存空间来做这个事情,如果资源本来就很有限,这就是一种无法容忍的浪费。
整理(Compact)
其实,不用分配一个对等的额外空间,也是可以完成内存的整理工作。
你可以把内存想象成一个非常大的数组,根据随机的 index 删除了一些数据。那么对整个数组的清理,其实是不需要另外一个数组来进行支持的,使用程序就可以实现。
它的主要思路,就是移动所有存活的对象,且按照内存地址顺序依次排列,然后将末端内存地址以后的内存全部回收。

我们可以用一个理想的算法来看一下这个过程。
last = 0
for(i=0; i<mems.length; i++) {if(mems[i] != null) {mems[last++] = mems[i]changeReference(mems[last])}
}
clear(mems,last,mems.length)
但是需要注意,这只是一个理想状态。对象的引用关系一般都是非常复杂的,我们这里不对具体的算法进行描述。你只需要了解,从效率上来说,一般整理算法是要低于复制算法的。
分代
我们简要介绍了一些常见的内存回收算法,目前,JVM 的垃圾回收器,都是对几种朴素算法的发扬光大。简单看一下它们的特点:
- 复制算法(Copy)
 
复制算法是所有算法里面效率最高的,缺点是会造成一定的空间浪费。
- 标记-清除(Mark-Sweep)
 
效率一般,缺点是会造成内存碎片问题。
- 标记-整理(Mark-Compact)
 
效率比前两者要差,但没有空间浪费,也消除了内存碎片问题。
所以,没有最优的算法,只有最合适的算法。
JVM 是计算节点,而不是存储节点。最理想的情况,就是对象在用完之后,它的生命周期立马就结束了。而那些被频繁访问的资源,我们希望它能够常驻在内存里。
研究表明,大部分对象,可以分为两类:
- 大部分对象的生命周期都很短;
 - 其他对象则很可能会存活很长时间。
 
大部分死的快,其他的活的长。这个假设我们称之为弱代假设(weak generational hypothesis)。
接下来划重点。

从图中可以看到,大部分对象是朝生夕灭的,其他的则活的很久。
现在的垃圾回收器,都会在物理上或者逻辑上,把这两类对象进行区分。我们把死的快的对象所占的区域,叫作年轻代(Young generation)。把其他活的长的对象所占的区域,叫作老年代(Old generation)。
老年代在有些地方也会叫作 Tenured Generation,你在看到时明白它的意思就可以了。

年轻代
年轻代使用的垃圾回收算法是复制算法。因为年轻代发生 GC 后,只会有非常少的对象存活,复制这部分对象是非常高效的。
我们前面也了解到复制算法会造成一定的空间浪费,所以年轻代中间也会分很多区域。

如图所示,年轻代分为:一个伊甸园空间(Eden ),两个幸存者空间(Survivor )。
当年轻代中的 Eden 区分配满的时候,就会触发年轻代的 GC(Minor GC)。具体过程如下:
- 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区(以下简称from);
 - Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理。存活的对象会被复制到 to 区;接下来,只需要清空 from 区就可以了。
 
所以在这个过程中,总会有一个 Survivor 分区是空置的。Eden、from、to 的默认比例是 8:1:1,所以只会造成 10% 的空间浪费。
这个比例,是由参数 -XX:SurvivorRatio 进行配置的(默认为 8)。
一般情况下,我们只需要了解到这一层面就 OK 了。但是在平常的面试中,还有一个点会经常提到,虽然频率不太高,它就是 TLAB,我们在这里也简单介绍一下。
TLAB 的全称是 Thread Local Allocation Buffer,JVM 默认给每个线程开辟一个 buffer 区域,用来加速对象分配。这个 buffer 就放在 Eden 区中。
这个道理和 Java 语言中的 ThreadLocal 类似,避免了对公共区的操作,以及一些锁竞争。

对象的分配优先在 TLAB上 分配,但 TLAB 通常都很小,所以对象相对比较大的时候,会在 Eden 区的共享区域进行分配。
TLAB 是一种优化技术,类似的优化还有对象的栈上分配(这可以引出逃逸分析的话题,默认开启)。这属于非常细节的优化,不做过多介绍,但偶尔面试也会被问到。
老年代
老年代一般使用“标记-清除”、“标记-整理”算法,因为老年代的对象存活率一般是比较高的,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式。
那么,对象是怎么进入老年代的呢?有多种途径。
(1)提升(Promotion)
如果对象够老,会通过“提升”进入老年代。
关于对象老不老,是通过它的年龄(age)来判断的。每当发生一次 Minor GC,存活下来的对象年龄都会加 1。直到达到一定的阈值,就会把这些“老顽固”给提升到老年代。
这些对象如果变的不可达,直到老年代发生 GC 的时候,才会被清理掉。
这个阈值,可以通过参数 ‐XX:+MaxTenuringThreshold 进行配置,最大值是 15,因为它是用 4bit 存储的(所以网络上那些要把这个值调的很大的文章,是没有什么根据的)。
(2)分配担保
看一下年轻代的图,每次存活的对象,都会放入其中一个幸存区,这个区域默认的比例是 10%。但是我们无法保证每次存活的对象都小于 10%,当 Survivor 空间不够,就需要依赖其他内存(指老年代)进行分配担保。这个时候,对象也会直接在老年代上分配。
(3)大对象直接在老年代分配
超出某个大小的对象将直接在老年代分配。这个值是通过参数 -XX:PretenureSizeThreshold 进行配置的。默认为 0,意思是全部首选 Eden 区进行分配。
(4)动态对象年龄判定
有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。比如,如果幸存区中相同年龄对象大小的和,大于幸存区的一半,大于或等于 age 的对象将会直接进入老年代。
这些动态判定一般不受外部控制,我们知道有这么回事就可以了。通过下图可以看一下一个对象的分配逻辑。

卡片标记(card marking)
你可以看到,对象的引用关系是一个巨大的网状。有的对象可能在 Eden 区,有的可能在老年代,那么这种跨代的引用是如何处理的呢?由于 Minor GC 是单独发生的,如果一个老年代的对象引用了它,如何确保能够让年轻代的对象存活呢?
对于是、否的判断,我们通常都会用 Bitmap(位图)和布隆过滤器来加快搜索的速度。如果你不知道这个概念就需要课后补补课了。
JVM 也是用了类似的方法。其实,老年代是被分成众多的卡页(card page)的(一般数量是 2 的次幂)。
卡表(Card Table)就是用于标记卡页状态的一个集合,每个卡表项对应一个卡页。
如果年轻代有对象分配,而且老年代有对象指向这个新对象, 那么这个老年代对象所对应内存的卡页,就会标识为 dirty,卡表只需要非常小的存储空间就可以保留这些状态。
垃圾回收时,就可以先读这个卡表,进行快速判断。
HotSpot 垃圾回收器
接下来介绍 HotSpot 的几个垃圾回收器,每种回收器都有各自的特点。我们在平常的 GC 优化时,一定要搞清楚现在用的是哪种垃圾回收器。
在此之前,我们把上面的分代垃圾回收整理成一张大图,在介绍下面的收集器时,你可以对应一下它们的位置。

年轻代垃圾回收器
(1)Serial 垃圾收集器
处理 GC 的只有一条线程,并且在垃圾回收的过程中暂停一切用户线程。
这可以说是最简单的垃圾回收器,但千万别以为它没有用武之地。因为简单,所以高效,它通常用在客户端应用上。因为客户端应用不会频繁创建很多对象,用户也不会感觉出明显的卡顿。相反,它使用的资源更少,也更轻量级。
(2)ParNew 垃圾收集器
ParNew 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。清理过程依然要停止用户线程。
ParNew 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。
(3)Parallel Scavenge 垃圾收集器
另一个多线程版本的垃圾回收器。它与 ParNew 的主要区别是:
- Parallel Scavenge:追求 CPU 吞吐量,能够在较短时间内完成指定任务,适合没有交互的后台计算。弱交互强计算。
 - ParNew:追求降低用户停顿时间,适合交互式应用。强交互弱计算。
 
老年代垃圾收集器
(1)Serial Old 垃圾收集器
与年轻代的 Serial 垃圾收集器对应,都是单线程版本,同样适合客户端使用。
年轻代的 Serial,使用复制算法。
老年代的 Old Serial,使用标记-整理算法。
(2)Parallel Old
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
(3)CMS 垃圾收集器
CMS(Concurrent Mark Sweep)收集器是以获取最短 GC 停顿时间为目标的收集器,它在垃圾收集时使得用户线程和 GC 线程能够并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。我们会在后面的课时详细介绍它。
长期来看,CMS 垃圾回收器,是要被 G1 等垃圾回收器替换掉的。在 Java8 之后,使用它将会抛出一个警告。
Java HotSpot™ 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.
配置参数
除了上面几个垃圾回收器,我们还有 G1、ZGC 等更加高级的垃圾回收器,它们都有专门的配置参数来使其生效。
通过 -XX:+PrintCommandLineFlags 参数,可以查看当前 Java 版本默认使用的垃圾回收器。你可以看下我的系统中 Java13 默认的收集器就是 G1。
java -XX:+PrintCommandLineFlags -version
-XX:G1ConcRefinementThreads=4 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
java version “13.0.1” 2019-10-15
Java™ SE Runtime Environment (build 13.0.1+9)
Java HotSpot™ 64-Bit Server VM (build 13.0.1+9, mixed mode, sharing)
以下是一些配置参数:
- -XX:+UseSerialGC 年轻代和老年代都用串行收集器
 - -XX:+UseParNewGC 年轻代使用 ParNew,老年代使用 Serial Old
 - -XX:+UseParallelGC 年轻代使用 ParallerGC,老年代使用 Serial Old
 - -XX:+UseParallelOldGC 新生代和老年代都使用并行收集器
 - -XX:+UseConcMarkSweepGC,表示年轻代使用 ParNew,老年代的用 CMS
 - -XX:+UseG1GC 使用 G1垃圾回收器
 - -XX:+UseZGC 使用 ZGC 垃圾回收器
 
为了让你有个更好的印象,请看下图。它们的关系还是比较复杂的。尤其注意 -XX:+UseParNewGC 这个参数,已经在 Java9 中就被抛弃了。很多程序(比如 ES)会报这个错误,不要感到奇怪。

有这么多垃圾回收器和参数,那我们到底用什么?在什么地方优化呢?
目前,虽然 Java 的版本比较高,但是使用最多的还是 Java8。从 Java8 升级到高版本的 Java 体系,是有一定成本的,所以 CMS 垃圾回收器还会持续一段时间。
线上使用最多的垃圾回收器,就有 CMS 和 G1,以及 Java8 默认的 Parallel Scavenge。
- CMS 的设置参数:-XX:+UseConcMarkSweepGC。
 - Java8 的默认参数:-XX:+UseParallelGC。
 - Java13 的默认参数:-XX:+UseG1GC。
 
我们的实战练习的课时中,就集中会使用这几个参数。
STW
你有没有想过,如果在垃圾回收的时候(不管是标记还是整理复制),又有新的对象进入怎么办?
为了保证程序不会乱套,最好的办法就是暂停用户的一切线程。也就是在这段时间,你是不能 new 对象的,只能等待。表现在 JVM 上就是短暂的卡顿,什么都干不了。这个头疼的现象,就叫作 Stop the world。简称 STW。
标记阶段,大多数是要 STW 的。如果不暂停用户进程,在标记对象的时候,有可能有其他用户线程会产生一些新的对象和引用,造成混乱。
现在的垃圾回收器,都会尽量去减少这个过程。但即使是最先进的 ZGC,也会有短暂的 STW 过程。我们要做的就是在现有基础设施上,尽量减少 GC 停顿。
你可能对 STW 的影响没有什么概念,我举个例子来说明下。
某个高并发服务的峰值流量是 10 万次/秒,后面有 10 台负载均衡的机器,那么每台机器平均下来需要 1w/s。假如某台机器在这段时间内发生了 STW,持续了 1 秒,那么本来需要 10ms 就可以返回的 1 万个请求,需要至少等待 1 秒钟。

在用户那里的表现,就是系统发生了卡顿。如果我们的 GC 非常的频繁,这种卡顿就会特别的明显,严重影响用户体验。
虽然说 Java 为我们提供了非常棒的自动内存管理机制,但也不能滥用,因为它是有 STW 硬伤的。
小结
本课时的内容很多。由于篇幅有限,我们仅介绍了最重要的点,要是深挖下去,估计一本书都写不完。
归根结底,各色的垃圾回收器就是为了解决头疼的 STW 问题,让 GC 时间更短,停顿更小,吞吐量更大。
现在的回收器,基于弱代假设,大多是分代回收的理念。针对年轻代和老年代,有多种不同的垃圾回收算法,有些可以组合使用。
我们尤其讲解了年轻代的垃圾回收。
- 年轻代是 GC 的重灾区,大部分对象活不到老年代;
 - 面试经常问,都是些非常朴素的原理;
 - 为我们后面对 G1 和 ZGC 的介绍打下基础。
 
我们也接触了大量的名词。让我们来总结一下:
算法
- Mark
 - Sweep
 - Copy
 - Compact
 
分代
- Young generation
 - Survivor
 - Eden
 - Old generation | Tenured Generation
 - GC
- Minor GC
 - Major GC
 
 
名词
- weak generational hypothesis
 - 分配担保
 - 提升
 - 卡片标记
 - STW
 
文中图片关于 Eden、from、to 区的划分以及堆的划分,是很多面试官非常喜欢问的。但是有些面试官的问题非常陈旧,因为 JVM 的更新迭代有点快,你不要去反驳。有些痛点是需要实践才能体验到,心平气和的讲解这些变化,会让你在面试中掌握主动地位。
第06讲:深入剖析:垃圾回收你真的了解吗?(下)
由于上一课时篇幅比较多,我们在这一课时重点讲解上一课时中提到的 CMS 垃圾回收器,让你可以更好的理解垃圾回收的过程。
在这里首先给你介绍几个概念:
- Minor GC:发生在年轻代的 GC。
 - Major GC:发生在老年代的 GC。
 - Full GC:全堆垃圾回收。比如 Metaspace 区引起年轻代和老年代的回收。
 
理解了这三个概念,我们再往下看。
CMS 的全称是 Mostly Concurrent Mark and Sweep Garbage Collector(主要并发标记清除垃圾收集器),它在年轻代使用复制算法,而对老年代使用标记-清除算法。你可以看到,在老年代阶段,比起 Mark-Sweep,它多了一个并发字样。
CMS 的设计目标,是避免在老年代 GC 时出现长时间的卡顿(但它并不是一个老年代回收器)。如果你不希望有长时间的停顿,同时你的 CPU 资源也比较丰富,使用 CMS 是比较合适的。
CMS 使用的是 Sweep 而不是 Compact,所以它的主要问题是碎片化。随着 JVM 的长时间运行,碎片化会越来越严重,只有通过 Full GC 才能完成整理。
为什么 CMS 能够获得更小的停顿时间呢?主要是因为它把最耗时的一些操作,做成了和应用线程并行。接下来我们简要看一下这个过程。
CMS 回收过程
初始标记(Initial Mark)
初始标记阶段,只标记直接关联 GC root 的对象,不用向下追溯。因为最耗时的就在 tracing 阶段,这样就极大地缩短了初始标记时间。
这个过程是 STW 的,但由于只是标记第一层,所以速度是很快的。

注意,这里除了要标记相关的 GC Roots 之外,还要标记年轻代中对象的引用,这也是 CMS 老年代回收,依然要扫描新生代的原因。
并发标记(Concurrent Mark)
在初始标记的基础上,进行并发标记。这一步骤主要是 tracinng 的过程,用于标记所有可达的对象。
这个过程会持续比较长的时间,但却可以和用户线程并行。在这个阶段的执行过程中,可能会产生很多变化:
- 有些对象,从新生代晋升到了老年代;
 - 有些对象,直接分配到了老年代;
 - 老年代或者新生代的对象引用发生了变化。
 

还记得我们在上一课时提到的卡片标记么?在这个阶段受到影响的老年代对象所对应的卡页,会被标记为 dirty,用于后续重新标记阶段的扫描。
并发预清理(Concurrent Preclean)
并发预清理也是不需要 STW 的,目的是为了让重新标记阶段的 STW 尽可能短。这个时候,老年代中被标记为 dirty 的卡页中的对象,就会被重新标记,然后清除掉 dirty 的状态。
由于这个阶段也是可以并发的,在执行过程中引用关系依然会发生一些变化。我们可以假定这个清理动作是第一次清理。
所以重新标记阶段,有可能还会有处于 dirty 状态的卡页。
并发可取消的预清理(Concurrent Abortable Preclean)
因为重新标记是需要 STW 的,所以会有很多次预清理动作。并发可取消的预清理,顾名思义,在满足某些条件的时候,可以终止,比如迭代次数、有用工作量、消耗的系统时间等。
这个阶段是可选的。换句话说,这个阶段是“并发预清理”阶段的一种优化。
这个阶段的第一个意图,是避免回扫年轻代的大量对象;另外一个意图,就是当满足最终标记的条件时,自动退出。
我们在前面说过,标记动作是需要扫描年轻代的。如果年轻代的对象太多,肯定会严重影响标记的时间。如果在此之前能够进行一次 Minor GC,情况会不会变得好了许多?
CMS 提供了参数 CMSScavengeBeforeRemark,可以在进入重新标记之前强制进行一次 Minor GC。
但请你记住一件事情,GC 的停顿是不分什么年轻代老年代的。设置了上面的参数,可能会在一个比较长的 Minor GC 之后,紧跟着一个 CMS 的 Remark,它们都是 STW 的。
这部分有非常多的配置参数。但是一般都不会去改动。
最终标记(Final Remark)
通常 CMS 会尝试在年轻代尽可能空的情况下运行 Final Remark 阶段,以免接连多次发生 STW 事件。
这是 CMS 垃圾回收阶段的第二次 STW 阶段,目标是完成老年代中所有存活对象的标记。我们前面多轮的 preclean 阶段,一直在和应用线程玩追赶游戏,有可能跟不上引用的变化速度。本轮的标记动作就需要 STW 来处理这些情况。
如果预处理阶段做的不够好,会显著增加本阶段的 STW 时间。你可以看到,CMS 垃圾回收器把回收过程分了多个部分,而影响最大的不是 STW 阶段本身,而是它之前的预处理动作。
并发清除(Concurrent Sweep)
此阶段用户线程被重新激活,目标是删掉不可达的对象,并回收它们的空间。
由于 CMS 并发清理阶段用户线程还在运行中,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次 GC 中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。

并发重置(Concurrent Reset)
此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。
内存碎片
由于 CMS 在执行过程中,用户线程还需要运行,那就需要保证有充足的内存空间供用户使用。如果等到老年代空间快满了,再开启这个回收过程,用户线程可能会产生“Concurrent Mode Failure”的错误,这时会临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了(STW)。
这部分空间预留,一般在 30% 左右即可,那么能用的大概只有 70%。参数 -XX:CMSInitiatingOccupancyFraction 用来配置这个比例(记得要首先开启参数UseCMSInitiatingOccupancyOnly)。也就是说,当老年代的使用率达到 70%,就会触发 GC 了。如果你的系统老年代增长不是太快,可以调高这个参数,降低内存回收的次数。
其实,这个比率非常不好设置。一般在堆大小小于 2GB 的时候,都不会考虑 CMS 垃圾回收器。
另外,CMS 对老年代回收的时候,并没有内存的整理阶段。这就造成程序在长时间运行之后,碎片太多。如果你申请一个稍大的对象,就会引起分配失败。
CMS 提供了两个参数来解决这个问题:
(1) UseCMSCompactAtFullCollection(默认开启),表示在要进行 Full GC 的时候,进行内存碎片整理。内存整理的过程是无法并发的,所以停顿时间会变长。
(2)CMSFullGCsBeforeCompaction,每隔多少次不压缩的 Full GC 后,执行一次带压缩的 Full GC。默认值为 0,表示每次进入 Full GC 时都进行碎片整理。
所以,预留空间加上内存的碎片,使用 CMS 垃圾回收器的老年代,留给我们的空间就不是太多,这也是 CMS 的一个弱点。

小结
一般的,我们将 CMS 垃圾回收器分为四个阶段:
- 初始标记
 - 并发标记
 - 重新标记
 - 并发清理
 
我们总结一下 CMS 中都会有哪些停顿(STW):
- 初始标记,这部分的停顿时间较短;
 - Minor GC(可选),在预处理阶段对年轻代的回收,停顿由年轻代决定;
 - 重新标记,由于 preclaen 阶段的介入,这部分停顿也较短;
 - Serial-Old 收集老年代的停顿,主要发生在预留空间不足的情况下,时间会持续很长;
 - Full GC,永久代空间耗尽时的操作,由于会有整理阶段,持续时间较长。
 
在发生 GC 问题时,你一定要明确发生在哪个阶段,然后对症下药。gclog 通常能够非常详细的表现这个过程。
我们再来看一下 CMS 的 trade-off。
优势:
低延迟,尤其对于大堆来说。大部分垃圾回收过程并发执行。
劣势:
- 内存碎片问题。Full GC 的整理阶段,会造成较长时间的停顿。
 - 需要预留空间,用来分配收集阶段产生的“浮动垃圾”。
 - 使用更多的 CPU 资源,在应用运行的同时进行堆扫描。
 
CMS 是一种高度可配置的复杂算法,因此给 JDK 中的 GC 代码库带来了很多复杂性。由于 G1 和 ZGC 的产生,CMS 已经在被废弃的路上。但是,目前仍然有大部分应用是运行在 Java8 及以下的版本之上,针对它的优化,还是要持续很长一段时间。
第07讲:大厂面试题:有了G1还需要其他垃圾回收器吗?
本课时我们主要来看下这两个高频的面试考题:
- G1 的回收原理是什么?为什么 G1 比传统 GC 回收性能好?
 - 为什么 G1 如此完美仍然会有 ZGC?
 
我们在上一课时,简要的介绍了 CMS 垃圾回收器,下面我们简单回忆一下它的一个极端场景(而且是经常发生的场景)。
在发生 Minor GC 时,由于 Survivor 区已经放不下了,多出的对象只能提升(promotion)到老年代。但是此时老年代因为空间碎片的缘故,会发生 concurrent mode failure 的错误。这个时候,就需要降级为 Serail Old 垃圾回收器进行收集。这就是比 concurrent mode failure 更加严重的 promotion failed 问题。

一次简单的 Major GC,竟然能演化成耗时最长的 Full GC。最要命的是,这个停顿时间是不可预知的。
有没有一种办法,能够首先定义一个停顿时间,然后反向推算收集内容呢?就像是领导在年初制定 KPI 一样,分配的任务多就多干些,分配的任务少就少干点。
很久之前就有领导教导过我,如果你列的目标太大,看起来无法完成,不要怕。有一个叫作里程碑的名词,可以让我们以小跑的姿态,完成一次马拉松。
G1 的思路说起来也类似,它不要求每次都把垃圾清理的干干净净,它只是努力做它认为对的事情。
我们要求 G1,在任意 1 秒的时间内,停顿不得超过 10ms,这就是在给它制定 KPI。G1 会尽量达成这个目标,它能够推算出本次要收集的大体区域,以增量的方式完成收集。
这也是使用 G1 垃圾回收器不得不设置的一个参数:
-XX:MaxGCPauseMillis=10
为什么叫 G1
G1 的目标是用来干掉 CMS 的,它同样是一款软实时垃圾回收器。相比 CMS,G1 的使用更加人性化。比如,CMS 垃圾回收器的相关参数有 72 个,而 G1 的参数只有 26 个。
G1 的全称是 GarbageFirst GC,为了达成上面制定的 KPI,它和前面介绍的垃圾回收器,在对堆的划分上有一些不同。
其他的回收器,都是对某个年代的整体收集,收集时间上自然不好控制。G1 把堆切成了很多份,把每一份当作一个小目标,部分上目标很容易达成。
那又有一个面试题来啦:G1 有年轻代和老年代的区分吗?

如图所示,G1 也是有 Eden 区和 Survivor 区的概念的,只不过它们在内存上不是连续的,而是由一小份一小份组成的。
这一小份区域的大小是固定的,名字叫作小堆区(Region)。小堆区可以是 Eden 区,也可以是 Survivor 区,还可以是 Old 区。所以 G1 的年轻代和老年代的概念都是逻辑上的。
每一块 Region,大小都是一致的,它的数值是在 1M 到 32M 字节之间的一个 2 的幂值数。
但假如我的对象太大,一个 Region 放不下了怎么办?注意图中有一块面积很大的黄色区域,它的名字叫作 Humongous Region,大小超过 Region 50% 的对象,将会在这里分配。
Region 的大小,可以通过参数进行设置:
-XX:G1HeapRegionSize=M
那么,回收的时候,到底回收哪些小堆区呢?是随机的么?
这当然不是。事实上,垃圾最多的小堆区,会被优先收集。这就是 G1 名字的由来。
G1 的垃圾回收过程
在逻辑上,G1 分为年轻代和老年代,但它的年轻代和老年代比例,并不是那么“固定”,为了达到 MaxGCPauseMillis 所规定的效果,G1 会自动调整两者之间的比例。
如果你强行使用 -Xmn 或者 -XX:NewRatio 去设定它们的比例的话,我们给 G1 设定的这个目标将会失效。
G1 的回收过程主要分为 3 类:
(1)G1“年轻代”的垃圾回收,同样叫 Minor GC,这个过程和我们前面描述的类似,发生时机就是 Eden 区满的时候。
(2)老年代的垃圾收集,严格上来说其实不算是收集,它是一个“并发标记”的过程,顺便清理了一点点对象。
(3)真正的清理,发生在“混合模式”,它不止清理年轻代,还会将老年代的一部分区域进行清理。

在 GC 日志里,这个过程描述特别有意思,(1)的过程,叫作 [GC pause (G1 Evacuation Pause) (young),而(2)的过程,叫作 [GC pause (G1 Evacuation Pause) (mixed)。Evacuation 是转移的意思,和 Copy 的意思有点类似。
这三种模式之间的间隔也是不固定的。比如,1 次 Minor GC 后,发生了一次并发标记,接着发生了 9 次 Mixed GC。
RSet
RSet 是一个空间换时间的数据结构。
在第 6 课时中,我们提到过一个叫作卡表(Card Table)的数据结构,用来解决跨代引用的问题。RSet 的功能与此类似,它的全称是 Remembered Set,用于记录和维护 Region 之间的对象引用关系。
但 RSet 与 Card Table 有些不同的地方。Card Table 是一种 points-out(我引用了谁的对象)的结构。而 RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象),有点倒排索引的味道。
你可以把 RSet 理解成一个 Hash,key 是引用的 Region 地址,value 是引用它的对象的卡页集合。

有了这个数据结构,在回收某个 Region 的时候,就不必对整个堆内存的对象进行扫描了。它使得部分收集成为了可能。
对于年轻代的 Region,它的 RSet 只保存了来自老年代的引用,这是因为年轻代的回收是针对所有年轻代 Region 的,没必要画蛇添足。所以说年轻代 Region 的 RSet 有可能是空的。
而对于老年代的 Region 来说,它的 RSet 也只会保存老年代对它的引用。这是因为老年代回收之前,会先对年轻代进行回收。这时,Eden 区变空了,而在回收过程中会扫描 Survivor 分区,所以也没必要保存来自年轻代的引用。
RSet 通常会占用很大的空间,大约 5% 或者更高。不仅仅是空间方面,很多计算开销也是比较大的。
事实上,为了维护 RSet,程序运行的过程中,写入某个字段就会产生一个 post-write barrier 。为了减少这个开销,将内容放入 RSet 的过程是异步的,而且经过了很多的优化:Write Barrier 把脏卡信息存放到本地缓冲区(local buffer),有专门的 GC 线程负责收集,并将相关信息传给被引用 Region 的 RSet。
参数 -XX:G1ConcRefinementThreads 或者 -XX:ParallelGCThreads 可以控制这个异步的过程。如果并发优化线程跟不上缓冲区的速度,就会在用户进程上完成。
具体回收过程
G1 还有一个 CSet 的概念。这个就比较好理解了,它的全称是 Collection Set,即收集集合,保存一次 GC 中将执行垃圾回收的区间(Region)。GC 是在 CSet 中的所有存活数据(Live Data)都会被转移。
了解了上面的数据结构,我们再来简要看一下回收过程。
年轻代回收
年轻代回收是一个 STW 的过程,它的跨代引用使用 RSet 数据结构来追溯,会一次性回收掉年轻代的所有 Region。
JVM 启动时,G1 会先准备好 Eden 区,程序在运行过程中不断创建对象到 Eden 区,当所有的 Eden 区都满了,G1 会启动一次年轻代垃圾回收过程。

年轻代的收集包括下面的回收阶段:
(1)扫描根
根,可以看作是我们前面介绍的 GC Roots,加上 RSet 记录的其他 Region 的外部引用。
(2)更新 RS
处理 dirty card queue 中的卡页,更新 RSet。此阶段完成后,RSet 可以准确的反映老年代对所在的内存分段中对象的引用。可以看作是第一步的补充。
(3)处理 RS
识别被老年代对象指向的 Eden 中的对象,这些被指向的 Eden 中的对象被认为是存活的对象。
(4)复制对象
没错,收集算法依然使用的是 Copy 算法。
在这个阶段,对象树被遍历,Eden 区内存段中存活的对象会被复制到 Survivor 区中空的 Region。这个过程和其他垃圾回收算法一样,包括对象的年龄和晋升,无需做过多介绍。
(5)处理引用
处理 Soft、Weak、Phantom、Final、JNI Weak 等引用。结束收集。
它的大体示意图如下所示。

并发标记(Concurrent Marking)
当整个堆内存使用达到一定比例(默认是 45%),并发标记阶段就会被启动。这个比例也是可以调整的,通过参数 -XX:InitiatingHeapOccupancyPercent 进行配置。
Concurrent Marking 是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。这个过程和 CMS 垃圾回收器的回收过程非常类似,你可以类比 CMS 的回收过程看一下。具体标记过程如下:
(1)初始标记(Initial Mark)
这个过程共用了 Minor GC 的暂停,这是因为它们可以复用 root scan 操作。虽然是 STW 的,但是时间通常非常短。
(2)Root 区扫描(Root Region Scan)
(3)并发标记( Concurrent Mark)
这个阶段从 GC Roots 开始对 heap 中的对象标记,标记线程与应用程序线程并行执行,并且收集各个 Region 的存活对象信息。
(4)重新标记(Remaking)
和 CMS 类似,也是 STW 的。标记那些在并发标记阶段发生变化的对象。
(5)清理阶段(Cleanup)
这个过程不需要 STW。如果发现 Region 里全是垃圾,在这个阶段会立马被清除掉。不全是垃圾的 Region,并不会被立马处理,它会在 Mixed GC 阶段,进行收集。
了解 CMS 垃圾回收器后,上面这个过程就比较好理解。但是还有一个疑问需要稍微提一下。
如果在并发标记阶段,又有新的对象变化,该怎么办?
这是由算法 SATB 保证的。SATB 的全称是 Snapshot At The Beginning,它作用是保证在并发标记阶段的正确性。

这个快照是逻辑上的,主要是有几个指针,将 Region 分成个多个区段。如图所示,并发标记期间分配的对象,都会在 next TAMS 和 top 之间。
混合回收(Mixed GC)
能并发清理老年代中的整个整个的小堆区是一种最优情形。混合收集过程,不只清理年轻代,还会将一部分老年代区域也加入到 CSet 中。
通过 Concurrent Marking 阶段,我们已经统计了老年代的垃圾占比。在 Minor GC 之后,如果判断这个占比达到了某个阈值,下次就会触发 Mixed GC。这个阈值,由 -XX:G1HeapWastePercent 参数进行设置(默认是堆大小的 5%)。因为这种情况下, GC 会花费很多的时间但是回收到的内存却很少。所以这个参数也是可以调整 Mixed GC 的频率的。
还有参数 G1MixedGCCountTarget,用于控制一次并发标记之后,最多执行 Mixed GC 的次数。
ZGC
你有没有感觉,在系统切换到 G1 垃圾回收器之后,线上发生的严重 GC 问题已经非常少了?
这归功于 G1 的预测模型和它创新的分区模式。但预测模型也会有失效的时候,它并不是总如我们期望的那样运行,尤其是你给它定下一个苛刻的目标之后。
另外,如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个 Heap 的回收,那么 G1 要做的工作量就一点也不会比其他垃圾回收器少,而且因为本身算法复杂了,还可能比其他回收器要差。
所以垃圾回收器本身的优化和升级,从来都没有停止过。最新的 ZGC 垃圾回收器,就有 3 个令人振奋的 Flag:
- 停顿时间不会超过 10ms;
 - 停顿时间不会随着堆的增大而增大(不管多大的堆都能保持在 10ms 以下);
 - 可支持几百 M,甚至几 T 的堆大小(最大支持 4T)。
 

在 ZGC 中,连逻辑上的年轻代和老年代也去掉了,只分为一块块的 page,每次进行 GC 时,都会对 page 进行压缩操作,所以没有碎片问题。ZGC 还能感知 NUMA 架构,提高内存的访问速度。与传统的收集算法相比,ZGC 直接在对象的引用指针上做文章,用来标识对象的状态,所以它只能用在 64 位的机器上。
现在在线上使用 ZGC 的还非常少。即使是用,也只能在 Linux 平台上使用。等待它的普及,还需要一段时间。
小结
本课时,我们简要看了下 G1 垃圾回收器的回收过程,并着重看了一下底层的数据结构 RSet。基本思想很简单,但实现细节却特别多。这不是我们的重点,对 G1 详细过程感兴趣的,可以参考纸质书籍。我也会通过其他途径分享一些细节,你也可以关注拉勾教育公众号后进学习群与大家一起多多交流。
相对于 CMS,G1 有了更可靠的驾驭度。而且有 RSet 和 SATB 等算法的支撑,Remark 阶段更加高效。
G1 最重要的概念,其实就是 Region。它采用分而治之,部分收集的思想,尽力达到我们给它设定的停顿目标。
G1 的垃圾回收过程分为三种,其中,并发标记阶段,为更加复杂的 Mixed GC 阶段做足了准备。
以下是一个线上运行系统的 JVM 参数样例。这些参数,现在你都能看懂么?如果有问题可以在评论区讨论。
JAVA_OPTS="$JAVA_OPTS -XX:NewRatio=2 -XX:G1HeapRegionSize=8m -XX:MetaspaceSize
=256m -XX:MaxMetaspaceSize=256m -XX:MaxTenuringThreshold=10 -XX:+UseG1GC-XX:InitiatingHeapOccupancyPercent=45 -XX:MaxGCPauseMillis=200 -verbose:gc-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintReferenceGC-XX:+PrintAdaptiveSizePolicy -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=6-XX:GCLogFileSize=32m -Xloggc:./var/run/gc.log.$(date +%Y%m%d%H%M)-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./var/run/heap-dump.hprof-Dfile.encoding=UTF-8 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=${JMX_PORT:-0} -Dcom.sun.management.jmxremote.ssl=false-Dcom.sun.management.jmxremote.authenticate=false"
第08讲:案例实战:亿级流量高并发下如何进行估算和调优
本课时主要讲解如何在大流量高并发场景下进行估算和调优。
我们知道,垃圾回收器一般使用默认参数,就可以比较好的运行。但如果用错了某些参数,那么后果可能会比较严重,我不只一次看到有同学想要验证某个刚刚学到的优化参数,结果引起了线上 GC 的严重问题。
所以你的应用程序如果目前已经满足了需求,那就不要再随便动这些参数了。另外,优化代码获得的性能提升,远远大于参数调整所获得的性能提升,你不要纯粹为了调参数而走了弯路。
那么,GC 优化有没有可遵循的一些规则呢?这些“需求”又是指的什么?我们可以将目标归结为三点:
- 系统容量(Capacity)
 - 延迟(Latency)
 - 吞吐量(Throughput)
 
考量指标

系统容量
系统容量其实非常好理解。比如,领导要求你每个月的运维费用不能超过 x 万,那就决定了你的机器最多是 2C4G 的。
举个比较极端的例子。假如你的内存是无限大的,那么无论是存活对象,还是垃圾对象,都不需要额外的计算和回收,你只需要往里放就可以了。这样,就没有什么吞吐量和延迟的概念了。
但这毕竟是我们的一厢情愿。越是资源限制比较严格的系统,对它的优化就会越明显。通常在一个资源相对宽松的环境下优化的参数,平移到另外一个限制资源的环境下,并不是最优解。
吞吐量-延迟
接下来我们看一下吞吐量和延迟方面的概念。
假如你开了一个面包店,你的首要目标是卖出更多的面包,因为赚钱来说是最要紧的。
为了让客人更快买到面包,你引进了很多先进的设备,使得制作面包的间隔减少到 30 分钟,一批面包可以有 100 个。
工人师傅是拿工资的,并不想和你一样加班。按照一天 8 小时工作制,每天就可以制作 8x2x100=1600 个面包。
但是你很不满意,因为每天的客人都很多,需求大约是 2000 个面包。
你只好再引进更加先进的设备,这种设备可以一次做出 200 个面包,一天可以做 2000~3000 个面包,但是每运行一段时间就需要冷却一会儿。
原来每个客人最多等 30 分钟就可以拿到面包,现在有的客人需要等待 40 分钟。客人通常受不了这么长的等待时间,第二天就不来了。
考虑到我们的营业目标,就可以抽象出两个概念。
- 吞吐量,也就是每天制作的面包数量。
 - 延迟,也就是等待的时间,涉及影响顾客的满意度。
 

吞吐量大不代表响应能力高,吞吐量一般这么描述:在一个时间段内完成了多少个事务操作;在一个小时之内完成了多少批量操作。
响应能力是以最大的延迟时间来判断的,比如:一个桌面按钮对一个触发事件响应有多快;需要多长时间返回一个网页;查询一行 SQL 需要多长时间,等等。
这两个目标,在有限的资源下,通常不能够同时达到,我们需要做一些权衡。
选择垃圾回收器
接下来,再回顾一下前面介绍的垃圾回收器,简单看一下它们的应用场景。
- 如果你的堆大小不是很大(比如 100MB),选择串行收集器一般是效率最高的。参数:-XX:+UseSerialGC。
 - 如果你的应用运行在单核的机器上,或者你的虚拟机核数只有 1C,选择串行收集器依然是合适的,这时候启用一些并行收集器没有任何收益。参数:-XX:+UseSerialGC。
 - 如果你的应用是“吞吐量”优先的,并且对较长时间的停顿没有什么特别的要求。选择并行收集器是比较好的。参数:-XX:+UseParallelGC。
 - 如果你的应用对响应时间要求较高,想要较少的停顿。甚至 1 秒的停顿都会引起大量的请求失败,那么选择 G1、ZGC、CMS 都是合理的。虽然这些收集器的 GC 停顿通常都比较短,但它需要一些额外的资源去处理这些工作,通常吞吐量会低一些。参数:-XX:+UseConcMarkSweepGC、-XX:+UseG1GC、-XX:+UseZGC 等。
 
从上面这些出发点来看,我们平常的 Web 服务器,都是对响应性要求非常高的。选择性其实就集中在 CMS、G1、ZGC 上。
而对于某些定时任务,使用并行收集器,是一个比较好的选择。
大流量应用特点
这是一类对延迟非常敏感的系统。吞吐量一般可以通过堆机器解决。
如果一项业务有价值,客户很喜欢,那亿级流量很容易就能达到了。假如某个接口一天有 10 亿次请求,每秒的峰值大概也就 5~6 w/秒,虽然不算是很大,但也不算小。最直接的影响就是:可能你发个版,几万用户的请求就抖一抖。
一般达到这种量级的系统,承接请求的都不是一台服务器,接口都会要求快速响应,一般不会超过 100ms。
这种系统,一般都是社交、电商、游戏、支付场景等,要求的是短、平、快。长时间停顿会堆积海量的请求,所以在停顿发生的时候,表现会特别明显。我们要考量这些系统,有很多指标。
- 每秒处理的事务数量(TPS);
 - 平均响应时间(AVG);
 - TP 值,比如 TP90 代表有 90% 的请求响应时间小于 x 毫秒。
 
可以看出来,它和 JVM 的某些指标很像。
尤其是 TP 值,最能代表系统中到底有多少长尾请求,这部分请求才是影响系统稳定性的元凶。大多数情况下,GC 增加,长尾请求的数量也会增加。
我们的目标,就是减少这些停顿。本课时假定使用的是 CMS 垃圾回收器。
估算
在《编程珠玑》第七章里,将估算看作程序员的一项非常重要的技能。这是一种化繁为简的能力,不要求极度精确,但对问题的分析有着巨大的帮助。
拿一个简单的 Feed 业务来说。查询用户在社交网站上发送的帖子,还需要查询第一页的留言(大概是 15 条),它们共同组成了每次查询后的实体。
class Feed {private User user;private List<Comment> commentList;private String content;
}
这种类型的数据结构,一般返回体都比较大,大概会有几 KB 到几十 KB 不等。我们就可以对这些数据进行以大体估算。具体的数据来源可以看日志,也可以分析线上的请求。

这个接口每天有 10 亿次请求,假如每次请求的大小有 20KB(很容易达到),那么一天的流量就有 18TB 之巨。假如高峰请求 6w/s,我们部署了 10 台机器,那么每个 JVM 的流量就可以达到 120MB/s,这个速度算是比较快的了。
如果你实在不知道怎么去算这个数字,那就按照峰值的 2 倍进行准备,一般都是 OK 的。
调优
问题是这样的,我们的机器是 4C8GB 的,分配给了 JVM 10248GB/32= 5460MB 的空间。那么年轻代大小就有 5460MB/3=1820MB。进而可以推断出,Eden 区的大小约 1456MB,那么大约只需要 12 秒,就会发生一次 Minor GC。不仅如此,每隔半个小时,会发生一次 Major GC。
不管是年轻代还是老年代,这个 GC 频率都有点频繁了。
提醒一下,你可以算一下我们的 Survivor 区大小,大约是 182MB 左右,如果稍微有点流量偏移,或者流量突增,再或者和其他接口共用了 JVM,那么这个 Survivor 区就已经装不下 Minor GC 后的内容了。总有一部分超出的容量,需要老年代来补齐。这些垃圾信息就要保存更长时间,直到老年代空间不足。

我们发现,用户请求完这些信息之后,很快它们就会变成垃圾。所以每次 MinorGC 之后,剩下的对象都很少。 也就是说,我们的流量虽然很多,但大多数都在年轻代就销毁了。如果我们加大年轻代的大小,由于 GC 的时间受到活跃对象数的影响,回收时间并不会增加太多。 如果我们把一半空间给年轻代。也就是下面的配置:
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn2730M
重新估算一下,发现 Minor GC 的间隔,由 12 秒提高到了 18 秒。
线上观察:
[ParNew: 2292326K‐>243160K(2795520K), 0.1021743 secs]
3264966K‐>10880154K(1215800K), 0.1021417 secs]
[Times: user=0.52 sys=0.02, real=0.2 secs]
Minor GC 有所改善,但是并没有显著的提升。相比较而言,Major GC 的间隔却增加到了 3 小时,是一个非常大的性能优化。这就是在容量限制下的初步调优方案。
此种场景,我们可以更加激进一些,调大年轻代(顺便调大了幸存区),让对象在年轻代停留的时间更长一些,有更多的 buffer 空间。这样 Minor GC 间隔又可以提高到 23 秒。参数配置:
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn3460M
一切看起来很美好,但还是有一个瑕疵。
问题如下:由于每秒的请求都非常大,如果应用重启或者更新,流量瞬间打过来,JVM 还没预热完毕,这时候就会有大量的用户请求超时、失败。
为了解决这种问题,通常会逐步的把新发布的机器进行放量预热。比如第一秒 100 请求,第二秒 200 请求,第三秒 5000 请求。大型的应用都会有这个预热过程。

如图所示,负载均衡器负责服务的放量,server4 将在 6 秒之后流量正常流通。但是奇怪的是,每次重启大约 20 多秒以后,就会发生一次诡异的 Full GC。
注意是 Full GC,而不是老年代的 Major GC,也不是年轻代的 Minor GC。
事实上,经过观察,此时年轻代和老年代的空间还有很大一部分,那 Full GC 是怎么产生的呢?
一般,Full GC 都是在老年代空间不足的时候执行。但不要忘了,我们还有一个区域叫作 Metaspace,它的容量是没有上限的,但是每当它扩容时,就会发生 Full GC。
使用下面的命令可以看到它的默认值:
java -XX:+PrintFlagsFinal 2>&1 | grep Meta 默认值如下:
size_t MetaspaceSize = 21807104 {pd product} {default}
size_t MaxMetaspaceSize = 18446744073709547520 {product} {default}
可以看到 MetaspaceSize 的大小大约是 20MB。这个初始值太小了。
现在很多类库,包括 Spring,都会大量生成一些动态类,20MB 很容易就超了,我们可以试着调大这个数值。
按照经验,一般调整成 256MB 就足够了。同时,为了避免无限制使用造成操作系统内存溢出,我们同时设置它的上限。配置参数如下:
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn3460M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
经观察,启动后停顿消失。
这种方式通常是行之有效的,但也可以通过扩容机器内存或者扩容机器数量的办法,显著地降低 GC 频率。这些都是在估算容量后的优化手段。
我们把部分机器升级到 8C16GB 的机器,使用如下的参数:
-XX:+UseConcMarkSweepGC -Xmx10920M -Xms10920M -Xmn5460M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
相比较其他实例,系统运行的特别棒,系统平均 1 分钟左右发生一次 MinorGC,老年代观察了一天才发生 GC,响应水平明显提高。
这是一种非常简单粗暴的手段,但是有效。我们看到,对 JVM 的优化,不仅仅是优化参数本身。我们的目的是解决问题,寻求多种有用手段。
总结
其实,如果没有明显的内存泄漏问题和严重的性能问题,专门调优一些 JVM 参数是非常没有必要的,优化空间也比较小。 所以,我们一般优化的思路有一个重要的顺序:
- 程序优化,效果通常非常大;
 - 扩容,如果金钱的成本比较小,不要和自己过不去;
 - 参数调优,在成本、吞吐量、延迟之间找一个平衡点。
 
本课时主要是在第三点的基础上,一步一步地增加 GC 的间隔,达到更好的效果。
我们可以再加一些原则用以辅助完成优化。
- 一个长时间的压测是必要的,通常我们使用 JMeter 工具。
 - 如果线上有多个节点,可以把我们的优化在其中几个节点上生效。等优化真正有效果之后再全面推进。
 - 优化过程和目标之间可能是循环的,结果和目标不匹配,要推翻重来。
 

我们的业务场景是高并发的。对象诞生的快,死亡的也快,对年轻代的利用直接影响了整个堆的垃圾收集。
- 足够大的年轻代,会增加系统的吞吐,但不会增加 GC 的负担。
 - 容量足够的 Survivor 区,能够让对象尽可能的留在年轻代,减少对象的晋升,进而减少 Major GC。
 
我们还看到了一个元空间引起的 Full GC 的过程,这在高并发的场景下影响会格外突出,尤其是对于使用了大量动态类的应用来说。通过调大它的初始值,可以解决这个问题。
