Java 编译与优化

# Java 编译与优化

Java 的编译器有三类

  1. 前端编译器,将 .java 文件编译为 .class 文件
    • Javac、ECJ
  2. 运行时编译器,JIT
    • HotSpot 的 C1、C2 编译器
  3. AOT(Ahead Of Time Compiler),直接将 Java 代码编译为本地可执行文件
    • GCJ(GNU Compiler for the Java)、Excelsior JET

下面简要介绍下 Java 的编译与优化

graph TB A[Java 编译与优化] B[javac JIT AOT] C[Java 特性] D[伪泛型<br/>陷阱] E[装箱陷阱] F[-128 127] G[运行时优化 C1 C2] I[栈顶采样<br/>执行计数] J[Java<br/>vs<br/>C++] K[条件编译] L[调用&回边] M[逃逸分析] N[分支频率<br/>剪枝] A --> B A --> C C --> D C --> E C --> F A --> G G --> I G --> J E --> F C --> K I --> L G --> M J --> N

编译与优化

词法 & 语法分析

几乎是所有编译器的第一步,经过这一步,编译器可以生成初始语法树,因为注解的存在,最终语法树的生成在注解处理之后

语法糖

泛型

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 (即时编译)对其进行编译,转化为平台相关的机器码并进行各种优化以提高执行效率

graph LR A[解释器<br/>Interperter] B[编译器<br/>C1 & C2] A -->|即时编译 JIT|B B -->|<b>逆优化</b> 尝试优化失败时回退| A

HotSpot 有两个即时编译器,分别称为 Client Compiler 和 Server Compiler,或者简称 C1 和 C2 编译器,JVM 会根据运行模式选择不同的编译器,用户可以通过 -client 或者 -server 指定编译器。当然也可以强制禁止 JVM 使用运行时编译器,JVM 全程使用解释方式运行

栈上替换

OSR(On Stack Replacement),JIT 编译并优化某个方法的形象说法,优化的方法都位于栈,方法优化即替换已有的解释执行方法,即栈上替换

判断一个函数是不是热点代码片段有两种方式:基于采样的热点探测和基于计数器的热点探测。前者 JVM 周期性的检查栈顶函数类型,后者 JVM 会为每一个函数维护一个计算器,后者更精确严谨些

HotSpot 使用第二种方式并使用了两类计数器:

  1. 方法调用计数器
    • C1 下函数默认被调用 1500 则执行优化,C2 为 10000 次
    • 方法计数器在一个时间间隔内会被减半,称之为计数器热度衰减,所以在一段时间内未被优化的函数依旧不会被优化
  2. 回边计数器
    • 统计一个方法中循环体代码执行的次数

当前大部分 JVM 设计都把代码的优化重心放在了 JIT 上,除非特殊情况,不要关闭 JIT

常见编译优化技术

JIT 编译使用了大量编译技术,本文不做过多介绍,下面仅给出几个便于理解的方法

  1. 公共子表达式消除,如果一个表达式 E 已经计算过了,后面就不再重复计算
  2. 数组边界检查消除
  3. 方法内联
  4. 逃逸分析,如果能证明一个方法内的对象不会被对象之外的实体(其他对象或者线程)访问到,即对象没有逃逸当前方法,则可以对这个对象进行深度的优化,例如将对象分配到栈上,避免垃圾回收等机制造成的消耗
    • 这项技术暂时好像还不稳定,尽量不要在生产环境开启
    • C++ 不存在这个问题,因为 C++ 需要程序员自己管理内存空间

编译性能对比

C++ 为静态编译语言而 Java 为动态编译,二者编译器各有优劣。C++ 可以在编译时进行比较耗时的优化而Java却不行,因为这样会影响服务性能;Java 可以搜集大量运行时信息(调用频率、分支频率预测、裁剪未选中分支等等)来优化代码但 C++ 不行。其他对比可以查阅周志明《深入理解 Java 虚拟机》

posted @ 2019-11-08 16:30  jiahu  阅读(305)  评论(0编辑  收藏  举报