Java开发人员? 优化您的生产监控。 请在所有已记录的错误,警告和异常之后查看源代码,调用堆栈和变量状态- 尝试Takipi 。
最有用的JVM JIT优化有哪些?如何使用它们?
即使您没有积极计划,JVM也有很多技巧可以帮助您的代码更好地执行。 有些人实际上不需要您提供任何帮助,其他人可以在此过程中使用一些帮助。
在这篇文章中,我们与Takipi的研发团队负责人Moshe Tsur进行了聊天,并有机会分享了他有关JVM的即时(JIT)编译器的一些技巧。
让我们看看幕后发生了什么。
编写一次,随处运行,及时优化
大多数人都知道,Javac编译器将其Java源代码转换为字节码,然后由JVM运行,然后JVM将其编译为Assembly,并将其提供给CPU。
很少有人知道兔子的洞越来越深。 JVM有2种不同的操作模式。
生成的字节码是原始Java源代码的准确表示,没有进行任何优化。 当JVM将其转换为Assembly时,事情变得更加棘手,魔术开始了:
- 解释模式 – JVM照原样读取和运行字节码。
- 编译模式(字节码到汇编) – JVM放松了控制,并且不涉及字节码。
两者之间的链接是JIT编译器。 您可能已经猜到,解释模式比没有中间人直接在CPU上运行要慢得多。
解释模式为Java平台提供了宝贵的机会来收集有关代码及其在实践中的实际行为的信息-使其有机会学习如何优化所生成的Assembly代码。
经过足够的运行时间后,JIT编译器便能够在程序集级别执行优化。 主要是,根据代码的实际行为,最大程度地减少不必要的跳转,从而增加应用程序的大量开销。 此外,该过程不会在应用程序的“预热阶段”后停止,并且可能会多次发生以进一步优化组装。
在Takipi ,我们正在构建一个Java代理来监视生产中的服务器,我们非常重视开销。 每一点代码都经过优化,在此过程中,我们有机会使用和学习了一些很酷的JIT功能。
以下是5个更有用的示例:
1.空检查消除
在许多情况下,在开发中将某些条件检查添加到代码中非常有意义。 就像臭名昭著的null检查一样。 空值得到特殊处理也就不足为奇了,因为这是生产中发生最高例外的原因 。
但是,在大多数情况下,可以从优化的汇编代码中消除显式的空检查,并且这种情况适用于很少发生的任何情况。 在这种情况确实发生的极少数情况下,JIT使用一种称为“罕见陷阱”的机制,让它知道需要回退并通过撤回在执行时需要执行的指令集来修复优化。发生–无需显示实际的NullPointerException。
这些检查之所以成为优化代码的原因,是因为它们可能会在汇编代码中引入跳转,从而减慢其执行速度。
让我们看一个简单的例子:
private static void runSomeAlgorithm(Graph graph) {if (graph == null) {return;}// do something with graph
}
如果JIT看到从未使用null调用该图,则编译后的版本看起来就像它反映的代码未经null检查:
private static void runSomeAlgorithm(Graph graph) {// do something with graph
}
底线:我们不需要做任何特殊的事情就能享受到这种优化,并且当出现罕见情况时,JVM会知道“反优化”并获得理想的结果。
2.分支预测
与Null Check Elimination(空检查消除)相似,还有另一种JIT优化技术,可帮助它确定某些代码行是否比其他代码“ 更热 ”并且发生频率更高。
我们已经知道,如果某种条件很少或永远不会成立,那么“罕见陷阱”机制很可能会介入并将其从已编译的大会中消除。
如果存在不同的平衡,则IF条件的两个分支都成立,但是一个分支的发生比另一个分支多,JIT编译器可以根据最常见的一个对它们进行重新排序,并显着减少Assembly跳转的次数。
这是实际的工作方式:
private static int isOpt(int x, int y) {int veryHardCalculation = 0;if (x >= y) {veryHardCalculation = x * 1000 + y;} else {veryHardCalculation = y * 1000 + x;}return veryHardCalculation;
}
现在,假设大部分时间x <y,该条件将被翻转以统计地减少Assembly跳转的次数:
private static int isOpt(int x, int y) {int veryHardCalculation = 0;if (x < y) {// this would not require a jumpveryHardCalculation = y * 1000 + x;return veryHardCalculation;} else {veryHardCalculation = x * 1000 + y;return veryHardCalculation;}
}
如果您不确定,我们实际上已经对其进行了一些测试,并提取了经过优化的Assembly代码:
0x00007fd715062d0c: cmp %edx,%esi
0x00007fd715062d0e: jge 0x00007fd715062d24 ;*if_icmplt; - Opt::isOpt@4 (line 117)
如您所见,跳转指令是jge(如果大于或等于,则跳转),与条件的else分支相对应。
底线:我们享受的另一种即用型JIT优化。 在最近的文章中,我们还讨论了分支预测,其中涉及一些最有趣的Stackoverflow Java答案 。
在此处显示的示例中,我们看到了如何使用分支预测来解释为什么在有助于分支预测的某些条件下在排序数组上运行操作要快得多的原因。
3.循环展开
您可能已经注意到,JIT编译器不断尝试消除已编译代码中的Assembly跳转。
这就是为什么循环闻起来像麻烦。
每次迭代实际上都是一个Assembly跳转回指令集的开始。 通过循环展开,JIT编译器将打开循环,并仅一个接一个地重复相应的Assembly指令。
从理论上讲,当将Java转换为字节码时,javac可以做类似的事情,但是JIT编译器对代码将在其上运行的实际CPU有了更好的了解,并且知道如何以更加有效的方式微调代码。
例如,让我们看一下将矩阵乘以向量的方法:
private static double[] loopUnrolling(double[][] matrix1, double[] vector1) {double[] result = new double[vector1.length];for (int i = 0; i < matrix1.length; i++) {for (int j = 0; j < vector1.length; j++) {result[i] += matrix1[i][j] * vector1[j];}}return result;
}
展开的版本如下所示:
private static double[] loopUnrolling2(double[][] matrix1, double[] vector1) {double[] result = new double[vector1.length];for (int i = 0; i < matrix1.length; i++) {result[i] += matrix1[i][0] * vector1[0];result[i] += matrix1[i][1] * vector1[1];result[i] += matrix1[i][2] * vector1[2];// and maybe it will expand even further - e.g. 4 iterations, thus// adding code to fix the indexing// which we would waste more time doing correctly and efficiently}return result;
}
一次又一次地重复相同的操作,而不会产生跳转开销:
....
0x00007fd715060743: vmovsd 0x10(%r8,%rcx,8),%xmm0 ;*daload; - Opt::loopUnrolling@26 (line 179)
0x00007fd71506074a: vmovsd 0x10(%rbp),%xmm1 ;*daload; - Opt::loopUnrolling@36 (line 179)
0x00007fd71506074f: vmulsd 0x10(%r12,%r9,8),%xmm1,%xmm1
0x00007fd715060756: vaddsd %xmm0,%xmm1,%xmm0 ;*dadd; - Opt::loopUnrolling@38 (line 179)
0x00007fd71506075a: vmovsd %xmm0,0x10(%r8,%rcx,8) ;*dastore; - Opt::loopUnrolling@39 (line 179)
....
底线: JIT编译器在展开循环方面做得很好,只要您使它们的内容简单而没有任何不必要的复杂性即可。
同时也不建议您尝试优化此过程并自行展开,结果可能会导致更多问题,甚至无法解决。
4.内联方法
接下来,进行另一个跳跃杀手优化。 实际上,方法调用是汇编跳转的重要来源。 在可能的情况下,JIT编译器将尝试内联它们,并消除往返的跳转,发送参数和返回值的需要-将其全部内容传递给调用方法。
还可以通过2个JVM参数来微调JIT编译器内联方法的方式:
- -XX:MaxInlineSize –可以内联的方法的字节码的最大大小(即使不经常执行,对于任何方法也都可以这样做)。 默认值约为35个字节。
- -XX:FreqInlineSize-应该内联的被视为热点(频繁执行)的方法的字节码的最大大小。 默认值因平台而异。
例如,让我们看一下一种计算简单线的坐标的方法:
private static void calcLine(int a, int b, int from, int to) {Line l = new Line(a, b);for (int x = from; x <= to; x++) {int y = l.getY(x);System.err.println("(" + x + ", " + y + ")");}
}static class Line {public final int a;public final int b;public Line(int a, int b) {this.a = a;this.b = b;}// Inliningpublic int getY(int x) {return (a * x + b);}
}
优化的内联版本将消除跳转,参数l和x的发送以及y的返回:
private static void calcLine(int a, int b, int from, int to) {Line l = new Line(a, b);for (int x = from; x <= to; x++) {int y = (l.a * x + l.b);System.err.println("(" + x + ", " + y + ")");}
}
底线:内联是一种超级有用的优化,但是只有在您使用最少的行数使方法尽可能简单的情况下,内联才会启动。 复杂的方法不太可能内联,因此这是您可以帮助JIT编译器完成其工作的一点。
5.线程字段和线程本地存储(TLS)
事实证明,线程字段比常规变量快得多。 线程对象存储在实际的CPU寄存器中,从而使其字段成为非常有效的存储空间。
使用线程本地存储,您可以创建存储在Thread对象上的变量。
以下是请求计数器的简单示例,说明了如何访问线程本地存储:
private static void handleRequest() {if (counter.get() == null) {counter.set(0);}counter.set(counter.get() + 1);
在相应的汇编代码中,我们可以看到数据直接放置在寄存器中,与静态类变量相比,可以更快地访问数据:
0x00007fd71508b1ec: mov 0x1b0(%r15),%r10 ;*invokestatic currentThread; - java.lang.ThreadLocal::get@0 (line 143); - Opt::handleRequest@3 (line 70)0x00007fd71508b1f3: mov 0x50(%r10),%r11d ;*getfield threadLocals; - java.lang.ThreadLocal::getMap@1 (line 213); - java.lang.ThreadLocal::get@6 (line 144); - Opt::handleRequest@3 (line 70)
底线:某些敏感数据类型可能更好地存储在“线程本地存储”中,以便更快地访问和检索。
最后的想法
JVMs JIT编译器是Java平台上引人入胜的机制之一。 它在不牺牲可读性的情况下优化了代码的性能。 不仅如此,除了内联的“静态”优化方法之外,它还基于代码在实践中的执行方式来做出决策。
我们希望您喜欢一些JVM最有用的优化技术,并且很高兴在下面的评论部分中听到您的想法。
Java开发人员? 优化您的生产监控。 请在所有已记录的错误,警告和异常之后查看源代码,调用堆栈和变量状态- 尝试Takipi 。
翻译自: https://www.javacodegeeks.com/2016/08/java-steroids-5-super-useful-jit-optimization-techniques.html