本文使用一组不同的模式描述了一系列Java虚拟机(JVM)垃圾收集器(GC)微基准及其结果。对于当前问题,我包括了AdoptOpenJDK 64位服务器VM版本13(内部版本13 + 33)中的所有垃圾收集器:
- 串行GC
- Parallel / ParallelOld GC (启动Java 7u4 ParallelGC和ParallelOld GC基本上是同一收集器)
-
并发标记扫描CMS GC (目前不建议使用,它将根据 JEP 363
在Java 14版本中删除)
- 垃圾优先G1 GC
- Shenandoah GC
- ZGC (目前处于实验阶段)
- Epsilon GC (目前处于实验状态)
我故意选择了AdoptOpenJDK,因为并非所有的OpenJDK构建都包括Shenandoah GC。
当前所有GC基准测试都集中在以下指标上:
- (a)在相对较小和较大对象的不同分配率下,GC对象回收的效率;(b)具有或不具有恒定的堆预分配部分。
- 遍历和/或更新堆数据结构时尝试读写屏障的影响,并尝试避免在基准方法中进行任何显式分配,除非它是由基础生态系统引起的。
- 内部GC本机结构的占用空间。
配置
-
所有基准都是用Java编写的,并使用 JMH
v1.22
- 基准测试源代码不是公开的,但我详细介绍了它们所依赖的优化模式。
- 每个基准测试使用5x10s的热身迭代,5x10s的测量迭代,3个JVM分支,并且是单线程的。
- 所有针对高对象分配率的基准测试,都将初始堆大小设置为与最大堆大小相同的值,而且还会预先触摸页面以避免调整大小和内存提交打commit(例如-Xms4g -Xmx4g -XX:+ AlwaysPreTouch)。
-
所有测试均在具有以下配置的计算机上启动:
- CPU:Intel i7-8550U Kaby Lake R
- 内存:32GB DDR4 2400 MHz
- 作业系统:Ubuntu 19.04 / 5.0.0-37-generic
- 为了消除动态频率缩放的影响,我禁用了intel_pstate驱动程序,并将CPU调节器设置为performance。
- 请记住,当前的基准测试可能会受到其他因素的影响,例如即时编译器优化,底层库(例如JMH),CPU缓存和分支预测效果,内存分配器子系统等。
-
所有基准测试结果(包括吞吐量,gc.alloc.rate.norm,gc.count,gc.time等)都合并在我的GitHub帐户的专用 HTML报告中
。为了获得更好的图表质量,我建议您打开 HTML报告,
因为当前帖子仅包含打印屏幕(通常用于吞吐量测量)。您还可以在GitHub的同一存储库下找到 原始基准测试结果
(即JSON测试结果)。
一点理论
在进一步介绍之前,我想简要地介绍一些理论,以更好地了解即将到来的基准测试。
什么是垃圾回收机制(GC)? 它是一种自动内存管理形式。垃圾收集器尝试回收由程序不再使用的对象所占用的垃圾或内存。值得一提的是,垃圾回收器除了回收(对于不再可访问的对象)外,还进行对象的分配。
分代 GC
意味着将数据划分为多个分配区域,这些区域根据对象的使用期限(即,幸存的GC迭代次数)保持分开。虽然有些收集器是单代的,但其他收集器则使用两个堆代:
(1)年轻代(划分为Eden 代和两个Survivor代)
(2)老生代。
单代GC:
两代GC:
- 串行GC
- Parallel/ParallelOld GC
- CMS GC
- G1 GC
读/写屏障(Read/write barriers)是一种在对对象进行读/写时执行一些额外的内存管理代码的机制。即使没有真正的GC发生,这些障碍通常也会影响应用程序的性能(只是读/写)。让我们考虑以下伪代码:
object.field = some_other_object <font><i>// write</i></font><font>
object = some_other_object.field </font><font><i>// read</i></font><font>
</font>
使用读/写障碍的概念,从概念上讲,它可能类似于:
write_barrier(object).field = some_other_object
object = read_barrier(some_other_object).field
关于上述AdoptOpenJDK收集器,使用的读/写障碍如下:
-
写屏障
-
一个写屏障(用于跟踪从老生代到年轻代的引用,例如, 卡片表
),用于:
- 串行GC
- Parallel/ParallelOld GC
- CMS GC
-
一个写屏障(在程序运行时并发标记的情况下,例如, Snapshot-At-The-Beginning
(SATB)用于:
-
两个写屏障:(a)首先,在并发标记(例如SATB)的情况下是PreWrite屏障,(b)其次,再是PostWrite屏障,不仅要跟踪从老生代到年轻代的引用,还要跟踪任何跨区域引用(例如,记忆集):
-
读屏障
-
Shenandoah GC
- 在OpenJDK版本<= 12的情况下通过引用访问对象的字段(即,每次访问都引用Brooks指针)时
- 如果OpenJDK版本> = 13,则从堆中加载引用(即,加载引用屏障(LRB))时
- ZGC,当从堆中加载引用时
-
没有障碍
- Epsilon GC完全不使用任何屏障,并且可以用作所有其他收集器的基准
超出范围
- 有关每个垃圾收集器如何工作的更多详细信息。互联网上有很多这样的材料(例如演示,书籍,博客等),其呈现方式比我可能写的要好。
- 任何其他JVM实现和GC类型(至少目前是这样)。
- 除4GB以外的任何其他JVM Heap大小。
- 除了报告的JHM时序外,还有任何关于为何基准X优于或劣于基准Y的详细说明。但是,我可以将资源提供给可能对重现该场景并进行进一步分析感兴趣的任何HotSpot工程师。
- 真实应用程序上的任何端到端宏基准测试。这可能是最有代表性的,但是,当前的重点是微基准测试。
BurstHeapMemoryAllocator基准
该基准测试创建了许多临时对象,在ArrayList中保持对它们的强引用,直到它填充了一定比例的Heap占用率,然后释放了它们(即调用blackhole.consume()),因此它们都突然有资格使用垃圾收集器。
结论
- ZGC和Shenandoah GC的性能明显优于其他所有收集器。
- G1 GC的吞吐量比ZGC和Shenandoah GC差,但是在_4_MB对象(即,根据G1术语为巨大对象)的情况下,其性能明显优于CMS GC,ParallelOld GC和串行GC。
(banq注:适合突然而来的尖锋访问)
ConstantHeapMemoryOccupancyBenchmark
此基准最初(在设置过程中)分配了许多对象,作为堆的预分配部分,并对其保持强烈引用,直到它填满一定百分比的堆占用率(例如70%)。预先分配的对象由大量的复合类组成(例如,类C1->类C2->…->类C32)。这可能会影响GC根遍历(例如,在“并行”标记阶段),因为遍历对象图时指针间接定向(即参考处理)的程度不可忽略。
然后,在基准测试方法中,分配了临时对象(大小为8 MB)并立即释放,因此它们很快就可以使用垃圾收集器。由于这些对象被认为是大对象,因此它们通常遵循缓慢的路径分配,直接驻留在“老生代”中(对于代收集者而言),从而增加了使用完整GC的可能性。
结论
- CMS GC和G1 GC的性能明显优于其他所有GC。
- 也许令人惊讶的是,ZGC和Shenandoah GC的吞吐量最差。
(banq注:程序有缓存机制,启动时预先warm了内存,加载了一些热点数据在内存中,或者使用类似Redis原理的常驻内存机制)
HeapMemoryBandwidthAllocatorBenchmark
此基准测试分配大小不同块的分配率。与以前的基准测试(例如,ConstantHeapMemoryOccupancyBenchmark)相比,它只是分配临时对象并立即释放它们,而没有保留任何预分配的对象。
结论
- 对于较大的对象(例如_4_MB),G1 GC的响应时间最差(比所有其他Collector慢5倍),而ParallelOld GC似乎是最高效的。
- 对于相对较小的对象(例如_4_KB),结果几乎相同,但对Shenandoah GC的支持略好一些,但没有相关的区别。
对读写屏障测试点击标题见原文
- 每个GC都有不同的内存占用空间来保存其内部GC本机结构。尽管这可能会受到堆大小的影响(即,增加/减小堆大小也可能会增加/减少GC本机结构的占用空间),但很显然,所有GC都需要额外的本机内存来进行堆管理。
- 除Epsilon GC外,最小的内存属于串行GC,其次是CMS GC,ZGC,Shenandoah GC,ParallelOld GC和G1 GC。
最终结论
请不要过于虔诚地接受此报告,因为它涵盖了所有可能的用例。此外,某些基准可能存在缺陷,而另一些基准可能需要付出更多的努力才能深入研究并试图理解这些数字背后的真正原因(超出范围)。即使这样,我认为它仍可以为您提供更广泛的了解,并证明没有一个垃圾收集器适合所有情况。双方各有利弊,各有千秋。
根据当前的基准设置和此特定设置很难提供一般性结论。不过,我将其总结为:
- 对于G1 GC,“记忆集”的管理有很大的开销。
- 当大量已分配实例(占堆大小的60%)有资格进行回收时,ZGC和Shenandoah GC似乎非常有效。
- 当堆Heap不包含许多其他可以在GC迭代之间存活的强可访问实例时,ParallelOld GC可以很好地回收短期分配的对象。
- CMS GC和G1 GC似乎提供了更好的吞吐量,同时当大量Heap堆(大约70%)不断被占用时,回收临时分配的大对象(例如8_MB),因此在GC迭代之间可以生存的实例非常强大。
即使有一些通用的GC特性,您也可以大致了解哪种特性更适合您的应用程序:
- 串行GC占用空间最小,并且可能是参考实现(即最简单的GC算法)。
- ParallelOld GC尝试针对高吞吐量进行优化。
- G1 GC努力在吞吐量和暂停时间之间取得平衡。
- ZGC努力争取尽可能短的暂停时间(例如,最大10毫秒),并且旨在从较小的堆大小到较大的堆大小(即,从数百MB到很多TB)更好地扩展。
- Shenandoah GC的目标是低暂停时间,不再与堆大小成正比。
(banq注:吞吐量与暂停是一对矛盾,ParallelOld GC注重高吞吐量,而Shenandoah GC是注重低暂停,其他是在这两个极端之间平滑)