JVM的早期优化与晚期优化

JVM的早期优化与晚期优化

Scroll Down

前言

“温故而知新” 这话真的一点也不假!至于 “可以为师矣” 肥壕只能长叹一声:未可也~

最近重新翻开《深入理解Java虚拟机》,看到肥壕三年前留下的稚嫩的笔记,感叹道年轻真好。再次重新阅读 “早期优化与晚期优化”的章节,来自内心的拷问:

我的天!!!我是失忆了吗???三年前我看的是什么???我当时是脑子进水了???

于是再次捧起熟悉的书本,挑灯阅读(是的,没错,肥壕装B起来一点都不含糊),也借此整理这章节的读书笔记,以便后面温故。

正文

这里说的早期优化指的是编译期优化,晚期优化指运行期优化。

关于编译期与运行期,肥壕相信不少老爪哇友并不陌生,参考下面的这个图可能会更加直观

下面是书本里面对编译器的一些定义:

  • 前端编译器:把 *.java 文件转成 *.class 文件,Sun 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)
  • 后端编译器:把字节码编程机器码,即JIT编译器,有 HotSpot VM 的 C1、C2 编译器
  • 静态提前编译器:直接把 *.java文件编译成本地机器代码,即AOT编译器,有 GNU Compiler for the Java(GCJ)、Excelsior JET

那么肥壕再粗俗的描述一下:

  • 编译器:将Java源文件(.java 文件)编译成字节码文件(.class 文件,是特殊的二进制文件,二进制字节码文件),这种字节码就是JVM的“机器语言”。javac.exe 可以简单看成是 Java 编译器。
  • 解释器:是JVM的一部分。Java解释器用来解释执行Java编译器编译后的程序。java.exe可以简单看成是Java解释器。
  • 即时编译(JIT):实时编译、即时编译。是指一种在运行时期把字节码编译成原生机器码的技术,把热点代码编译成与本地平台相关的机器码,并缓存起来以降低性能耗损。这项技术是被用来改善虚拟机的性能的。

早期(编译期)优化

Java很多的语法特性,都是靠前端编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持。

Javac 的编译过程大致分为3个过程

  • 解析与填充符号表
  • 插入式注解处理器的注解处理
  • 分析与字节码生成

解析与填充符号表

  1. 词法、语法解析

词法分析:将源代码的字符流转变成标记(Token)集合

语法分析:根据Token序列构造抽象语法树(Abstract Syntax Tree),语法树每个节点代表程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至带埋注释等

  1. 填充符号表

符号表(Symbol)是由一组符号地址和符号信息构成的表格。符号表所登记的信息在编译的不同阶段都会用到。

如:

  • 在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码
  • 在目标代码生成阶段,当对符号名进行地质分配时,符号表示地址分配的依据
  • 生成默认构造函数

注解处理器

提供插入式注解处理器的标准API,在编译期间对注解进行处理。可以读取、修改、添加抽象语法树中的任意元素。

如果对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理(对应上图的回环过程)

语义分析与字节码生成

语法分析后,获得语法树,但是无法保证源程序的逻辑性。

语义分析是对结构上正确的源程序进行上下文有关性质的审查。

语义分析过程分为:标注检查数据及控制流分析

  • 标注检查
    • 变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。
    • 还有一个重要动作:常量折叠。比如 int a = 1 + 2 会优化成 a = 3
  • 数据及控制流分析
    • 对程序上下文逻辑更进一步的验证,如检查程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理等
    • 变量的不变性,仅仅有编译器在编译期间保障
  • 解语法糖
  • 字节码生成
    • Javac 编译过程的最后一个阶段
    • 生成实例构造器和类构造器
    • 优化工作,如字符串的加操作替换成 StringBuffer 或 StringBuilder 的 append() 操作等

语法糖

泛型与类型擦除

简单说,它只在程序源代码中存在,在编译后的字节码文件中,已经替换为原来的原生类型(Raw Type,也成为裸类型),并且强制转型代码。可以理解为伪泛型。

泛型擦除前的例子:

public static void main (String[] args) {
  Map<String, String> map = new HashMap<String, String>();
  map.put("hello", "你好");
  System.out.println(map.get("hello"));
}

泛型擦除后的例子:

public static void main (String[] args) {
  Map map = new HashMap();
  map.put("hello", "你好");
  System.out.println((String)map.get("hello"));
}

因为是这种伪泛型的方式实现,而且还会导致如下问题:

public class GenericTypes {
  
  public static void method (List<String> list) {
    // do something
  }
  
  public static void method (List<Integer> list) {
    // do something
  }
}

上面这段代码会编译失败。因为参数 List<String>List<Integer> 编译之后都会被擦除了,变成原生类型 List<E>

但是如果添加不同的返回值,方法重载居然是可以的?!!!

public class GenericTypes {
  
  public static String method (List<String> list) {
    return "";
  }
  
  public static int method (List<Integer> list) {
    return 0;
  }
}

这跟我们所认识的 Java语言中,返回值不参与重载的基本认识是一个公然的挑战吗?

噢,原因其实也是很简单,方法重载的判断依据是:要求方法具备不同的特征签名,返回值并不包含在签名中,所以返回值不参与重载选择。但是在 Class 文件格式中,只要描述符不是完全一致的两个方法就可以共存。

自动装箱、拆箱与遍历循环

这个是我们在平时开发中用得最多的语法糖了,直接看代码比较直观:

编译前:

public static void main (String[] args) {
  List<Integer> list = Arrays.asList(1,2,3);
  int sum = 0;
  for (int i : list) {
    sum += i;
  }
  System.out.println(sum);
} 

编译后:

public static void main (String[] args) {
  List list = Arrays.asList(new Integer[] {
    Integer.valueOf(1),
    Integer.valueOf(2),
    Integer.valueOf(3)
  });
  int sum = 0;
  for (Iterator localIterator = list.iterator(); localIterator.hasNext();) {
    int i = (localIterator.next()).intValue();
    sum += i;
  }
  System.out.println(sum);
} 

这个例子已经包含了泛型、自动拆箱、自动装箱、遍历循环与变长参数 5 中语法糖

条件编译

对于条件为常量的 if 语句,Java会进行条件编译

编译前:

public static void main(String[] args) {
	if (true) {
		System.out.println("true");
	}else {
		System.out.println("false");
	}
}

编译后:

public static void main(String[] args) {
	System.out.println("true");
}

晚期(运行期)优化

即时编译器 JIT(Just In Time Compiler)在这个过程中扮演着至关重要的角色。

当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为 热点代码(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,而 JIT 就是完成这个任务。

解释器与编译器交互使用的优势

当程序启动的时候,解释器首先发挥作用,它能直接运行字节码文件,省去编译的时间,立即执行;随着时间的推移,编译器发挥作用,越来越多的热点代码被编译器编译成机器码,从而获取更高的执行效率。当程序运行环境内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

同时,解释器还可以作为编译器激进优化时的一个“逃生门”,当编译器的激进优化手段不成立时,如加载了新类后类型继承结构出现变化等,可以通过逆优化(Deoptimization)退回到解释状态继续由解释器执行。

肥壕简单概括为:解释器执行速度快,但是运行效率低,需要逐一翻译再运行;即时编译器运行效率高,但编译的时间成本高,需要占用程序运行时间;两者相互取长补短。

编译器又分为两种,C1 编译器(Client Compiler)和 C2 编译器(Server Compiler),HotSpot 虚拟机会选择哪个编译器是由虚拟机运行于 Client 模式还是 Server 模式决定的。

使用模式:

  • 混合模式(Mixed Mode)默认
  • 解释模式(Interpreted Model)-Xint
  • 编译模式(Compiled Model)-Xcomp

分层编译策略:

  • 第 0 层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
  • 第 1 层,也成为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑
  • 第 2 (或 2 层以上),也成为 C2 编译,也就是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

Client Compiler获取更高的编译速度,ServerComiler有更好的编译质量

即时编译的对象和触发条件

热点代码会被 JIT 编译有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

第一种情况,编译器会以整个方法作为编译对象;第二种情况,虽然是循环体所触发,但依然会以整个方法作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(On Stack Replacement,简称为OSR 编译,即方法栈帧还在栈上,方法就被替换了)

目前热点代码探测方式主要有两种:

  • 基于采样的热点探测
    • 周期性检查各线程的栈顶
  • 基于计数器的热点探测
    • 使用计数器统计方法的执行次数

HotSpot 虚拟机中使用的是第二种 — 基于计数器的热点探测方法,它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)回边计数器(Back Edge Counter)

先看一下方法的整个JIT编译交互流程:

有个需要注意的点,如果发现当前方法这次的访问已经达到了调用的阈值,但是它还是会走解释的方式,只有下次访问才会执行编译后的本地代码。

方法调用计数器的相关 JVM 参数如下:

  1. -XX:CompileThreshold 设置方法调用计数器的阈值,Client 模式下默认是 1500 次, Server 模式下默认是 10000 次
  2. -XX:UseCounterDecay 设置 true/false 来开启/关闭热度衰减,默认开启
  3. -XX:CounterHalfLifeTime 设置半衰期的周期,单位是秒

我们再看一下热点循环体,也就是回边计数的流程:

回边计数器的相关 JVM 参数如下:

  1. -XX:OnStackReplacePercentage OSR 比率,Client 模式下默认是 933,Server 模式下默认是 140;
  2. -XX:InterpreterProfilePercentage 解释器监控比率,默认值是 33
  3. Client 模式的回边计数器阈值 = CompileThreshold * OnStackReplacePercentage / 100,默认是 13995 次
  4. Server 模式的回边计数器阈值 = CompileThreshold * (OnStackReplacePercentage - InterpreterProfilePercentage)/ 100,默认是 10700 次

编译优化

公共子表达式消除

如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共表达式,可以直接用之前的结果替换。
例:int d = (c * b) * 12 + a + (a + b * c)

=> int d = E * 12 + a + (a + E)

=> int d = E * 13 + a * 2 (代数化简)

数组边界检查消除

在 Java 语言中访问数组元素会自动进行上下界的范围检查,如果是越界访问会抛出运行时异常:java.lang.ArrayIndexOutOfBoundsException

这对软件开发者来说是一件很好的事情,即使没有专门编写防御代码,也可以避免大部分的溢出攻击。但是对于虚拟机来说,每次数组元素的读写都要进行隐式的条件判断操作,这无疑也是一种性能负担。

虚拟机一般是在即时编译期间通过数据流分析来确定是否可以消除这种检查,比如 foo[3] 的访问,只有在编译的时候确定 3 不会超过 foo.length - 1 的值,就可以判断该次数组访问没有越界,就可以把数组边界检查消除。

另一种方法叫做隐式异常处理,Java 中空指针的判断和算术运算中除数为0的检查都采用了这个思路:

if(foo != null){
    return foo.value;
}else{
    throw new NullPointException();
}

使用隐式优化后变成:

try{
    return foo.value;
}catch(segment_fault){
    uncommon_trap();
}

当 foo 极少为空时,隐式异常优化是值得的,但假如 foo 经常为空的话,这样的优化反而会让程序更慢,还好 HotSpot 虚拟机足够“聪明”,它会根据运行期收集到的 Profile 信息自动选择最优方案。

方法内联

方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本之外,同时也为其他优化手段建立良好的基础。

但是 Java 中默认的实例方法都是虚方法,调用都需要在运行时进行方法接受者的多态选择,并且都有可能存在于一个版本的方法接收者。为了解决虚方法的内联问题,首先引入了“类型继承关系分析(Class Hierarchy Analysis,CHA)”的技术。

  1. 在进行内联时,如果是非虚方法,则直接进行内联
  2. 遇到虚方法,向 CHA 查询此方法是否有多个目标版本,若只有一个,可以直接内联,但是需要预留一个“逃生门”,称为守护内联,若在程序的后续执行过程中,加载了导致继承关系发生变化的新类,就需要抛弃已经编译的代码,退回到解释状态执行,或者重新编译
  3. 若 CHA 判断此方法有多个目标版本,则编译器会使用“内联缓存”,第一次调用缓存记录下方法接收者的版本信息,并且每次调用都比较版本,若一致则可以一直使用,若不一致则取消内联,查找虚方法表进行方法分派。

逃逸分析

逃逸分析的基本行为就是分析对象动态作用域,当方法中的对象被外部方法引用,称为方法逃逸;当方法中的对象呗外部线程访问到,称为线程逃逸

如果能够保证方法中的对象不会逃逸,则可以为这个变量进行一些高效的优化:

  1. 栈上分配

    如果确定一个对象不会逃逸,可以在栈上给她分配内存,这样对象就会随着方法的结束而自动销毁,减少垃圾收集系统的压力

  2. 同步消除

    线程同步是一个耗时的过程,如果能够确保变量不会线程逃逸,那这个变量的读写肯定就不会有竞争,对这个变量的同步操作是可以消除。

  3. 标量替换

    如果逃逸分析证明一个对象不会被外部访问,并且可以被拆散的话,那么程序真正执行的时候可以不创建这个对象,改为直接创建它的成员变量,可以让对象的成员变量在栈上分配和读写。

Java 与 C/C++ 的编译器对比

大多数猿友都普遍认为 C/C++ 会比 Java 语言快,肥壕刚开始接触 Java 后也是这么认为的 。

C/C++ 属于编译型语言,程序直接编译成机器语言。程序的执行效率高,but...严重依赖编译器,跨平台性差。

Java 属于解释型语言,程序运行时才翻译成机器语言,每执行一次都要翻译一次,因此效率比较低,但是优点依赖解释器,跨平台性好,“一次编译,到处运行”。

但是目前即时编译技术已经十分成熟了,Java 语言有可能在速度上与 C/C++ 一争高下吗?让我们从两者的编译器谈起。

Java 虚拟机的即时编译器与 C/C++ 的静态优化编译器相比,可能会由于下列这些原因而导致输出的本地代码有一些劣势:

  1. 即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,因此不敢随便引入大规模的优化技术
  2. Java 语言是动态的类型安全语言,虚拟机需要频繁地进行动态检查,如空指针、数组上下界范围、继承关系等
  3. Java 语言中使用虚方法的频率远大于 C/C++ ,即时编译器的优化难度更大(方法内联)
  4. Java 语言可以动态扩展,运行时加载新的类可能改变原有的继承关系,许多全局的优化措施只能以激进优化的方式来完成;
  5. Java 语言的对象内存都在堆上分配,垃圾回收的压力比 C/C++ 大

看了这么多 Java 的劣势,那问什么我们还要用 Java 呢???

其实嘛,换个角度来看上面讲的这么多劣势其实也是 Java 的优势,都是为了换取开发效率上的优势而付出的代价。

总结

  1. JVM 的优化有两个阶段:早期(编译期)优化和晚期(运行时)优化
  2. 早期(编译期)的主要目的是将Java源文件(.java 文件)编译成字节码文件(.class 文件)。Java 的很多语法糖实现都是在编译期的时候实现,如泛型与类型擦除、自动装箱拆箱
  3. 在运行期阶段中,默认是解释器即时编译器是交互使用,解释器能够减少编译时间,即时编译器能够提高执行效率;即时编译器是虚拟机中最核心且最能提现虚拟机技术水平的部分。
  4. 即时编译器的优化对象是调用多次的方法循环体,而目前主要的探测方式是基于计数器的热点探测,比较经典常见的编译优化有公共子表达式消除数组边界检查消除方法内联逃逸分析

OK,这章节的内容就大概总结到这里啦(其实很多内容都是直接拿书本上🤦),肥壕还是真心建议各位阅读一下原作,每次看都有不同的心得和领悟。

如果本篇博客有任何错误,请批评指教,不胜感激 !

参考资料:

  • 《深入理解 Java 虚拟机》第 2 版

普通的改变,将改变普通

我是肥壕,一个在互联网低调前行的小青年

欢迎关注我的博客📖 edisonz.cn,查看更多分享文章