为什么返回一个Java对象引用比返回一个原语慢得多

我们正在研究一个对延迟敏感的应用程序,并且已经微调了各种方法(使用jmh )。 在对查找方法进行微观基准testing并对结果满意之后,我实现了最终版本,结果发现最终版本比我刚刚testing的版本3倍

罪魁祸首是实施的方法是返回一个enum对象,而不是一个int 。 以下是基准代码的简化版本:

 @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Thread) public class ReturnEnumObjectVersusPrimitiveBenchmark { enum Category { CATEGORY1, CATEGORY2, } @Param( {"3", "2", "1" }) String value; int param; @Setup public void setUp() { param = Integer.parseInt(value); } @Benchmark public int benchmarkReturnOrdinal() { if (param < 2) { return Category.CATEGORY1.ordinal(); } return Category.CATEGORY2.ordinal(); } @Benchmark public Category benchmarkReturnReference() { if (param < 2) { return Category.CATEGORY1; } return Category.CATEGORY2; } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder().include(ReturnEnumObjectVersusPrimitiveBenchmark.class.getName()).warmupIterations(5) .measurementIterations(4).forks(1).build(); new Runner(opt).run(); } } 

以上的基准testing结果:

 # VM invoker: C:\Program Files\Java\jdk1.7.0_40\jre\bin\java.exe # VM options: -Dfile.encoding=UTF-8 Benchmark (value) Mode Samples Score Error Units benchmarkReturnOrdinal 3 thrpt 4 1059.898 ± 71.749 ops/us benchmarkReturnOrdinal 2 thrpt 4 1051.122 ± 61.238 ops/us benchmarkReturnOrdinal 1 thrpt 4 1064.067 ± 90.057 ops/us benchmarkReturnReference 3 thrpt 4 353.197 ± 25.946 ops/us benchmarkReturnReference 2 thrpt 4 350.902 ± 19.487 ops/us benchmarkReturnReference 1 thrpt 4 339.578 ± 144.093 ops/us 

只是改变函数的返回types,将性能改变了近3倍。

我认为返回一个枚举对象与整数的唯一区别是返回一个64位的值(引用),另一个返回一个32位的值。 我的一位同事猜测,返回枚举会增加开销,因为需要跟踪潜在GC的参考。 (但是,鉴于枚举对象是静态的最终引用,这似乎很奇怪,它将需要这样做)。

性能差异的解释是什么?


UPDATE

我在这里分享了maven项目,这样任何人都可以克隆它并运行基准testing。 如果任何人有时间/兴趣,看看其他人是否可以复制相同的结果会很有帮助。 (我已经复制了两个不同的机器,Windows 64和Linux 64,都使用Oracle Java 1.7 JVM的口味)。 ZhekaKozlov说他没有看到方法之间的任何区别。

运行:(克隆存储库之后)

 mvn clean install java -jar .\target\microbenchmarks.jar function.ReturnEnumObjectVersusPrimitiveBenchmark -i 5 -wi 5 -f 1 

TL; DR:你不应该把BLIND信任放进任何东西。

首先要做的是:在跳出实验数据之前,validation实验数据是非常重要的。 只是声称某事是3倍快/慢是奇怪的,因为你确实需要追踪性能差异的原因,而不仅仅是相信数字。 对于像您这样的纳米基准来说,这一点尤其重要。

其次,实验者应该清楚地知道他们控制什么,什么不控制。 在你的具体例子中,你从@Benchmark方法中返回值,但是你能够合理地确定外部的调用者会为原语和引用做同样的事情吗? 如果你问自己这个问题,那么你会意识到你基本上是测量testing基础设施。

到了这一点。 在我的机器上(i5-4210U,Linux x86_64,JDK 8u40),testing结果如下:

 Benchmark (value) Mode Samples Score Error Units ...benchmarkReturnOrdinal 3 thrpt 5 0.876 ± 0.023 ops/ns ...benchmarkReturnOrdinal 2 thrpt 5 0.876 ± 0.009 ops/ns ...benchmarkReturnOrdinal 1 thrpt 5 0.832 ± 0.048 ops/ns ...benchmarkReturnReference 3 thrpt 5 0.292 ± 0.006 ops/ns ...benchmarkReturnReference 2 thrpt 5 0.286 ± 0.024 ops/ns ...benchmarkReturnReference 1 thrpt 5 0.293 ± 0.008 ops/ns 

好的,所以参考testing的速度要慢3倍。 但是,等一下,它使用一个老的JMH(1.1.1),让我们更新到当前最新的(1.7.1):

 Benchmark (value) Mode Cnt Score Error Units ...benchmarkReturnOrdinal 3 thrpt 5 0.326 ± 0.010 ops/ns ...benchmarkReturnOrdinal 2 thrpt 5 0.329 ± 0.004 ops/ns ...benchmarkReturnOrdinal 1 thrpt 5 0.329 ± 0.004 ops/ns ...benchmarkReturnReference 3 thrpt 5 0.288 ± 0.005 ops/ns ...benchmarkReturnReference 2 thrpt 5 0.288 ± 0.005 ops/ns ...benchmarkReturnReference 1 thrpt 5 0.288 ± 0.002 ops/ns 

哎呀,现在他们只是勉强慢点。 顺便说一句,这也告诉我们这个testing是基础架构的。 好的,我们可以看到真的发生了什么?

如果您构build基准,并查看究竟是什么调用了您的@Benchmark方法,那么您将看到如下所示的内容:

 public void benchmarkReturnOrdinal_thrpt_jmhStub(InfraControl control, RawResults result, ReturnEnumObjectVersusPrimitiveBenchmark_jmh l_returnenumobjectversusprimitivebenchmark0_0, Blackhole_jmh l_blackhole1_1) throws Throwable { long operations = 0; long realTime = 0; result.startTime = System.nanoTime(); do { l_blackhole1_1.consume(l_longname.benchmarkReturnOrdinal()); operations++; } while(!control.isDone); result.stopTime = System.nanoTime(); result.realTime = realTime; result.measuredOps = operations; } 

l_blackhole1_1有一个consume方法,它会“消耗”这些值(见Blackhole的基本原理)。 Blackhole.consume具有引用和基元的重载,这足以certificate性能的差异。

这些方法看起来不同的原因是:他们试图尽可能快地争取他们的论点。 即使我们尝试匹配它们,它们也不一定performance出相同的性能特征,因此与更新的JMH更加对称。 现在,您甚至可以使用-prof perfasm来查看为您的testing生成的代码,并了解为什么性能不同,但这超出了这一点。

如果您真的了解如何返回原始图片和/或引用的不同性能,您将需要进入一个微妙的性能基准灰色区域 。 例如像这样的testing:

 @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) @Fork(5) public class PrimVsRef { @Benchmark public void prim() { doPrim(); } @Benchmark public void ref() { doRef(); } @CompilerControl(CompilerControl.Mode.DONT_INLINE) private int doPrim() { return 42; } @CompilerControl(CompilerControl.Mode.DONT_INLINE) private Object doRef() { return this; } } 

…对基元和引用产生相同的结果:

 Benchmark Mode Cnt Score Error Units PrimVsRef.prim avgt 25 2.637 ± 0.017 ns/op PrimVsRef.ref avgt 25 2.634 ± 0.005 ns/op 

正如我上面所说,这些testing需要跟踪结果的原因。 在这种情况下,两者生成的代码几乎相同,这就解释了结果。

整洁:

  [Verified Entry Point] 12.69% 1.81% 0x00007f5724aec100: mov %eax,-0x14000(%rsp) 0.90% 0.74% 0x00007f5724aec107: push %rbp 0.01% 0.01% 0x00007f5724aec108: sub $0x30,%rsp 12.23% 16.00% 0x00007f5724aec10c: mov $0x2a,%eax ; load "42" 0.95% 0.97% 0x00007f5724aec111: add $0x30,%rsp 0.02% 0x00007f5724aec115: pop %rbp 37.94% 54.70% 0x00007f5724aec116: test %eax,0x10d1aee4(%rip) 0.04% 0.02% 0x00007f5724aec11c: retq 

参考:

  [Verified Entry Point] 13.52% 1.45% 0x00007f1887e66700: mov %eax,-0x14000(%rsp) 0.60% 0.37% 0x00007f1887e66707: push %rbp 0.02% 0x00007f1887e66708: sub $0x30,%rsp 13.63% 16.91% 0x00007f1887e6670c: mov %rsi,%rax ; load "this" 0.50% 0.49% 0x00007f1887e6670f: add $0x30,%rsp 0.01% 0x00007f1887e66713: pop %rbp 39.18% 57.65% 0x00007f1887e66714: test %eax,0xe3e78e6(%rip) 0.02% 0x00007f1887e6671a: retq 

[讽刺]看看它是多么容易! [/讽刺]

模式是:这个问题越简单,你就越需要做出合理可靠的答案。

为了清除对引用内存的误解,有一些已经陷入了(@Mzf),让我们深入Java虚拟机规范。 但在去那里之前,必须澄清一件事 – 一件东西永远不能从记忆中获得,只有它的领域才能获得 。 事实上,没有操作码可以执行如此广泛的操作。

本文档将引用定义为堆栈types (这样它可能是对堆栈执行操作的指令的结果或参数),即第一个类别(采用单个堆栈字(32位))的types类别。 见表2.3 Java堆栈类型的列表

此外,如果方法调用按照规范正常完成,则将从栈顶popup的值压入方法调用者的堆栈(第2.6.4节)。

你的问题是什么导致执行时间的差异。 第2章前言答案:

不属于Java虚拟机规范的实现细节将不必要地限制实现者的创造力。 例如,运行时数据区域的内存布局,所使用的垃圾收集algorithm以及Java虚拟机指令的任何内部优化(例如,将它们转换为机器代码)均由实现者自行决定。

换句话说,由于逻辑原因(最终只是一个堆栈词作为intfloat ),在文档中没有提及引用的使用方面的性能损失,所以您只剩下search实现的源代码或者从来没有发现。

从某种程度上说,我们不应该总是责怪实施,在寻找答案的时候,你可以采取一些线索。 Java定义了用于操作数字和引用的单独指令。 参考操作指令以a (例如, astoreaload或者areturn )开始,并且是允许使用引用的唯一指令。 特别是你可能有兴趣看看areturn实施。