1. 解释器与 JIT 编译器
首先我们先来了解一下运行在虚拟机之上的解释器与 JIT 编译器.
当我们的虚拟机在运行一个 java 程序的时候, 它可以采用两种方式来运行这个 java 程序:
采用解释器的形式, 也就是说, 在运行. class 文件的时候, 解释器一边把. class 文件翻译成本地机器码, 一边执行. 显然这种一边解释翻译一边执行发方式, 可以使我们立即启动和执行程序, 省去编译的时间. 不过由于需要一遍解释翻译, 会让程序的执行速度比较慢.
采用 JIT 编译器的方式: 注意, JIT 编译器是把. class 文件翻译成本地机器码, 而 javac 编译器是把. java 源文件编译成. class 文件. 如果采用 JIT 编译器的方式则是在启动运行一个程序的时候, 先把. class 文件全部翻译成本地机器码, 然后再来执行, 显然, 这种方式在执行的时候由于不用对. clasa 文件进行翻译, 所以执行的速度会比较快. 当然, 代价就是我们需要花销一定的时间来把字节码翻译成本地机器码. 这样, 程序在启动的时候, 会有更多的延迟.
这两种方式可以说是各有优势, 虚拟机 (特指 HotSpot 虚拟机) 在执行的时候, 一般会采用两种方式结合的策略.
也就是说, 在程序执行的时候, 有些代码采用解释器的方式, 有些代码采用编译器, 称之为即时编译. 一般我们会对热点代码采用编译器的方式.
2. 编译对象与触发条件
上面已经说了, 运行过程中, 如果遇到热点代码就会触发对该代码进行编译, 编译成本地机器码.
什么是热点代码?
热点代码主要有一下两类:
被多次调用的方法.
被多次执行的循环体.
不过这里需要注意的是, 由于循环体是存在方法之中的, 尽管编译动作是由循环体触发的, 但编译器仍然会以这个方法来作为编译的对象.
3. 热点探测
判断一段代码是不是热点代码, 是不是需要触发即时编译, 这样的行为我们称之为热点探测. 热点探测判定有以下两种方式:
基于采样的热点探测: 这种方式虚拟机会周期性着检查各个线程的栈顶, 如果发现某个方法经常出现在栈顶, 那么这个方法就是热点方法. 可能有人会问, 所谓经常, 那什么样才算经常, 对于这个我只能告诉你, 这个取决于你自己的设置, 如果自己没有进行相应的设置的话, 就采用虚拟机的默认设置.
基于计数器的热点探测: 这种方法我们会为每个方法设置一个计数器, 统计方法被调用的次数, 如果到达一定的次数, 我们就把它当作是热点方法.
两种方法的优缺点:
显然第一种方法在实现上是比较简单, 高效的, 但是缺点也很明显, 精确度不高, 容易受到线程阻塞等别的外界因素的干扰.
第二种方式的统计结果会很精确, 但需要为每个方法建立并维护一个计数器. 实现上会相对复杂一点并且开销也会大点.
不过, 这里需要指出的是, 我们的 HotSpot 虚拟机采用的是基于计数器的方式.
说明: 虚拟机在执行方法的时候, 会先判断该方法是否存在已经编译好的版本, 如果存在, 则执行编译好的本地机器码, 否则, 采用一边解释一边编译的方式.
4. 编译优化技术
先看一段代码:
- int a = 1;
- if(false){
- System.out.println("无用代码");
- }
- int b = 2;
复制代码
对于这段代码, 我们都知道是 if 语句体里面的代码是一定不可能会被执行到的, 也就是说, 这实际上是一段一点用处也没有的代码, 在执行时只能浪费判断时间.
实际上, 对于我们书写的代码, 编译器在编译的时候是会进行优化的. 对于上面的代码, 编译优化之后会变成这样:
- int a = 1;
- int b = 2;
复制代码
那段无用的代码会被消除掉.
各种编译优化策略
我们刚才已经说了, 对于有些被多次调用的方法或者循环体, 虚拟机会先把他们编译成本地机器码. 由于这些热点代码都是一些会被多次重复执行的代码, 为了使得编译好的代码更加完美, 运行的更快. 编译器做了很多的编译优化策略, 例如上面的无用代码消除就是其中的一种.
下面我们来讲讲大概都有那些优化策略:
大概预览一波:
公共子表达式消除.
数组范围检查消除.
方法内联.
逃逸分析.
(1). 公共子表达式消除
含义: 如果一个表达式 E 已经计算过了, 并且从先前的计算到现在 E 中的所有变量的值都没有发生变化, 那个 E 的这次出现就成为了公共子表达式. 对于这样的表示式, 没有必要对它再次进行计算了, 直接沿用之前的结果就可以了.
我们来举个例子. 例如
int d = (c * b) * 10 + a + (a + b * c);
复制代码
这段代码到了即时编译器的手里, 它会进行如下优化:
表达式中有两个 b * c 的表达式, 并且在计算期间 b 与 c 的值并不会变. 所以这条表达式可能会被视为:
int d = E * 10 + a+ (a + E);
复制代码
接着继续优化成
int d = E * 11 + a + a;
复制代码
接着
int d = E * 11 + 2a;
复制代码
这样, 代码在执行的时候, 就会节省了一些时间了.
(2). 数组范围检查消除
我们知道, java 是一门动态安全的语言, 对数组的访问不像 c/c++ 那样, 可以采用指针指向一块可能不存在的区域. 例如假如有一个数组 arr[], 在 java 语言中访问数组 arr[i]的时候, 是会先进行上下界范围检查的, 即先检查 i 是否满足 i>= 0 && i < arr.length 这个条件. 如果不满足则会抛出相应的异常. 这种安全检查策略可以避免溢出. 但每次数组访问都会进行这样一次检查无疑在速度性能上造成一定的影响.
实际上, 对于这样一种情况, 编译器也是可以帮助我们做出相应的优化的. 例如对于数组的下标是一个常量的, 如 arr[2], 只要在编译期根据数据流分析来确定 arr.length 的值, 并判断下标'2'并没有越界, 这样在执行的时候就无需在判断了.
更常见的情况是数组访问发生在循环体中, 并且使用循环变量来进行数组的访问, 对于这样的情况, 只要编译器通过数据流就可以判断循环变量的取值范围是否在 [0, arr.length) 之内, 如果是, 那么整个循环中就可以节省很多次数组边界检测判断的操劳了.
对于这些安全检查所消耗的时间, 实际上, 我们还可以采用另外一种策略 -- 隐式异常处理. 例如当我们在访问一个对象 arr 的属性 arr.value 的时候, 没有优化之前虚拟机是这样处理的:
- if(arr != null){
- return arr.value;
- }else{
- throw new NollPointException();
- }
复制代码
采用优化策略之后编程这样子:
- try{
- return arr.value;
- }catch(segment_fault){
- uncommon_trap();
- }
复制代码
就是说, 虚拟机会注册一个 Segment Fault 信号的异常处理器(uncommon_trap()), 这样当 arr 不为空的时候, 对 value 的访问可以省去对 arr 的判断. 代价就是当 arr 为空时, 必须转入到异常处理器中恢复并抛出 NullPointException 异常, 这个过程会从用户态转到内核态中处理, 结束后在回到用户态, 速度远比一次判断空检查慢. 当 arr 极少为 null 的时候, 这样做是值得的, 但假如 arr 经常为 null 时, 那么会得不偿失.
不过, 虚拟机还是挺聪明的, 它会根据运行期收集到的信息来自动选择最优方案.
(3). 方法内联
先看一段代码
- public static void f(Object obj){
- if)(obj != null){
- System.out.println("do something");
- }
- }
- public static void test(String[] args){
- Object obj = null;
- f(obj);
- }
复制代码
对于这段代码, 如果把两个方法结合在一起看, 我们可以发现 test()方法里面都是一些无用的代码. 因为 f(obj)这个方法的调用, 没啥卵用. 但是如果不做内联优化, 后续尽管进行了无用代码的消除, 也是无法发现任何无用代码的, 因为如果把 f(Object obj)和 test(String[] args)两个发放分开看的话, 我们就无法得只 f(obj)是否有用了.
内联优化后的代码可以是这样:
- public static void f(Object obj){
- if)(obj != null){
- System.out.println("do something");
- }
- }
- public static void test(String[] args){
- Object obj = null;
- // 该方法直接不执行了
- }
复制代码
(4). 逃逸分析
逃逸分析是目前 Java 虚拟机比较前沿的优化技术, 它并非是直接优化代码, 而是为其他优化手段提供依据发分析技术.
逃逸分析主要是对对象动态作用域进行分析: 当一个对象在某个方法被定义后, 它有可能被外部的其他方法所引用, 例如作为参数传递给其他方法, 称之为方法逃逸, 也有可能被外部线程访问到, 例如类变量, 称之为线程逃逸.
假如我们可以证明一个对象并不会发生逃逸的话, 我们就可以通过一些方式对这个变量进行一些高效的优化了. 如下所示:
1). 栈上分配
我们都知道一个对象创建之后是放在堆上的, 这个对象可以被其他线程所共享, 并且我们知道在堆上的对象如果不再使用时, 虚拟机的垃圾收集系统就会对它进行帅选并回收. 但无论是回收还是帅选, 都是需要花费时间的.
但是假如我们知道这个对象不会逃逸的话, 我们就可以直接在栈上对这个对象进行内存分配了, 这样, 这个对象所占用的内存空间就可以随进栈和出栈而自动被销毁了. 这样, 垃圾收集系统就可以省了很多帅选, 销毁的时间了.
2). 同步消除
线程同步本身是一个相对耗时的过程, 如果我们能判断这个变量不会逃出线程的话, 那么我们就可以对这个变量的同步措施进行消除了.
3). 标量替换
什么是标量?
当一个数据无法分解成更小的时候, 我们称之为变量, 例如像 int,long,char 等基本数据类型. 相对地, 如果一个变量可以分解成更小的, 我们称之为聚合量, 例如 Java 中的对象.
假如这个对象不会发生逃逸.
我们可以根据程序访问的情况, 如果一个方法只是用到一个对象里面的若干个属性, 我们在真正执行这个方法的时候, 我们可以不创建这个对象, 而是直接创建它那几个被使用到的变量来代替. 这样, 不仅可以节省内存以及时间, 而且这些变量可以随出栈入栈而销毁.
不过, 对于编译器优化的技术还有很多, 上面这几种算是比较典型的.
本次讲解到这里.
完
参考书籍: 深入 Java 虚拟机
来源: https://juejin.im/post/5b6e7d7de51d451917177408