Java 编译与优化
# Java 编译与优化
Java 的编译器有三类
- 前端编译器,将 .java 文件编译为 .class 文件
- Javac、ECJ
- 运行时编译器,JIT
- HotSpot 的 C1、C2 编译器
- AOT(Ahead Of Time Compiler),直接将 Java 代码编译为本地可执行文件
- GCJ(GNU Compiler for the Java)、Excelsior JET
下面简要介绍下 Java 的编译与优化
编译与优化
词法 & 语法分析
几乎是所有编译器的第一步,经过这一步,编译器可以生成初始语法树,因为注解的存在,最终语法树的生成在注解处理之后
语法糖
泛型
Java 的泛型是伪泛型
C++ 中的模板是泛型的一种形式,编译时不同类型的泛型参数会促使模板生成新的代码,这种通过类型膨胀的形式实现的泛型是真实泛型
Java 的泛型只展现在代码中,编译成字节码后泛型信息就已经丢失了(底层使用 Object 引用实际对象,实际使用时会有强制类型转换的过程),下面两行代码在相同的 java 文件中时是无法通过编译的,因为 class 文件中不允许出现签名相同的函数(此时泛型信息已经丢失)
public void method(List<String> list) { ... }
public void method(List<Integer> list) { ... }
在 C++ 中,上面的两个函数在编译时的签名是不同的,但在 Java 的 class 文件中,这两个函数的签名相同,故编译器报错
泛型陷阱
class 格式规定,只要描述符不完全一致的两个方法就可以共存
Java 中类型的描述符是包含返回值的,也就是说只要修改上面那两个方法中的返回值,这两个方法就可以共存,这是设计中的 缺陷,虽然对程序的正常运行没有影响,但违反了返回值不参与重载的规定
Java 新标准中提出了一些新的规范来减少这种情况对语言的影响,如 Signature 等
自动装箱 & 循环遍历
拆箱与装箱的陷阱
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); // true,Java的==比的是引用地址,小整数有缓存故地址相同
System.out.println(e == f); // false,默认情况下 Java 缓存 -128~127 之间的所有整数
System.out.println(c == (a + b)); // true,有运算符,故先拆箱后比较
System.out.println(c.equals(a + b)); // true,同类比较
System.out.println(g == (a + b)); // true,包装类的 == 只会在遇到算术运算符时拆箱
// Java API 中对 Long 中equals的定义为:
// The result is true if and only if the argument is not null and is a Long object
// that contains the same long value as this object.
System.out.println(g.equals(a + b)); // false,异类比较,equals不处理类型转换,故long!=int
}
条件编译
Java 的条件编译很简单,只能使用 if 且 if 的条件必须为常量,编译后 class 文件中只会留下满足需求的语句块
注解处理
JDK 5 之后 Java 提供了对注解的支持,Java 支持编译时注解和运行时注解,使用编译时注解我们可以干涉编译器的行为
运行时优化
部分商用虚拟机中 Java 程序最开始执行时使用解释器进行解释执行,如果发现一块代码执行频繁,JVM 会使用 JIT (即时编译)对其进行编译,转化为平台相关的机器码并进行各种优化以提高执行效率
HotSpot 有两个即时编译器,分别称为 Client Compiler 和 Server Compiler,或者简称 C1 和 C2 编译器,JVM 会根据运行模式选择不同的编译器,用户可以通过 -client 或者 -server 指定编译器。当然也可以强制禁止 JVM 使用运行时编译器,JVM 全程使用解释方式运行
栈上替换
OSR(On Stack Replacement),JIT 编译并优化某个方法的形象说法,优化的方法都位于栈,方法优化即替换已有的解释执行方法,即栈上替换
判断一个函数是不是热点代码片段有两种方式:基于采样的热点探测和基于计数器的热点探测。前者 JVM 周期性的检查栈顶函数类型,后者 JVM 会为每一个函数维护一个计算器,后者更精确严谨些
HotSpot 使用第二种方式并使用了两类计数器:
- 方法调用计数器
- C1 下函数默认被调用 1500 则执行优化,C2 为 10000 次
- 方法计数器在一个时间间隔内会被减半,称之为计数器热度衰减,所以在一段时间内未被优化的函数依旧不会被优化
- 回边计数器
- 统计一个方法中循环体代码执行的次数
当前大部分 JVM 设计都把代码的优化重心放在了 JIT 上,除非特殊情况,不要关闭 JIT
常见编译优化技术
JIT 编译使用了大量编译技术,本文不做过多介绍,下面仅给出几个便于理解的方法
- 公共子表达式消除,如果一个表达式 E 已经计算过了,后面就不再重复计算
- 数组边界检查消除
- 方法内联
- 逃逸分析,如果能证明一个方法内的对象不会被对象之外的实体(其他对象或者线程)访问到,即对象没有逃逸当前方法,则可以对这个对象进行深度的优化,例如将对象分配到栈上,避免垃圾回收等机制造成的消耗
- 这项技术暂时好像还不稳定,尽量不要在生产环境开启
- C++ 不存在这个问题,因为 C++ 需要程序员自己管理内存空间
编译性能对比
C++ 为静态编译语言而 Java 为动态编译,二者编译器各有优劣。C++ 可以在编译时进行比较耗时的优化而Java却不行,因为这样会影响服务性能;Java 可以搜集大量运行时信息(调用频率、分支频率预测、裁剪未选中分支等等)来优化代码但 C++ 不行。其他对比可以查阅周志明《深入理解 Java 虚拟机》