在我们探究new一个对象背后的故事前,我们首先应该牢记两个概念,同时这两个概念对于学习虚拟机从头到尾都是很有帮助的。
前端编译:把*.java文件(我们程序员编写的代码文件)转变成*.class文件(虚拟机能读取解释的格式文件)。
后端编译:把*.class文件转变成机器码文件(计算机能读取解释的格式文件)。
我们将通过以下代码开启虚拟机探索之旅。
class GirlFriend{
private int age;
private String name;
public GirlFriend(int age, String name){
this.age = age;
this.name = name;
}
public String getName() {
return name;
}
}
class Main{
public static void main(String[] args) {
GirlFriend bing = new GirlFriend(23, "bing bing");
System.out.println(bing.getName());
}
}
前端编译就是使用javac编译器将*.java文件转换成二进制字节码文件(*.class文件)。编译过程可大致分为1个准备过程和3个处理过程,理论较抽象,会在下文拿案例进行分析。
接下来我们按照上文对前端编译的描述,一步步地分析我们的代码在前端编译期间都发生了什么。
由于我们的代码较为简单,不涉及注解,所以忽略步骤1和步骤3。
词法分析将我们编写的每一条程序语句更加细分的切成token集合,因为每一条程序语句是我们编写程序的最小元素,而token是编译器编译的最小元素。
//词法分析前
private int age;
//词法分析后
private、int、age
通过词法分析我们得到了一些token的集合,但是编译器此时仍然无法知道我们编写的代码的逻辑结构,于是更进一步将token集合梳理成一个树,用来描述程序的体系结构。具体来说,根据token流,利用TreeMaker,以JCTree的子类作为语法节点来构建抽象语法树。语法树的每一个节点都代表着程序代码中的一个语法结构, 如包、类型、修饰符、运算符、接口、返回值都可以是一个语法结构。
代码:
package com.hou.test;
public class GirlFriend {
int age;
String name;
public GirlFriend(int age, String name){
this.age = age;
this.name = name;
}
String getName() {
return name;
}
}
填充符号表的过程就是将java类中的符号输入到符号表的过程。
符号包含在字节码常量池中,主要包括在该类中用到的类、方法名、包、接口、字段等的名字。
符号表就是一组由符号信息和符号地址构成的表格。
符号表填充过程就是将类所对应的表中未出现的符号填充到表内,符号表中所记录的信息在编译的不同阶段都可以用到,比如语义分析中的语义检查(检查一个名字的使用与原来的声明是否一致)、目标代码生成阶段(对符号名进行地址分配)。
插入式注解处理器使得在编译阶段可以实现注解的处理。
在注解处理期间,我们可以获得所有的抽象语法树,并可以对其进行增删改查,当更改抽象语法树后还需要回到第一阶段词法分析并从头开始。
标注检查主要的工作就是检查变量使用前是否已经被声明(检查的依据就是上文提到的符号表)以及变量与赋值类型之间的数据类型能否匹配等工作。
该部分的工作就是检查程序变量在使用前是否赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理等问题。
顺带一提,final
关键字修饰的常量也是在这个时候被检查的,Class
文件中局部变量没有访问标志,也就是说被final
修饰的局部变量经过编译阶段后生成的字节码文件中是没有final
的,因此只能在编译阶段才能被检查。
语法糖指的是在计算机语言中添加的某种语法,方便程序员使用,使用语法糖能够减少代码量、增加程序可读性、减少代码出错的机会,主要包括泛型、自动装箱、自动拆箱、条件编译等。
解语法糖就是虚拟机将减少的那部分代码量补充还原回来。
该阶段的主要工作是将经过语义分析检查后的抽象语法树生成字节码指令写入磁盘中,文件后缀名为.class
,并且编译器还进行了少量的代码添加和转换,比如实例构造器
和类构造器
以及部分优化工作,如将字符串的加操作替换成StringBuffer
或StringBuilder
的append()
操作。
通过编译我们得到了GrilFriend
类的字节码文件,下面贴出我们的要执行的主函数:
class Main{
public static void main(String[] args) {
GirlFriend bing = new GirlFriend(23, "bing bing");
System.out.println(bing.getName());
}
}
主函数较为简单,只涉及new
一个对象,调用该对象的getName()
方法,并将结果打印。
需要说明的是,运行程序时虚拟机是从字节码文件中读取字节码指令并解释执行,另一种编译手段称之为即时编译,是将程序中经常出现的代码编译成本地机器代码,提高程序执行效率。本文代码简单,因此不涉及即时编译,故不做赘述。
由于涉及到new
操作,因此类的加载动作是不可避免的,在介绍具体的加载流程前先熟悉以下加载器。
类加载器有两个作用,一是实现类的加载动作,二是确定类的唯一性。
类加载器和该类本身共同确定该类在虚拟机中的唯一性。即比较两个类是否相等的前提是二者是否是由同一个加载器加载出来的。
双亲委派模型生动得阐述了类加载器加载的过程,概括下来可以总结为两点:
当虚拟机解释执行到new GirlFriend(23, "bing bing")
这里,通过new
指令首先在方法区中寻找这个类是否被加载过,如果没被加载过则触发了类加载机制。
加载阶段虚拟机主要干三件事:
(GirlFriend)
获取该类的二进制字节流。此处的二进制字节流不一定通过.class
文件获取,也可以通过其他手段,例如网络、加密文件等。但该类经过编译已经生成了.class
文件并保存在了磁盘中,因此我们只需要在磁盘中获取即可。Class
对象,作为程序访问方法去中的类型数据的外部接口。这个也就是我们反射调用得到的Class
对象了。这一步的主要目的是确保Class
文件的字节流中包含的信息是否符合所有的约束要求,并保证这些信息运行后不会危害虚拟机的安全。
由于它起到一个保安的作用,所以它和加载是交叉执行的,即确定了文件安全,才允许加载。
验证阶段主要包括以下四个阶段的检验工作:
Class
文件格式的规范。目的是保证输入的字节流能正确解析并存储在方法区内。该阶段为类中定义的变量分配内存并设置类变量初始值(零值)。
int age;
String name;
准备阶段结束后,这两个类变量的值就分别是0
和null
。
当一个类中调了外部的类,比如我们的代码中的Main
类中调用了GirlFriend
类,该类编译时,并不知道所引用的外部类的实际地址,因此只能用一个符号引用来代替。
那么解析阶段要做的工作就是将符号引用替换成直接引用。直接引用就是可以直接指向目标的指针。
此处的符号引用不单单指类的符号引用,还包括字段、方法的符号引用,但工作过程无非都是将符号引用转换为直接引用。但要注意的是解析时要先解析该字段或方法所在的类,搜索该类,如果没有对应的方法或字段就递归搜索父类或父接口。
初始化阶段就是执行类构造器
方法的过程。以下是该方法的介绍:
()
方法,编译结束后,该方法也生成完毕。()
方法执行前,父类的()
方法执行完毕。所以说第一个执行完的肯定是Object类
的()
方法。()
方法不需要先执行父接口的()
方法,只有当父接口定义的变量被使用时,才会执行父接口的初始化。并且接口的而实现类在初始化时也不执行接口的()
方法。()
方法被正确同步加锁。此时我们刚刚结束了new GirlFriend(23, "bing bing")
代码的分析,程序继续往下执行,在此之前先连接下java的内存区域划分。
接下来就可以很方便地用图来描述下面这一行代码。
GirlFriend bing = new GirlFriend(23, "bing bing");
GirlFriend
创建一个对象引用,指向堆内存中的对象实例。
当我们调用该类的方法或字段时,就时通过对象引用找到对象实例,再从对象实例中寻找对应的字段或方法的过程。