为什么返回 Java 对象引用比返回原始对象慢得多

Why is returning a Java object reference so much slower than returning a primitive

我们正在开发一个对延迟敏感的应用程序,并且一直在对各种方法进行微基准测试(使用 jmh)。在对查找方法进行微基准测试并对结果感到满意后,我实现了最终版本,结果发现最终版本比我刚刚进行基准测试的版本慢 3 倍

罪魁祸首是实现的方法是 returning 一个 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();
    }

}

以上基准测试结果:

# 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

只需更改函数的 return 类型,性能就会提高近 3 倍。

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

性能差异的解释是什么?


更新

我分享了 Maven 项目 here 以便任何人都可以克隆它和 运行 基准。如果有人有 time/interest,看看其他人是否可以复制相同的结果会很有帮助。 (我在 2 台不同的机器上进行了复制,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

为了消除对referencememory的误解,有些人陷入了(@Mzf),让我们进入Java 虚拟机规范。 但在去那里之前,必须澄清一件事 - 永远不能从内存中检索对象,只有它的字段可以。事实上,没有可以执行如此广泛操作的操作码。

本文档将引用定义为第一类的堆栈类型(因此它可能是对堆栈执行操作的指令的结果或参数)——采用的类型类别单个堆栈字(32 位)。参见 table 2.3 .

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

您的问题是导致执行时间不同的原因。第2章前言答案:

Implementation details that are not part of the Java Virtual Machine's specification would unnecessarily constrain the creativity of implementors. For example, the memory layout of run-time data areas, the garbage-collection algorithm used, and any internal optimization of the Java Virtual Machine instructions (for example, translating them into machine code) are left to the discretion of the implementor.

换句话说,因为出于逻辑原因,文档中没有说明有关引用使用的性能惩罚(它最终只是一个堆栈词,如 intfloat 是) ,您只能搜索实现的源代码或根本找不到。

在某种程度上,我们实际上不应该总是责怪实施,在寻找答案时可以利用一些线索。 Java 定义了用于处理数字和引用的单独指令。引用操作指令以 a 开头(例如 astorealoadareturn)并且是唯一允许使用引用的指令。特别是您可能有兴趣查看 areturn 的实现。

TL;DR:你不应该盲目信任任何东西。

首先要做的是:在得出结论之前验证实验数据很重要。仅仅声称某物是 3x faster/slower 是奇怪的,因为您确实需要跟进性能差异的原因,而不仅仅是相信数字。这对于像您这样的纳米基准尤其重要。

其次,实验者要清楚自己控制什么,不控制什么。在您的特定示例中,您从 @Benchmark 方法返回值,但是您能合理地确定外部调用者会对原语和引用做同样的事情吗?如果您问自己这个问题,那么您会发现您基本上是在测量测试基础设施。

言归正传。在我的机器上(i5-4210U,Linux x86_64,JDK 8u40),测试结果:

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

好的,参考测试似乎慢了 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

哎呀,现在他们只是慢了一点点。顺便说一句,这也告诉我们测试受基础设施限制。好的,我们可以看看到底发生了什么吗?

如果您构建基准测试,并查看究竟是什么调用了您的 @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 方法,其中 "consumes" 值(请参阅 Blackhole 了解基本原理)。 Blackhole.consumereferences and primitives 有重载,仅此一项就足以证明性能差异。

这些方法看起来不同是有原因的:他们试图尽可能快地处理他们的论证类型。它们不一定表现出相同的性能特征,即使我们尝试匹配它们,因此较新的 JMH 的结果更对称。现在,您甚至可以转至 -prof perfasm 查看为您的测试生成的代码,了解性能差异的原因,但这超出了此处的重点。

如果您真的想要了解返回原始 and/or 引用在性能方面有何不同,您需要输入 可怕的灰色地带 细微差别的性能基准测试。例如。像这样的测试:

@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

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

原色:

                  [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    [=15=]x30,%rsp         
 12.23%   16.00%    0x00007f5724aec10c: mov    [=15=]x2a,%eax   ; load "42"
  0.95%    0.97%    0x00007f5724aec111: add    [=15=]x30,%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    [=16=]x30,%rsp         
 13.63%   16.91%    0x00007f1887e6670c: mov    %rsi,%rax     ; load "this"
  0.50%    0.49%    0x00007f1887e6670f: add    [=16=]x30,%rsp
  0.01%             0x00007f1887e66713: pop    %rbp
 39.18%   57.65%    0x00007f1887e66714: test   %eax,0xe3e78e6(%rip)
  0.02%             0x00007f1887e6671a: retq   

[讽刺] 看看这多简单! [/讽刺]

规律是:问题越简单,您就越需要努力才能做出合理可靠的答案。