机器码/指令/汇编语言/字节码
机器码
机器语言(Machine Language)―― 处理器的指令集及使用它们编写程序的规则。
机器码(机器指令):
每个机器指令对应一个二进制数0和1组成的代码(Code),每一串的0和1的组合会被CPU硬件所解读成不同的指令(CPU电路板上刻录了一个指令集,传入CPU的二级制文件会依据他而解读)这是处理器能够直接执行的命令。
一个机器语言程序就是一段二进制代码序列。一条指令(Instruction)就是机器语言的一个语句。
指令
指令的基本格式:
| 操作码字段 | 地址码字段 |
依据不同CPU种类上的CPU指令集,将指令分为变长和定长,一般采用的变长(依据操作码决定由需要多少地址码)
指令集=指令系统(Instruction Set)―― 处理器支持的所有指令的集合。
而指令集也分为CISC(如:x86)和RISC(如:ARM)两种,因为耗能和需要的晶体管数量的不同一般分别用于PC和移动设备
CISC的英文全称为“Complex Instruction Set Computer”,即“复杂指令系统计算机”
RISC的英文全称为“Reduced Instruction Set Computer”,即“精简指令集计算机”
1、指令系统
CISC
计算机的指令系统比较丰富,有专用指令来完成特定的功能。因此,处理特殊任务效率较高。RISC
设计者把主要精力放在那些经常使用的指令上,尽量使它们具有简单高效的特色。对不常用的功能,常通过组合指令来完成。因此,在RISC 机器上实现特殊功能时,效率可能较低。但可以利用流水技术和超标量技术加以改进和弥补。
2、存储器操作
CISC
机器的存储器操作指令多,操作直接。RISC
对存储器操作有限制,使控制简单化。
3、程序
CISC
汇编语言程序编程相对简单,科学计算及复杂操作的程序社设计相对容易,效率较高。
RISC
汇编语言程序一般需要较大的内存空间,实现特殊功能时程序复杂,不易设计。
4、中断
CISC
机器是在一条指令执行结束后响应中断。
RISC
机器在一条指令执行的适当地方可以响应中断。
5、CPU
CISC
CPU包含有丰富的电路单元,因而功能强、面积大、功耗大。
RISC
CPU包含有较少的单元电路,因而面积小、功耗低。
6、设计周期
CISC
微处理器结构复杂,设计周期长。
RISC
微处理器结构简单,布局紧凑,设计周期短,且易于采用最新技术。
7、用户使用
CISC
微处理器结构复杂,功能强大,实现特殊功能容易。
RISC
微处理器结构简单,指令规整,性能容易把握,易学易用。
8、应用范围
CISC机器则更适合于通用机。
RISC
由于RISC指令系统的确定与特定的应用领域有关,故RISC 机器更适合于专用机。
汇编语言(Assembly Language)―― 用助记符表示的指令以及使用它们编写程序的规则。
汇编(Assembly)―― 将汇编语言书写的程序翻译成机器语言程序的过程。
汇编程序(Assembler)―― 将汇编语言书写的程序翻译成机器语言程序的软件。不要与汇编语言程序这个说法混淆,后者表示用汇编语言书写的程序,或称汇编语言源程序。
反汇编(Disassembly)—— 将机器码转换为汇编代码(常用于我们阅读编译器编译后生成机器码时,方便我们阅读时使用)
不同的平台需要依据其指令集用不同的汇编或反汇编适配器
字节码
字节码(Bytecode)是一种包含执行程序、由一序列 op 代码/数据对 组成的二进制文件。字节码是一种中间码,它比机器码更抽象,需要直译器转译后才能成为机器码的中间代码。
通常情况下它是已经经过编译,但与特定机器码无关。字节码通常不像源码一样可以让人阅读,而是编码后的数值常量、引用、指令等构成的序列。
字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。字节码的典型应用为Java bytecode()通过前端编译器JDK的javac。
再由后端的即时编译器将其编译为机器码(二进制代码)进行运行
jdk与JRE
jdk与jre的主要区别便是在编译器的不同上
jre中除了基于JVM规范的即时编译器的实现如HotSpot等还有其相关的lib类库外,不包含其他的编译器
jdk包含了前端编译器和jre与提前编译器、依赖的类库与测试时依据不同参数的检测工具等。
HotSpot
因为吸收了Sun和Oracle融合了SunJDK和BEASpot的优点而有了JDK8之后(移除掉永久代,添加了Java Mission Control监控工具),Sun/OeacleJDK的统治地位导致其JVM的实现HotSpot也具有了很高的地位.
为了保证HotSpot和生命力,在逐步修改HotSpot代码保证开发接口后不被侵入HotSpot源码后逐步开放了虚拟机的工具接口
现在,HotSpot虚拟机也有了与J9类似的能力,能够在编译时指定一系列特性开关,让编译输出的 HotSpot虚拟机可以裁剪成不同的功能,譬如支持哪些编译器,支持哪些收集器,是否支持JFR现在,HotSpot虚拟机也有了与J9类似的能力,能够在编译时指定一系列特性开关,让编译输出的 HotSpot虚拟机可以裁剪成不同的功能,譬如支持哪些编译器,支持哪些收集器,是否支持JFR、 AOT、CDS、NMT等都可以选择。能够实现这些功能特性的组合拆分,反映到源代码不仅仅是条件编 译,更关键的是接口与实现的分离。
早期(JDK 1.4时代及之前)的HotSpot虚拟机为了提供监控、调试等不会在《Java虚拟机规范》 中约定的内部功能和数据,就曾开放过Java虚拟机信息监控接口(Java Virtual Machine ProfilerInterface,JVMPI)与Java虚拟机调试接口(Java Virtual Machine Debug Interface,JVMDI)供运维和性 能监控、IDE等外部工具使用。
到了JDK 5时期,又抽象出了层次更高的Java虚拟机工具接口(JavaVirtual Machine Tool Interface,JVMTI)来为所有Java虚拟机相关的工具提供本地编程接口集合,到 JDK 6时JVMTI就完全整合代替了JVMPI和JVMDI的作用。
在JDK 9时期,HotSpot虚拟机开放了Java语言级别的编译器接口[3](Java Virtual Machine CompilerInterface,JVMCI),使得在Java虚拟机外部增加、替换即时编译器成为可能,这个改进实现起来并不费劲,但比起之前JVMPI、JVMDI和JVMTI却是更深层次的开放,它为不侵入HotSpot代码而增加或修改HotSpot虚拟机的固有功能逻辑提供了可行性。Graal编译器就是通过这个接口植入到HotSpot之 中.
到了JDK 10,HotSpot又重构了Java虚拟机的垃圾收集器接口[4](Java Virtual Machine CompilerInterface),统一了其内部各款垃圾收集器的公共行为。有了这个接口,才可能存在日后(今天尚未) 某个版本中的CMS收集器退役,和JDK 12中Shenandoah这样由Oracle以外其他厂商领导开发的垃圾收 集器进入HotSpot中的事情。如果未来这个接口完全开放的话,甚至有可能会出现其他独立于HotSpot 的垃圾收集器实现。 AOT、CDS、NMT等都可以选择。能够实现这些功能特性的组合拆分,反映到源代码不仅仅是条件编译,更关键的是接口与实现的分离。
Graal VM
Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式 (例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示(Intermediate Representation, IR),譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为程 序特化(Specialized,也常被称为Partial Evaluation)。Graal VM提供了Truffle工具集来快速构建面向一 种新语言的解释器,并用它构建了一个称为Sulong的高性能LLVM字节码解释器。
对Java而言,Graal VM本来就是在HotSpot基础上诞生的,天生就可作为一套完整的符合Java SE 8 标准的Java虚拟机来使用。它和标准的HotSpot的差异主要在即时编译器上,其执行效率、编译质量目前与标准版的HotSpot相比也是互有胜负。但是HotSpot以及其编译器Client和Server Complier都是C编写的,而Graal VM则是java编写的,可以与前端编译器一样更好地阅读
前端编译器
·前端编译器:JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)
把.java文件转变成.class文件的过程;
Javac编译器
在JDK 6以前,Javac并不属于标准Java SE API的一部分,它实现代码单独存放在tools.jar中,要在程序中使用的话就必须把这个库放到类路径上。在JDK 6发布时通过了JSR 199编译器API的提案,使得Javac编译器的实现代码晋升成为标准Java类库之一,它的源码就改为放在
JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac中[1]。到了JDK 9时,整个JDK所有的Java类库都采用模块化进行重构划分,Javac编译器就被挪到了jdk.compiler模块(路径为:JDK_SRC_HOME/src/jdk.compiler/share/classes/com/sun/tools/javac)里面。
Javac编译器除了JDK自身的标准类库外,就只引用了 JDK_SRC_HOME/langtools/src/share/classes/com/sun/*里面的代码,所以我们的代码编译环境建立时基 本无须处理依赖关系,相当简单便捷。
导入了Javac的源码后,就可以运行com.sun.tools.javac.Main的main()方法来执行编译了,可以使用 的参数与命令行中使用的Javac命令没有任何区别,编译的文件与参数在Eclipse的“Debug Configurations”面板中的“Arguments”页签中指定。
《Java虚拟机规范》中严格定义了Class文件格式的各种细节,可是对如何把Java源码编译为Class 文件却描述得相当宽松。规范里尽管有专门的一章名为“Compiling for the Java Virtual Machine”,但这 章也仅仅是以举例的形式来介绍怎样的Java代码应该被转换为怎样的字节码,并没有使用编译原理中 常用的描述工具(如文法、生成式等)来对Java源码编译过程加以约束。这是给了Java前端编译器较大的实现灵活性,但也导致Class文件编译过程在某种程度上是与具体的JDK或编译器实现相关的,譬如 在一些极端情况下,可能会出现某些代码在Javac编译器可以编译,但是ECJ编译器就不可以编译的问题(反过来也有可能,后文中将会给出一些这样的例子)。
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。
1)准备过程:初始化插入式注解处理器。
2)解析与填充符号表过程,包括:
·词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
·填充符号表。产生符号地址和符号信息。
3)插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段
4)分析与字节码生成过程,包括:
·标注检查。对语法的静态信息进行检查。
·数据流及控制流分析。对程序动态运行过程进行检查。
·解语法糖。将简化代码编写的语法糖还原为原有的形式。
·字节码生成。将前面各个步骤所生成的信息转化成字节码。
上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号,从总体来看,三者之间的关系与交互顺序如图
我们可以把上述处理过程对应到代码中,Javac编译动作的入口是 com.sun.tools.javac.main.JavaCompiler类,上述3个过程的代码逻辑集中在这个类的compile()和compile2() 方法里,其中主体代码如图10-5所示,整个编译过程主要的处理由图中标注的8个方法来完成。
即时编译器
即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程;
HotSpot虚拟机中含有两个即时编译器,分别是编译耗时短但输出代码优化程度较低的客户端编译 器(简称为C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2),通常它们 会在分层编译机制下与解释器互相配合来共同构成HotSpot虚拟机的执行子系统。
自JDK 10起,HotSpot中又加入了一个全新的即时编译器:Graal编译器。C2的历史已经非常长了,可以追溯到Cliff Click大神读博士期间的作品,这个由C++写成的编译器尽管目前依然效果拔 群,但已经复杂到连Cliff Click本人都不愿意继续维护的程度。
而Graal编译器本身就是由Java语言写 成,实现时又刻意与C2采用了同一种名为“Sea-of-Nodes”的高级中间表示(High IR)形式,使其能够 更容易借鉴C2的优点。
C1 / C2
譬如HotSpot、OpenJ9等,内部都同时包含解释器与编译器[1],解释器与编译器两者各有优势:
如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序 启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。
当程序运行环境中内存资源限制较大,可以使用解释执行 节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升效率。
同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许, HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率 选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设 不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通 过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与 编译器经常是相辅相成地配合工作,其交互关系如图11-1所示。
解释器
解释器是一行一行地将字节码解析成机器码,解释到哪就执行到哪,狭义地说,就是for循环100次,你就要将循环体中的代码逐行解释执行100次。当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。
无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode)
用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也
可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。
直到JDK 6时期才被初步实现,后来一直处于改进阶段,最终在JDK 7的服务端模式虚拟机中作为默认
编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包
括:
·第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
·第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启 性能监控功能。
·第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
·第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如 分支跳转、虚方法调用版本等全部的统计信息。
·第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启 用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次
编译之间的交互、转换关系如图11-2所示。实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多 次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行 的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客 户端编译器可先采用简单优化来为它争取更多的编译时间。
编译对象与触发条件
在运行过程中会被即时编译器编译的目标是“热点代码”,这里所指的热点代 码主要有两类,包括:(对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。)
·被多次调用的方法(被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”)
·被多次执行的循环体(当一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。)
这个行为称为“热点探测”(HotSpot Code Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点
探测判定方式有两种[2],分别是:
·基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
·基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为 它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨.
J9用过第一种采样热点探测,而在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈 值,计数器阈值一旦溢出,就会触发即时编译。
方法调用计数器
这个计数器就是用于统计方法被调用的次数,它的 默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX: CompileThreshold来人为设定。
当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的 版本,如果存在,则优先使用编译后的本地代码来执行。
如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。
如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解 释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如图11-3所示。 (客户端)
在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频 率,即一段时间之内方法被调用的次数。
当超过一定的时间限度,如果方法的调用次数仍然不足以让 它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time), 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。
回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。
关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前的HotSpot虚拟机(jdk1.8)实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式有如下两种。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图11-4所示。(客户端)
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
编译过程
服务端编译器和客户端编译器的编译 过程是有所差别的。对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段.
在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配 (Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、
常量传播等优化将会在字节码被构造成HIR之前完成。在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level IntermediateRepresentation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。 最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。客户端编译器大致的执行过程如图 11-5所示
而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编 译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的 优化强度。它会执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)、循环展开 (Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic BlockReordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range CheckElimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等.
服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。
观察细节:
编译后若是要查看机器码内容,需要反汇编才能更加方便地阅读指令,虚拟机(如HotSpot)为我们提供了反汇编接口,我们只需要下载相应的反汇编适配器,如使用32位x86平台应选用hsdis-i386适配器,64位则需要选用hsdis-amd64[2],其余平台的 适配器还有如hsdis-sparc、hsdis-sparcv9和hsdis-aarch64等,读者可以下载或自己编译出与自己机器相 符合的反汇编适配器,之后将其放置在JAVA_HOME/lib/amd64/server下[3],只要与jvm.dll或libjvm.so的路径相同即可被虚拟机调用。为虚拟机安装了反汇编适配器之后,我们就可以使用-XX:+PrintAssembly参数要求虚拟机打印编译方法的汇编代码了
如果除了本地代码的生成结果外,还想再进一步跟踪本地代码生成的具体过程,那可以使用参数- XX:+PrintCFGToFile(用于客户端编译器)或-XX:PrintIdealGraphFile(用于服务端编译器)要求 Java虚拟机将编译过程中各个阶段的数据(譬如对客户端编译器来说包括字节码、HIR生成、LIR生成、寄存器分配过程、本地代码生成等数据)输出到文件中。然后使用Java HotSpot Client CompilerVisualizer[4](用于分析客户端编译器)或Ideal Graph Visualizer[5](用于分析服务端编译器)打开这些数据文件进行分析。
服务端编译器的中间代码表示是一种名为理想图(Ideal Graph)的程序依赖图(ProgramDependence Graph,PDG),在运行Java程序的FastDebug或SlowDebug优化级别的虚拟机上的参数中加入“-XX:PrintIdealGraphLevel=2-XX:PrintIdeal-GraphFile=ideal.xml”,即时编译后将会产生一个名为ideal.xml的文件,它包含了服务端编译器编译代码的全过程信息,可以使用Ideal Graph Visualizer对这些信息进行分析。
Graal编译器
Graal编译器在JDK 9时以Jaotc提前编译工具的形式首次加入到官方的JDK中,从JDK 10起,Graal编译器可以替换服务端编译器,成为HotSpot分层编译中最顶层的即时编译器。这种可替换的即时编译 器架构的实现,得益于HotSpot编译器接口的出现。早期的Graal曾经同C1及C2一样,与HotSpot的协作是紧耦合的,这意味着每次编译Graal均需重新 编译整个HotSpot。
JDK 9时发布的JEP 243:Java虚拟机编译器接口(Java-Level JVM Compiler Interface,JVMCI)使得Graal可以从HotSpot的代码中分离出来。JVMCI主要提供如下三种功能:
·响应HotSpot的编译请求,并将该请求分发给Java实现的即时编译器。
·允许编译器访问HotSpot中与即时编译相关的数据结构,包括类、字段、方法及其性能监控数据等,并提供了一组这些数据结构在Java语言层面的抽象表示。
·提供HotSpot代码缓存(Code Cache)的Java端抽象表示,允许编译器部署编译完成的二进制机器码。
综合利用上述三项功能,我们就可以把一个在HotSpot虚拟机外部的、用Java语言实现的即时编译器(不局限于Graal)集成到HotSpot中,响应HotSpot发出的最顶层的编译请求,并将编译后的二进制代码部署到HotSpot的代码缓存中。此外,单独使用上述第三项功能,又可以绕开HotSpot的即时编译系统,让该编译器直接为应用的类库编译出二进制机器码,将该编译器当作一个提前编译器去使用(如Jaotc)。
构建编译调试环境
由于Graal编译器要同时支持Graal VM下的各种子项目,如Truffle、Substrate VM、Sulong等(Graal编译器除了比C2容易维护外,还应有这些工具子项目的支持而扩展性更好),还要支持作为HotSpot和Maxine虚拟机的即时编译器,所以只用Maven或Gradle的话,配置管理过程会相当复杂。为了降低代码管理、依赖项管理、编译和测试等环节的复杂度,Graal团队专门用Python 2写了一个名为mx的小工具来自动化做好这些事情。
JVMCI编译器接口
代码中间表示
Graal编译器在设计之初就刻意采用了与HotSpot服务端编译器一致(略有差异但已经非常接近) 的中间表示形式,也即是被称为Sea-of-Nodes的中间表示,或者与其等价的被称为理想图(Ideal Graph,在代码中称为Structured Graph)的程序依赖图(Program Dependence Graph,PDG)形式。
在理想图上翻译和 优化输入代码的整体过程,从编译器内部来看即:字节码→理想图→优化→机器码(以Mach NodeGraph表示)的转变过程。
编译后机器码存储
链接:https://www.zhihu.com/question/52487484/answer/130785455
“JIT编译器”的部分实现的就是题主所提到的“把Class文件编译到机器码”的功能。
把Class文件(中的Java字节码)编译到机器码的功能是JVM的实现细节,而不是Java语言规范或JVM规范所要求的功能。所以只能针对每个JVM实现单独讨论。
就一个有JIT编译器的JVM而言,JIT编译发生在运行时,通常是直接在内存里把Java字节码编译到机器码的。也就是说生成的机器码会直接生成在内存里,然后直接投入执行,而不会被保存到磁盘(或其它持久化存储媒体)上。
有少量JVM实现会带有“dynamic AOT编译”功能,可以把JIT编译的结果存在磁盘上,以后再运行的时候读出来直接用,例如IBM J9 JVM。这是比较少见的、有趣的做法。请参考传送门:IBM Knowledge Center
就一个有AOT编译器的JVM而言,AOT(Ahead-of-Time)编译发生在Java程序运行之前,通常会把Class文件读进来然后把编译生成的机器码存储到磁盘上。等到实际运行Java程序的时候直接从磁盘上读出之前编译好的结果来执行。使用这种模型的典型例子有例如Excelsior JET、GCJ、Android Runtime(ART)等。
Oracle JDK9里的新的JAOTC也是这样的例子。请参考传送门:JEP 295: Ahead-of-Time Compilation
回到“如何查看编译出来的机器码”的问题。这个只能针对具体的实现来说。
-
Oracle/Sun JDK的HotSpot VM:对于其中的JIT编译器,查看它生成的代码的办法是指导 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 参数,搭配一个叫做“hsdis”的插件来使用。我以前写过相关的答复:X86 asm代码 是怎么才能看到? 现在相关资料已经很多了,题主可以用“PrintAssembly hsdis”关键字搜出各方面的资料来参考。
-
- 现在还有一个JITWatch项目,以可视化图像界面来现实PrintAssembly的结果,很是方便。题主感兴趣的话可以参考它的文档来切入
-
Oracle JDK9新添加的JAOTC:这是一个AOT编译器,可以把指定的Java Class文件 / module文件给编译成机器码。目前它只支持在Linux/x86-64上使用,生成的机器码被包装在ELF格式的文件中。所以用常规的工具就可以查看它的内容,例如用Linux自带的objdump。
-
IBM J9的JIT编译器:它也有提供trace出编译出来的机器码的功能,不过我不知道具体的命令是怎样的。以前我还在学校的时候,把玩J9研究它生成的代码,是通过trace JIT编译事件,在日志文件中得到编译生成的代码的内存地址,然后连接一个native调试器上去把这个地址开始的机器码dump出来,这样的办法来弄的。
-
Android Runtime的AOT编译器:ART的AOT编译器生成的OAT文件虽然也是ELF格式的包装,但它的ELF结构…并不是特别完整。要查看它的内容最好用ART自带的oatdump工具。
-
Maxine VM:用Maxine Inspector简直无敌。
下面放一张Maxine Inspector的截图给同学们感受一下Java字节码 -> 机器码的对应关系以及整个JVM的执行状态:
提前编译器
·提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。
(常称AOT编译器,Ahead Of Time Compiler)直接把程 序编译成与目标机器指令集相关的二进制代码的过程。
- 违反了“一次编写,到处运行”的承诺,但是适应了微服务的发展
对不需要长时间运行的,或者小型化的应用而言,Java(而不是指Java ME)天生就带有一些劣势,这里并不只是指跑个HelloWorld也需要百多兆的JRE之类的问题,更重要的是指近几年在从大型单体应用架构向小型微服务应用架构发展的技术潮流下,Java表现出来的不适应。 (比如一个手机应用启动,没有人喜欢在启动页面等半天,即使启动后可能用起来会越来越流畅)
在微服务架构的视角下,应用拆分后,单个微服务很可能就不再需要面对数十、数百GB乃至TB 的内存,有了高可用的服务集群,也无须追求单个服务要7×24小时不间断地运行,它们随时可以中断和更新;但相应地,Java的启动时间相对较长,需要预热才能达到最高性能等特点就显得相悖于这样的应用场景。在无服务架构中,矛盾则可能会更加突出,比起服务,一个函数的规模通常会更小,执行时间会更短,当前最热门的无服务运行环境AWS Lambda所允许的最长运行时间仅有15分钟。
一直把软件服务作为重点领域的Java自然不可能对此视而不见,在最新的几个JDK版本的功能清单中,已经陆续推出了跨进程的、可以面向用户程序的类型信息共享(Application Class Data Sharing,AppCDS,允许把加载解析后的类型信息缓存起来,从而提升下次启动速度,原本CDS只支 持Java标准库,在JDK 10时的AppCDS开始支持用户的程序代码)、无操作的垃圾收集器(Epsilon, 只做内存分配而不做回收的收集器,对于运行完就退出的应用十分合适)等改善措施。而酝酿中的一个更彻底的解决方案,是逐步开始对提前编译(Ahead of Time Compilation,AOT)提供支持。
但是提前编译的坏处也很明显,它破坏了Java“一次编写,到处运行”的承诺,必须为每个不同的 硬件、操作系统去编译对应的发行包;也显著降低了Java链接过程的动态性,必须要求加载的代码在编译期就是全部已知的,而不能在运行期才确定,否则就只能舍弃掉已经提前编译好的版本,退回到原来的即时编译执行状态
大家原本期望的是类似于Excelsior JET那样的编译过后能生成本地代码完全脱离Java虚拟机运行的 解决方案,但Jaotc其实仅仅是代替即时编译的一部分作用而已,仍需要运行于HotSpot之上。
直到Substrate VM出现,才算是满足了人们心中对Java提前编译的全部期待。Substrate VM是在 Graal VM 0.20版本里新出现的一个极小型的运行时环境,包括了独立的异常处理、同步调度、线程管理、内存管理(垃圾收集)和JNI访问等组件,目标是代替HotSpot用来支持提前编译后的程序执行。(少了HotSpot一些对于提前编译不需要的工具与监控资源的消耗) 它还包含了一个本地镜像的构造器(Native Image Generator),用于为用户程序建立基于Substrate VM的本地运行时镜像。这个构造器采用指针分析(Points-To Analysis)技术,从用户提供的程序入口出发,搜索所有可达的代码。在搜索的同时,它还将执行初始化代码,并在最终生成可执行文件时,将已初始化的堆保存至一个堆快照之中。
这样一来,Substrate VM就可以直接从目标程序开始运行,而无须重复进行Java虚拟机的初始化过程。但相应地,原理上也决定了Substrate VM必须要求目标程序是完全封闭的,即不能动态加载其他编译器不可知的代码和类库。基于这个假设,Substrate VM才能探索整个编译空间,并通过静态分析推算出所有虚方法调用的目标方法。 Substrate VM带来的好处是能显著降低内存占用及启动时间,由于HotSpot本身就会有一定的内存消耗(通常约几十MB),这对最低也从几GB内存起步的大型单体应用来说并不算什么,但在微服务 下就是一笔不可忽视的成本。根据Oracle官方给出的测试数据,运行在Substrate VM上的小规模应用,其内存占用和启动时间与运行在HotSpot上相比有5倍到50倍的下降。
提前编译的优劣得失
第一条:这是传统的提前编译应用形式,它在Java中存在的价值直指即时编译的最大弱点:即时编译要占用程序运行时间和运算资源。
在编译过程中最耗时的优化措施之一是通过“过程间分析”(Inter-Procedural Analysis,IPA,也经常被称为全程序分析,即Whole Program Analysis)来 获得诸如某个程序点上某个变量的值是否一定为常量、某段代码块是否永远不可能被使用、在某个点 调用的某个虚方法是否只能有单一版本等的分析结论。
这些信息对生成高质量的优化代码有着极为巨 大的价值,但是要精确(譬如对流敏感、对路径敏感、对上下文敏感、对字段敏感)得到这些信息, 必须在全程序范围内做大量极耗时的计算工作,目前所有常见的Java虚拟机对过程间分析的支持都相当有限,要么借助大规模的方法内联来打通方法间的隔阂,以过程内分析(Intra-Procedural Analysis, 只考虑过程内部语句,不考虑过程调用的分析)来模拟过程间分析的部分效果;要么借助可假设的激进优化,不求得到精确的结果,只求按照最可能的状况来优化,有问题再退回来解析执行。
但如果是在程序运行之前进行的静态编译,这些耗时的优化就可以放心大胆地进行了,譬如Graal VM中的Substrate VM,在创建本地镜像的时候,就会采取许多原本在HotSpot即时编译中并不会做的全程序优化措施以获得更好的运行时性能,反正做镜像阶段慢一点并没有什么大影响。
但提前编译需要耗费大量的时间和资源,从Android 7.0版本起重新启用了解释执行和即时编译,此时的即时编译大部分的C1等级的事情,减少资源消耗(但这已与Dalvik无关,它彻底凉透了,从优化过程到优化效率全面落后),等空闲时系统再在后台自动进行提前编译。
第二条路径,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(DynamicAOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)。
这是一个基于Graal编译器实现的新工具,目的是让用户可以针对目标机器,为应用程序进行提前编译(一些基本必用到的类库,如java.base)。HotSpot运行时可以直接加载这些编译的结果,实现加快程序启动速度,减少程序达到全速运行状态所需时间的目的。
Jaotc做的提前编译属于本节开头所说的“第二条分支”,即做即时编译的缓存;而Substrate VM则是选择的“第一条分支”,做的是传统的静态提前编译
即时编译器相对于提前编译器的天然优势
性能分析制导优化(Profile-Guided Optimization,PGO)。
在解释器或者客户端编译器运行过程中,会不断收集性能监控信息,譬如某个程序点抽象类通常会是什么实际类型、条件判断通常会走哪条分支、方法调用通常会选择哪个版本、循环通常会进行多少次等
这些数据一般在静态分析时是无法得到的,或者不可能存在确定且唯一的解,最多只能依照一些启发性的条件去进行猜测。但在动态运行时却能看出它们具有非常明显的偏好性。
如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。
激进预测性优化(Aggressive Speculative Optimization)
静态优化无论如何都必须保证优化后所有的程序外部可见影响(不仅仅是执行结果) 与优化前是等效的,不然优化之后会导致程序报错或者结果不对,若出现这种情况,则速度再快也是 没有价值的。
然而,相对于提前编译来说,即时编译的策略就可以不必这样保守,如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不 会出现无法挽救的后果。
只要出错概率足够低,这样的优化往往能够大幅度降低目标程序的复杂度,输出运行速度非常高的代码。
譬如在Java语言中,默认方法都是虚方法调用,部分C、C++程序员(甚至一些老旧教材)会说虚方法是不能内联的,但如果Java虚拟机真的遇到虚方法就去查虚表而不做内联的话,Java技术可能就已经因性能问题而被淘汰很多年了。
链接时优化(Link-Time Optimization,LTO)
Java语言天生就是动态链接的,一个个Class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码,这类事情 在Java程序员眼里看起来毫无违和之处。
但如果类似的场景出现在使用提前编译的语言和程序上,譬如C、C++的程序要调用某个动态链接库的某个方法,就会出现很明显的边界隔阂,还难以优化。这是因为主程序与动态链接库的代码在它们编译时是完全独立的,两者各自编译、优化自己的代码。
这些代码的作者、编译的时间,以及编译器甚至很可能都是不同的,当出现跨链接库边界的调用时,那些理论上应该要做的优化——譬如做对调用方法的内联,就会执行起来相当的困难。
如果刚才说的虚方法内联让C、C++程序员理解还算比较能够接受的话(其实C++编译器也可以通过一些技巧来做到虚方 法内联),那这种跨越动态链接库的方法内联在他们眼里可能就近乎于离经叛道了(但实际上依然是可行的)。