为什么观察到重复内存分配使用 Epsilon 与 G1 相比更慢?
Why are repeated memory allocations observed to be slower using Epsilon vs. G1?
我很想测量在 JDK13 中使用 G1 和 Epsilon 分配内存所花费的时间。我观察到的结果是出乎意料的,我有兴趣了解正在发生的事情。最终,我希望了解如何使 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 测试会明显更快,但实际上我发现速度慢了约 2 倍。 G1 的最大分配时间肯定更高,但只是间歇性的——大多数 G1 分配明显慢于 Epsilon,几乎慢一个数量级。
这是来自 运行 repeatedAllocationsWithTimingAndOutput()
的 1,024 次图,使用 G1 和 Epsilon。深绿色代表G1;浅绿色代表 Epsilon; Y轴为"nanos per allocation"; Y 轴次网格线每 250,000 纳米。它表明 Epsilon 分配时间非常一致,每次大约 300-400k 纳米。它还显示 G1 时间在大多数情况下明显更快,但也间歇性地比 Epsilon 慢 10 倍左右。我假设这将归因于垃圾收集器 运行,这将是理智和正常的,但似乎也否定了 G1 足够聪明,知道它不需要分配任何新内存的想法。
@Holger 上面的评论解释了我在原始测试中遗漏的部分——从 OS 获取新内存比在 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
我相信您看到了在首次访问时连接内存的成本。
在 Epsilon 的情况下,分配总是到达新内存,这意味着 OS 本身必须将物理页面连接到 JVM 进程。在 G1 的情况下,同样的事情发生了,但是在第一个 GC 周期之后,它会在已经连接的内存中分配对象。 G1 偶尔会遇到与 GC 暂停相关的延迟跳跃。
但是有 OS 个特点。至少在 Linux 上,当 JVM(或者实际上,任何其他进程)"reserves" 和 "commits" 内存时,内存 实际上 没有连接:那个物理页面 尚未分配给它。作为优化,Linux 在对页面的第一次写访问时进行此连接。顺便说一句,OS activity 会显示为 sys%
,这就是您在计时中看到它的原因。
这可以说是 OS 要做的正确事情,当您优化占用空间时,例如,许多进程 运行 在机器上运行,(预)分配大量内存,但几乎不使用它。例如,-Xms4g -Xmx4g
会发生这种情况:OS 会很高兴地报告所有 4G 都是 "committed",但是在 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 足迹付款的 low-latency/high-throughput 工作负载的推荐选项。
UPD:仔细想想,这是 Epsilon UX 错误,简单的特性应该会产生 warning to users。
我很想测量在 JDK13 中使用 G1 和 Epsilon 分配内存所花费的时间。我观察到的结果是出乎意料的,我有兴趣了解正在发生的事情。最终,我希望了解如何使 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 测试会明显更快,但实际上我发现速度慢了约 2 倍。 G1 的最大分配时间肯定更高,但只是间歇性的——大多数 G1 分配明显慢于 Epsilon,几乎慢一个数量级。
这是来自 运行 repeatedAllocationsWithTimingAndOutput()
的 1,024 次图,使用 G1 和 Epsilon。深绿色代表G1;浅绿色代表 Epsilon; Y轴为"nanos per allocation"; Y 轴次网格线每 250,000 纳米。它表明 Epsilon 分配时间非常一致,每次大约 300-400k 纳米。它还显示 G1 时间在大多数情况下明显更快,但也间歇性地比 Epsilon 慢 10 倍左右。我假设这将归因于垃圾收集器 运行,这将是理智和正常的,但似乎也否定了 G1 足够聪明,知道它不需要分配任何新内存的想法。
@Holger 上面的评论解释了我在原始测试中遗漏的部分——从 OS 获取新内存比在 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
我相信您看到了在首次访问时连接内存的成本。
在 Epsilon 的情况下,分配总是到达新内存,这意味着 OS 本身必须将物理页面连接到 JVM 进程。在 G1 的情况下,同样的事情发生了,但是在第一个 GC 周期之后,它会在已经连接的内存中分配对象。 G1 偶尔会遇到与 GC 暂停相关的延迟跳跃。
但是有 OS 个特点。至少在 Linux 上,当 JVM(或者实际上,任何其他进程)"reserves" 和 "commits" 内存时,内存 实际上 没有连接:那个物理页面 尚未分配给它。作为优化,Linux 在对页面的第一次写访问时进行此连接。顺便说一句,OS activity 会显示为 sys%
,这就是您在计时中看到它的原因。
这可以说是 OS 要做的正确事情,当您优化占用空间时,例如,许多进程 运行 在机器上运行,(预)分配大量内存,但几乎不使用它。例如,-Xms4g -Xmx4g
会发生这种情况:OS 会很高兴地报告所有 4G 都是 "committed",但是在 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 足迹付款的 low-latency/high-throughput 工作负载的推荐选项。
UPD:仔细想想,这是 Epsilon UX 错误,简单的特性应该会产生 warning to users。