在最近关于如何优化某些代码的讨论中,我被告知将代码分解为许多小方法可以显着提高性能,因为JIT编译器不喜欢优化大型方法。
我不确定这一点,因为看起来JIT编译器本身应该能够识别自包含的代码段,而不管它们是否采用自己的方法。
任何人都可以确认或反驳此声明吗?
答案 0 :(得分:20)
Hotspot JIT仅内联小于某个(可配置)大小的方法。因此,使用较小的方法可以实现更多的内联,这很好。
请参阅this page上的各种内联选项。
修改强>
详细说明:
示例(完整代码,如果您尝试使用相同的行号)
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
太大但重构的m
和{{1}低于阈值 - 使用其他值可能会获得不同的输出。)
您会看到m2
和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
未内联(我使用了这些JVM标志:m
) - 它将如下所示:
-XX:+PrintAssembly -XX:PrintAssemblyOptions=intel
如果你像这样重构代码(我在一个单独的方法中提取了if / else):
0x0000000002780624: int3 ;*invokevirtual m
; - javaapplication27.TestInline::main@20 (line 10)
您将看到以下编译操作:
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
基本上常见的指令是“共同的”。
<强>结论强>
我并不是说这个例子具有代表性,但似乎证明了几点:
最后:如果您的代码的一部分对性能至关重要而这些考虑因素很重要,那么您应该检查JIT输出以微调您的代码并重要地在之前和之后进行配置。
答案 1 :(得分:7)
如果您使用完全相同的代码并将它们分解为许多小方法,那么根本不会帮助JIT。
更好的方法是,现代的HotSpot JVM不会因为编写很多小方法而惩罚你。它们确实得到了积极的内联,所以在运行时你并没有真正支付函数调用的成本。即使对于调用接口方法的调用虚拟调用也是如此。
几年前我做了一个blog post,它描述了如何看待JVM是内联方法。该技术仍适用于现代JVM。我还发现查看与invokedynamic相关的讨论很有用,其中广泛讨论了现代HotSpot JVM如何编译Java字节代码。答案 2 :(得分: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之间的随机数(并且对此进行了精心配置,以便两个基准使用完全相同的随机数序列,以避免产生偏差的风险。
使用6个“ fork”运行基准测试后,每个分支涉及5秒钟的预热,然后进行5秒钟的6次迭代,结果如下:
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可能会以稍微更好的性能来奖励您,这是一个奖励,但是整洁的代码应该是大多数情况下的主要目标。哦,给每个方法一个清晰的描述性名称也很重要,该名称准确地概括了该方法的职责(不同于我在基准测试中使用的可怕名称)。
答案 3 :(得分:1)
我真的不明白它是如何工作的,但基于the link AurA provided,我猜想如果重用相同的位,JIT编译器必须编译更少的字节码,而不是必须编译不同的字节码这在不同的方法中是相似的。
除此之外,您越能够将代码分解为多种意义,您将从代码中获得更多的重用,并且这将允许对运行它的VM进行优化(您是提供更多架构以供使用)。
但是我怀疑如果你在不提供代码重用的情况下破解你的代码会产生任何好的影响。