介绍
java 作为静态语言十分特殊,他需要编译,但并不是在执行之前就编译为本地机器码。
所以,在谈到 java的编译机制的时候,其实应该按时期,分为两个部分。一个是 javac指令 将java源码变为 java字节码的静态编译过程。 另一个是 java字节码编译为 本地机器码的过程,并且因为这个过程是在程序运行时期完成的所以称之为即时编译。
静态编译过程,通过javac 完成,而即时编译是通过虚拟机来完成的,即时编译机制,被内嵌于 java字节码执行引擎之中,可以算的上是 jvm的一个内存组件。
jvm的执行引擎中 有 一个解释器用来识别字节码指令,并将字节码指令映射为机器指令 调用操作系统来完成程序的运行。 这样来看,虽然实现了 java的跨平台特性,但是 却以牺牲了极大的的性能为代价。 为了提高java程序的性能,jvm实现了 即时编译机制。即,在程序运行期间,根据对热点字节码的探测(运行次数超过某个阀值的代码),将这部分热点代码进行特别的优化,将其直接编译为本地机器码执行。 这个过程由java字节码执行引擎中的 两个编译器完成,C1与C2编辑器,一个用于客户端,一个用于服务器。 c1相比较与c2他的编译优化程度要低一些,c2将针对服务器进行一些激进的优化,以保证代码在服务器运行时性能更加突出。
分层编译:
现代虚拟机实现中,制定了多种不同的编译级别以达到适应多种开发场景的目的。 即时编译机制本身也需要占用用户内存。造成一定的内存开销,在一些场景下可能会造成较高的延迟。
同样,对于服务器端程序来说会长久的运行,花销一定的编译时间可以换来之后更高的性能,所以直接全部编译可能效果更佳。
而还存在一些 很久都不会使用一次的代码,编译这些代码就显得浪费时间。
为了解决上述的问题, 现代虚拟机,提出了 分层编译策略,类似于 分代垃圾回收机制,一种根据不同时期场景调整编译级别的优化策略。
分层编译分为 三层:
一层: 仅进行解释执行,c1与c2编译器被禁用。 这时不存在即时编译情况。
二层: 仅c1编译器运行,c1编译器是客户端编译器,仅会进行一些常规的 编译优化机制。使用大多数情况。
三层: 混合编译 c1与c2同时使用,c2编译器是服务端编译器,可以对代码进行 高性能的 激进优化,同样设定逃生门,在一些特殊情况下,激进优化后的代码并不能有更高的性能。需要进行优化回退,将重新对代码进行解释执行。
对于分层编译来说代码的编译优化级别是可以提升的,也可以使用 jvm参数进行控制。
即时编译的基本流程:
方法调用栈上运行着 方法栈帧,即时编译的流程从这里开始:
字节码开始是解释执行的,解释字节码的任务由解释器完成,但真实操作的是内存中的 方法栈中栈帧内的操作数栈与局部变量表。所以 java程序解释执行时运行速度相对较慢。
java程序的执行伴随着 栈帧的弹栈出栈(方法调用)以及pc寄存器的顺序执行及跳转。 即时编译的第一步就是要 探测 热点代码.
使用 热点探测技术 来统计 热点代码。 热点探测技术 实质就是 统计 某段代码频繁调用的次数,一旦超过指定的阈值就会触发即时编译。
触发条件: 热点探测
jvm 通过统计 每个方法调用栈的栈顶 一个方法栈帧的弹出频率 来作为一个指标。有两种方法,第一使用精确的计数器进行精确计数,超过阈值触发编译。二是记录一段时间内方法调用次数(方法调用的频 率) 超过阈值触发编译。并存在热度衰减,超过一定时间范围没有继续调用 该方法则会 将其值减半。 二者各有优缺点,前者 精确计算开销大,后者不够严谨但适用大多数情况。
一旦超过阈值将触发方法级别的即时编译,以整个方法为编译对象。
还存在 循环体级别的热点探测,适用回边计数器来进行计数,pc寄存器向后跳转一次记为 一个回边。 当每次跳转时,都会触发计数器加一,并将计数器的值与该循环体所在方法的频率计数器的值相加。
其值超过阈值就会触发即时编译,若没超过阈值并不存在半衰,继续以解释形式执行代码。
循环体级别的探测,也是会将整个方法进行编译的。
即时编译:
一旦判定代码段是热点代码,则解释器将发送一次请求编译器,进行编译,在编译成功之前 解释器仍旧运行着。 等编译完成后,直接将pc寄存器中方法的调用地址进行替换,替换为编译后的方法地址。
这一过程就是 栈上替换---OSR.
编译优化:
javac只能进行一些 静态优化,优化上存在一些局限性。而在jvm中即时编译过程中进行的优化,是一种动态编译优化。
即时编译器会进行很多优化,介绍几种比较 经典的优化。
公共子表达式的消除:
在一个表达式中 有一部门表达式被计算过,并且在之后的代码中出现了同样的表达式并且表达式的值没有发生改变。那么编译器就会将 这部分表达式用计算结果进行替换。以避免重复计算造成的时间开销。
方法内联:
c/c++这种静态编译的语言,实现方法内联是很简单的,但java作为动态编译语言,方法内联存在不确定性。
在编译时,将方法调用 直接使用 方法体中的代码进行替换,这就是方法内联,这样做,减少了 方法调用过程中 压栈与入栈的开销。同时 为之后的一些优化手段提供条件。
对非虚方法进行内联是容易的,但对虚方法而言就比较复杂了,需要禁用 运行时类型继承分析机制 来确定虚方法的实际调用者。 因为多态机制的存在,方法的调用者仅在运行时期才能知晓。并且会发生改变。 这就要求对虚方法的内联必须存在 逃生门,可以在 方法调用者,也就是继承关系发生变化时 取消内联。
逃逸分析:
如果一个变量的使用,在运行期检测 他的作用范围不会超过一个方法或者一个线程的作用域。那么这个变量就不会被多个线程所共享,也就是说 可以不将其分配在堆空间中,而是将其线程私有化。
那么 如何来检测一个变量的作用域仅在 一个方法或者线程中呢? jvm中使用 数据流分析机制 实现的一种机制。 称之为 逃逸分析,作为其他一些激进优化的前提判断条件。
栈上分配:
如果一个变量经过逃逸分析后,判定可以被线程私有的,那么jvm将进行 一个大胆的优化手段, 栈上分配。 java 仅允许在 堆空间创建对象,但jvm的发展已经打破了这一规定。 如果一个对象,注定是线程私有的 那么为什么要放在堆空间,GC的回收以及主存与工作内存的同步都需要消耗大量资源。 而放在栈空间则不在需要担忧这些,对象将跟随栈的创建而创建,销毁而销毁。
标量替换:
标量,指的是 jvm中描述数据最基本的单位。 列如 原始数据类型等。
当确定一个对象不会逃逸后,那么就要分配他到栈空间上,然而栈空间是有限的,为了进一节省栈空间,就需要将 对象(聚合量) 拆散为标量。 这样 在jvm不会在栈中创建 对象而是仅仅创建对象的成员变量。
这样就节省了空间,因为没有对象头以及对齐填充的空间浪费。
同步消除:
同样基于 逃逸分析,当加锁的变量不会发生逃逸,是线程私有的那么,完全没有必要加锁。 在jit编译时期就可以将同步锁去掉,以减少 加锁与解锁造成的资源开销。