ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

即时(Just-In-Time,JIT)编译器

2021-05-25 12:01:13  阅读:207  来源: 互联网

标签:Just 代码 JIT server 编译 编译器 client JVM


即时(Just-In-Time,JIT)编译器是 Java 虚拟机的核心。对 JVM 性能影响最大的莫过于编译器,而选择编译器是运行 Java 程序时首先要做的选择之一——无论你是 Java 开发人员还是最终用户。幸运的是,在绝大多数情况下,只需要对编译器做一些基本的调优。

4.1 JIT编译器:概览

计算机——更具体说是 CPU——只能执行相对少而特定的指令,这被称为汇编码或者二进制码。因此,CPU 所执行的所有程序都必须翻译成这种指令。

像 C++ 和 Fortran 这样的语言被称为编译型语言,因为它们的程序都以二进制(编译后的)形式交付:先写程序,然后用编译器静态生成二进制文件。这个二进制文件中的汇编码是针对特定 CPU 的。只要是兼容的 CPU,都可以执行相同的二进制代码:比如,AMD 和 Intel CPU 共享一个基本的、常用的汇编语言指令集,新版本的 CPU 几乎总是能执行与老版本 CPU 相同的指令集。但反过来并不总是成立,新版本的 CPU 时常会引入一些指令,这些指令无法在老版本 CPU 上运行。

另外还有一些像 PHP 和 Perl 这样的语言,则是解释型的。只要机器上有合适的解释器(即称为 php 或 perl 的程序),相同的程序代码可以在任何 CPU 上运行。执行程序时,解释器会将相应代码转换成二进制代码。

每种语言类型都各有长处和不足。解释性型语言的程序可移植:相同的代码你丢到任何有适当解释器的机器上,它都能运行。但是,它运行起来可能就慢了。举个简单的例子,不妨考虑一下执行循环时会发生什么:当解释器执行循环体时,会重新翻译每一行代码。编译过后的代码就不必再重复做这样的转换了。

好的编译器在生成二进制代码时需要考虑许多因素。一个简单例子是二进制代码中的语句顺序:生成的汇编语言指令与执行时的顺序并不完全相同。执行两个寄存器值相加的语句可能只需要一个时钟周期,但(从主存储器)获取加法所需要的数据可能需要好几个周期

因此,好的编译器生成的二进制代码需要包括装载数据、执行其他指令,然后——当数据准备好时——执行加法。而一次只能看一行的解释器就没有足够的信息生成这样的代码了。它会请求内存数据,然后一直等到数据准备好之后再执行加法。稍差点的编译器也这么干,而且顺便说一句,即便是最好的编译器偶尔也需要等待指令完成。

由于这些(或其他的)原因,解释型代码几乎总是明显比编译型代码要慢:编译器有足够的程序信息,这些信息可用来大量优化二进制代码,这些是简单解释器无法做到的

解释型代码的优势在于可移植。很显然,SPARC CPU 的二进制编译器无法在 Intel CPU 上运行。而用 Intel Sandy Bridge 处理器最新 AVX 指令的二进制代码也无法在老的 Intel 处理器上运行。因此,商业软件通常会在较老的处理器上编译,从而无法利用最新的指令。这里面有很多技巧,例如,发布二进制代码时附带多个共享库,而这些共享库执行的代码都是对性能较敏感的,还要有多种版本与各种类型的 CPU 相匹配。

Java 试图走一条中间路线。Java 应用会被编译——但不是编译成特定 CPU 所专用的二进制代码,而是被编译成一种理想化的汇编语言。然后该汇编语言(称为 Java 字节码)可以用 java 运行(与 php 解释运行 PHP 脚本是相同的道理)。这使得 Java 成为一门平台独立的解释型语言。因为 java 程序运行的是理想化的二进制代码,所以它能在代码执行时将其编译成平台特定的二进制代码。由于这个编译是在程序执行时进行的,因此被称为“即时编译”(即 JIT)。

热点编译

常用的 Java 实现是 Oracle 的 HotSpot JVM。HotSpot 的名字来自于它看待代码编译的方式。对于程序来说,通常只有一部分代码被经常执行,而应用的性能就取决于这些代码执行得有多快。这些关键代码段被称为应用的热点,代码执行得越多就被认为是越热。

因此 JVM 执行代码时,并不会立即编译代码。有两个基本理由。第一,如果代码只执行一次,那编译完全就是浪费精力。对于只执行一次的代码,解释执行 Java 字节码比先编译然后执行的速度快。

但如果代码是经常被调用的方法,或者是运行很多次迭代的循环,编译就值得了:编译的代码更快,多次执行累积节约的时间超过了编译所花费的时间。这种权衡是编译器先解释执行代码的原因之一——编译器可以找出哪个方法被调用得足够频繁,可以进行编译。

第二个理由是为了优化:JVM 执行特定方法或者循环的次数越多,它就会越了解这段代码。这使得 JVM 可以在编译代码时进行大量优化。

寄存器和主内存

编译器最重要的优化包括何时使用主内存中的值,以及何时在寄存器中存贮值。考虑以下代码:

public class RegisterTest {
    private int sum;
    
    public void calculateSum(int n) {
        for (int i = 0; i < n; i++) {
            sum += i;
        }
    }
}

在某个时刻,实例变量 sum 必须驻留在主内存中,但从主内存获取数据是昂贵的操作,需要花费多个时钟周期才能完成。如果每次循环迭代都从主内存获取(或保存)sum 的值,性能就比较糟糕了。编译器不会这么做,它会将 sum 的初始值装入寄存器,用寄存器中的值执行循环,然后(在某个不确定的时刻)将最终的结果从寄存器写回到主内存

这种优化非常高效,但这意味着线程同步的语义对应用行为非常重要。一个线程无法看到另一个线程所用寄存器中保存变量的值,同步机制使得从寄存器写回主内存时其他线程可以准确地读到这个值

使用寄存器是编译器普遍采用的优化方法,当开启逃逸分析(escape analysis)时,寄存器的使用更为频繁。

4.2 调优入门:选择编译器类型(Client、Server或二者同用的分层编译)

有两种编译器 client 和 server。名字来自于命令行上用于选择编译器的参数(例如 -client 或 -server)。

两种编译器的最主要的差别在于编译代码的时机不同。client 编译器开启编译比 server 编译器要早。意味着在代码执行的开始阶段,client 编译器比 server 编译器要快,因为它的编译代码相比 server 编译器而言要多。

此处工程上考虑的权衡是,server 编译器等待编译的时候是否还能做更有价值的事:server 编译器在编译代码时可以更好地进行优化。最终,server 编译器生成的代码要比 client 编译器快。从用户角度看,权衡的取舍在于程序要运行多久,程序的启动时间有多重要

此处最明显的问题是,为什么需要人来做这种选择?为什么 JVM 不能在启动时用 client 编译器,然后随着代码变热使用 server 编译器?这种技术被称为分层编译。代码先由 client 编译器编译,随着代码变热,由 server 编译器重新编译(-XX:+TieredCompilation)

Java 8 中,分层编译默认为开启。

4.2.1 优化启动

  1. 如果应用的启动时间是首要的性能考量,那 client 编译器就是最有用的。

  2. 分层编译的启动时间可以非常接近于 client 编译器所获得的启动时间。

4.2.2 优化批处理

  1. 对于计算量固定的任务来说,应该选择实际执行任务最快的编译器。

  2. 分层编译是批处理任务合理的默认选择。

4.2.3 优化长时间运行的应用

衡量长时间运行的应用的性能,通常来说,是在应用“热身”之后——意味着它已经运行了足够长的时间,重要的代码都已经被编译——测量它处理的吞吐量。

对于长时间运行的应用来说,应该一直使用 server 编译器,最好配合分层编译。

4.3 Java和JIT编译器版本

实际上 JIT 编译器有 3 种版本:

  • 32 位 client 编译器(-client)

  • 32 位 server 编译器(-server)

  • 64 位 server 编译器(-d64)

编译器的选择取决于安装的 JVM 是 32 位还是 64 位,以及传递给 JVM 的编译器参数。表 4-4 显示了在给定 JVM 安装版本、给定参数时的编译器。

表4-4:不同JVM安装版本和不同参数下的编译器

安装的JVM版本-client-server-d64
Linux 32 位32 位 client 编译器32 位 server 编译器出错
Linux 64 位64 位 server 编译器664 位 server 编译器64 位 server 编译器
Mac OS X64 位 server 编译器64 位 server 编译器64 位 server 编译器
Solaris 32 位32 位 client 编译器32 位 server 编译器出错
Solaris 64 位32 位 client 编译器32 位 server 编译器64 位 server 编译器
Windows 32 位32 位 client 编译器32 位 server 编译器出错
Windows 64 位64 位 server 编译器64 位 server 编译器64 位 server 编译器

在 Java 8 中,上述所有情况的默认值是 server 编译器,同时也默认开启分层编译。

表4-5:不同OS和机器上的默认编译器

操作系统默认编译器
Windows 32 位,任意数量的 CPU-client
Windows 64 位,任意数量的 CPU-server
MacOS,任意数量的 CPU-server
Linux/Solaris 32 位,1 个 CPU-client
Linux/Solaris 32 位,2 个或以上的 CPU-server
Linux 64 位,任意数量的 CPU-server
Solaris 32 位 /64 位 overlay,1 个 CPU-client
Solaris 32 位 /64 位 overlay,2 个或以上的 CPU-server(32 位模式)
确定默认的编译器

想确定所安装的 Java 的默认编译器,可以运行以下命令:

% java -version
java version"1.7.0"
Java(TM) SE Runtime Environment (build 1.7.0-b147)
Java HotSpot(TM) Server VM (build 21.0-b17, mixed mode)

上述示例来自我的 Linux 桌面系统,32 位 Java。最后一行表示所用的编译器为以下三种之一:client(32 位)、server(32 位)或 64-bit server。 如果所安装的 Java 不支持特定的编译器,最后一行将显示实际使用的编译器:

% java -client -version
java version"1.7.0"
Java(TM) SE Runtime Environment (build 1.7.0-b147)
Java HotSpot(TM) 64-Bit Server VM (build 21.0-b17, mixed mode)

这个例子用的是 64 位 Java,在 Linux 上只支持 64 位 server 编译器。

默认值是基于一个理念的,即启动时间对 32 位 Windows 机器来说是最重要的,而基于 Unix 的系统一般来说更关注长期运行的性能。

4.4 编译器中级调优

大多数情况下,所谓编译器调优,其实就只是为目标机器上的 Java 选择正确的 JVM 和编译器开关(-client、-server 或 -XX:+TieredCompilation)而已。分层编译通常是长期运行应用的最佳选择,而对于运行时间短的应用来说,分层编译与 client 编译器的性能差别也只在毫厘之内

除了选择 JVM 和编译器开关,有些场景还需要进行额外的调优工作。

4.4.1 调优代码缓存

JVM 编译代码时,会在代码缓存中保留编译之后的汇编语言指令集。代码缓存的大小固定,所以一旦填满,JVM 就不能编译更多代码了。

这个问题在使用 client 编译器或进行分层编译时很常见。使用常规的 server 编译器时,因为通常只有少量类会被编译,所以能被编译的类不太可能填满代码缓存。而用 client 编译器时,可被编译的类可能会非常多(因此也适合开启分层编译)。

确实没有什么好的机制可以算出程序所需要的代码缓存。所以,如何增加代码缓存,基本上就是摸着石头过河,通常的做法是简单地增加 1 倍或 3 倍(-XX:ReservedCodeCacheSize=N)。

  1. 代码缓存是一种有最大值的资源,它会影响 JVM 可运行的编译代码总量。

  2. 分层编译很容易达到代码缓存默认配置的上限(特别是在 Java 7 中)。使用分层编译时,应该监控代码缓存,必要时应该增加它的大小。

4.4.2 编译阈值(主要因素:代码执行频度)

在触发代码编译的条件中,其中最主要的因素是代码执行的频度。一旦执行达到一定次数,且达到了编译阈值,编译器就可以获得足够的信息编译代码了

编译是基于两种 JVM 计数器的:方法调用计数器和方法中的循环回边计数器。回边实际上可看作是循环完成执行的次数,所谓循环完成执行,包括达到循环自身的末尾,也包括执行了像 continue 这样的分支语句。

JVM 执行某个 Java 方法时,会检查该方法的两种计数器总数,然后判定该方法是否适合编译。如果适合,该方法就进入编译队列。这种编译没有正式的名称,通常叫标准编译。

但是,如果循环真的很长——或因包含所有程序逻辑而永远不退出,又该如何?在这种情况下,JVM 不等方法调用完成就会编译循环。所以循环每完成一轮,回边计数器就会增加并被检测。如果循环的回边计数器超过阈值,那这个循环(不是整个方法)就可以被编译

这种编译称为栈上替换(On-Stack Replacement,OSR)。由于仅仅编译循环还不够,JVM 必须在循环进行的时候还能编译循环。在循环代码编译结束后,JVM 就会替换还在栈上的代码,循环的下一次迭代就会执行快得多的编译代码

标准编译由 -XX:CompileThreshold=N 标志触发。使用 client 编译器时,N 的默认值是 1500,使用 server 编译器时为 10 000。更改 CompileThreshold 标志的值,将使编译器提早(或延后)编译。然而请注意,尽管有一个标志,但这个标志的阈值等于回边计数器加上方法调用计数器的总和。

  1. 当方法和循环执行次数达到某个阈值的时候,就会发生编译。

  2. 改变阈值会导致代码提早或推后编译。

  3. 由于计数器会随着时间而减少,以至于“温热”的方法可能永远都达不到编译的阈值(特别是对 server 编译器来说)。

4.4.3 检测编译过程

  1. 观察代码如何被编译的最好方法是开启 PrintCompilation。

  2. PrintCompilation 开启后所输出的信息可用来确认编译是否和预期一样。

4.5 高级编译器调优(真不建议改这些值)

本节将补充一些编译如何工作的细节,在此过程中探索一些可以影响编译的调优方法。不过,虽然可以更改这些值,但真的不建议这么做,因为这些调优标志很大程度上是为了帮助 JVM 工程诊断 JVM 行为的。

4.5.1 编译线程

4.4.2 节“编译阈值”曾提到,当方法(或循环)适合编译时,就会进入到编译队列。队列则由一个或多个后台线程处理。这是件好事,意味着编译过程是异步的。

编译队列并不严格遵守先进先出的原则:调用计数次数多的方法有更高的优先级。所以,即便在程序开始执行并有大量代码需要编译时,这样的优先顺序仍然有助于确保最重要的代码优先编译

当使用 client 编译器时,JVM 会开启一个编译线程使用 server 编译器时,则会开启两个这样的线程。当启用分层编译时,JVM 默认开启多个 client 和 server 线程,线程数依据一个略复杂的等式而定,包括目标平台 CPU 数取双对数之后的数值。表 4-7 中显示的值即为计算出的数值。

表4-7:分层编译中编译器C1和C2的默认线程数

CPU数量C1的线程数C2的线程数
111
211
412
812
1626
3237
6448
128410

编译器的线程数(3 种编译器都是如此)可通过 -XX:CICompilerCount=N 标志来设置(默认值参见前表)。这是 JVM 处理队列的线程总数;对分层编译来说,其中三分之一(至少一个)将用来处理 client 编译器队列,其余的线程(至少一个)用来处理 server 编译器队列。

4.5.2 内联(inline)

编译器所做的最重要的优化是方法内联。遵循面向对象设计的良好代码通常都会包括一些需要通过 getter(也可能包含 setter)访问的属性:

内联默认是开启的。可通过 -XX:-Inline 关闭,然而由于它对性能的影响巨大,事实上永远不能这么做。

方法是否内联取决于它有多热以及它的大小。

  1. 内联是编译器所能做的最有利的优化,特别是对属性封装良好的面向对象的代码来说。

  2. 几乎用不着调节内联参数,且提倡这样做的建议往往忽略了常规内联和频繁调用内联之间的关系。当考察内联效应时,确保考虑这两种情况。

4.5.3 逃逸分析

如果开启逃逸分析(-XX:+DoEscapeAnalysis,默认为 true),server 编译器将会执行一些非常激进的优化措施。

  1. 逃逸分析是编译器能做得最复杂的优化。此类优化常常会导致微基准测试失败。

  2. 逃逸分析常常会给不正确的同步代码引入“bug”。

4.6 逆优化

逆优化意味着编译器不得不“撤销”之前的某些编译;结果是应用的性能降低——至少是直到编译器重新编译相应代码为止。

有两种逆优化的情形:代码状态分别为“made not entrant”(代码被丢弃)和“made zombie”(产生僵尸代码)时。

  1. 逆优化使得编译器可以回到之前版本的编译代码。

  2. 先前的优化不再有效时(例如,所涉及的对象类型发生了更改),才会发生代码逆优化。

  3. 代码逆优化时,会对性能产生一些小而短暂的影响,不过新编译的代码会尽快地再次热身。

  4. 分层编译时,如果代码之前由 client 编译器编译而现在由 server 编译器优化,就会发生逆优化。

4.8 小结

从调优角度看,简单的选择就是对所有应用都使用 server 编译器和分层编译,这将解决 90% 的与编译器相关的性能问题。只要确保代码缓存足够大,编译器就能提供尽善尽美的性能

只要有必要时,你就应该使用 final:比如你不打算改变的不可变对象或原生值,内部类引用的外部参数等等。但无论有没有 final 关键字,都不会影响应用的性能。

(1) 不用担心小方法——特别是 getter 和 setter,因为它们很容易内联。如果你觉得某个方法的负载过大,那你可能只是在理论上是正确的(通过移除内联给性能造成的巨大影响可以展示这点)。而实际上并不是这样,因为编译器会修复这些问题。

(2) 需要编译的代码在编译队列中。队列中代码越多,程序达到最佳性能的时间越久。

(3) 虽然代码缓存的大小可以(也应该)调整,但它仍然是有限的资源。

(4) 代码越简单,优化越多。分析反馈和逃逸分析可以使代码更快,但复杂的循环结构和大方法限制了它的有效性。

标签:Just,代码,JIT,server,编译,编译器,client,JVM
来源: https://blog.csdn.net/CmdSmith/article/details/117249236

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有