尝试对Lambda性能进行基准测试

3
我已阅读了这篇文章:Java 8 Lambda表达式和匿名内部类的性能差异,其中提供了文章
在文章中,它说:

Lambda调用的行为与匿名类调用完全相同。

我说:“好吧”,并决定编写自己的基准测试,我使用了jmh,下面是代码(我还添加了方法引用的基准测试)。
public class MyBenchmark {

    public static final int TESTS_COUNT = 100_000_000;

    @Benchmark
    public void testMethod_lambda() {
        X x = i -> test(i);
        for (long i = 0; i < TESTS_COUNT; i++) {
            x.x(i);
        }
    }
    @Benchmark
    public void testMethod_methodRefernce() {
        X x = this::test;
        for (long i = 0; i < TESTS_COUNT; i++) {
            x.x(i);
        }
    }
    @Benchmark
    public void testMethod_anonymous() {
        X x = new X() {
            @Override
            public void x(Long i) {
                test(i);
            }
        };
        for (long i = 0; i < TESTS_COUNT; i++) {
            x.x(i);
        }
    }

    interface X {
        void x(Long i);
    }

    public void test(Long i) {
        if (i == null) System.out.println("never");
    }
}

结果如下(在Intel Core i7 4770k上):

Benchmark                                     Mode  Samples   Score  Score error  Units
t.j.MyBenchmark.testMethod_anonymous         thrpt      200  16,160        0,044  ops/s
t.j.MyBenchmark.testMethod_lambda            thrpt      200   4,102        0,029  ops/s
t.j.MyBenchmark.testMethod_methodRefernce    thrpt      200   4,149        0,022  ops/s

如您所见,lambda表达式和匿名方法调用之间存在4倍差距,其中lambda表达式的速度较慢。

问题是:我做错了什么或者对于lambda表达式的性能理论存在误解?

编辑:

# VM invoker: C:\Program Files\Java\jre1.8.0_31\bin\java.exe
# VM options: <none>
# Warmup: 20 iterations, 1 s each
# Measurement: 20 iterations, 1 s each

3
观察生成的字节码,我注意到匿名类使用 invokespecial 构造对象,而 lambda 使用 invokedynamic。如果在测试之外构造每个对象的一个实例,我很想看看是否有速度差异。 - resueman
2
你为什么在JMH测试中运行循环?JMH会代替你执行循环。另外,你为什么使用那个“never”而不是blackhole?编译器足够智能以将其优化掉。 - the8472
2
此外,你引用的“Lambda调用的行为与匿名类调用完全相同”的事情... 你方便地省略了脚注。 - the8472
1
请注意,如果您将创建lambda、方法引用和匿名类的代码移动到私有静态字段中(并重复使用它们),则您的基准测试将显示方法引用和匿名类具有相同的性能,而lambda的性能较差。 - Tunaki
5
“我做错了什么?” 1)手动编写基准测试循环是错误的;JMH会为您完成。 2)结果未被Blackhole消耗 => JVM可能会奇怪地优化代码,您将测量到意料之外的结果。 - apangin
显示剩余4条评论
3个回答

9
问题出在你的基准测试中:你成为了死代码消除的受害者。
JIT编译器有时会很聪明地理解自动装箱的结果永远不会是null,所以对于匿名类,它只是删除了你的检查,这反过来使得循环体几乎为空。请用一些对JIT不太明显的东西来替换它,比如这样:
public void test(Long i) {
    if (i == Long.MAX_VALUE) System.out.println("never");
}

你会发现具有相同性能(匿名类变得更慢了,而Lambda表达式和方法引用在相同的水平上执行)。

由于某种原因,对于Lambda表达式/方法引用,它没有进行相同的优化。但是你不必担心:在真实代码中,很难完全优化掉这样的方法。

总的来说,@apangin是正确的:应该使用Blackhole。


3
没错,我刚刚测试了一下,这确实是问题所在。总的来说,@Andremoniy,你在基准测试中不应该试图变得聪明,因为编译器或JIT会更加聪明。 - Tunaki
1
@TagirValeev 是的,看起来我真的搞错了。非常感谢你的教训。 - Andremoniy
我真的很惊讶 i == Long.MAX_VALUE 是某种“不太明显”的东西,足以欺骗JVM。毕竟,我们有一个完全可预测的循环计数器和一个装箱操作,这是JVM已知的… - Holger

5
除了@TagirValeev提出的问题之外,你采用的基准测试方法基本上是有缺陷的,因为你正在测量一个组合指标(尽管你试图不这样做)。
你想独立衡量的重要成本是联接、捕获和调用。但是,你所有的测试都会将一定数量的这些内容混在一起,破坏你的结果。我的建议是只关注调用成本--这对于整体应用吞吐量最相关,也是最容易测量的(因为它受到多层缓存的影响较小)。
底线:在动态编译环境中测量性能真的非常困难。即使使用JMH。

0
我的问题是另一个不应该进行基准测试的例子。我根据其他回答中的建议重新创建了我的测试。
希望现在已经接近正确,因为它显示Lambda和匿名方法调用性能之间没有任何显着差异。请见下文:
@State(Scope.Benchmark)
public class MyBenchmark {

    @Param({"1", "100000", "500000"})
    public int arg;

    @Benchmark
    public void testMethod_lambda(Blackhole bh) {
        X x = (i, bh2) -> test(i, bh2);
        x.x(arg, bh);
    }

    @Benchmark
    public void testMethod_methodRefernce(Blackhole bh) {
        X x = this::test;
        x.x(arg, bh);
    }

    @Benchmark
    public void testMethod_anonymous(Blackhole bh) {
        X x = new X() {
            @Override
            public void x(Integer i, Blackhole bh) {
                test(i, bh);
            }
        };
        x.x(arg, bh);
    }

    interface X {
        void x(Integer i, Blackhole bh);
    }

    public void test(Integer i, Blackhole bh) {
        bh.consume(i);
    }
}
Benchmark                                     (arg)   Mode  Samples          Score  Score error  Units
t.j.MyBenchmark.testMethod_anonymous              1  thrpt      200  415893575,928  1353627,574  ops/s
t.j.MyBenchmark.testMethod_anonymous         100000  thrpt      200  394989882,972  1429490,555  ops/s
t.j.MyBenchmark.testMethod_anonymous         500000  thrpt      200  395707755,557  1325623,340  ops/s
t.j.MyBenchmark.testMethod_lambda                 1  thrpt      200  418597958,944  1098137,844  ops/s
t.j.MyBenchmark.testMethod_lambda            100000  thrpt      200  394672254,859  1593253,378  ops/s
t.j.MyBenchmark.testMethod_lambda            500000  thrpt      200  394407399,819  1373366,572  ops/s
t.j.MyBenchmark.testMethod_methodRefernce         1  thrpt      200  417249323,668  1140804,969  ops/s
t.j.MyBenchmark.testMethod_methodRefernce    100000  thrpt      200  396783159,253  1458935,363  ops/s
t.j.MyBenchmark.testMethod_methodRefernce    500000  thrpt      200  395098696,491  1682126,737  ops/s

1
我认为你仍然想将IC/lambda的创建因素分解为静态代码。否则,您仍会通过链接/捕获成本来污染某些调用测量。 - Brian Goetz
2
你正在测量两个具有独立性能特征的不同操作的成本总和。就像测量“去商店买牛奶”和“坐在厨房桌前喝一杯牛奶”的成本一样。因为,你的常见操作不是“买牛奶+喝牛奶”,而是“喝牛奶”。恰好需要先购买一些才能喝。但显然,购买牛奶受到许多与饮用无关的因素的影响(时间、交通状况、商店营业时间等)。你想分别测量每个操作的成本。 - Brian Goetz
好的,我明白你的意思。但是我已经尝试过测量常用情况(链接+调用)。很难想象有人会为这些事情创建静态字段。在一般情况下,例如Intellij Idea总是建议如果可能的话将匿名类替换为内联lambda,因此我决定进行测量。 - Andremoniy
1
我觉得你还没有理解。链接比调用(a)要昂贵得多,(b)只需执行一次,并且(c)受到许多因素的影响而容易扭曲。如果你认为常见情况是链接+调用一次,那么几乎可以定义为你不关心性能!(内部类的链接涉及到访问文件系统,从磁盘读取类文件字节,并解析/验证/加载类。)这就像说常见操作是“建造建筑物,然后乘电梯到10楼。”使性能分析变得困难的部分是知道什么需要衡量! - Brian Goetz
1
你仍然可以将test作为一个实例方法;静态地捕获X的一个实例,并在@Bench方法中测量x.x(..)。 - Brian Goetz
显示剩余4条评论

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