43

在最近关于如何优化某些代码的讨论中,有人告诉我将代码分解为许多小方法可以显着提高性能,因为 JIT 编译器不喜欢优化大方法。

我不确定这一点,因为 JIT 编译器本身似乎应该能够识别自包含的代码段,无论它们是否在自己的方法中。

任何人都可以确认或反驳这一说法吗?

4

4 回答 4

30

Hotspot JIT 仅内联小于某个(可配置)大小的方法。所以使用更小的方法允许更多的内联,这很好。

请参阅此页面上的各种内联选项。


编辑

稍微详细说明一下:

  • 如果一个方法很小,它将被内联,因此很少有机会因将代码拆分为小方法而受到惩罚。
  • 在某些情况下,拆分方法可能会导致更多的内联。

示例(如果您尝试,完整的代码将具有相同的行号)

package javaapplication27;

public class TestInline {
    private int count = 0;

    public static void main(String[] args) throws Exception {
        TestInline t = new TestInline();
        int sum = 0;
        for (int i  = 0; i < 1000000; i++) {
            sum += t.m();
        }
        System.out.println(sum);
    }

    public int m() {
        int i = count;
        if (i % 10 == 0) {
            i += 1;
        } else if (i % 10 == 1) {
            i += 2;
        } else if (i % 10 == 2) {
            i += 3;
        }
        i += count;
        i *= count;
        i++;
        return i;
    }
}

使用以下 JVM 标志运行此代码时:(-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:FreqInlineSize=50 -XX:MaxInlineSize=50 -XX:+PrintInlining是的,我使用了证明我的情况的值:m太大但重构mm2都低于阈值 - 使用其他值可能会得到不同的输出)。

您将看到m()main()编译,但m()不会内联:

 56    1             javaapplication27.TestInline::m (62 bytes)
 57    1 %           javaapplication27.TestInline::main @ 12 (53 bytes)
          @ 20   javaapplication27.TestInline::m (62 bytes)   too big

您还可以检查生成的程序集以确认它m不是内联的(我使用了这些 JVM 标志:)-XX:+PrintAssembly -XX:PrintAssemblyOptions=intel- 它看起来像这样:

0x0000000002780624: int3   ;*invokevirtual m
                           ; - javaapplication27.TestInline::main@20 (line 10)

如果您像这样重构代码(我已在单独的方法中提取了 if/else):

public int m() {
    int i = count;
    i = m2(i);
    i += count;
    i *= count;
    i++;
    return i;
}

public int m2(int i) {
    if (i % 10 == 0) {
        i += 1;
    } else if (i % 10 == 1) {
        i += 2;
    } else if (i % 10 == 2) {
        i += 3;
    }
    return i;
}

您将看到以下编译操作:

 60    1             javaapplication27.TestInline::m (30 bytes)
 60    2             javaapplication27.TestInline::m2 (40 bytes)
            @ 7   javaapplication27.TestInline::m2 (40 bytes)   inline (hot)
 63    1 %           javaapplication27.TestInline::main @ 12 (53 bytes)
            @ 20   javaapplication27.TestInline::m (30 bytes)   inline (hot)
            @ 7   javaapplication27.TestInline::m2 (40 bytes)   inline (hot)

所以m2被内联到m,这是你所期望的,所以我们回到了原来的场景。但是当main被编译时,它实际上内联了整个东西。在汇编级别,这意味着您将不再找到任何invokevirtual说明。你会发现这样的行:

 0x00000000026d0121: add    ecx,edi   ;*iinc
                                      ; - javaapplication27.TestInline::m2@7 (line 33)
                                      ; - javaapplication27.TestInline::m@7 (line 24)
                                      ; - javaapplication27.TestInline::main@20 (line 10)

基本上常见的指令是“相互化的”。

结论

我并不是说这个例子具有代表性,但它似乎证明了几点:

  • 使用更小的方法可以提高代码的可读性
  • 较小的方法通常会被内联,因此您很可能不会支付额外方法调用的成本(这将是性能中性的)
  • 在某些情况下,使用较小的方法可能会改善全局内联,如上面的示例所示

最后:如果您的代码的一部分对于这些考虑事项很重要的性能真的很重要,您应该检查 JIT 输出以微调您的代码和重要的前后配置文件。

于 2013-04-02T07:52:43.693 回答
7

如果您采用完全相同的代码并将它们分解为许多小方法,那根本不会帮助 JIT。

一个更好的说法是现代的 HotSpot JVM 不会因为你写了很多小方法而惩罚你。它们确实被积极地内联,因此在运行时您并没有真正支付函数调用的成本。即使对于 invokevirtual 调用也是如此,例如调用接口方法的调用。

几年前我写了一篇博文,描述了如何看到 JVM 是内联方法。该技术仍然适用于现代 JVM。我还发现查看与 invokedynamic 相关的讨论很有用,其中广泛讨论了现代 HotSpot JVM 如何编译 Java 字节码。

于 2013-04-02T07:27:08.623 回答
3

我读过许多文章,其中指出较小的方法(以将方法表示为 Java 字节码所需的字节数来衡量)更有可能符合 JIT(即时编译器)的内联条件。将热方法(最常运行的方法)编译为机器代码。他们描述了方法内联如何产生更好的机器代码性能。简而言之:更小的方法为 JIT 在识别热方法时如何将字节码编译为机器码方面提供了更多选择,这允许更复杂的优化。

为了测试这个理论,我创建了一个带有两个基准方法的 JMH 类,每个方法包含相同的行为但不同的因素。第一个基准被命名monolithicMethod(所有代码都在一个方法中),第二个基准被命名smallFocusedMethods并被重构,以便每个主要行为都被移出到它自己的方法中。smallFocusedMethods基准如下所示:

@Benchmark
public void smallFocusedMethods(TestState state) {
    int i = state.value;
    if (i < 90) {
        actionOne(i, state);
    } else {
        actionTwo(i, state);
    }
}

private void actionOne(int i, TestState state) {
    state.sb.append(Integer.toString(i)).append(
            ": has triggered the first type of action.");
    int result = i;
    for (int j = 0; j < i; ++j) {
        result += j;
    }
    state.sb.append("Calculation gives result ").append(Integer.toString(
            result));
}

private void actionTwo(int i, TestState state) {
    state.sb.append(i).append(" has triggered the second type of action.");
    int result = i;
    for (int j = 0; j < 3; ++j) {
        for (int k = 0; k < 3; ++k) {
            result *= k * j + i;
        }
    }
    state.sb.append("Calculation gives result ").append(Integer.toString(
            result));
}

您可以想象monolithicMethod外观(相同的代码但完全包含在一种方法中)。TestState简单地创建一个新对象(这样这个StringBuilder对象的创建不计入基准时间)和为每次调用选择一个介于 0 和 100 之间的随机数(并且这是故意配置的,以便两个基准都使用完全相同的随机数序列,以避免偏差风险)。

在使用六个“分叉”运行基准测试后,每个分叉都涉及五次一秒的预热,然后是六次五秒的迭代,结果如下所示:

Benchmark                                         Mode   Cnt        Score        Error   Units

monolithicMethod                                  thrpt   30  7609784.687 ± 118863.736   ops/s
monolithicMethod:·gc.alloc.rate                   thrpt   30     1368.296 ±     15.834  MB/sec
monolithicMethod:·gc.alloc.rate.norm              thrpt   30      270.328 ±      0.016    B/op
monolithicMethod:·gc.churn.G1_Eden_Space          thrpt   30     1357.303 ±     16.951  MB/sec
monolithicMethod:·gc.churn.G1_Eden_Space.norm     thrpt   30      268.156 ±      1.264    B/op
monolithicMethod:·gc.churn.G1_Old_Gen             thrpt   30        0.186 ±      0.001  MB/sec
monolithicMethod:·gc.churn.G1_Old_Gen.norm        thrpt   30        0.037 ±      0.001    B/op
monolithicMethod:·gc.count                        thrpt   30     2123.000               counts
monolithicMethod:·gc.time                         thrpt   30     1060.000                   ms

smallFocusedMethods                               thrpt   30  7855677.144 ±  48987.206   ops/s
smallFocusedMethods:·gc.alloc.rate                thrpt   30     1404.228 ±      8.831  MB/sec
smallFocusedMethods:·gc.alloc.rate.norm           thrpt   30      270.320 ±      0.001    B/op
smallFocusedMethods:·gc.churn.G1_Eden_Space       thrpt   30     1393.473 ±     10.493  MB/sec
smallFocusedMethods:·gc.churn.G1_Eden_Space.norm  thrpt   30      268.250 ±      1.193    B/op
smallFocusedMethods:·gc.churn.G1_Old_Gen          thrpt   30        0.186 ±      0.001  MB/sec
smallFocusedMethods:·gc.churn.G1_Old_Gen.norm     thrpt   30        0.036 ±      0.001    B/op
smallFocusedMethods:·gc.count                     thrpt   30     1986.000               counts
smallFocusedMethods:·gc.time                      thrpt   30     1011.000                   ms

简而言之,这些数字表明该smallFocusedMethods方法的运行速度提高了 3.2%,并且差异具有统计学意义(置信度为 99.9%)。请注意,内存使用(基于垃圾收集分析)没有显着差异。因此,您可以在不增加开销的情况下获得更快的性能。

我运行了各种类似的基准测试来测试小型、集中的方法是否能提供更好的吞吐量,我发现在我尝试过的所有情况下,改进都在 3% 到 7% 之间。但实际收益很可能在很大程度上取决于所使用的 JVM 的版本、跨 if/else 块的执行分布(我在第一个块上选择了 90%,在第二个块上选择了 10%,以夸大热度第一个“动作”,但我已经看到吞吐量的提高,即使在 if/else 块链中分布更均匀),以及每个可能的动作正在完成的工作的实际复杂性。因此,如果您需要确定什么适用于您的特定应用程序,请务必编写自己的特定基准。

我的建议是:编写小而专注的方法,因为它使代码更整洁,更易于阅读,并且在涉及继承时更容易覆盖特定行为。JIT 可能会以稍微更好的性能来奖励您这一事实是一个奖励,但在大多数情况下,整洁的代码应该是您的主要目标。哦,给每个方法一个清晰的描述性名称也很重要,它准确地总结了方法的责任(不像我在基准测试中使用的可怕名称)。

于 2019-06-22T18:19:49.660 回答
1

我不太明白它是如何工作的,但是根据AurA 提供的链接,我猜想如果重复使用相同的位,JIT 编译器将不得不编译更少的字节码,而不是编译不同的字节码。不同的方法。

除此之外,您越能够将代码分解为有意义的片段,您将从代码中获得的重用就越多,这将允许优化运行它的 VM(您提供了更多模式跟...共事)。

但是,我怀疑如果您在没有提供任何代码重用的任何意义的情况下分解代码,它会产生什么好的影响。

于 2013-04-02T05:27:59.263 回答