为什么传递两个字符串参数比传递一个列表参数更有效

2022-09-03 14:01:50

下面的代码调用两个简单函数,每个函数调用 100 亿次。

public class PerfTest {
    private static long l = 0;

    public static void main(String[] args) {
        List<String> list = Arrays.asList("a", "b");
        long time1 = System.currentTimeMillis();
        for (long i = 0; i < 1E10; i++) {
            func1("a", "b");
        }
        long time2 = System.currentTimeMillis();
        for (long i = 0; i < 1E10; i++) {
            func2(list);
        }
        System.out.println((time2 - time1) + "/" + (System.currentTimeMillis() - time2));
    }

    private static void func1(String s1, String s2) { l++; }
    private static void func2(List<String> sl) { l++; }
}

我的假设是,这两个调用的性能将接近相同。如果有的话,我会猜到传递两个参数会比传递一个参数稍微慢一些。鉴于所有参数都是对象引用,我没想到一个是列表的事实会有什么不同。

我已经多次运行测试,典型结果是“12781/30536”。换句话说,使用两个字符串的调用需要 13 秒,使用列表的调用需要 30 秒。

这种性能差异的原因是什么?还是这是一个不公平的测试?我尝试过切换两个调用(以防由于启动效果),但结果是相同的。

更新

由于许多原因,这不是一个公平的测试。但是,它确实展示了Java编译器的真实行为。请注意以下两个新增功能来演示这一点:

  • 将表达式和添加到函数中会使两个函数调用性能相同s1.getClass()sl.getClass()
  • 运行 测试 也使两个函数调用执行相同的-XX:-TieredCompilation

这种行为的解释在下面接受的答案中。@apangin的答案的非常简短的总结是,热点编译器没有内联,因为它的参数(即)的类没有被解析。强制解析类(例如 using )会导致它被内联,从而显着提高其性能。正如答案中指出的那样,未解析的类不太可能出现在实际代码中,这使得此代码成为不切实际的边缘情况。func2ListgetClass


答案 1

然而,基准是不公平的,它揭示了一个有趣的效果。

正如Sotirios Delimanolis所注意到的那样,性能差异是由HotSpot编译器内联的事实引起的,而事实并非如此。原因是类型的参数,在基准测试执行期间从未被解析过的类。func1func2func2List

请注意,实际上并未使用类:不调用 List 方法,不声明 List 类型的字段,不强制转换类,也不执行通常会导致类解析的其他操作。如果在代码中的任何位置添加类的用法,则 将内联。ListListfunc2

影响编译策略的另一个特点是方法的简单性。它是如此简单,以至于JVM决定在第1层(C1,没有进一步的优化)中编译它。如果它是用 C2 编译的,那么类将被解析。尝试运行 ,您将看到 它已成功内联,并且执行速度与 一样快。List-XX:-TieredCompilationfunc2func1

手动编写逼真的微基准标记是一项非常困难的工作。有很多方面可能导致令人困惑的结果,例如内联,死代码消除,堆栈替换,配置文件污染,重新编译等。这就是为什么强烈建议使用适当的基准测试工具,如JMH。手写的基准测试可以很容易地欺骗JVM。特别是,实际应用程序不太可能具有具有从未使用过的类的方法。


答案 2

推荐