GC总览
Java是一门面向对象的语言。在使用Java的过程中,会创建大量的对象在内存中。而这些对象,需要在用完之后“回收”掉,释放内存资源。这件事我们称它为垃圾收集(Garbage Collection,简称GC),而实际执行者就是各种各样的“垃圾收集器”(Garbage Collector,以下也简称GC)。
为什么会有各种各样的GC?因为时代在发展,以前的GC可能不能满足现在的需求,所以就会有源源不断的GC推出来。先来看一下都有哪些主流的GC:
新生代:
Serial:单线程,新生代;
ParNew: 多线程,新生代;
Parallel Scavenge:多线程,新生代,关注吞吐量;
老年代:
Serial Old: 单线程,Serial的老年代版本;
Parallel Old:多线程,Parallel Scavenge的老年代版本,关注吞吐量;
CMS:多线程,标记-清除算法,关注停顿时间,可以与Serial和ParNew配合。
其它
G1:同时负责新生代和老年代,是目前一段时间主流的垃圾收集器(JDK 9到现在JDK 16的默认垃圾收集器)。
ZGC:在大堆下也可以控制STW时间极短(几毫秒内),在JDK 11 为实验阶段,在JDK 15转正。Oracle发起,2017年贡献给OpenJDK。
Shenandoah GC:停顿时间极短,在JDK 12为试验阶段,在JDK 15转正。Red Hat发起,与ZGC和G1是竞争关系。
STW: Stop The World,指的是停止用户线程。GC应该尽量避免STW或者缩减STW的时间。
各JDK版本默认GC
下面来看一下从JDK 7开始在默认GC(JDK 7之前的,我就不考古了,现在大家项目上用的也少)。
- 在JDK 7,默认是Parallel Scavenge + Serial Old。
- 在JDK 8 及JDK 7u4之后的版本,默认是Parallel Scavenge + Parallel Old。
- 在JDK 9 到JDK 16,默认是G1
严格来说之前的版本应该是JDK 1.7, JDK 1.8,这里及下文为了表述方便,我就写成了JDK 7, JDK 8。
聊聊几种主流的GC
考虑到现在大家用的Java版本主要都是JDK 8起,所以主要聊JDK 8及之后可能会用到的GC。
Parallel Scavenge + Parallel Old
以下简称为PS,PO
PS + PO是JDK 7u4之后一直到JDK 9之前server模式都在用的默认GC组合。
PS用于新生代,是一个并行的多线程收集器,使用了复制算法。它关注吞吐量,可以通过参数设置最大GC停顿时间、吞吐量的大小等。
PS收集器相比于ParNew来说,多了一个“自适应调节策略”。当+UseAdaptiveSizePolicy
这个参数打开之后,就不需要手动指定新生代的大小,Eden和Survivor区的比例,晋升老年代对象等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量。
PO用于老年代,同样是关注吞吐量。它是一个多线程的收集器,采用“标记-整理”算法。PO是在JDK 1.6之后才提供的,目的就是为了与PS配合。在此之前,PS只能与Serial Old配合(因为框架不适配的原因,不能与CMS配合),老年代GC效率低下,所以PO应运而生。
ParNew + CMS
ParNew是一个新生代多线程收集器,使用复制算法。它的出现比PS更早,主要是为了替代Serial单线程低下的效率。在新生代复制期间,使用多线程来降低STW的时间。
CMS是JDK 5才推出的一款关注停顿时间的GC。与PO不同的是,CMS使用的是“标记-清除”算法,这能够让它实现更小的停顿时间,但代价是会产生大量的空间碎片,可能会在大对象晋升老年代时由于没有连续的内存空间,触发full gc。
了解到这里,其实我有两个疑问。
1 CMS为什么不使用“标记-整理”算法?
参考《并发垃圾收集器(CMS)为什么没有采用标记-整理算法来实现?》这篇文章(原文是在iteye的一个讨论,但现在已经找不到了)的说法。GC代码和用户代码需要保持同步,才能保证两者观察到的对象图是一致的。而保持同步有两种做法,一种是read barrier,一种是write barrier。因为读远大于写,所以CMS使用的是write barrier。这就导致CMS如果用“标记-整理”算法的话,需要在“整理”的时候STW,而如果使用“标记-清除”算法的话,在“清除”阶段是不用STW的。
2 为什么CMS从来没作为默认垃圾收集器?
参考知乎这篇文章《为什么 JDK 8 默认使用 Parallel Scavenge 收集器?》的回答。总结有三个原因:
- Java使用服务端的场景为主,服务端更专注吞吐量,所以JDK 8默认的是PS + PO;
- 使用、调优很复杂,有高达70多个参数
- “后浪”太优秀,CMS比G1早不了多少,5开始加入,6成熟,9被标记弃用,14被删除。而G1是7加入,8成熟,9正式成为默认。
然而事实上,有很多互联网大厂选择了CMS+ParNew的组合。前段时间美团出了一篇文章《Java中9种常见的CMS GC问题分析与解决》,阿里也用得比较多,所以还是可以了解一下。
G1
前面提到了,G1是一个优秀的后浪。从JDK 9到当前JDK 16一直都是默认的GC。相较于之前的几款GC来说,G1可以说是有一些颠覆性的设计,比如Region、Card Table、Remember Set等。
关于G1,需要一篇单独的文章来介绍。我之前在个人网站上有一篇文章介绍,《JVM - G1》
这里大致介绍一下它的特点:
- 低延迟优先,即主要侧重于响应能力;
- 与CMS相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序;
- 更精确的预测GC停顿时间,可以根据-XX:MaxGCPauseMillis参数指定停顿时间;
- 收缩空闲空间不会造成由长GC引起的应用停顿时间;
- G1没有CMS的碎片化问题(或者说不那么严重)。
ZGC
关于ZGC,笔者了解得也不多。ZGC在JDK 11推出实验版,在JDK 15成为正式版。ZGC的目标是在尽量不牺牲吞吐量(官方宣称,目标是对程序吞吐量影响小于15%)的情况下,做到极低的停顿时间,并且停顿时间不会随着对的内存大小变大而升高。
虽然ZGC在JDK 15已经转正,但还是在不断完善和迭代。JDK 16最近发布,在这个版本中ZGC有46 个功能增强以及25 个 bug 修复。已经可以做到平均暂停时间约为 50 微秒(0.05 毫秒),最大暂停时间约为 500 微秒(0.5 毫秒)。暂停时间不受堆、活动集和根集大小的影响。这个停顿时间,对业务的影响可以说已经微乎其微了,我等只能大呼NB。
想要深入了解ZGC原理的同学,可以参考美团技术的这篇文章《新一代垃圾回收器ZGC的探索与实践》(只找到知乎官方号的链接)。
Shenandoah GC
按理说G1和ZGC已经很牛逼了,为什么还有其它的GC出来?学不动了啊,有木有。。。
Shenandoah GC,我们称它为SGC吧,它是Red Hat发起的一款GC,与ZGC是竞争关系。Shenandoah更像是G1的继承者,有很多相同之处。它跟ZGC一样,目标也是低停顿时间,不过实现原理有些不同,ZGC是基于colored pointers来实现,而Shenandoah GC是基于brooks pointers来实现。
SGC没有承诺停顿时间小于10ms,也没有说要牺牲吞吐量(但实际吞吐量有没有降低就不知道了)。在官方的性能测试图来看,停顿时间也是极低的。
GC(3) Pause Init Mark 0.771ms GC(3) Concurrent marking 76480M->77212M(102400M) 633.213ms GC(3) Pause Final Mark 1.821ms GC(3) Concurrent cleanup 77224M->66592M(102400M) 3.112ms GC(3) Concurrent evacuation 66592M->75640M(102400M) 405.312ms GC(3) Pause Init Update Refs 0.084ms GC(3) Concurrent update references 75700M->76424M(102400M) 354.341ms GC(3) Pause Final Update Refs 0.409ms GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms
它只有在标记和更新引用的时候会暂停,看起来也是只有几毫秒的停顿时间。
总结一下
现在市面上很大一部分Java程序都还是基于Java 8(甚至有些是Java 7的),所以PS + PO,和ParNew + CMS都是可以了解一下的,毕竟工作中很有可能会用到。尤其是CMS,大厂用得很多,面试基本上必问。
ZGC和SGC,虽然在JDK 15都已经成为正式版,但实际生产中使用很少,有些潜在问题不一定能很好地发现,可以慢慢去了解和尝试。
可以预见的是,G1会在未来很长一段时间内成为最主流的垃圾收集器,所以很有必要好好了解一下G1,做好知识储备。