> 《面试必备:GC 垃圾回收十大考点及图文详解》 Java 发展历史上出现过很多垃圾回收器,各有各的适应场景,很多网上的旧内容已经跟不上最新的变化。而且“Java 的内存布局以及 GC 原理”又是 Java 开发人员绕不开的话题,更是面试中常见的高频问题之一。 我们将详细介绍 Java 的内存布局,以及各种 GC 原理。这也就包括了经典面试考点:垃圾判断法、4 种 GC 算法、8 种垃圾回收器原理等内容。其中分代收集算法、ZGC 收集器我将重点介绍,最后会再加以实战,带你进行垃圾回收器练习。 学习之前,你可以看看以下十大考点,你是否能清晰作答? Question 1:JVM 运行时内存布局分为哪些区域?它们之间又是怎样的关系? Question 2:JVM 内存布局中,哪些区域会产生 OOM? Question 3:总体看 JVM 把内存划分为“栈(stack)”与“堆(heap)”两大类,为何要这样设计? Question 4:如果 A 引用 B,B 又引用 A(发生了循环引用问题),这 2 个对象是否能被 GC回收? Question 5:说一说常用的 GC 算法及其优缺点 Question 6:请以 Hotspot 为例,分析一下 GC 全过程(并指出其中 GC 算法的综合运用)。 Question 7:CMS 收集器工作的整个流程主要分为哪几个阶段? Question 8:G1 垃圾收集器的原理是什么? Question 9:G1 垃圾收集器的运行过程,分为哪几个阶段? Question 10:ZGC 在 G1 基础上做了哪些改进? 希望学习后,你能对这方面的知识不再陌生,有所收获。 ## JVM 运行时内存布局 ### Question 1:JVM 运行时内存布局分为哪些区域?它们之间又是怎样的关系? 按 Java 8 [虚拟机规范](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5)的原始表达:(jvm)Run-Time Data Areas,暂时翻译为“jvm 运行时内存布局”。从概念上大致分为以下 6 个(逻辑)区域,参考下图。 > 注:Method Area 中还有一个常量池区,图中未明确标出,所以图中就是 5 个区域。 ![1.png](https://s0.lgstatic.com/i/image6/M00/56/29/Cgp9HWEsoWmAcdmIAAFBWAdWemc383.png) 这 6 块区域按是否被线程共享,可以分为两大类:一类是每个线程所独享的(下图绿色部分),另一类是所有线程共享的(下图非绿色部分)。 ![2.jpg](https://s0.lgstatic.com/i/image6/M00/56/26/Cgp9HWEsl86AWeQWAAArQmSxWbY367.jpg) **分类一:是每个线程所独享的** 1. PC Register:也称为程序计数器, 记录每个线程当前执行的指令信。eg:当前执行到哪一条指令,下一条该取哪条指令。 2. JVM Stack:也称为虚拟机栈,记录每个栈帧(Frame)中的局部变量、方法返回地址等。注:这里出现了一个新名词“栈帧”,它的结构如下。 > 线程中每次有方法调用时,会创建 Frame,方法调用结束时 Frame 销毁。 > > ![3.jpg](https://s0.lgstatic.com/i/image6/M00/56/27/Cgp9HWEsmFmAQdtCAAAb3P-d7ws695.jpg) 3. Native Method Stack:本地(原生)方法栈,顾名思义就是调用操作系统原生本地方法时,所需要的内存区域。 上述 3 类区域,生命周期与 Thread 相同,即: 线程创建时,相应的区域分配内存; 线程销毁时,释放相应内存。 **分类二:是所有线程共享的** 1. Heap:即鼎鼎大名的堆内存区,也是 GC 垃圾回收的主站场,用于存放类的实例对象及 Arrays 实例等。 (注:Heap 被所有线程共享,严格来说并不严谨。事实上,由于 TLAB 的存在,为了防止并发对象分配时,多个对象分配到同一块内存。heap 中的 TLAB 区域,在分配时,是被线程独占写入的。) 2. Method Area:方法区,主要存放类结构、类成员定义、static 静态成员等。 3. Runtime Constant Pool:运行时常量池,比如字符串、int -128~127 范围的值等,它是 Method Area 中的一部分。 > Heap、Method Area 都是在虚拟机启动时创建,虚拟机退出时释放。 注:Method Area 区,虚拟机规范只是说必须要有,但是具体怎么实现(比如是否需要垃圾回收),交给具体的 JVM 实现去决定。逻辑上讲,它可视为 Heap 区的一部分,所以如果你看见类似下面的图,也不要觉得画错了。 ![4.jpg](https://s0.lgstatic.com/i/image6/M01/56/2F/CioPOWEsmOWAdpluAAA5IldyRXs168.jpg) ### Question 2:哪些区域会产生 OOM? 上述 6 个区域,除了 PC Register 区不会抛出 StackOverflowError 或 OutOfMemoryError,其它 5 个区域,当请求分配的内存不足时,均会抛出 OutOfMemoryError(即:OOM),其中 thread 独立的 JVM Stack 区及 Native Method Stack 区还会抛出 StackOverflowError。 **【堆外内存与堆内存】** 最后,还有一类不受 JVM 虚拟机管控的内存区,这里也提一下,即堆外内存。可以通过 Unsafe 和 NIO 包下的 DirectByteBuffer 来操作堆外内存。如下图,虽然堆外内存不受 JVM 管控,但是堆内存中会持有对它的引用,以便进行 GC。 ![5.jpg](https://s0.lgstatic.com/i/image6/M00/56/2F/CioPOWEsmWmAEapzAAALMHafOd4507.jpg) ### Question 3:总体来看,JVM 把内存划分为“栈(stack)”与“堆(heap)”两大类,为何要这样设计? 个人理解,程序运行时,内存中的信息大致分为两类: 跟程序执行逻辑相关的指令数据,这类数据通常不大,而且生命周期短; 跟对象实例相关的数据,这类数据可能会很大,而且可以被多个线程长时间内反复共用,比如字符串常量、缓存对象这类。 将这两类特点不同的数据分开管理,体现了软件设计上“模块隔离”的思想。好比我们通常会把后端 service 与前端 website 解耦类似,也更便于内存管理。 #### GC 垃圾回收原理 ##### 1.如何判断对象是垃圾? **经典判断方法 1:引用计数法** ![6.jpg](https://s0.lgstatic.com/i/image6/M01/56/27/Cgp9HWEsmeyANNc7AAA9QBsPYME826.jpg) 思路很简单,但是如果出现循环引用,即 A 引用 B,B 又引用 A,这种情况下就不好办了。所以 JVM 中使用了另一种称为“可达性分析”的判断方法。 **经典判断方法 2:可达性分析** ![7.jpg](https://s0.lgstatic.com/i/image6/M01/56/27/Cgp9HWEsmfyAEgFaAABA5PRUFiU013.jpg) ### Question 4:如果 A 引用 B,B 又引用 A(发生了循环引用问题),这 2 个对象是否能被 GC回收? 关键不是在于 A、B 之间是否有引用,而是 A、B 是否可以一直向上追溯到 GC Roots。如果与 GC Roots 没有关联,则会被回收;否则,将继续存活。 ![8.jpg](https://s0.lgstatic.com/i/image6/M01/56/27/Cgp9HWEsmkGAf7_pAAA0kOaBKyA839.jpg) 上图是一个用“可达性分析”标记垃圾对象的示例图,灰色的对象表示不可达对象,将等待回收。 #### 2.哪些内存区域需要 GC ? ![9.jpg](https://s0.lgstatic.com/i/image6/M01/56/27/Cgp9HWEsmlOABzEaAABLevaZD-w747.jpg) 在刚刚一开始讲到的 JVM 内存布局中,我们知道了 thread 独享的区域:PC Regiester、JVM Stack、Native Method Stack,其生命周期都与线程相同(即与线程共生死),所以无需 GC。而线程共享的 Heap 区、Method Area 则是 GC 关注的重点对象。 #### 3.常用的 4 种 GC 算法 ### Question 5:说一说常用的 GC 算法及其优缺点 ![10.jpg](https://s0.lgstatic.com/i/image6/M00/56/27/Cgp9HWEsmp-AV969AAAXLK9JRog778.jpg) **1)mark-sweep 标记清除法** 如上图,黑色区域表示待清理的垃圾对象,标记出来后直接清空。 - 优:简单快速; - 缺:产生很多内存碎片。 **2)mark-copy 标记复制法** ![11.jpg](https://s0.lgstatic.com/i/image6/M00/56/30/CioPOWEsmsiAaFzWAAAZCCjflgw041.jpg) 思路也很简单,将内存对半分,总是保留一块空着(上图中的右侧),将左侧存活的对象(浅灰色区域)复制到右侧,然后左侧全部清空。 - 优:避免了内存碎片问题; - 缺:内存浪费很严重,相当于只能使用 50% 的内存。 **3)mark-compact 标记-整理(也称标记-压缩)法** ![12.jpg](https://s0.lgstatic.com/i/image6/M00/56/28/Cgp9HWEsmuuAVl1CAAAXeNiLceQ497.jpg) 将垃圾对象清理掉后,同时将剩下的存活对象进行整理挪动(类似于 windows 的磁盘碎片整理),保证它们占用的空间连续。 - 优:节约了内存,并避免了内存碎片问题。 - 缺:整理过程会降低 GC 的效率。 上述三种算法,每种都有各自的优缺点,都不完美;在现代 JVM 中,往往是综合使用的。经过大量实际分析,发现内存中的对象,大致可以分为两类: - 有些生命周期很短,比如一些局部变量/临时对象; - 而另一些则会存活很久,典型的比如 websocket 长连接中的 connection 对象。如下图,纵向 y 轴可以理解分配内存的字节数,横向 x 轴理解为随着时间流逝(伴随着 GC)。 ![13.jpg](https://s0.lgstatic.com/i/image6/M00/56/30/CioPOWEsmxuAXJBzAAAXcpdwihU560.jpg) 可以发现大部分对象其实相当短命,很少有对象能在 GC 后活下来,因此诞生了分代的思想。 **4)generation-collect 分代收集算法** 咱们以 Hotspot 为例(JDK 7)进行讲解,如下图所示,可以将内存分成了三大块:年青代(Young Genaration)、老年代(Old Generation)、永久代(Permanent Generation)。其中 Young Genaration 更是又细为分 eden、S0、S1 三个区。 ![14.jpg](https://s0.lgstatic.com/i/image6/M00/56/28/Cgp9HWEsmzOALLLdAAAteML8kLI706.jpg) 结合我们经常使用的一些 jvm 调优参数后,一些参数能影响的各区域内存大小值,示意图如下: ![15.jpg](https://s0.lgstatic.com/i/image6/M01/56/28/Cgp9HWEsnDaANqB8AABT8uplhYI656.jpg) 注:jdk8 开始,用 MetaSpace 区取代了 Perm 区(永久代),所以相应的 jvm 参数变成-XX:MetaspaceSize 及 -XX:MaxMetaspaceSize。 ### Question 6:请以 Hotspot 为例,分析一下 GC 全过程(并指出其中 GC 算法的综合运用)。 刚开始时,对象分配在 eden 区,s0(即:from)及 s1(即:to)区几乎是空着。 ![16.jpg](https://s0.lgstatic.com/i/image6/M01/56/28/Cgp9HWEsnHSAe44fAAAyBIlKU2g132.jpg) 随着应用的运行,越来越多的对象被分配到 eden 区。 ![17.jpg](https://s0.lgstatic.com/i/image6/M01/56/28/Cgp9HWEsnJCARKtWAAAyUE3yOd4626.jpg) 当 eden 区放不下时,就会发生 minor GC(也被称为 young GC)。 - 首先当然是要先标识出不可达垃圾对象(即下图中的黄色块); - 然后将可达对象,移动到 s0 区(即:4个淡蓝色的方块挪到s0区); - 然后将黄色的垃圾块清理掉,这一轮后 eden 区就成空的了。 注:这里其实已经综合运用了“【标记-清理eden】+【标记-复制 eden->s0】”算法。 ![18.jpg](https://s0.lgstatic.com/i/image6/M01/56/30/CioPOWEsnOWAC91nAAA7mBY9r_U036.jpg) 随着时间推移,eden 如果又满了,再次触发 minor GC,同样还是先做标记,这时 eden 和 s0 区可能都有垃圾对象了(下图中的黄色块)。 - 这时 s1(即:to)区是空的,s0 区和 eden 区的存活对象,将直接搬到 s1 区。 - 然后将 eden 和 s0 区的垃圾清理掉,这一轮 minor GC 后,eden 和 s0 区就变成了空的了。 ![19.jpg](https://s0.lgstatic.com/i/image6/M01/56/28/Cgp9HWEsnRKAe-vTAAA4wqe0anE150.jpg) 继续随着对象的不断分配,eden 空可能又满了,这时会重复刚才的 minor GC 过程。不过要注意的是: - 这时候 s0 是空的,所以 s0 与 s1 的角色其实会互换。即存活的对象,会从 eden 和 s1 区,向 s0 区移动。 - 然后再把 eden 和 s1 区中的垃圾清除,这一轮完成后,eden 与 s1 区变成空的,如下图。 ![20.jpg](https://s0.lgstatic.com/i/image6/M01/56/31/CioPOWEsnTuATA-lAAA60jlAwtA023.jpg) **【代龄与晋升】** 对于那些比较“长寿”的对象一直在 s0 与 s1 中挪来挪去,一来很占地方,而且也会造成一定开销,降低 gc 效率,于是有了“代龄(age)”及“晋升”。 对象在年青代的 3 个区(edge,s0,s1)之间,每次从一个区移到另一区,年龄 +1,在 young 区达到一定的年龄阈值后,将晋升到老年代。 下图中是 8,即挪动 8 次后,如果还活着,下次 minor GC 时,将移动到 Tenured 区。 ![21.jpg](https://s0.lgstatic.com/i/image6/M01/56/28/Cgp9HWEsnXiAZiONAAA6qgCYUZc566.jpg) **【晋升的主要过程】** 下图是晋升的主要过程:对象先分配在年青代,经过多次 Young GC 后,如果对象还活着,晋升到老年代。 ![22.jpg](https://s0.lgstatic.com/i/image6/M01/56/28/Cgp9HWEsnYuAPI_DAAAiMNdNNFI753.jpg) 如果老年代,最终也放满了,就会发生 major GC(即 Full GC)。由于老年代的的对象通常会比较多,标记-清理-整理(压缩)的耗时通常也会比较长,会让应用出现卡顿的现象。这也就是为什么很多应用要优化,尽量避免或减少 Full GC 的原因。 ![23.jpg](https://s0.lgstatic.com/i/image6/M00/56/28/Cgp9HWEsna-AYYftAAA5rifCKkc411.jpg) 注:上面的过程主要来自 oracle 官网的资料,但是有一个细节官网没有提到:如果分配的新对象比较大,eden 区放不下,但是 old 区可以放下时,会直接分配到 old 区。 > 即没有晋升这一过程,直接到老年代了。 **【GC 流程图】** 下图引自阿里出品的《码出高效-Java开发手册》一书,梳理了 GC 的主要过程。 ![24.jpg](https://s0.lgstatic.com/i/image6/M00/56/28/Cgp9HWEsndeATQYWAABUVHtEAHE399.jpg) ## 8 种垃圾回收器 不算上后面出现的神器 ZGC,历史上出现过 7 种经典的垃圾回收器。 ![25.png](https://s0.lgstatic.com/i/image6/M00/56/29/Cgp9HWEsoXuAR2WEAAEJEJx1RMY327.png) 这些回收器都是基于分代的,把 G1 除外,按回收的分代划分如下。 - 横线以上的 3 种:Serial、ParNew、Parellel Scavenge 都是回收年青代的; - 横线以下的 3 种:CMS、Serial Old、Parallel Old 都是回收老年代的。 接下来,我们将以上提到的 8 种垃圾回收器逐一讲解,其中 CMS、G1、ZGC 这三种收集器是面试考试重点,我也会着重讲解。 ##### 1.Serial 收集器 单线程用标记-复制算法,快刀斩乱麻,单线程的好处避免上下文切换,早期的机器,大多是单核,也比较实用。但执行期间会发生 STW(Stop The World)。 ##### 2.ParNew 收集器 是 Serial 的多线程版本,也同样会 STW,在多核机器上会更适用。 ##### 3.Parallel Scavenge 收集器 ParNew 的升级版本,主要区别在于提供了两个参数: -XX:MaxGCPauseMillis 最大垃圾回收停顿时间; -XX:GCTimeRatio 垃圾回收时间与总时间占比。 通过这 2 个参数,可以适当控制回收的节奏,更关注于吞吐率,即总时间与垃圾回收时间的比例。 ##### 4.Serial Old 收集器 因为老年代的对象通常比较多,占用的空间通常也会更大。如果采用复制算法,得留 50% 的空间用于复制,相当不划算;而且因为对象多,从一个区,复制到另一个区,耗时也会比较长。 所以老年代的收集,通常会采用“标记-整理”法。从名字就可以看出来,这是单线程(串行)的, 依然会有 STW。 ##### 5.Parallel Old 收集器 一句话:Serial Old 的多线程版本。 ##### 6.重点!CMS 收集器 全称:Concurrent Mark Sweep,从名字上看,就能猜出它是并发多线程的。这是 JDK 7 中广泛使用的收集器,有必要多说一下。 ![26.jpg](https://s0.lgstatic.com/i/image6/M00/56/29/Cgp9HWEsoZSAEqicAABcDI3t_Mo844.jpg) ### Question 7:CMS 收集器工作的整个流程主要分为哪几个阶段? 相对前面两个 Serial Old 收集器或 Parallel Old 收集器而言,这个明显要复杂多了,分为 4 个阶段: 1. Inital Mark 初始标记:主要是标记GC Root开始的下级(注:仅下一级)对象,这个过程会STW,但是跟GC Root直接关联的下级对象不会很多,因此这个过程其实很快。 2. Concurrent Mark 并发标记:根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有 STW。 3. Remark 再标志:为啥还要再标记一次?因为第2步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。 > 试想下,高铁上的垃圾清理员,从车厢一头开始吆喝“有需要扔垃圾的乘客,请把垃圾扔一下”,一边工作一边向前走,等走到车厢另一头时,刚才走过的位置上,可能又有乘客产生了新的空瓶垃圾。 > 所以,要完全把这个车厢清理干净的话,她应该喊一下:所有乘客不要再扔垃圾了(STW),然后把新产生的垃圾收走。当然,因为刚才已经把收过一遍垃圾,所以这次收集新产生的垃圾,用不了多长时间(即 STW 时间不会很长)。 4. Concurrent Sweep 并行清理:这里使用多线程以“Mark Sweep-标记清理”算法,把垃圾清掉,其它工作线程仍然能继续支行,不会造成卡顿。 等等,刚才我们不是提到过“标记清理”法,会留下很多内存碎片吗? 确实,但是也没办法,如果换成“Mark Compact 标记-整理”法,把垃圾清理后,剩下的对象也顺便排整理,会导致这些对象的内存地址发生变化。别忘了,此时其他线程还在工作,如果引用的对象地址变了,就天下大乱了。 另外,由于这一步是并行处理,并不阻塞其它线程,所以还有一个副使用:在清理过程中,仍然可能会有新垃圾对象产生,只能等到下一轮 GC,才会被清理掉。 虽然仍不完美,但是从这 4 步的处理过程来看,以往收集器中最让人诟病的长时间 STW,通过上述设计,被分解成二次短暂的 STW。所以从总体效果上看,应用在 GC 期间卡顿的情况会大大改善,这也是 CMS 一度十分流行的重要原因。 ##### 7.重点!G1 收集器 鉴于 CMS 的一些不足之外,比如:老年代内存碎片化,STW 时间虽然已经改善了很多,但是仍然有提升空间。G1 就横空出世了,它对于 heap 区的内存划思路很新颖,有点算法中分治法“分而治之”的味道。 > G1 的全称是 Garbage-First,为什么叫这个名字?呆会儿会详细说明。 ### Question 8:G1 垃圾收集器的原理是什么? 如下图,G1 将 heap 内存区,划分为一个个大小相等(1-32M,2的 n 次方)、内存连续的 Region 区域,每个 region 都对应 Eden、Survivor 、Old、Humongous 四种角色之一,但是 region 与 region 之间不要求连续。 > 注:Humongous,简称 H 区是专用于存放超大对象的区域,通常 >= 1/2 Region Size,且只有 Full GC 阶段,才会回收 H 区,避免了频繁扫描、复制/移动大对象。 ![27.jpg](https://s0.lgstatic.com/i/image6/M01/56/32/CioPOWEsoyiAPrJoAAAlpnfE74Y082.jpg) 所有的垃圾回收,都是基于 1 个个 region 的。JVM 内部知道,哪些 region 的对象最少(即该区域最空),总是会优先收集这些 region(因为对象少,内存相对较空,肯定快)。这就是 Garbage-First 得名的由来,G 即是 Garbage 的缩写,1 即 First。 ##### 1)G1 Young GC young GC 前: ![28.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEso1-ASR07AAAooj52DT8141.jpg) young GC 后: ![29.jpg](https://s0.lgstatic.com/i/image6/M00/56/32/CioPOWEso26ATLb5AAAhJlGHuaA144.jpg) 理论上讲,只要有一个 Empty Region(空区域),就可以进行垃圾回收。 ![30.jpg](https://s0.lgstatic.com/i/image6/M00/56/32/CioPOWEso4CAQAEVAAAZ5lASmzU156.jpg) 由于 region 与 region 之间并不要求连续,而使用 G1 的场景通常是大内存,比如 64G 甚至更大,为了提高扫描根对象和标记的效率,G1 使用了二个新的辅助存储结构: - Remembered Sets:简称 RSets,用于根据每个 region 里的对象,是从哪指向过来的(即谁引用了我),每个 Region 都有独立的 RSets(Other Region -> Self Region)。 - Collection Sets :简称 CSets,记录了等待回收的 Region 集合,GC 时这些 Region 中的对象会被回收(copied or moved)。 ![31.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEso76AGea-AAAgqInKaYY726.jpg) RSets 的引入,在 YGC 时,将年青代 Region 的 RSets 做为根对象,可以避免扫描老年代的 region,能大大减轻 GC 的负担。 > 注:在老年代收集 Mixed GC 时,RSets 记录了 Old->Old 的引用,也可以避免扫描所有 Old 区。 ##### 2)Old Generation Collection(也称为 Mixed GC) ### Question 9:G1 垃圾收集器的运行过程,分为哪几个阶段? 按 oracle 官网文档描述,分为 5 个阶段:Initial Mark(STW) -> Root Region Scan -> Cocurrent Marking -> Remark(STW) -> Copying/Cleanup(STW && Concurrent) > 注:也有很多文章会把 Root Region Scan 省略掉,合并到 Initial Mark 里,变成 4 个阶段。 ![32.jpg](https://s0.lgstatic.com/i/image6/M00/56/32/CioPOWEspASAXxudAAAc8vriADo007.jpg) ##### 阶段 1:存活对象的“初始标记”依赖于 Young GC,GC 日志中会记录成 young 字样。 ``` 2019-06-09T15:24:37.086+0800: 500993.392: [GC pause (G1 Evacuation Pause) (young), 0.0493588 secs] [Parallel Time: 41.9 ms, GC Workers: 8] [GC Worker Start (ms): Min: 500993393.7, Avg: 500993393.7, Max: 500993393.7, Diff: 0.1] [Ext Root Scanning (ms): Min: 1.5, Avg: 2.2, Max: 4.4, Diff: 2.8, Sum: 17.2] [Update RS (ms): Min: 15.8, Avg: 18.1, Max: 18.9, Diff: 3.1, Sum: 144.8] [Processed Buffers: Min: 110, Avg: 144.9, Max: 163, Diff: 53, Sum: 1159] [Scan RS (ms): Min: 4.7, Avg: 5.0, Max: 5.1, Diff: 0.4, Sum: 39.7] [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] [Object Copy (ms): Min: 16.4, Avg: 16.5, Max: 16.6, Diff: 0.2, Sum: 132.0] [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] [Termination Attempts: Min: 1, Avg: 4.9, Max: 7, Diff: 6, Sum: 39] [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.3] [GC Worker Total (ms): Min: 41.7, Avg: 41.8, Max: 41.8, Diff: 0.1, Sum: 334.1] [GC Worker End (ms): Min: 500993435.5, Avg: 500993435.5, Max: 500993435.5, Diff: 0.0] [Code Root Fixup: 0.0 ms] [Code Root Purge: 0.0 ms] [Clear CT: 0.2 ms] [Other: 7.2 ms] [Choose CSet: 0.0 ms] [Ref Proc: 4.3 ms] [Ref Enq: 0.1 ms] [Redirty Cards: 0.1 ms] [Humongous Register: 0.1 ms] [Humongous Reclaim: 0.1 ms] [Free CSet: 0.6 ms] [Eden: 1340.0M(1340.0M)->0.0B(548.0M) Survivors: 40.0M->64.0M Heap: 2868.2M(12.0G)->1499.8M(12.0G)] [Times: user=0.35 sys=0.00, real=0.05 secs] ``` ![33.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEspKCAWO06AAAjPuht2jw948.jpg) ##### 阶段 2:并发标记过程中,如果发现某些 region 全是空的,会被直接清除。 ![34.jpg](https://s0.lgstatic.com/i/image6/M01/56/32/CioPOWEspL-AeIrMAAAZ0L4aFgU868.jpg) ##### 阶段 3:进入重新标记阶段。 ![35.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEspNCAShnGAAAs2sVtmIk851.jpg) ##### 阶段 4:并发复制/清查阶段。 这个阶段,Young 区和 Old 区的对象有可能会被同时清理。GC 日志中,会记录为 mixed 字段,这也是 G1 的老年代收集,也称为 Mixed GC 的原因。 ``` 2019-06-09T15:24:23.959+0800: 500980.265: [GC pause (G1 Evacuation Pause) (mixed), 0.0885388 secs] [Parallel Time: 74.2 ms, GC Workers: 8] [GC Worker Start (ms): Min: 500980270.6, Avg: 500980270.6, Max: 500980270.6, Diff: 0.1] [Ext Root Scanning (ms): Min: 1.7, Avg: 2.2, Max: 4.1, Diff: 2.4, Sum: 17.3] [Update RS (ms): Min: 11.7, Avg: 13.7, Max: 14.3, Diff: 2.6, Sum: 109.8] [Processed Buffers: Min: 136, Avg: 141.5, Max: 152, Diff: 16, Sum: 1132] [Scan RS (ms): Min: 42.5, Avg: 42.9, Max: 43.1, Diff: 0.5, Sum: 343.1] [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1] [Object Copy (ms): Min: 14.9, Avg: 15.2, Max: 15.4, Diff: 0.5, Sum: 121.7] [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1] [Termination Attempts: Min: 1, Avg: 8.2, Max: 11, Diff: 10, Sum: 66] [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.2] [GC Worker Total (ms): Min: 74.0, Avg: 74.0, Max: 74.1, Diff: 0.1, Sum: 592.3] [GC Worker End (ms): Min: 500980344.6, Avg: 500980344.6, Max: 500980344.6, Diff: 0.0] [Code Root Fixup: 0.0 ms] [Code Root Purge: 0.0 ms] [Clear CT: 0.5 ms] [Other: 13.9 ms] [Choose CSet: 4.1 ms] [Ref Proc: 1.8 ms] [Ref Enq: 0.1 ms] [Redirty Cards: 0.2 ms] [Humongous Register: 0.1 ms] [Humongous Reclaim: 0.1 ms] [Free CSet: 5.6 ms] [Eden: 584.0M(584.0M)->0.0B(576.0M) Survivors: 28.0M->36.0M Heap: 4749.3M(12.0G)->2930.0M(12.0G)] [Times: user=0.61 sys=0.00, real=0.09 secs] ``` ![36.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEspReALKoGAAAe7uQdsCU229.jpg) 上图是,老年代收集完后的示意图。 通过这几个阶段的分析,虽然看上去很多阶段仍然会发生 STW,但是 G1 提供了一个预测模型,通过统计方法,根据历史数据来预测本次收集,需要选择多少个 Region 来回收,尽量满足用户的预期停顿值(-XX:MaxGCPauseMillis 参数可指定预期停顿值)。 > 注:如果 Mixed GC 仍然效果不理想,跟不上新对象分配内存的需求,会使用 Serial Old GC(Full GC)强制收集整个 Heap。 小结:与 CMS 相比,G1 有内存整理过程(标记-压缩),避免了内存碎片;STW 时间可控(能预测 GC 停顿时间)。 ##### 8.重点!ZGC(截止目前史上最好的 GC 收集器) ### Question 10:ZGC 在 G1 基础上做了哪些改进? 在 G1 的基础上,它做了如下 7 点改进(JDK 11 开始引入)。 ##### 1)动态调整大小的 Region G1 中每个 Region 的大小是固定的,创建和销毁 Region,可以动态调整大小,内存使用更高效。 ![37.jpg](https://s0.lgstatic.com/i/image6/M00/56/2A/Cgp9HWEspYeAXzknAAAIiMoiXjE128.jpg) ##### 2)不分代,干掉了 RSets G1 中每个 Region 需要借助额外的 RSets 来记录“谁引用了我”,占用了额外的内存空间,每次对象移动时,RSets 也需要更新,会产生开销。 > ZGC 没有为止,没有实现分代机制,每次都是并发的对所有 region 进行回收,不象 G1 是增量回收,所以用不着 RSets。不分代的带来的可能性能下降,会用下面马上提到的 Colored Pointer && Load Barrier 来优化。 ##### 3)带颜色的指针 Colored Pointer ![38.jpg](https://s0.lgstatic.com/i/image6/M00/56/2A/Cgp9HWEspaqAZJgwAAAnVLyDuLg609.jpg) 这里的指针类似 Java 中的引用,意为对某块虚拟内存的引用。ZGC 采用了64位指针(注:目前只支持 linux 64 位系统),将 42-45 这 4 个 bit 位置赋予了不同含义,即所谓的颜色标志位,也换为指针的 metadata。 - finalizable 位:仅 finalizer(类比 C++ 中的析构函数)可访问; - remap 位:指向对象当前(最新)的内存地址,参考下面提到的relocation; - marked0 && marked1 位:用于标志可达对象。 这 4 个标志位,同一时刻只会有 1 个位置是 1。每当指针对应的内存数据发生变化,比如内存被移动,颜色会发生变化。 ##### 4)读屏障 Load Barrier 传统 GC 做标记时,为了防止其他线程在标记期间修改对象,通常会简单的 STW。而 ZGC 有了 Colored Pointer 后,引入了所谓的“读屏障”。 当指针引用的内存正被移动时,指针上的颜色就会变化,ZGC 会先把指针更新成最新状态,然后再返回(你可以回想下 Java 中的 volatile 关键字,有异曲同工之妙)。这样仅读取该指针时,可能会略有开销,而不用将整个 heap STW。 ##### 5)重定位 Relocation 如下图,在标记过程中,先从 Roots 对象找到了直接关联的下级对象 1,2,4。 ![39.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEspqWAAHS6AAAcoge7Pv8625.jpg) 然后继续向下层标记,找到了 5,8 对象, 此时已经可以判定 3,6,7 为垃圾对象。 ![40.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEsprCAZrtCAAAeqKPoHXw488.jpg) 如果按常规思路,一般会将 8 从最右侧的 Region,移动或复制到中间的 Region,然后再将中间 Region 的 3 干掉,最后再对中间 Region 做压缩 compact 整理。 ![41.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEspr6AMxziAAAeYmBBjoU744.jpg) 但 ZGC 做得更高明,它直接将 4,5 复制到了一个空的新 Region 就完事了,然后中间的 2 个 Region 直接废弃,或理解为“释放”,作为下次回收的“新” Region。这样的好处是避免了中间 Region 的 compact 整理过程。 ![42.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEspsuAFh42AAAsCgyJwEk919.jpg) 最后,指针重新调整为正确的指向(即:remap),而且上一阶段的 remap 与下一阶段的mark是混在一起处理的,相对更高效。 【 Remap 的流程图】 ![43.jpg](https://s0.lgstatic.com/i/image6/M01/56/2A/Cgp9HWEsptuABwmwAAA8njSoyvA729.jpg) ##### 6)多重映射 Multi-Mapping 这个优化,说实话没完全看懂,只能谈下自己的理解(如果有误,欢迎指正)。虚拟内存与实际物理内存,OS 会维护一个映射关系,才能正常使用,如下图: ![44.jpg](https://s0.lgstatic.com/i/image6/M00/56/33/CioPOWEspu-AMzsrAAAexu5Y-5Y191.jpg) zgc 的 64 位颜色指针,在解除映射关系时,代价较高(需要屏蔽额外的 42-45 的颜色标志位)。考虑到这 4 个标志位,同 1 时刻,只会有 1 位置成 1(如下图),另外 finalizable 标志位,永远不希望被解除映射绑定(可不用考虑映射问题)。 所以剩下 3 种颜色的虚拟内存,可以都映射到同1段物理内存。即映射复用,或者更通俗点讲,本来 3 种不同颜色的指针,哪怕 0-41 位完全相同,也需要映射到 3 段不同的物理内存,现在只需要映射到同 1 段物理内存即可。 ![45.jpg](https://s0.lgstatic.com/i/image6/M01/56/33/CioPOWEspvuAPsezAAA3EnbGU_s779.jpg) ![46.jpg](https://s0.lgstatic.com/i/image6/M01/56/33/CioPOWEspwaACvveAAB6uIWxwWM451.jpg) ##### 7)支持 NUMA 架构 NUMA 是一种多核服务器的架构,简单来讲,一个多核服务器(比如 2core),每个 cpu 都有属于自己的存储器,会比访问另一个核的存储器会慢很多(类似于就近访问更快)。 相对之前的 GC 算法,ZGC 首次支持了 NUMA 架构,申请堆内存时,判断当前线程属是哪个CPU在执行,然后就近申请该 CPU 能使用的内存。 小结:革命性的 ZGC 经过上述一堆优化后,每次 GC 总体卡顿时间按官方说法<10ms。 > 注:启用 zgc,需要设置 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC。