只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

55、编译执行概述

内容来自王争 Java 编程之美

从本节开始,我们进入专栏的 JVM 模块,JVM 模块分四部分讲解,它们分别是:编译执行、内存模型、垃圾回收、JVM 实战

在专栏的第一节中我们讲到,高级语言可以粗略的分为三类:编译型语言、解释型语言、混合型语言,其中 Java 属于混合型语言
混合型语言也叫做半解释型语言,它融合了编译型语言和解释型语言的特点,既兼顾了可移植性,又兼顾了执行效率
本节我们就先粗略介绍一下 Java 的编译执行的整个过程,其中就包括:前端编译、类加载、解释执行、JIT 编译执行、AOT 编译这 5 部分内容
在后面的几节中,我们对其中较复杂的类加载、JIT 编译再进行详细讲解
image

我们经常听说,Java 语言的执行效率没有 C / C++ 语言高,为什么这么说呢?这样的说法是否是事实呢?带着这些问题,我们开始今天的学习

1、前端编译

前端编译指的是将 .java 文件编译成 .class 文件(字节码)的过程,由 javac 编译器来完成,之所以叫前端编译,主要是为了跟 JIT 编译作区分
实际上前端编译的过程跟编译型语言中编译的过程类似,它们都需要经过词法分析、语法分析、语义分析等经典的编译过程,两者的区别在于

  • Java 前端编译的结果为字节码,编译型语言中的编译结果为机器码
  • Java 前端编译还做了一些特殊的工作:注解处理和解语法糖

接下来我们重点看下注解处理和解语法糖,对于词法分析、语法分析、语义分析,它们属于编译原理中所讲的内容,你可以查看编译原理书籍来了解

1.1、注解处理

从 JDK 6 开始,javac 编译器开始支持 JSR269(Pluggable Annotation Processing API)规范
我们只需要按照这个规范来开发注解插件(插件包含定义注解、使用注解、以及对应的注解处理器三部分内容)
那么 javac 编译器在执行前端编译时,就会调用注解插件执行相应的注解处理器代码

我们在开发中经常用到的 Lombook 插件,便是按照 JSR269 规范开发的注解插件
在编译代码时,javac 编译器会调用 Lombook 插件的注解处理器,注解处理器根据 @getter、@setter 等注解,为类、成员变量生成 getter、setter 等方法
Lombook 插件中定义的注解大部分都是 SOURCE 级别的,也就是只存在于源码中
当代码编译成字节码之后,这些注解便没有存在的意义了,毕竟 JVM 并不关心 getter、setter 方法来自于程序员手敲,还是 Lombook 注解

1.2、解语法糖

Java 作为一种高级语言,从一开始就特别重视开发效率(易用),而非一味追求执行效率(性能)
这跟 C / C++ 语言正好相反,这也是两类语言使用场景的重要区别依据
Java 更适合做业务系统开发,C / C++ 语言更适合偏底层的系统级开发

Java 为了提高开发效率,提供了很多语法糖,所谓语法糖,指的是对已经存在的基本语法的二次封装,目的是提高易用性
比如:泛型、自动装箱拆箱、for-each 遍历、内部类等等都是语法糖
在执行前端编译时,编译器会将语法糖解封装为基本语法,也就是说,字节码并不包含语法糖,JVM 也不会感知到语法糖,语法糖仅存在于源码中

接下来,我们依次来看下这些语法糖

  • 对于泛型来说,泛型只存在于源码中,在编译时会进行类型擦除
    也就是说 List<Integer> 和 List<String> 在字节码中都是 List,里面存储的是 Object 类型的数据,字节码中并不存在使用两个尖括号指定类型的语法
    泛型中的类型仅仅是编译器做类型检查所用,这也是 Java 中的泛型被称为伪泛型的原因
  • 自动装箱和拆箱是为了方便基本类型和包装类互相转换,如下所示
    自动装箱:Integer 类的 valueof() 方法
    自动拆箱:Integer 类的 intValue() 方法
Integer num = 12; // Integer num = Integer.valueof(12);
int i = num; // int i = num.intValue();

for-each 遍历也叫做增强 for 循环,底层依赖迭代器来实现,如下代码所示

List<String> arr = Arrays.asList("xiao", "zheng", "ge");
// for-each 循环遍历等价于下面的迭代器遍历
for (String s : arr) {
System.out.println(s);
}
// 迭代器遍历
Iterator<String> itr = arr.iterator();
while (itr.hasNext()) {
System.out.println(itr.next());
}

内部类也是一种语法糖,当编译成字节码之后,外部类编译为 A.class,内部类编译为 A$B.class,匿名内部类编译为 A$1.class,均为独立的类

// A.class
public class A {
// 内部类 -> A$B.class
public class B {
}
public void f() {
// 匿名内部类 -> A$1.class
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("I am in anonymous inner class.");
}
});
}
}

2、类加载

在 Java 应用程序执行的过程中,类的字节码是按需加载到内存中的:当第一次创建某个类的对象,或者调用某个类的方法时,这个类就会被加载到内存中,之后便一直保存在内存中

类加载的过程又可以细分为:验证、准备、解析、初始化等几个步骤,并且类的加载遵从双亲委派规则,不同的类由不同的 classLoader 加载器来加载
对于类加载的详细介绍,我们留在后面的章节中进行

我们拿以下代码举例来看下,类的字节码格式

public class Demo {
private String greeting = "hello";
public String greet(String name) {
return greeting;
}
}

我们将上述 Demo 类,经过 javac 编译器编译为 Demo.class 文件之后,再利用 javap 工具对 Demo.class 进行反编译,得到的结果如下所示
image

在以上字节码结构中,方法表中存储的是各个函数的字节码,字段表中存储的是类的成员变量信息
这两个部分以及前面的魔数、版本号、访问标志、类、父类、接口信息等都很好理解
唯一比较复杂的就是常量池,常量池中包含的内容非常多,主要分为两类:字面量和符号引用

  • 上述代码中的 "hello" 便是字符串字面量
  • 符号引用是编译原理中的概念,包括这个类所涉及到的类、接口、方法、成员变量的名称和描述符
    当在执行代码时,虚拟机需要根据符号引用,找到所引用的类、接口、方法等的真实内存存储地址,然后跳转执行

3、解释执行

对于 C / C++ 这样的编译型语言,代码会事先被编译成机器指令(可执行文件),然后再一股脑儿交给 CPU 来执行
在执行时,CPU 面对是已经编译好的机器指令,直接逐条执行即可
而对于 Java 语言来说,经过前端编译之后的 .class 文件,加载到内存之后,仍然为字节码格式,无法被 CPU 直接执行
JVM 虚拟机需要将字节码逐条取出,边解释为机器码,边交由 CPU 执行

我们举个例子解释一下,如下代码所示

  • 虚拟机从 main() 函数开始执行,当执行到 Demo 类对象的创建语句时,虚拟机发现内存中没有 Demo 类的字节码信息
    于是就通过类加载器在 classpath 对应的路径下查找 Demo.class 文件,并将其加载到内存中
  • 之后虚拟机根据类的字节码在堆中创建 demo 对象,当虚拟机执行 demo.greet("wangzheng") 方法时
    虚拟机根据对象 demo 中的类指针(请参看第 9 节对象的内存结构),找到内存中的 Demo 类,然后在类的方法表中查找 greet() 函数对应的字节码,最后逐句解释执行
public class App {
public static void main(String[] args) {
Demo demo = new Demo();
demo.greet("wangzheng");
}
}

上述执行流程,如下图所示
image

4、JIT 编译

解释执行需要在执行的过程中,将字节码解释为机器码,再行交由 CPU 执行
边解释边执行,显然会影响到程序的执行效率,这也是在 Java 语言发明之初,被认为执行效率没有编译型语言(比如 C / C++)高的原因
随着 Java 语言的演进
为了解决解释执行的执行效率低的问题,Java 引入了 JIT(Just-in-Time)编译(也叫做即时编译或者运行时编译),效率已经在很多场景下接近 C++ 等编译型语言

对于一些经常运行的热点代码,比如多次调用的方法或者多次执行的循环,JIT 编译器在代码的运行过程中,将其编译为机器代码并存储下来
当下次执行这些热点代码时,虚拟机直接将对应的机器码交由 CPU 执行即可,不需要边解释边执行,执行效率匹敌编译型语言

实际上 JIT 编译器还可以在运行期收集代码的运行情况,在进行编译时针对性的做优化,生成更加高效的机器码,这种编译优化称为动态编译优化
而 C / C++ 编译型语言的编译优化,只发生在运行前的编译时期,无法利用运行信息做优化,这种编译优化称为静态编译优化
这也是 Java 语言在性能上有可能超车 C / C++ 编译型语言的地方

默认情况下,虚拟机运行于混合模式:解释执行和 JIT 编译执行共存

  • 使用 -Xint JVM 参数强制虚拟机运行于解释模式,仅支持解释执行
  • 使用 -Xcomp 强制虚拟机运行于编译模式,仅支持 JIT 编译执行
  • 我们可以使用 -version 命令查看虚拟机的工作模式,如下所示

image

5、AOT 编译

实际上跟 JIT 编译相对应的编译方法称作 AOT 编译(Ahead Of Time Compile),也叫做提前编译或者运行前编译
C / C++ 等编译型语言中的编译便是 AOT 编译,在运行前将代码编译成机器码,实际上 Java 除了支持 JIT 编译之外,也支持 AOT 编译,只是相对来说用的不多而已

不过 Java 中的 AOT 编译跟 C / C++ 等编译型语言中的编译有些许不同
Java AOT 编译尽管不支持 "一次编译,到处运行",但仍然支持 "一次编写,到处运行",代码的可移植性完全由 AOT 编译器来负责
针对不同的操作系统,我们使用不同的 AOT 编译器,生成不同的机器码
而对于 C / C++ 等编译型语言,代码的可移植性完全由程序员来负责,很难做到 "一次编写,到处运行"

接下来我们再来思考这样一个问题:既然 AOT 编译可以在运行前将代码编译成机器码,为什么 Java 还执着于在运行的过程中执行 JIT 编译呢?

实际上编译包含两部分内容

  • 基本的编译操作:把代码编译成字节码、机器码等
  • 编译优化:AOT 编译中进行的编译优化为静态编译优化,JIT 编译中进行的编译优化为动态编译优化
    相比静态编译优化,动态编译优化有很多优势,可以基于运行时的统计信息,进行一些比较激进的优化
    比如:根据运行时的统计信息,直接移除执行概率比较小的代码分支
    如果在实际的运行中,万一需要执行被移除的代码分支,虚拟机会退回到使用原始的字节码来解释执行
    只要退回解释执行的概率足够低,这种激进优化就是值得的,带来的性能提升就是非常可观的
    而这种激进优化进行的前提是依据运行时的统计信息,因此静态编译优化是无法进行的,这就是 Java 执着于 JIT 编译而非 AOT 编译的原因

不过 JIT 编译相对于 AOT 编译有一定的优势,并不代表使用 JIT 编译的 Java 语言的性能就比使用 AOT 编译的 C++ 语言的性能好
毕竟这种编译优化的优势在实际的应用过程中并不是特别明显
而且编程语言之间的性能,除了受编译优化的影响之外,还有其他很多影响因素,比如:内存布局、内存访问、内存管理等

6、课后思考题

除了本节讲到的语法糖,你还知道有哪些语法糖?底层都是依赖哪些基本语法实现的?
Lambda 表达式也是一种语法糖,基于函数接口和匿名内部类实现

posted @   lidongdongdong~  阅读(74)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开