关于垃圾回收
垃圾收集器是JVM中的垃圾回收子系统,负责管理堆对象的生命周期。其作用是扫描堆中的对象,发现对象不再被引用时自动回收其所占用的内存空间
垃圾收集是JVM在Java堆中释放未使用的Java对象的过程,相当于保洁员的工作,处理不再使用的对象,将开发人员从繁杂的内存管理工作中释放出来,但是随着业务越来越复杂,GC算法造成的固有停顿会对业务响应时间造成一定的影响
JDK提供了多种垃圾回收器以供选择,它们适用于不同的场景,基本上可以以下三个标准对GC进行性能度量:
- 吞吐量:吞吐量 = (运行用户代码时间)/(运行用户代码时间 + 垃圾收集时间)
- 延迟:延迟指的是单次操作所需的时间,在GC过程中,可以理解为最长一次"Stop The World"时间
- 内存空间:在垃圾回收的过程中可能会产生内存空间碎片,好的GC也会关注如何提高内存利用率
Stop The World(STW)指的是在用户不可见的情况下,把用户正常工作的线程全部停掉进行GC的过程
垃圾收集器的进化
垃圾回收器主要经历了入如下的发展历史:
- Serial收集器:串行单线程收集器
- Parallel收集器:并行多线程收集器
- CMS收集器:并发收集器
- G1收集器:分区回收
- ZGC收集器:全并发
G1之前的垃圾收集器采用分区收集的方式,从G1开始的垃圾收集器都是分区收集器

目前JDK8的主流组合是ParNew + CMS,然而在JDK11中就几乎被废除,G1收集器的STW时间更短,且能够整合内存碎片,基本改用G1收集器
从CMS到G1

CMS垃圾收集器
- ParNew:Serial的多线程版本,用于新生代的垃圾回收,采用标记-复制算法,整个GC过程都会STW
- CMS:CMS收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法,整个过程分为四个步骤:初始标记-并发标记-重新标记-并发清除

CMS存在的问题
- 吞吐量低
CMS收集器对CPU资源十分敏感,默认启动回收线程数是(CPU数量 + 3) / 4,也就是当CPU在4个以上时,并发回收时垃圾回收线程不少于25%的CPU资源,因此当CPU数量不足时,CPU本身负载就比较大,还需要分出将近一半的运算能力执行垃圾收集线程,可能导致用户程序的执行速度大幅下降
- 无法处理浮动垃圾,并发模式失败之后会切换到Serial Old
浮动垃圾:CMS的并发清理阶段会和用户线程并行运行,在该阶段伴随着用户线程的运行,程序还会有新的垃圾不断产生,这一部分垃圾出现在初始标记之后,因此无法在当次垃圾回收时进行回收,而留待下一次垃圾回收时进行清理,这一部分垃圾称为浮动垃圾。
CMS无法处理浮动垃圾,因此需要预留空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎填满之后再进行收集,而是要设置一个回收阈值,到达阈值就需要进行一次Full GC。
如果CMS运行期间预留的内存无法满足程序需要,就会出现“Concurrent Mode Failure”,此时虚拟机会选择启用Serial Old重新进行老年代的垃圾回收(单线程收集),停顿时间反而会变长,导致性能降低
- 内存碎片
CMS基于标记-清除算法,收集结束会有内存碎片产生,当空间碎片过多时,无法找到足够大的连续空间分配当前对象,不得不提前触发Full GC,CMS的解决方案是在Full GC之后进行内存碎片的合并整理,但是内存整理的过程无法并发,空间碎片问题解决,但停顿时间不得不变长
G1垃圾收集器
针对以上提出的CMS的问题,G1做出了如下的改进:
- 采用化整为零的分区思想
- 采用标记-整理的垃圾回收算法
- 可预测的GC停顿时间,可以由用户决定最大STW时间
运作流程

G1收集器的运作大致可以分为以下几个步骤:
- 初始标记:标记GC Root直接关联到的对象
- 并发标记:可达性分析
- 最终标记:修正并发用户线程产生的新垃圾
- 筛选回收:回收垃圾
分区收集
- 与其他收集器一样,分代概念在G1中依然得到保留,但是在物理上不对内存堆进行新生代和老年代的划分,即G1可以应用于整个堆内存之中,新生代和老年代不再物理隔离。
- 整块内存被划分成一个个reigon,并对region进行标记来区分eden/survivior/old/humongous,并且它们不需要连续,以每个region作为最小的回收单位
region是如何让划分的?JVM如果使用的是G1回收器,会默认将内存划分成2048个region
- 维护跨分区引用:
分区收集的问题:分区收集意味着垃圾收集器不再物理区分新生代和老年代,Region之间不是互相孤立的,可以与Java堆中的任意对象发生跨代引用关系,因此回收新生代对象时,为了判断对象是否存活,还不得不扫描老年代的对象,这样做会导致Minor GC的效率下降。
Rememebered Set
G1的解决方案:如何避免全堆扫描?G1的解决方法是维护一个Remembered Set
在了解Remembered Set之前,需要了解一些前置的知识,其他垃圾收集器也会遇见跨代引用的问题,采取的解决方案是使用Card Table:
Card Table:
Card Table主要用于辅助标记需要回收的垃圾,解决跨代引用的问题。
回收新生代产生的垃圾时,可能存在老年代对象引用新生代对象的情况,这一类的新生代对象也不能被清除,为了不遍历整个老年代,Card Table将old区分成多个Card,一个Card有多个对象,如果Card中的对象引用了新生代的对象,就会被标记为dirty card。
因此在进行Minor GC的过程中会扫描新生代 + 老年代的dirty区域。
并发标记时,对象引用发生变化,就标记该对象所在的card为dirty card,重新标记时重新扫描dirty cards同时清除Dirty标记
为了实现Card Table,G1收集器使用了Remembered Set的设计概念
在G1收集器中,堆内存被分配成不同的region,垃圾收集也需要按region独立地进行。每个region会有一个对应的Remembered Set,记录哪些内存区域中存在对当前region中对象的引用

Rset会记录其他内存区域中对象所在的Card编号。
当进行内存回收时,在GC root的枚举范围中加入Remembered Set即可保证不对全堆扫描
回收时间用户可设定
“筛选回收”
从G1和CMS的运行示意图来看,G1的筛选回收阶段需要STW而CMS不需要,为什么实际表现来看G1反而缩短了STW的时间?并且做到了回收时间用户可设定呢?
因为G1回收的第四步,是“筛选回收”,只选择一些内存块,而不是对整个新生代/老年代进行内存回收,其他垃圾回收器只能对整个新生代/老年代内存区域进行垃圾回收,所以其垃圾回收的时间是不可控制的,取决于内存的大小以及垃圾的多少,但是G1按照region进行回收,如果用户的设定的时间比较短,就少回收一些内存块,如果用户设定的时间比较长,就多回收一些内存块:其回收的排序按照各个region的回收价值和成本进行排序(可以通过维护一个优先队列来实现)
对于用户来说,可以设定垃圾回收时间,能更好地定制延迟与吞吐量之间的平衡(但是在相同垃圾回收时间下比较吞吐量,G1和CMS应该没有太大的差异)。
Mixed GC
和其他的内存收集器一样,G1收集器会为新生对象分配内存,直到新生代占内存比达到阈值(默认是60%)此时会触发Minor GC,某次Minor GC之后,收集器发现存活对象超过了Survivor的50%,就会将一些年龄大的对象标记为老年代的对象。
什么时候回收老年代的对象呢?G1同样有参数来规定老年代占比的阈值,到达阈值之后就会触发一个新生代+老年代的混合回收
CMS和G1的对比
综上所述,可以发现G1针对CMS的问题做出了很多改进,尤其是STW,对于追求低停顿的应用来说,G1可能是更合适的选择;其次,CMS只被应用于老年代,新生代需要配合使用ParNew垃圾收集器,G1摒弃了物理分区,适用于新生代和老年代,在新生代G1的STW相较于ParNew也当然更短。
Q. 在堆内存比较小的情况下,G1的吞吐量和回收效率表现都不如CMS,这是为什么?
A: 首先G1采用了分区模型,跨region的对象引用使用Remembered Set记录,导致记录这种对象的引用需要占用额外的内存(虽然CMS也需要实现Card Table,但是CMS只需要老年代记录其引用的对象,而G1则是正好相反,是记录当前对象被哪些对象所引用),如果是一个热点的region,它的Rset会更大,由于这种设计,导致G1需要更多的内存用于Rset,在堆内存较小的情况下,G1更容易导致Full GC,表现劣于CMS;其次,堆很小的情况下,对于G1来说,每一个region也会很小,很容易就会产生大对象,大对象过多,就会频繁进行Mixed GC导致回收效率下降
jdk8 vs jdk11

从GC算法占比选择可以看出,jdk11的ParNew + CMS的占比已经远远低于G1,因为G1已经成为jdk11的默认垃圾回收器。
相较于最初的G1和经过了几个版本的实验最终成为默认垃圾收集器的G1,它也做了一些其他的改进:前文提到过当CMS出现垃圾收集失败,无法为新的对象提供更多内存空间的时候,会采用备用的Serial Old垃圾收集器,进行基于标记-清除-压缩进行单线程Full GC而导致长时间的STW,大大降低垃圾回收的速度。G1同样会出现这样的情况。
在之前的版本中,G1也会退化成Serial Old,jdk10开始,G1开始支持并行线程的Full GC,在一定程度上也提高了GC的性能。
总结与思考
- 从CMS到G1的改进可以看出一些计算机算法设计上的思想:和排序算法的演进相似,很多的计算机设计都是采用的分治的思想,将一个大问题分割成一些小问题从而加速问题的解决;其次,evacuation的过程如何实现高效的合并?查阅资料的过程中没有找到具体的解答,思考这个问题的时候或许可以参考cache的合并,使用header/footer等多种方式。
- G1的垃圾收集相较于CMS一大优势,就是可以根据用户的要求进行调优,指定垃圾回收的时间,但是实际上如何简化用户的调优,实现真正的开箱即用,可能才是jvm一直努力的目标,因为最近业内在AI上投入了大量的关注度,深度学习本身就是一个不断调优的过程,未来AI是否会和jvm调优结合以适应用户多种多样的需求?这是否也是jvm发展的方向?这些可能都是可以思考的问题
附录:G1常见参数一览
|
参数与默认值 |
说明 |
|
-XX:+UserG1GC |
使用 G1 收集器 |
|
-XX:MaxGCPauseMillis=200 |
设置最大停顿时间,软目标,虚拟机不保证总是达到要求 |
|
-XX:InitiatingHeapOccupancyPercent=45 |
当堆内存使用率达到多少时启动一次 GC 周期。GC 周期针对整个堆内存而不是某个年龄代。设为 0 时表示持续进行 GC 周期 |
|
-XX:NewRatio=2 |
新生代与老年代大小比例 |
|
-XX:SurvivorRatio=8 |
Eden 区与 survivor 区大小比例 |
|
-XX:MaxTenuringThreshold=15 |
新生代存活过多少次 youngGC 后晋升老年代 |
|
-XX:ConcGCThreads |
并发垃圾回收器使用的线程数,默认值根据 JVM 运行平台不同而变化 |
|
-XX:ParallelGCThreads |
在并行阶段垃圾回收器线程数 |
|
-XX:G1ReservePercent=10 |
预留多少个区域,防止出现转移失败导致 to-space overflow |
|
-XX:G1HeapRegionSize |
G1 将堆内存划分为大小相同的多个区域。区域大小由这个参数确定。默认值与堆内存大小有关。不小于 1M,不大于 32M。总数 2048 |
|
-XX:-G1UseAdaptiveIHOP |
关闭 G1 的自动 IHOP 分析机制 |
|
-XX:+AlwaysPreTouch |
启动时分配好机器的物理内存;(JDK8 以前单线程初始化, 8u60 后此参数生效 ) |
|
-XX:+ParallelRefProcEnabled |
并行的处理 Reference 对象 |
|
-XX:G1NewSizePercent=5 |
新生代比例下限 |
|
-XX:G1MaxNewSizePercent=60 |
新生代比例上限 |
|
-XX:G1MixedGCCountTarget=8 |
指定一个周期内触发 Mixed GC 最大次数 |
|
-XX:G1OldCSetRegionThresholdPercent=10 |
指定每轮 Mixed GC 回收的 Region 最大比例 |
1866

被折叠的 条评论
为什么被折叠?



