为什么使用Epsilon相比G1,重复的内存分配速度更慢?

20

我很好奇用G1和Epsilon测量JDK 13中分配内存所花费的时间。我观察到的结果出乎意料,我想了解其中的原因。最终,我希望了解如何使Epsilon的使用比G1更具性能(或如果不可能,则为什么不行)。

我编写了一个小测试,重复地分配内存。根据命令行输入,它要么:

  • 创建1,024个新的1 MB数组;或者
  • 创建1,024个新的1 MB数组,测量分配周围的时间,并打印出每次分配的经过时间。这不仅仅是测量分配本身,还包括在System.nanoTime()两次调用之间发生的任何其他事情的经过时间——尽管这似乎是一个有用的信号。

以下是代码:

public static void main(String[] args) {
    if (args[0].equals("repeatedAllocations")) {
        repeatedAllocations();
    } else if (args[0].equals("repeatedAllocationsWithTimingAndOutput")) {
        repeatedAllocationsWithTimingAndOutput();
    }
}

private static void repeatedAllocations() {
    for (int i = 0; i < 1024; i++) {
        byte[] array = new byte[1048576]; // allocate new 1MB array
    }
}

private static void repeatedAllocationsWithTimingAndOutput() {
    for (int i = 0; i < 1024; i++) {
        long start = System.nanoTime();
        byte[] array = new byte[1048576]; // allocate new 1MB array
        long end = System.nanoTime();
        System.out.println((end - start));
    }
}

这是我正在使用的JDK版本信息:

$ java -version
openjdk version "13-ea" 2019-09-17
OpenJDK Runtime Environment (build 13-ea+22)
OpenJDK 64-Bit Server VM (build 13-ea+22, mixed mode, sharing)

这是我运行程序的不同方式:

  • 仅使用G1进行分配:$ time java -XX:+UseG1GC Scratch repeatedAllocations
  • 仅进行分配,使用Epsilon:$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
  • 使用G1进行分配、计时和输出:$ time java -XX:+UseG1GC Scratch repeatedAllocationsWithTimingAndOutput
  • 进行分配、计时和输出,使用Epsilon:time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocationsWithTimingAndOutput

以下是仅使用G1进行分配时的一些计时信息:

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.280s
user    0m0.404s
sys     0m0.081s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.293s
user    0m0.415s
sys     0m0.080s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.295s
user    0m0.422s
sys     0m0.080s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.296s
user    0m0.422s
sys     0m0.079s

以下是仅使用分配运行 Epsilon 的一些时间:

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.665s
user    0m0.314s
sys     0m0.373s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.652s
user    0m0.313s
sys     0m0.354s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.659s
user    0m0.314s
sys     0m0.362s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.665s
user    0m0.320s
sys     0m0.367s

使用G1时,无论是否启用时间和输出,速度都比Epsilon更快。作为额外的衡量标准,使用repeatedAllocationsWithTimingAndOutput中的计时数字,当使用Epsilon时平均分配时间较长。具体而言,其中一个本地运行显示G1GC平均每次分配需要227,218纳秒,而Epsilon平均需要521,217纳秒(我捕获了输出数字,将其粘贴到电子表格中,并对每组数字使用了average功能)。

我的期望是Epsilon测试会明显更快,但在实践中,我看到的速度要慢大约两倍。G1的最大分配时间确实更高,但仅间歇性地 - 大多数G1分配速度要比Epsilon慢得多,几乎慢了一个数量级。

下面是运行repeatedAllocationsWithTimingAndOutput() 1024次的结果绘图,其中深绿色表示G1;浅绿色表示Epsilon;Y轴是“每个分配的纳秒数”;Y轴小网格线每250,000个纳秒。它显示Epsilon分配时间非常一致,每次大约在300-400k纳秒左右。它还显示G1的时间大部分时间明显更快,但也时断时续地比Epsilon慢了大约10倍。我认为这可能归因于垃圾收集器运行,这是合理和正常的,但似乎也是否定了G1聪明地知道它不需要分配任何新内存的想法。

enter image description here


1
问题可能在于JVM注意到array没有被使用,因此它变得可被垃圾收集器回收,然后旧的内存就被重用了,而Epsilon可能需要向操作系统请求更多的内存。 简而言之:这个测试没有显示任何东西。 - Johannes Kuhn
不,完全不是。但系统时间可能是这样的结果。我会从外部观察JVM的内存消耗情况。或者以某种方式跟踪操作系统获取的内存请求。现代虚拟机中有很多事情要处理。也许还可以将数组存储在另一个数组中,这样它就不会被垃圾回收? - Johannes Kuhn
我更新了问题,包括一张图表和一些评论。 - Kaan
4
G1 能够收集垃圾,而 epsilon 不能。如果想要进行公平的比较,需要将所有已分配的内存“泄露”,也就是保持对大量内存的引用保持活跃,就像许多实际应用程序中所发生的一样。或者,您需要为 G1 预先分配足够大的堆,以便它永远不会进行垃圾回收。 - the8472
6
两个垃圾收集器分配内存的成本相同,回收内存的成本微不足道(正如我一直说的,它们随着生还对象数量而变化,但在这里,没有生还对象)。但是一个不回收对象的JVM必须经常从操作系统获取新内存。这很昂贵,也是为什么主要差异在“sys”时间方面可见的原因。这是完全合理的结果。 - Holger
显示剩余3条评论
2个回答

34

我相信你正在看到第一次访问时内存布线的成本。

在Epsilon情况下,分配总是会寻找新的内存,这意味着操作系统本身必须将物理页面连接到JVM进程。在G1情况下,同样的事情发生了,但在第一次GC周期之后,它会在已经布线的内存中分配对象。G1会体验偶尔与GC暂停相关的延迟跳跃。

但是有些操作系统特殊性。至少在Linux上,当JVM(或任何其他进程)“保留”和“提交”内存时,内存实际上并没有被布线:也就是说,物理页面尚未分配给它。作为优化,Linux在第一次写入页面时进行布线。顺便说一下,这种操作系统活动会表现为sys%,这就是为什么你在计时中看到它的原因。

当你优化占用空间时,这可能是操作系统应该做的正确事情,例如在机器上运行许多进程,(预)分配大量内存,但几乎不使用它。这将在例如-Xms4g -Xmx4g的情况下发生:操作系统将愉快地报告所有4G都“提交”了,但在JVM开始写入之前什么也不会发生。

所有这些都是为了这个奇怪的技巧:在JVM启动时使用-XX:+AlwaysPreTouch预触摸所有堆内存(注意head,这些是第一个样本):

$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xms4g -Xmx4g \
       Scratch repeatedAllocationsWithTimingAndOutput | head
491988
507983
495899
492679
485147

$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+AlwaysPreTouch -Xms4g -Xmx4g \
       Scratch repeatedAllocationsWithTimingAndOutput | head
45186
42242
42966
49323
42093

在这里,开箱即用的运行确实使Epsilon看起来比G1更糟糕(注意tail,这些是最后的样本):

$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xms4g -Xmx4g \
       Scratch repeatedAllocationsWithTimingAndOutput | tail
389255
386474
392593
387604
391383

$ java -XX:+UseG1GC -Xms4g -Xmx4g \
  Scratch repeatedAllocationsWithTimingAndOutput | tail
72150
74065
73582
73371
71889

...但是一旦内存的连接被排除在外(注意tail,这些是最后的样本):

$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+AlwaysPreTouch -Xms4g -Xmx4g \
       Scratch repeatedAllocationsWithTimingAndOutput | tail
42636
44798
42065
44948
42297

$ java -XX:+UseG1GC -XX:+AlwaysPreTouch -Xms4g -Xmx4g \
        Scratch repeatedAllocationsWithTimingAndOutput | tail
52158
51490
45602
46724
43752

G1也有所改进,因为每个周期后都会触碰一点新的内存。Epsilon更快,因为它需要处理的东西更少。
总体而言,这就是为什么-XX:+AlwaysPreTouch是建议用于低延迟/高吞吐量工作负载的选项,可以接受前期启动成本和前期RSS占用支付。
更新:深思熟虑,这是Epsilon UX错误,简单的怪癖应该给用户警告

4
优秀的细节和信息。此外,需要添加的内容是来自Java HotSpot VM选项中的-XX:+AlwaysPreTouch:在JVM初始化期间预先触摸Java堆。这样,在初始化期间会对堆的每个页面进行需求清零,而不是在应用程序执行过程中递增地进行。 - Kaan

4

@Holger的评论解释了我在原始测试中缺少的一点——从操作系统获取新内存比在JVM内部回收内存更加昂贵。@the8472的评论指出应用程序代码没有保留对任何已分配数组的引用,因此测试并没有测试我想要的内容。通过修改测试以保留对每个新数组的引用,结果现在显示Epsilon的表现优于G1。

以下是我在代码中用来保留引用的方法。将其定义为成员变量:

static ArrayList<byte[]> savedArrays = new ArrayList<>(1024);

然后在每个分配之后添加以下内容:

savedArrays.add(array);

Epsilon 分配与以前类似,这是可以预料的:

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.587s
user    0m0.312s
sys     0m0.296s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.589s
user    0m0.313s
sys     0m0.297s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.605s
user    0m0.316s
sys     0m0.313s

G1的时间现在比以前慢得多,也比Epsilon慢:

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.884s
user    0m1.265s
sys     0m0.538s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.884s
user    0m1.251s
sys     0m0.533s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.864s
user    0m1.214s
sys     0m0.528s

重新运行使用repeatedAllocationsWithTimingAndOutput()的每个分配时间,平均值现在表明Epsilon更快。
average time (in nanos) for 1,024 consecutive 1MB array allocations
Epsilon 491,665
G1      883,981

我猜你现在有足够的声望来接受你的答案了 :) (太遗憾@Holger没有把他的评论发表成答案...) - Matthieu
1
我猜是这样的!如果没有确切的答案,很高兴引用评论中提供的良好意见/考虑。;-) - Kaan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接