深入理解java虚拟机---从new一个对象开始

文章目录

  • 前言
  • 一、前端编译阶段
    • 1.前端编译都做了什么?
    • 2.回到new一个对象
    • 2.1 词法分析
    • 2.2 语法分析
    • 2.3 填充符号表
    • 2.4 插入式注解处理器
    • 2.4 语义分析之标注检查
    • 2.5 语义分析之数据流与控制流分析
    • 2.6 解语法糖
    • 2.6 字节码生成
  • 二、代码运行
    • 1.类的加载过程
    • 1.1 类加载器
    • 1.2 双亲委派模型
    • 1.3 加载
    • 1.4 验证
    • 1.5 准备
    • 1.6 解析
    • 1.7 初始化
    • 2.继续往下走


前言

在我们探究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());
    }
}

一、前端编译阶段

1.前端编译都做了什么?

前端编译就是使用javac编译器将*.java文件转换成二进制字节码文件(*.class文件)。编译过程可大致分为1个准备过程和3个处理过程,理论较抽象,会在下文拿案例进行分析。

  1. 准备过程:初始化注解处理器
    这里仅需要知道注解处理器是将对注解的处理提前至编译期即可。
  2. 解析与填充符号表过程
    词法分析—将我们写的代码转变为一个个token(关键字、变量名、字面量、运算符等)集合
    语法分析—将上述的token集合转化为一个语法树,概括了程序结构体系。
    填充符号表—符号表存储着符号地址和符号信息的键值对。在程序的编译不同阶段都会用到,比如程序生成时,对符号名进行地址分配。
  3. 注解处理
  4. 语义分析与字节码生成
    标注检查—包括变量是否已经被声明、变量与赋值之间的数据类型是否能够匹配等。
    数据流与控制流分析—对程序上下文逻辑进一步检验。
    解语法糖
    字节码生成—将前面各个步骤生成的信息转化为字节码指令写到磁盘中,并且进行少量的代码添加和转化工作。

2.回到new一个对象

接下来我们按照上文对前端编译的描述,一步步地分析我们的代码在前端编译期间都发生了什么。
由于我们的代码较为简单,不涉及注解,所以忽略步骤1和步骤3。

2.1 词法分析

词法分析将我们编写的每一条程序语句更加细分的切成token集合,因为每一条程序语句是我们编写程序的最小元素,而token是编译器编译的最小元素。

//词法分析前
private int age; 
//词法分析后
privateint、age

2.2 语法分析

通过词法分析我们得到了一些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虚拟机---从new一个对象开始_第1张图片

2.3 填充符号表

填充符号表的过程就是将java类中的符号输入到符号表的过程。
符号包含在字节码常量池中,主要包括在该类中用到的类、方法名、包、接口、字段等的名字。
符号表就是一组由符号信息和符号地址构成的表格。
符号表填充过程就是将类所对应的表中未出现的符号填充到表内,符号表中所记录的信息在编译的不同阶段都可以用到,比如语义分析中的语义检查(检查一个名字的使用与原来的声明是否一致)、目标代码生成阶段(对符号名进行地址分配)。

2.4 插入式注解处理器

插入式注解处理器使得在编译阶段可以实现注解的处理。
在注解处理期间,我们可以获得所有的抽象语法树,并可以对其进行增删改查,当更改抽象语法树后还需要回到第一阶段词法分析并从头开始。
深入理解java虚拟机---从new一个对象开始_第2张图片


通过上述过程,我们最终得到了一棵能表示整个程序并且能被虚拟机解读的抽象语法树。下面的工作就是对这个语法树的检查,以及将其进一步转化为可供虚拟机解释执行的字节码。

2.4 语义分析之标注检查

标注检查主要的工作就是检查变量使用前是否已经被声明(检查的依据就是上文提到的符号表)以及变量与赋值类型之间的数据类型能否匹配等工作。

2.5 语义分析之数据流与控制流分析

该部分的工作就是检查程序变量在使用前是否赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理等问题。
顺带一提,final关键字修饰的常量也是在这个时候被检查的,Class文件中局部变量没有访问标志,也就是说被final修饰的局部变量经过编译阶段后生成的字节码文件中是没有final的,因此只能在编译阶段才能被检查。

2.6 解语法糖

语法糖指的是在计算机语言中添加的某种语法,方便程序员使用,使用语法糖能够减少代码量、增加程序可读性、减少代码出错的机会,主要包括泛型、自动装箱、自动拆箱、条件编译等。
解语法糖就是虚拟机将减少的那部分代码量补充还原回来。

2.6 字节码生成

该阶段的主要工作是将经过语义分析检查后的抽象语法树生成字节码指令写入磁盘中,文件后缀名为.class,并且编译器还进行了少量的代码添加和转换,比如实例构造器()方法和类构造器()方法以及部分优化工作,如将字符串的加操作替换成StringBufferStringBuilderappend()操作。


到此为止,我们已经梳理了程序文件转换为二进制字节码文件的整个过程,也在磁盘中得到了该类的`.class`文件。

二、代码运行

通过编译我们得到了GrilFriend类的字节码文件,下面贴出我们的要执行的主函数:

class Main{
    public static void main(String[] args) {
        GirlFriend bing = new GirlFriend(23, "bing bing");
        System.out.println(bing.getName());
    }
}

主函数较为简单,只涉及new一个对象,调用该对象的getName()方法,并将结果打印。
需要说明的是,运行程序时虚拟机是从字节码文件中读取字节码指令并解释执行,另一种编译手段称之为即时编译,是将程序中经常出现的代码编译成本地机器代码,提高程序执行效率。本文代码简单,因此不涉及即时编译,故不做赘述。

1.类的加载过程

由于涉及到new操作,因此类的加载动作是不可避免的,在介绍具体的加载流程前先熟悉以下加载器。

1.1 类加载器

类加载器有两个作用,一是实现类的加载动作,二是确定类的唯一性。
类加载器和该类本身共同确定该类在虚拟机中的唯一性。即比较两个类是否相等的前提是二者是否是由同一个加载器加载出来的。

1.2 双亲委派模型

深入理解java虚拟机---从new一个对象开始_第3张图片
双亲委派模型生动得阐述了类加载器加载的过程,概括下来可以总结为两点:

  1. 类加载器收到类加载的请求,它首先不会自己加载,而是将请求委派给父类加载器,也就是说每次类加载的请求都会一直被委派直至启动类加载器。(自下向顶)
  2. 各种类加载器负责加载的类的路径不同,也可以理解为优先级不同,当上面的加载器发现请求加载的类不在自己的加载范围,就开始让子加载器自己完成。(自顶向下)

此时我们已经确定出了要加载类的类加载器是哪一个,下面就是对该类的加载过程。

1.3 加载

当虚拟机解释执行到new GirlFriend(23, "bing bing")这里,通过new指令首先在方法区中寻找这个类是否被加载过,如果没被加载过则触发了类加载机制。
加载阶段虚拟机主要干三件事:

  1. 通过类的全限定名(GirlFriend)获取该类的二进制字节流。此处的二进制字节流不一定通过.class文件获取,也可以通过其他手段,例如网络、加密文件等。但该类经过编译已经生成了.class文件并保存在了磁盘中,因此我们只需要在磁盘中获取即可。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。此处将二进制字节流按照虚拟机所设定的格式存储在了方法区中。
  3. 在堆中生成一个代表该类的Class对象,作为程序访问方法去中的类型数据的外部接口。这个也就是我们反射调用得到的Class对象了。

1.4 验证

这一步的主要目的是确保Class文件的字节流中包含的信息是否符合所有的约束要求,并保证这些信息运行后不会危害虚拟机的安全。
由于它起到一个保安的作用,所以它和加载是交叉执行的,即确定了文件安全,才允许加载。
验证阶段主要包括以下四个阶段的检验工作:

  1. 文件格式验证。验证字节流是否符合Class文件格式的规范。目的是保证输入的字节流能正确解析并存储在方法区内。
  2. 元数据验证。主要涉及父类、接口、抽象类等的规范。
  3. 字节码验证。目的是通过数据流及控制流分析,确保程序语义合法、符合逻辑,对类的方法体校验,确保方法在运行时不会危害虚拟机。
  4. 符号引用验证。查看该类是否缺少或者被禁止访问它依赖的外部类、方法、字段等。

1.5 准备

该阶段为类中定义的变量分配内存并设置类变量初始值(零值)。

int age;
String name;

准备阶段结束后,这两个类变量的值就分别是0null

1.6 解析

当一个类中调了外部的类,比如我们的代码中的Main类中调用了GirlFriend类,该类编译时,并不知道所引用的外部类的实际地址,因此只能用一个符号引用来代替。
那么解析阶段要做的工作就是将符号引用替换成直接引用。直接引用就是可以直接指向目标的指针。
此处的符号引用不单单指类的符号引用,还包括字段、方法的符号引用,但工作过程无非都是将符号引用转换为直接引用。但要注意的是解析时要先解析该字段或方法所在的类,搜索该类,如果没有对应的方法或字段就递归搜索父类或父接口。

1.7 初始化

初始化阶段就是执行类构造器()方法的过程。以下是该方法的介绍:

  1. 编译器在编译过程中自动收集类变量的赋值动作、静态语句块,并将它们合并写入()方法,编译结束后,该方法也生成完毕。
  2. 虚拟机会保证在子类的()方法执行前,父类的()方法执行完毕。所以说第一个执行完的肯定是Object类()方法。
  3. 如果一个类中没有静态语句块也没有类变量赋值动作,则编译器不生成该方法。
  4. 接口和类略有不同,执行接口的()方法不需要先执行父接口的()方法,只有当父接口定义的变量被使用时,才会执行父接口的初始化。并且接口的而实现类在初始化时也不执行接口的()方法。
  5. 虚拟机确保了在多线程环境下一个类的()方法被正确同步加锁。

此时类的整个加载过程已加载完毕,我们得到了堆区的一个`Class`对象,方法区的该类的加载信息。

2.继续往下走

此时我们刚刚结束了new GirlFriend(23, "bing bing")代码的分析,程序继续往下执行,在此之前先连接下java的内存区域划分。
深入理解java虚拟机---从new一个对象开始_第4张图片

  1. 方法区存储虚拟机加载的类的信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  2. 堆存储对象实例。
  3. 程序计数器类似操作系统的寄存器,取指执行。
  4. 虚拟机栈存储栈帧,每个栈帧对应着一个方法,栈帧内存储着局部变量表、操作数栈、方法出口等信息,栈帧入栈出栈的过程对应着方法调用执行和执行结束的过程。
  5. 本地方法栈和虚拟机栈类似,只不过它使用的是本地方法。

接下来就可以很方便地用图来描述下面这一行代码。

GirlFriend bing = new GirlFriend(23, "bing bing");

深入理解java虚拟机---从new一个对象开始_第5张图片

GirlFriend创建一个对象引用,指向堆内存中的对象实例。
当我们调用该类的方法或字段时,就时通过对象引用找到对象实例,再从对象实例中寻找对应的字段或方法的过程。

你可能感兴趣的:(JAVA,jvm,java,大数据)