前言
提示:对于java代码运行的全流程你心里有清晰的脉络吗?
- 大家会不会跟我最开始一样,觉得在IDE里点一下RUN按钮,我们写的代码就直接直接跑起来了吧?
- 其实只是因为有人在为你负重前行,编译器和虚拟机默默的承受了这一切。
一、计算机真的能读懂我们写的代码么
- Java是一门"一次编写,到处运行"的语言,即它的跨平台性
- Java实现跨平台性,它的原理也很简答,就是利用中间格式来进行过渡,也就是我们常说的字节码,通过将Java源代码转换成字节码,保证JVM(Java虚拟机)读取到的一定是自己能够识别的字节码格式。
一个通俗的解释:你不会说法语,法国人不会讲中文,但是你们或多或少都会点英语,把英语作为你们的中间格式,保证双方都能明白对方的意思,这就是所谓的跨平台。
- 说的更通俗一点,Java源码首先被编译成字节码,而这个字节码就是实现平台无关性的关键,无论你是什么类型的平台,只要你安装了能够识别字节码的JVM(Java虚拟机),通过JVM对字节码文件进行解析,把字节码转换成具体平台上的机器指令,就可以实现跨平台的运行了。
- 操作系统不一定欣赏到我们写的代码,我们所写的每一行代码,都会被编译成计算机能看懂的指令。
二、编译器在Java体系中的位置
1.JDK与JRE的关系
- 我就想装个Java环境,怎么有两个奇奇怪怪的安装包,一个叫JDK,一个叫JRE,这两个安装包跟俗称的”Java“又有什么关系?
下图是JDK8的体系架构图
JDK全称是
Java
开发工具包(Java Development Kit),它包含了Java从开发到运行的各种工具。
JRE指的则是Java
运行环境(Java Runtime Environment),它包含了基础类库和JVM虚拟机。
- 既然安装JRE就能运行JAVA代码,但要需要完整的JDK才能完成开发,那他们之间的差集肯定跟开发的过程有关。所以接下来,我们来探讨一下为什么缺少这一块内容就只能成为运行环境,而不能承担开发功能呢?
这一块我们可以看到几个很熟悉的命令:
- javac:用于编译java源代码,生成
class
文件; - javap:用于反编译,根据
class
文件,反解析出其中的汇编指令和其他信息; - javadoc:用于生成
java
文档的命令。
我们用一个简单的例子看看,开发者编写好的java代码在完整的JDK架构下,经过JDK、JRE以及JVM的运行过程。
- 可以看到,通过JDK中的
Javac
命令,我们才能将java
源代码变异成class文件,class
文件最终会放到jvm中运行 - 我们把
java
源码到class
文件的过程称之为编译阶段,把class
文件到JVM中运行得到结果的阶段称为运行阶段。
可以看得出来,其实JRE缺少JDK是可以运行的,只不过是确实开发环境而已,但是要提前编译好class文件,所以说JVM并不关心是什么语言,只要生成JVM能够识别字节码文件就行了
像我们熟悉的
lombok
,就能够根据我们编写的注解生成字节码,实现字节码的修改增强(但lombok
也是利用了编译器的一些特性,是在编译阶段触发操作的)。
类似的还有诸如ASM等一些字节码增强技术,也是通过直接操作字节码来实现的。
通过字节码增强技术可以实现热部署等操作,让你修改代码之后无需重启服务就能生效;也可以实现日志注入等功能,在不需要改变客户端调用方式情况下完成对指定方法增加缓存或日志的功能。
2.编译阶段
编:将java源代码的结构组织成合适的格式,包括编译过程中的抽象语法树和符号表等,并在最终将源码编码成为
class
文件。译:对源代码中的语义进行解析,并准确地翻译成另一种形式(字节码)。这一步既要确保原格式正确(Java源代码中的语法正确),又要确保翻译后的字节码跟源代码表达的意思一致。
接下来,我们循序渐进地告诉大家编译的具体步骤,以及编译过程的各个阶段抛出的不同编译异常。
2.1. 词法分析&语法分析
例如,你用这样一段代码去编译:
public class Hello {
public static void main(String[] args) {
String enum = "world";
System.out.println("Hello world");
}
}
会报如下的错误:
error: as of release 5, ‘enum’ is a keyword, and may not be used as an identifier
-
因为
enum
是关键字,构建语法树的时候发现堂堂一个关键字居然出现在了标识符的位置,这可使不得啊! -
词法分析&语法分析是对源代码中文本的抽象,将.java源代码中的文本结构按照编译器特定的规则拆分、解析,为后续的编译工作铺平了道路,后面的操作都离不开这个AST。
2.2. 填充符号表
- 符号表就是由符号地址(位置)和符号信息构成的”表格“,它存储的是标识所对应的类型、作用域等。
这里说它是”表格“可能会对读者产生一定的误解,实际上它不是像我们想象的那种二维的表格,而是更接近hashTable那样的键值对结构,符号表可以由数组、树状结构或者栈等各种结构来实现。
填充符号表的过程可以描述为:
- 将每个AST的顶层节点都放到待处理的列表中,并逐个处理;
- 将所有的类符号(类的声明,名称)都输出到外层的作用域的符号表中;
- 如果发现有
package-info.java
文件(描述整个包的信息和包内的常量),将其顶层节点放到待处理的列表中; - 明确泛型类型的真实类型;
- 如果类中没有任何构造器,则添加默认的无参构造器;
- 将类中符号输入到类自身的符号表中。
2.3. 注解处理
-
自从JDK 5以来,Java提供了对注解的支持,现在程序中使用注解已经是非常常规的操作
-
然而要注意的是,并不是所有的注解都是在编译期起作用的,我们平时用反射处理的注解主要是指运行时注解,运行时注解在编译期不受影响,在编译之后的
class
文件中还是会保留,最终要在class
文件到JVM运行的过程中才生效。 -
而编译期注解是指以
@Retention(RetentionPolicy.SOURCE)
定义的,在编译期就处理了的注解,这一类注解不会保留到class
文件中。 -
听起来很懵,但其实编译过程中这一步注解处理其实大家在无意中已经接触过很多次了,比如大家常用的lombok,就是在这一步起作用的。
-
lombok采用的就是编译期注解处理的方法,因此当我们编译好用了lombok注解的.java文件后,打开生成的
class
文件就可以看到lombok
相关的注解已经消失,而相应的getter、setter
方法则已经被注入到class
文件中。
2.4. 语义分析
我们用周志明老师书中的一个例子来说明: 假设有如下3个变量定义的语句:
public static void main(String[] args) {
int a = 1;
boolean b = false;
char c= 2;
int d =a + c;
int e = b + c;
char f = a + c;
}
这一段代码能够通过第一步的词法分析和语法分析,并构成正确的AST,但是在语义分析中会报错。因为编译器发现变量e和f的运算都是不符合规范的,参与运算的两个值的类型不匹配该运算符的逻辑。
语义分析更进一步检查上下文中变量的规范性,例如变量是否已经声明,变量的数据类型与其参与的运算是否匹配等等。
2.5. 解语法糖
- 语法糖就是方便程序员编写的便捷写法,这种语法不会对最终的结果产生实际影响,但能够减少程序编写者的工作量。
- java中的自动拆箱装箱功能、foreach循环功能等,都是为了程序员能够更写出更简洁流程的代码而封装的语法糖。
- 但是到了程序运行阶段,这样的语法糖对计算机来说是不可识别的。因此需要在编译阶段先解语法糖,将语法还原为它本来”笨拙“的样子。将包装类型拆成普通类型,将增强for循环替换为普通的for循环。
2.6. 生成Class文件
终于到了生成最终需要的class文件的一步了,前面所构建的语法树、符号表等信息,在这一步被转换成字节码指令写到class文件中
由于类加载过程优先于对象实例化过程,因此它们完整的执行顺序就是
- 父类静态变量初始化
- 父类静态语句块
- 子类静态变量初始化
- 子类静态语句块
- 父类变量初始化
- 父类语句块
- 父类构造函数
- 子类变量初始化
- 子类语句块
- 子类构造函数
除了生成构造器之外,生成class文件时还会优化某些代码逻辑的实现方式,比如,将字符串的
+
运算操作,替换为StringBuffer
或者StringBuilder
的append()
方法。
总结
很多人会认为class
文件 = 字节码,这是不对的,class
文件并不等于字节码。我们从class
文件的结构中可以窥见端倪,class
文件中记录了如下的一些信息:
- 结构信息:
class
文件格式版本号; - 元数据:主要对应的是Java源代码中”声明“和”常量“对应的信息,包括类的声明信息、类中属性域与方法的声明信息、常量池等;
- 方法信息:主要对应Java源代码中”语句“和”表达式“对应的信息,包括 字节码、异常处理器表、操作数栈和局部变量区的大小等;
这下就很清晰了,字节码是Class
文件的一个子集,只是class
文件中众多组成部分的其中之一。