JVM中的编译类型:公开Black Magic会话

大家好!

今天,请您注意本文的翻译,其中示例编译了在JVM中进行编译的选项。 特别要注意Java 9及更高版本中支持的AOT编译。

祝您阅读愉快!

我想任何曾经用Java编程的人都听说过即时编译(JIT),甚至有可能在执行之前进行编译(AOT)。 此外,无需解释什么是“解释”语言。 本文将解释如何在Java虚拟机JVM中实现所有这些功能。

您可能知道,使用Java进行编程时,需要运行一个编译器(使用“ javac”程序),该编译器将Java源代码(.java文件)收集到Java字节码(.class文件)中。 Java字节码是一种中间语言。 之所以称它为“中间”,是因为它不为真正的计算设备(CPU)所理解,并且不能由计算机执行,因此代表了源代码与处理器中执行的“本机”机器代码之间的过渡形式。

为了使Java字节码能够执行任何特定的工作,有3种方法可以使它执行:

  1. 直接执行中间代码。 说它需要“解释”是更好和更正确的。 JVM具有Java解释器。 如您所知,要使JVM工作,您需要运行“ java”程序。
  2. 在执行中间代码之前,将其编译为本机代码,并强制CPU执行此新烘焙的本机代码。 因此,编译恰好在执行之前(及时)进行,因此称为“动态”。
  3. 3首先,即使在程序启动之前,中间代码也将转换为本地代码,并从头到尾通过CPU运行。 该编译在执行之前完成,称为AoT(提前)。

因此,(1)是解释器的工作,(2)是JIT编译的结果,而(3)是AOT编译的结果。

为了完整起见,我将提到有第四种方法-直接解释源代码,但是在Java中这是不接受的。 例如,这是在Python中完成的。
现在,让我们看看“ java”如何作为(1)(2)JIT编译器和/或(3)AOT编译器的解释器以及何时起作用的。

简而言之-通常,“ java”同时执行(1)和(2)。 从Java 9开始,第三个选项也是可能的。

这是我们的Test类,将在以后的示例中使用。

 public class Test { public int f() throws Exception { int a = 5; return a; } public static void main(String[] args) throws Exception { for (int i = 1; i <= 10; i++) { System.out.println("call " + Integer.valueOf(i)); long a = System.nanoTime(); new Test().f(); long b = System.nanoTime(); System.out.println("elapsed= " + (ba)); } } } 

如您所见,有一个main方法可实例化Test对象并连续10次循环调用f函数。 f函数几乎不执行任何操作。

因此,如果您编译并运行上述代码,则输出将是预期的(当然,经过时间的值对您而言将有所不同):

 call 1 elapsed= 5373 call 2 elapsed= 913 call 3 elapsed= 654 call 4 elapsed= 623 call 5 elapsed= 680 call 6 elapsed= 710 call 7 elapsed= 728 call 8 elapsed= 699 call 9 elapsed= 853 call 10 elapsed= 645 

现在的问题是:这个结论是“ java”作为解释器(即选项(1),“ java”作为JIT编译器,即选项(2))的结果还是与AOT编译有关? ,即选项(3)? 在本文中,我将为所有这些问题找到正确的答案。

我想给出的第一个答案很可能只有(1)发生在这里。 我说“最有可能”是因为我不知道这里是否设置了会更改默认JVM选项的环境变量。 如果没有安装多余的东西,并且默认情况下这就是“ java”的工作方式,那么这里我们100%只观察选项(1),也就是说,代码被完全解释了。 我确信这一点,因为:

  • 根据Java文档, -XX:CompileThreshold=invocations选项是在客户端JVM上以默认invocations=1500启动的(下面将介绍有关客户端JVM的更多信息)。 由于我只运行10次且10 <1500,所以这里我们不讨论动态编译。 通常,此命令行选项指定在动态编译步骤开始之前,函数必须被解释的次数(最大)。 我将在下面对此进行详细介绍。
  • 实际上,我运行了带有诊断标志的代码,因此我知道它是否是动态编译的。 我还将在下面解释这一点。

请注意:JVM可以在客户端或服务器模式下工作,并且在第一种情况和第二种情况下默认设置的选项将不同。 通常,将根据环境或启动JVM的计算机自动做出有关启动模式的决定。 在下文中,我将在所有启动过程中指定–client选项,以免怀疑程序是否在客户端模式下运行。 此选项不会影响我在本文中要演示的方面。

如果使用-XX:PrintCompilation运行“ java”,则在动态编译函数时,程序将打印一行。 不要忘记对每个函数分别执行JIT编译,该类中的某些函数可能仍保留在字节码中(即未编译),而其他一些函数可能已经通过了JIT编译,即准备在处理器中直接执行。

在下面,我还添加了-Xbatch选项。 仅需要-Xbatch选项才能使输出看起来更可呈现。 否则,JIT编译会竞争性地进行(连同解释),并且编译后的输出有时在运行时可能看起来很奇怪(由于-XX:PrintCompilation )。 但是, –Xbatch选项禁用后台编译,因此,在执行JIT编译之前,将停止执行程序。

(为了便于阅读,我将在新行中写入每个选项)

 $ java -client -Xbatch -XX:+PrintCompilation Test 

我不会在此处插入此命令的输出,因为默认情况下,JVM会编译许多内部函数(例如,与java,sun,jdk包相关),因此输出将非常长-因此,在我的屏幕上,内部函数上有274行以及更多内容-该程序的最终结论)。 为了Test.f这项研究,我将取消内部类的JIT编译,或者仅对我的方法( Test.f )有选择地启用它。 为此,再指定一个选项-XX:CompileCommand 。 您可以指定许多命令(编译),因此将它们放在单独的文件中会更容易。 幸运的是,我们有-XX:CompileCommandFile选项-XX:CompileCommandFile 。 因此,继续创建文件。 我称其为hotspot_compiler ,其原因是我稍后将进行解释并编写以下内容:

 quiet exclude java/* * exclude jdk/* * exclude sun/* * 

在这种情况下,应该完全清楚我们从以java,jdk和sun开头的所有程序包中排除所有类中的所有函数(最后一个*)(程序包名称以/分隔,您可以使用*)。 quiet命令告诉JVM不要写任何有关被排除的类的信息,因此只有现在编译的那些类才会输出到控制台。 因此,我运行:

 java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler Test 

在告诉您该命令的输出之前,我提醒您将文件命名为hotspot_compiler ,因为在Oracle JDK中似乎(我没有检查)使用编译器命令为文件默认设置了.hotspot_compiler名称。

因此,结论是:

 many lines like this 111 1 n 0 java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native) (static) call 1 some more lines like this 161 48 n 0 java.lang.invoke.MethodHandle::linkToStatic(ILIJL)I (native) (static) elapsed= 7558 call 2 elapsed= 1532 call 3 elapsed= 920 call 4 elapsed= 732 call 5 elapsed= 774 call 6 elapsed= 815 call 7 elapsed= 767 call 8 elapsed= 765 call 9 elapsed= 757 call 10 elapsed= 868 

首先,我不知道为什么某些java.lang.invoke.MethodHandler.方法仍在编译java.lang.invoke.MethodHandler. 可能有些事情无法关闭。 据我了解,这是最新消息。 但是,您可以看到,所有其他编译步骤(以前只有274行)现在都消失了。 在其他示例中,我还将从编译日志的输出中删除java.lang.invoke.MethodHandler

让我们看看我们得出的结论。 现在,我们有一个简单的代码,可以在其中运行函数10次。 如前所述,我曾提到此功能是解释性的,而不是编译性的,如文档中所示,现在我们在日志中看到了它(同时,我们在编译日志中没有看到它,这意味着它不受JIT编译的影响)。 好吧,您刚刚看到了“ java”工具的作用,仅在100%的情况下解释并解释了我们的功能。 因此,我们可以选中带有选项(1)的复选框。 我们传递给(2),动态编译。

根据文档,您可以运行该函数1500次,并确保JIT编译确实正在进行。 但是,您也可以使用-XX:CompileThreshold=invocations调用-XX:CompileThreshold=invocations ,设置所需的值而不是1500。 让我们在这里指出5。这意味着我们期望:在对函数f进行5次“解释”之后,JVM必须编译该方法,然后运行已编译的版本。
java-客户端-Xbatch

 -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 Test 

如果运行此命令,您可能会注意到与上面的示例相比,没有任何变化。 即,仍然不会发生编译。 事实证明,根据文档, -XX:CompileThreshold仅在禁用TieredCompilation时才起作用,这是默认设置。 它像这样-XX:-TieredCompilation-XX:-TieredCompilation 。 分层编译是Java 7中引入的一项功能,可以提高JVM的启动和巡航速度。 在本文的上下文中,它并不重要,请随时禁用它。 现在让我们再次运行此命令:

 java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation Test 

这是输出(我记得,我已经错过了有关java.lang.invoke.MethodHandle的行):

 call 1 elapsed= 9411 call 2 elapsed= 1291 call 3 elapsed= 862 call 4 elapsed= 1023 call 5 227 56 b Test::<init> (5 bytes) 228 57 b Test::f (4 bytes) elapsed= 1051739 call 6 elapsed= 18516 call 7 elapsed= 940 call 8 elapsed= 769 call 9 elapsed= 855 call 10 elapsed= 838 

我们欢迎(您好!)在调用数字5之后立即动态编译函数Test.f或Test::<init> ,因为我将CompileThreshold设置为5。JVM对该函数进行了5次解释,然后对其进行编译,最后运行编译版本。 由于该函数已编译,因此它应该运行得更快,但由于此函数不执行任何操作,因此我们无法在此进行验证。 我认为这是单独发帖的好话题。

您可能已经猜到了,这里编译了另一个函数Test::<init> ,它是Test类的构造函数。 由于代码调用了构造函数(new Test() ),因此无论何时调用f ,它都会在调用5次之后与f函数同时编译。

原则上,这可以结束对选项(2)(JIT编译)的讨论。 如您所见,在这种情况下,该函数首先由JVM解释,然后经过五重解释后动态编译。 我想添加有关JIT编译的最后一个细节,即提及选项-XX:+PrintAssembly 。 顾名思义,它会将功能的编译版本输出到控制台(编译版本=本机代码=汇编代码)。 但是,这仅在库路径中存在反汇编程序时才有效。 我猜反汇编程序在不同的JVM中可能有所不同,但是在这种情况下,我们要处理的是hsdis-openjdk的反汇编程序。 hsdis库的源代码或其二进制文件可以放在不同的位置。 在这种情况下,我编译了此文件,并将hsdis-amd64.so放入JAVA_HOME/lib/server

现在,我们可以执行此命令。 但是首先我必须添加它以运行-XX:+PrintAssembly还需要添加-XX:+UnlockDiagnosticVMOptions ,并且它必须在PrintAssembly选项之前。 如果不这样做,那么JVM将向您发出有关PrintAssembly选项使用不正确的警告。 让我们运行以下代码:

 java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Test 

输出将很长,并且将显示以下行:

 0x00007f4b7cab1120: mov 0x8(%rsi),%r10d 0x00007f4b7cab1124: shl $0x3,%r10 0x00007f4b7cab1128: cmp %r10,%rax 

如您所见,相应的功能被编译为本地机器代码。

最后,讨论选项3,AOT。 版本9之前的Java中没有执行执行前的编译AOT。

JDK 9 jaotc中出现了一个新工具-顾名思义,它是Java的AOT编译器。 这个想法是这样的:先运行Java“ javac”编译器,然后运行Java“ jaotc”的AOT编译器,然后照常运行JVM“ java”。 JVM通常执行解释和JIT编译。 但是,如果该函数具有AOT编译的代码,则直接使用它,而不求助于解释或JIT编译。 让我解释一下:您不必运行AOT编译器,它是可选的,如果使用它,则只能在执行之前编译所需的类。

让我们建立一个由AOT编译的Test::f组成的库。 不要忘了:要自己执行此操作,您将需要内部版本150+的JDK 9。

 jaotc --output=libTest.so Test.class 

结果,生成了libTest.solibTest.so一个库,其中包含Test类中包含的AOT编译的本机功能代码。 您可以查看此库中定义的字符:

 nm libTest.so 

在我们的结论中,除其他外,将:

 0000000000002120 t Test.f()I 00000000000021a0 t Test.<init>()V 00000000000020a0 t Test.main([Ljava/lang/String;)V 

因此,我们所有的函数,构造函数, f和静态方法main都存在于库libTest.so

与相应的“ java”选项一样,在这种情况下该选项可以附带一个文件,为此,jaotc有–compile-commands选项。 JEP 295提供了相关示例,在此不再赘述。

现在运行“ java”,看看是否使用了AOT编译的方法。 如果像以前一样运行“ java”,则将不使用AOT库,这并不奇怪。 要使用此新功能,必须提供-XX:AOTLibrary选项,您必须指定:

 java -XX:AOTLibrary=./libTest.so Test 

您可以指定多个AOT库,以逗号分隔。

该命令的输出与没有AOTLibrary “ java”启动时的输出完全相同,因为Test程序的行为完全没有改变。 要检查是否使用了AOT编译的功能,可以添加另一个新选项-XX:+PrintAOT

 java -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test 

Test程序输出之前,此命令显示以下内容:

  9 1 loaded ./libTest.so aot library 99 1 aot[ 1] Test.main([Ljava/lang/String;)V 99 2 aot[ 1] Test.f()I 99 3 aot[ 1] Test.<init>()V 

按照计划,将加载AOT库,并使用AOT编译的函数。

如果您有兴趣,可以运行以下命令并检查是否正在进行JIT编译。

 java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test 

正如预期的那样,不会发生JIT编译,因为Test类中的方法是在执行之前进行编译并作为库提供的。

一个可能的问题是:如果我们提供了本机功能代码,那么JVM如何确定本机代码是否过时/陈旧? 作为最后一个示例,让我们修改函数f并将a设置为6。

 public int f() throws Exception { int a = 6; return a; } 

我这样做只是为了修改类文件。 现在,我们使javac编译并运行与上面相同的命令。

 javac Test.java java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test 

如您所见,我没有在“ javac”之后运行“ jaotc”,因此AOT库中的代码现在已旧且不正确,并且函数f的值为5。

上面的“ java”命令的输出演示:

 228 56 b Test::<init> (5 bytes) 229 57 b Test::f (5 bytes) 

这意味着在这种情况下功能是动态编译的,因此未使用AOT编译产生的代码。 因此,已在类文件中检测到更改。 使用javac执行编译时,其指纹将输入到类中,并且该类指纹也存储在AOT库中。 由于该类的新指纹与AOT库中存储的指纹不同,因此未使用预先编译的本机代码(AOT)。 这就是我想在执行之前告诉您的最后一个编译选项的全部内容。

在本文中,我试图通过简单的实际示例来解释和说明JVM如何执行Java代码:解释它,动态编译(JIT)或预先编译(AOT)-而且,最后一次机会仅出现在JDK 9中。希望您能学到一些东西新的。

Source: https://habr.com/ru/post/zh-CN466529/


All Articles