目录
JVM垃圾收集器基准报告 – Ionuț Baloșin
各项基准:
BurstHeapMemoryAllocator基准
ConstantHeapMemoryOccupancyBenchmark
HeapMemoryBandwidthAllocatorBenchmark
ReadWriteBarriers基准
WriteBarriersLoopingOverArrayBenchmark
ReadBarriersLoopingOverArrayBenchmark
ReadBarriersChainOfClassesBenchmark
NMTMeasurementsMain
结论
本文是我根据 google 翻译自垃圾收集器基准报告,翻译的不清晰请勿怪。
希望读者甚于借鉴。
本文使用一组不同的模式描述了一系列Java虚拟机(JVM)垃圾收集器(GC)微基准及其结果。对于当前问题,我包括了AdoptOpenJDK 64位服务器VM版本13(内部版本13 + 33)中的所有垃圾收集器:
我故意选择了AdoptOpenJDK,因为并非所有的OpenJDK构建都包括Shenandoah GC。
当前所有GC基准测试都集中在以下指标上:
配置
一点理论
在进一步介绍之前,我想简要地介绍一些理论,以更好地了解即将到来的基准测试。
什么是垃圾回收机制(GC)? 它是一种自动内存管理形式。垃圾收集器尝试回收由程序不再使用的对象所占用的垃圾或内存。值得一提的是,垃圾回收器除了回收(对于不再可访问的对象)外,还进行对象的分配。
分代 GC意味着将数据划分为多个分配区域,这些区域根据对象的使用期限(即,幸存的GC迭代次数)保持分开。虽然有些收集器是单代的,但其他收集器则使用两个堆代:
(1)年轻代(划分为Eden 代和两个Survivor代)
(2)老生代。
单代GC:
两代GC:
读/写屏障(Read/write barriers)是一种在对对象进行读/写时执行一些额外的内存管理代码的机制。即使没有真正的GC发生,这些障碍通常也会影响应用程序的性能(只是读/写)。让我们考虑以下伪代码:
object.field = some_other_object // write object = some_other_object.field // read |
使用读/写障碍的概念,从概念上讲,它可能类似于:
write_barrier(object).field = some_other_object object = read_barrier(some_other_object).field |
关于上述AdoptOpenJDK收集器,使用的读/写障碍如下:
超出范围
该基准测试 创建了许多临时对象,在ArrayList中保持对它们的强引用,直到它填充了一定比例的Heap占用率,然后释放了它们(即调用blackhole.consume()),因此它们都突然有资格使用垃圾收集器。
void allocate(int sizeInBytes, int numberOfObjects) {
for (int iter = 0; iter < 4; iter++) {
List junk = new ArrayList<>(numberOfObjects);
for (int j = 0; j < numberOfObjects; j++) {
junk.add(new byte[sizeInBytes]);
}
blackhole.consume(junk);
}
}
sizeInBytes为:
自动计算numberOfObjects以仅消耗60%的可用堆内存
结论
(banq注:适合突然而来的尖锋访问)
此基准最初(在设置过程中)分配了许多对象,作为堆的预分配部分,并对其保持强烈引用,直到它填满一定百分比的堆占用率(例如70%)。预先分配的对象由大量的复合类组成(例如,类C1->类C2->…->类C32)。这可能会影响GC根遍历(例如,在“并行”标记阶段),因为遍历对象图时指针间接定向(即参考处理)的程度不可忽略。
然后,在基准测试方法中,分配了临时对象(大小为8 MB)并立即释放,因此它们很快就可以使用垃圾收集器。由于这些对象被认为是大对象,因此它们通常遵循缓慢的路径分配,直接驻留在“老生代”中(对于代收集者而言),从而增加了使用完整GC (FullGC) 的可能性。
void test(Blackhole blackhole) {
blackhole.consume(new byte[_8_MB]);
}
结论
(banq注:程序有缓存机制,启动时预先warm了内存,加载了一些热点数据在内存中,或者使用类似Redis原理的常驻内存机制)
对于ZGC和Shenandoah GC,这可以通过在每个周期标记整个堆以回收垃圾的成本来解释。在单代GC的情况下,收集(简单的)垃圾仍然需要标记整个可到达的预分配对象。通过使用-XX:ConcGCThreads =
ParallelOld GC和Serial GC陷入了由大量分配(例如8 MB大小的字节数组)引起的过早的完全GC综合症。但是,过早的完整GC也可能是由非大量分配引起的。
在G1 GC的情况下,巨大的分配具有特定的处理方式(即,它们被分配到专用区域中),并且在撤离暂停期间(在每个Young GC上)收集起始Java 8u40。
此基准测试分配大小不同块的分配率。与以前的基准测试(例如,ConstantHeapMemoryOccupancyBenchmark)相比,它只是分配临时对象并立即释放它们,而没有保留任何预分配的对象。
byte[] allocate() {
return new byte[sizeInBytes];
}
sizeInBytes为:
结论
对读写屏障测试点击标题见原文
测试读/写屏障的开销,同时遍历整数数组并在它们之间的每个值之间交换值(即array [i] <-> array [j])。整数数组在设置过程中初始化,因此在基准测试方法中,几乎不存在分配数。
void test() {
int lSize = size;
int mask = lSize - 1;
for (int i = 0; i < lSize; i++) {
Integer aux = array[i];
array[i] = array[(i + index) & mask];
array[(i + index) & mask] = aux;
}
index++;
}
在遍历整数数组的元素并更新其中的每个元素时,测试写屏障的开销。基准测试方法中的分配数量保持为零。
void test(Integer lRefInteger) {
int lSize = size;
for (int i = 0; i < lSize; i++) {
array[i] = lRefInteger;
}
}
由于缺少压缩的OOP,因此在ZGC的支持下产生了细微的差别。对于其他收集器(包括Shenandoah GC),访问压缩OOP需支付少量费用。由于ZGC不支持压缩OOP,因此在这里具有优势。
在遍历整数数组的元素并读取其中的每个元素时,测试读取障碍的开销。取消装箱效果(即int <-Integer之间的转换)也是此基准的副作用。
注意:遍历数组有利于可以提升屏障而不需要真正考虑屏障本身成本的算法。
int test() {
int lSize = size;
int sum = 0;
for (int i = 0; i < lSize; i++) {
sum += array[i];
}
return sum;
}
尽管在以后的版本中可能会修复读取屏障提升,但是在ZGC的情况下,压缩OOP的缺失是设计的约束。
在遍历一大堆预分配的复合类实例(例如,类H1->类H2->…->类H32)进行迭代时,测试读取屏障的开销,并返回由其最内部保留的字段属性。
int test() {
return h1.h2.h3.h4.h5.h6.h7.h8
.h9.h10.h11.h12.h13.h14.h15.h16
.h17.h18.h19.h20.h21.h22.h23.h24
.h25.h26.h27.h28.h29.h30.h31.h32.aValue;
}
// where:
class H1 {
H2 h2;
H1(H2 h2) {
this.h2 = h2;
}
}
// ...
class H32 {
int aValue;
public H32(int aValue) {
this.aValue = aValue;
}
}
此方案运行一个简单的“ Hello World” Java程序,该程序在JVM生命周期结束时打印堆管理所需的内部GC本机结构的大小。这样的结构可能是:
该测试依赖于本机内存跟踪(NMT),该程序跟踪(即检测)内部JVM分配并报告GC本机结构的占用空间。
public class NMTMeasurementsMain {
public static void main(String []args) {
System.out.println("Hello World");
}
}
使用以下JVM参数模式,每次通过指定不同的GC类型多次启动“ Hello World” Java程序:
-XX:+ UnlockDiagnosticVMOptions -XX:+使用 GC -XX:+ PrintNMTStatistics -XX:NativeMemoryTracking = summary -Xms4g -Xmx4g -XX:+ AlwaysPreTouch
// Serial GC
(reserved=13_694KB, committed=13_694KB)
(malloc=34 KB)
(mmap: reserved=13_660KB, committed=13_660KB)
// ParallelOld GC
(reserved=182_105KB, committed=182_105KB)
(malloc=28_861KB)
(mmap: reserved=153_244KB, committed=153_244KB)
// CMS GC
(reserved=34_574KB, committed=34_574KB)
(malloc=19_514KB)
(mmap: reserved=15_060KB, committed=15_060KB)
// G1 GC
(reserved=216_659KB, committed=216_659KB)
(malloc=27_711KB)
(mmap: reserved=188_948KB, committed=188_948KB)
// Shenandoah GC
(reserved=136_082KB, committed=70_538KB)
(malloc=4_994KB)
(mmap: reserved=131_088KB, committed=65_544KB)
// ZGC
(reserved=8_421_751KB, committed=65_911KB)
(malloc=375KB)
(mmap: reserved=8_421_376KB, committed=65_536KB)
// Epsilon GC
(reserved=29KB, committed=29KB)
(malloc=29KB)
说明:
最终结论
请不要过于虔诚地接受此报告,因为它涵盖了所有可能的用例。此外,某些基准可能存在缺陷,而另一些基准可能需要付出更多的努力才能深入研究并试图理解这些数字背后的真正原因(超出范围)。即使这样,我认为它仍可以为您提供更广泛的了解,并证明没有一个垃圾收集器适合所有情况。双方各有利弊,各有千秋。
根据当前的基准设置和此特定设置很难提供一般性结论。不过,我将其总结为:
即使有一些通用的GC特性,您也可以大致了解哪种特性更适合您的应用程序:
(banq注:吞吐量与暂停是一对矛盾,ParallelOld GC注重高吞吐量,而Shenandoah GC是注重低暂停,其他是在这两个极端之间平滑)