🏆「作者推荐!」【Java 技术之旅】彻底你明白什么是 JIT 编译器(Just In Time 编译器)
前提概要
我们都知道开发语言整体分为两类,一类是编译型语言,一类是解释型语言。那么你知道二者有何区别吗?编译器和解释器又有什么区别?
这是为了兼顾启动效率和运行效率两个方面。Java 程序最初是通过解释器进行解释运行的,当虚拟机返现某个方法或代码块的运行特别频繁时,就会把这段代码标记为热点代码,为了提供热点代码的运行效率,在运行时,虚拟机就会把这些代码编译成与本地平台相关的机器码。并进行各种层次的优化。
编译器和解释器
Java 编译器(javac)的作用是将 java 源程序编译成中间代码字节码文件,是最基本的开发工具。
Java 解释器(java)(英语:Interpreter),又译为直译器,是一种电脑程序,能够把高级编程语言一行一行直接转译运行。解释器不会一次把整个程序转译出来,只像一位“中间人”,每次运行程序时都要先转成另一种语言再作运行,因此解释器的程序运行速度比较缓慢。 它每转译一行程序叙述就立刻运行,然后再转译下一行,再运行,如此不停地进行下去。
当程序需要首次启动和执行的时候,解释器可以首先发挥作用,一行一行直接转译运行,但效率低下。
当多次调用方法或循环体时 JIT 编译器可以发挥作用,把越来越多的代码编译成本地机器码,之后可以获得更高的效率(占内存),此时就有了智能化的编译器(JIT 编译器)
解释器与编译器的交互:
HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Complier 和 Server Complier,它会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用"-client"或"-server"参数去强制指定虚拟机运行在 Client 模式或 Server 模式
什么是 JIT 编译器
即时(Just-In-Time)编译器是 Java 运行时环境的一个组件,它可提高运行时 Java 应用程序的性能。JVM 中没有什么比编译器更能影响性能,而选择编译器是运行 Java 应用程序时做出的首要决定之一。
当编译器做的激进优化不成立,如载入了新类后类型继承结构出现变化。出现了罕见陷阱时能够进行逆优化退回到解释状态继续运行。
解释器与编译器搭配使用的方式:
HotSpot JVM 内置了两个编译器,各自是 Client Complier 和 Server Complier,虚拟机默认是 Client 模式。我们也能够通过。
-client:强制虚拟机运行 Client 模式
-server:强制虚拟机运行 Server 模式
默认(java -version 混合模式)
而不管是 Client 模式还是 Server 模式,虚拟机都会运行在解释器和编译器配合使用的混合模式下。能够通过。
解释模式(java -Xint -version)强制虚拟机运行于解释模式,仅使用解释器方式执行。
编译模式(java -Xcomp -version)优先采用编译方式执行程序,但解释器要在编译无法进行的情况下介入执行过程。
Java 功能“一次编译,到处运行”的关键是 bytecode。字节码转换为应用程序的机器指令的方式对应用程序的速度有很大的影响。这些字节码可以被解释,编译为本地代码,或者直接在指令集架构中符合字节码规范的处理器上执行。
解释字节码的是 Java 虚拟机(JVM)的标准实现,这会使程序的执行速度变慢。为了提高性能,JIT 编译器在运行时与 JVM 交互,并将适当的字节码序列编译为本地机器代码。
使用 JIT 编译器时,硬件可以执行本机代码,而不是让 JVM 重复解释相同的字节码序列,并导致翻译过程相对冗长。这样可以提高执行速度,除非方法执行频率较低。
JIT 编译器编译字节码所花费的时间被添加到总体执行时间中,并且如果不频繁调用 JIT 编译的方法,则可能导致执行时间比用于执行字节码的解释器更长。
当将字节码编译为本地代码时,JIT 编译器会执行某些优化。
由于 JIT 编译器将一系列字节码转换为本机指令,因此它可以执行一些简单的优化。
JIT 编译器执行的一些常见优化操作包括数据分析,从堆栈操作到寄存器操作的转换,通过寄存器分配减少内存访问,消除常见子表达式等。
JIT 编译器进行的优化程度越高,在执行阶段花费的时间越多。
因此,JIT 编译器无法承担所有静态编译器所做的优化,这不仅是因为增加了执行时间的开销,而且还因为它只对程序进行了限制。
JIT 编译器默认情况下处于启用状态,并在调用 Java 方法时被激活。
JIT 编译器将该方法的字节码编译为本地机器代码,“即时”编译以运行。
编译方法后,JVM 会直接调用该方法的已编译代码,而不是对其进行解释。
从理论上讲,如果编译不需要处理器时间和内存使用量,则编译每种方法都可以使 Java 程序的速度接近本机应用程序的速度。
JIT 编译确实需要处理器时间和内存使用率。JVM 首次启动时,将调用数千种方法。即使程序最终达到了非常好的峰值性能,编译所有这些方法也会严重影响启动时间。
不同应用程序的不同编译器
JIT 编译器有两种形式,并且选择使用哪个编译器通常是运行应用程序时唯一需要进行的编译器调整。实际上,即使在安装 Java 之前,也要考虑知道要选择哪个编译器,因为不同的 Java 二进制文件包含不同的编译器。
客户端编译器
著名的优化编译器是 C1,它是通过-clientJVM 启动选项启用的编译器。顾名思义,C1 是客户端编译器。它是为客户端应用程序设计的,这些客户端应用程序具有较少的可用资源,并且在许多情况下对应用程序启动时间敏感。C1 使用性能计数器进行代码性能分析,以实现简单,相对无干扰的优化。
服务器端编译器
对于长时间运行的应用程序(例如服务器端企业 Java 应用程序),客户端编译器可能不够。可以使用类似 C2 的服务器端编译器。通常通过将 JVM 启动选项添加-server 到启动命令行来启用 C2 。由于大多数服务器端程序预计将运行很长时间,因此启用 C2 意味着您将能够比使用运行时间短的轻量级客户端应用程序收集更多的性能分析数据。因此,您将能够应用更高级的优化技术和算法。
分层编译
为什么要进行分层编译
这是由于编译器编译本机代码须要占用程序运行时间,要编译出优化程度更高的代码锁花费的时间可能更长,并且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息。这对解释运行的速度也有影响。为了在程序启动响应速度和运行效率之间寻找平衡点。因此採用分层编译的策略。
分层编译结合了客户端和服务器端编译。分层编译利用了 JVM 中客户端和服务器编译器的优势。
客户端编译器在应用程序启动期间最活跃,并处理由较低的性能计数器阈值触发的优化。
客户端编译器还会插入性能计数器,并为更高级的优化准备指令集,服务器端编译器将在稍后阶段解决这些问题。
分层编译是一种非常节省资源的性能分析方法,因为编译器能够在影响较小的编译器活动期间收集数据,以后可以将其用于更高级的优化。与仅使用解释的代码配置文件计数器所获得的信息相比,这种方法还可以产生更多的信息。
分层策略例如以下所看到的:
第 0 层:程序解释运行。解释器不开启性能监控功能,可触发第 1 层编译。
第 1 层:即 C1 编译。将字节码编译为本地代码。进行简单和可靠的优化,如有必要将增加性能监控的逻辑。
第 2 层:即 C2 编译,将字节码编译为本地代码,同一时候启用一些编译耗时较长的优化,甚至会依据性能监控信息进行一些不可靠的激进优化。
代码优化
当选择一种方法进行编译时,JVM 会将其字节码提供给即时编译器(JIT)。JIT 必须先了解字节码的语义和语法,然后才能正确编译该方法。
为了帮助 JIT 编译器分析该方法,首先将其字节码重新格式化为称为 trees,它比字节码更类似于机器代码。
然后对方法的树进行分析和优化。
最后,将树转换为本地代码。
JIT 编译器可以使用多个编译线程来执行 JIT 编译任务,使用多个线程可以潜在地帮助 Java 应用程序更快地启动。
编译线程的默认数量由 JVM 标识,并且取决于系统配置。如果生成的线程数不是最佳的,则可以使用该 XcompilationThreads 选项覆盖 JVM 决策。
编译包括以下阶段:
内联
内联是将较小方法的树合并或“内联”到其调用者的树中的过程。这样可以加速频繁执行的方法调用。
局部优化
局部优化可以一次分析和改进一小部分代码。许多本地优化实现了经典静态编译器中使用的久经考验的技术。
控制流优化
控制流优化分析方法(或方法的特定部分)内部的控制流,并重新排列代码路径以提高其效率。
全局优化
全局优化可一次对整个方法起作用。它们更加“昂贵”,需要大量的编译时间,但可以大大提高性能。
本机代码生成
本机代码生成过程因平台架构而异。通常,在编译的此阶段,将方法的树转换为机器代码指令;根据架构特征执行一些小的优化。
编译对象
编译对象即为会被编译优化的热点代码。有下面两类:
被多次调用的方法
被多次运行的循环体
触发条件
这就牵扯到触发条件这个概念,推断一段代码是否是热点代码。是否须要触发即时编译,这样的行为成为热点探测(Spot Dectection)。
热点探测有两种手段:
基于采样的热点探测(Sample Based Hot Spot Dectection)
虚拟机会周期性的检查各个线程的栈顶,假设发现某些方法常常性的出如今栈顶,那么这种方法就是热点方法。
基于计数器的热点探测(Counter Based Hot Spot Dectection)
虚拟机会为每一个方法或代码块建立计数器,统计方法的运行次数。假设运行次数超过一定的阈值就觉得他是热点方法。
HotSpot JVM 使用另外一种方法基于计数器的热点探測方法。它为每一个方法准备了两类计数器:
方法调用计数器
这个阈值在 Client 模式下是 1500 次。在 Server 模式下是 10000 此,这个阈值能够通过參数
-XX:CompileThreshold
来人为设定。
方法调用次数统计的并非方法被调用的绝对次数,而是相对的运行频率,即一段时间内方法被调用的次数,当超过一定时间限度,假设方法的调用次数仍然不足以让它提交给即时编译器编译,那这种方法的调用计数器会被降低一半,这个过程被称为方法调用计数器的热度衰减(Counter Decay)。
而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。相同也能够使用參数
-XX:-UseCounterDecay
来关闭热度衰减。
方法调用计数器触发即时编译的整个流程例如以下图所看到的:
回边计数器
什么是回边?
在字节码遇到控制流向后跳转的指令称为回边(Back Edge)。
回边计数器是用来统计一个方法中循环体代码运行的次数,回边计数器的阈值能够通过參数
-XX:OnStackReplacePercentage
来调整。
虚虚拟机运行在 Client 模式下,回边计数器阂值计算公式为:
当中 OnSlackReplacePercentage 默认值为 933,假设都取默认值,那 Client 模式虚拟机的回边计数器的阂值为 13995。
虚拟机运行在 Server 模式下,回边计数器阂值的 itm 公式为:
当中 OnSlackReplacePercentage 默认值为 140。 InterpreterProffePercentage 默认值为 33.
假设都取默认值。BF Server 模式虚拟机回边计数器的阈值为 10700。
回边计数器触发即时编译的流程例如以下图所看到的:
回边计数器与方法调用计数器不同的是,回边计数器没有热度衰减,因此这个计数器统计的就是循环运行的绝对次数。
编译流程
在默认设置下,不管是方法调用产生的即时编译请求,还是 OSR 编译请求,虚拟机在代码编译器还未完毕之前,都仍然依照解释方式继续进行,而编译动作则在后台的编译线程中继续进行。也能够使用-XX:-BackgroundCompilation 来禁止后台编译,则此时一旦遇到 JIT 编译,运行线程向虚拟机提交请求后会一直等待,直到编译完毕后再開始运行编译器输出的本地代码。
那么在后台编译过程中,编译器做了什么事呢?
Client Compiler 编译流程
第一阶段:一个平台独立的前端将字节码构造成一种高级中间码表示(High Level Infermediate Representaion),HIR 使用静态单分配的形式来表示代码值,这能够使得一些的构造过程之中和之后进行的优化动作更 easy 实现,在此之前编译器会在字节码上完毕一部分基础优化,如方法内联、常量传播等。
第二阶段:一个平台相关的后端从 HIR 中产生低级中间代码表示(Low Level Intermediate Representation),而在此之前会在 HIR 上完毕还有一些优化。如空值检查消除、范围检查消除等。以便让 HIR 达到更高效的代码表示形式。
第三阶段:在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔优化(Peephole)优化,然后产生机器码。
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/ce15b8d40e21513528ac88c7e】。文章转载请联系作者。
评论