通过一个Java方法的运行过程来看JVM

435 阅读12分钟

其实JVM大家都很了解,但我想通过一个Java方法的一生,去瞥见JVM的体系,这样私以为更容易理解,也更容易将知识串联起来,让沉睡已久的知识得到运用。

一、.java -> .class

因为我们编写的程序是.java文件,而Java虚拟机只能识别字节码文件,即.class文件,所以要调用一个类中的方法,那必须先进行编译

//没有使用lombok,这样看编译后的文件更加清晰一点
public class Employee {
    private String name;
    //存储基础薪水
    private double salary;
    
    public Employee(String name, double salary){
        this.name = name;
        this.salary = salary;
    }

    public String getName(){
        return name;
    }

    public void setName(String name){
        this.name = name;
    }

    public double getSalary() {
        return salary;
    }

    public void setSalary(double salary) {
        this.salary = salary;
    }
}

为了显示JVM是如何处理方法重写的,这里新建了一个子类Manager,主要是重写了获取薪水的方法,因为经理的工资会有1000的提成

public class Manager extends Employee {
    public Manager(String name, double salary) {
        super(name, salary);
    }
    @Override
    public double getSalary() {
        increaseSalary();
        return super.getSalary();
    }
    
    private void increaseSalary(){
        super.setSalary(super.getSalary() + 1000);
    }
}

最后我们在一个psvm(如果不知道的话可以试试在IDEA里面打出来按回车哦,小彩蛋hh)里面调用即可:

public class EmployeeDemo {
    private static Employee employee = new Employee("CVNot", 1000);
    private static Manager manager = new Manager("CVNot+", 2000);
    public static void main(String[] args) {
        System.out.println(employee.getSalary());
        System.out.println(manager.getSalary());
    }
}

然后我们调用javac编译,再使用javap反编译,如果我们先编译EmployeeDemo会得到下面的结果:

C:\项目学习\ConcurrentLearn\src\JVMDemo>javac EmployeeDemo.java
EmployeeDemo.java:4: 错误: 找不到符号
    private static Employee employee = new Employee("CVNot", 1000);
                   ^
  符号:   类 Employee
  位置: 类 EmployeeDemo
EmployeeDemo.java:5: 错误: 找不到符号
    private static Manager manager = new Manager("CVNot+", 2000);
                   ^
  符号:   类 Manager
  位置: 类 EmployeeDemo
EmployeeDemo.java:4: 错误: 找不到符号
    private static Employee employee = new Employee("CVNot", 1000);
                                           ^
  符号:   类 Employee
  位置: 类 EmployeeDemo
EmployeeDemo.java:5: 错误: 找不到符号
    private static Manager manager = new Manager("CVNot+", 2000);
                                         ^
  符号:   类 Manager
  位置: 类 EmployeeDemo
4 个错误

所以在JVM类加载机制中,在加载一个类时,会先加载其所依赖的类,然后我们修正我们的错误,再进行编译;

C:\项目学习\ConcurrentLearn\src\JVMDemo>javac Employee.java Manager.java EmployeeDemo.java

然后我们通过JDK的javap工具去查看一下EmployeeDemo.class的内部结构, 此处可以打开深入理解Java虚拟机的那本书对着看class文件结构和字节码指令

C:\项目学习\ConcurrentLearn\src\JVMDemo>javap -verbose EmployeeDemo.class

ps:如果想看16进制的.class文件,可以使用NotePad++将默认的ASCII码改为HEX码即可,这里就不去探究过多的细节啦~

我们在看之前,回味一下我们的EmployeeDemoemploy, manager两个引用、Employee()和Manager()两个对象,所以会有这两个对象的init()方法,并且为了创建这两个对象我们还创建了CVNot与CVNot+这两个字符串字面量和1000与2000两个double值类型;

然后我们调用了两次System.out.println()方法,两次不同对象的getSalary()方法,所以会有四个方法引用;

1.1 常量池:

然后再来看常量池(省略一些细节,不然实则过长):

//前面的#1,#3 等表示在常量池中的索引
//常量池中主要存储字面量和符号引用
//并且符号引用只有在虚拟机加载Class文件的时候才会进行动态连接,将符号引用转变为真正的内存入口地址
Constant pool:

    #1 = Methodref          #19.#33        // java/lang/Object."<init>":()V
    //这是一个方法符号引用,#19表示该方法所在的类 ,#33表示该该方法的名称和类型
    //#19 = Class #47 // java/lang/Object
    //#33 = NameAndType(方法的名称和返回类型  #24:#25    
    //#24 = Utf8  <init>  #25 = Utf8   ()V
    //常量池中第24项表示了方法名称,()V则表示返回类型为void()
    //至此翻译如下:那么这个就表示Object()的返回为void()的<init>构造器方法
   
   
   相当于我们.java文件中的 Employee employee 这句代码!
    #3 = Fieldref           #18.#36      //JVMDemo/EmployeeDemo.employee:LJVMDemo/Employee;
  //这是一个字段符号引用,#18表示声明该字段的类,#36表示该字段的名称和类型
  // #18 = Class #46  // JVMDemo/EmployeeDemo 说明该字段属于EmployeeDemo
  // #36 = NameAndType        #20:#21        // employee:LJVMDemo/Employee;
  // #20 = Utf8               employee //该字段的名称为employee
  // #21 = Utf8               LJVMDemo/Employee; //该字段的类型为Employee
  
   //这是Employee的getSalary()方法符号引用 #8说明了方法名,#37说明了方法的名称和类型
   #4 = Methodref          #8.#37         // JVMDemo/Employee.getSalary:()D
   
   相当于我们.java文件中的 Manager manager 这句代码!
  //这是一个字段引用 #18说明了申明该字段的类,#40说明了方法的名称和类型
  //如果当前类继承了父类,父类中声明的字段不会在当前类的常量池中出现
   #6 = Fieldref           #18.#40        // JVMDemo/EmployeeDemo.manager:LJVMDemo/Manager;
   
   //这是Manager的getSalary()方法符号引用 #13说明了方法名,#37说明了方法的名称和类型
   #7 = Methodref          #13.#37        // JVMDemo/Manager.getSalary:()D
  
   //这里表示第八个常量是Class常量,其类名由常量池的第41个UTF8值决定: #41 = Utf8 JVMDemo/Employee   
   //故该Class常量类名为Employee
   #8 = Class              #41            // JVMDemo/Employee
   
   //String字面量CVNot
   #9 = String             #42            // CVNot
   
   //Double常量1000,可以看到这个是没有常量池的引用的,和String不一样,Double是值类型
  #10 = Double             1000.0d
  
  //Employee()的<init>方法的符号引用
  #12 = Methodref          #8.#43        //JVMDemo/Employee."<init>":(Ljava/lang/String;D)V
 
  #13 = Class              #44            // JVMDemo/Manager Class对象
  
  #14 = String             #45            // CVNot+  String字面量
  
  #15 = Double             2000.0d  同上
  
  #17 = Methodref          #13.#43        //JVMDemo/Manager."<init>":(Ljava/lang/String;D)V
  
  //父类Object的全限定名
   #19 = Class              #47            // java/lang/Object
   
   //Code属性用于存放当前类定义的方法代码,如果没有重写父类的方法,父类的方法不会出现
    #26 = Utf8               Code

1.2 访问标志:

主要是标明类和接口层次的访问信息:

flags: ACC_PUBLIC, ACC_SUPER

说明这个类的访问权限是Public,而ACC_SUPER说明这个类基于的JDK版本在JDK1.0.2之后

1.3 类索引,父类索引,接口索引:

类索引:当前类的全限定名,即从根目录开始标明

父类索引:当前类父类的全限定名,比如说EmployeeDemo类没有显示继承任一个类,所以会默认继承Object类,而很明显在常量池的#19可以看出存放了Object的全限定名

接口索引: 即记录了当前类实现了哪些接口,并且因为Java类可以实现多个接口,所以实现的接口会按照从左到右的顺序排列好。

1.4 Code属性:

我们会很奇怪,我们在常量池中只发现了诸如Employee.getSalary()这样的方法,那方法里面的代码呢?如果不存在代码,虚拟机又如何调用编译成字节码的方法呢?

因为方法的代码是放在属性表的code属性中,并且一个方法对应一个code属性

但如果当前类有extends一个类,但code属性中只会出现当前类重写的方法的代码;

code属性除了用来存储字节码,也用来描述方法在栈帧中运行的一些条件,因为字节码最终还是要落实在虚拟机栈中通过栈帧去执行的,所以code属性也为栈帧的建立提供信息;

下面以EmployeeDemo类的静态方法举例

    //两个对象引用是static的,所以需要在类的静态方法中加载;
#4    private static Employee employee = new Employee("CVNot", 1000);
#5    private static Manager manager = new Manager("CVNot+", 2000);
//默认的<clinit>方法用于在类初始化时对静态变量重新赋值
  static {}; 
    descriptor: ()V
    flags: ACC_STATIC
    //Code属性:
    Code:
    //操作数栈的深度的最大值,locals代表了局部变量表所需的存储空间,arg_size代表方法的输入参数
      stack=5, locals=0, args_size=0
         0: new           #8                  // class JVMDemo/Employee
         3: dup
         4: ldc           #9                  // String CVNot
         6: ldc2_w        #10                 // double 1000.0d
         9: invokespecial #12                 // Method JVMDemo/Employee."<init>":(Ljava/lang/String;D)V
        12: putstatic     #3                  // Field employee:LJVMDemo/Employee;
        15: new           #13                 // class JVMDemo/Manager
        18: dup
        19: ldc           #14                 // String CVNot+
        21: ldc2_w        #15                 // double 2000.0d
        24: invokespecial #17                 // Method JVMDemo/Manager."<init>":(Ljava/lang/String;D)V
        27: putstatic     #6                  // Field manager:LJVMDemo/Manager;
        30: return
    //Java源码的行号与字节码指令的对应关系
      LineNumberTable:
        line 4: 0
        line 5: 15

二、类加载:虚拟机加载.Class文件

严格意义上说,虚拟机加载的不一定都是.class文件,也可以是jar包等等,这里以.class文件为例

2.1 什么时候需要加载一个类(类加载)?

  1. 当正在执行的字节码中出现了new和类层面的相关指令--getstatic, putstatic, invokestatic

new即为新建一个对象,如:

0: new           #8                  // class JVMDemo/Employee

当执行到这一句字节码时,我们需要对Employee这个类进行加载动作

后面的三个命令分别为:获取和设置静态变量,调用相关类的静态方法;

举个例子,上述代码中,虚拟机调用putstatic方法设置类的静态变量

27: putstatic     #6                  // Field manager:LJVMDemo/Manager;
  1. 使用reflect方法对类进行反射调用的时候,因为思考一下就知道,reflect本质上是通过.class对象去访问该类在方法区中的方法,所以要首先将这个类加载到方法区才可以呀!
  2. 初始化子类前必须初始化父类,我们编译的时候不亦是如此嘛,想要编译EmployeeDemo首先需要编译EmployeeManager
  3. 虚拟机启动时首先会加载含main方法的类;
  4. java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic, REF_putStatic, REF_invokeStatic时。(与第一条实际上是一样的);

2.2 如何加载一个类(类加载):

2.2.1 加载:

要使用EmployeeDemo的main()方法,那首先要把Employee, Manager, EmployeeDemo这三个类对应的.class文件加载到方法区中,并形成相应的运行时数据结构,加载是通过类加载器进行的;最后会生成.class对象作为访问类的类型数据的外部接口;

2.2.2 验证:

不详细讲,出现一步骤的原因是,JVM并不一定读取的是.class文件,有可能的话,我们甚至可以用16进制编辑器自己写出一系列字节流,但通过16进制编辑器写出来的字节流基本上是不能满足JVM解析字节流的相应规范的

2.2.3 准备:

这一步是正式为类变量分配内存并设置类变量初始值的阶段,这些类变量所使用的内存都需要在方法区中进行分配,因为static关键字修饰的变量是属于类的,那自然不会在Java堆上进行分配,并且如果静态变量没有被final修饰,则会按照默认值进行初始化;

而实例变量会在对象实例化的时候随对象一起分配在堆中

2.2.4 解析:

我们在分析EmployeeDemo.class文件的常量池时,我们见到很多符号引用(如Class符号引用,Field的符号引用,Method的符号引用),那么在这一步,既然类已经要被加载到内存中了,那肯定要将其改为真正的内存地址;(此处可以联系操作系统中的数据页从硬盘加载到内存中时,是不是需要将页的相对位置改为内存中的绝对位置呢?

这一步是为了可以在运行相应方法的字节码时,虚拟机可以在堆中直接找到相应对象的方法,而不是.class文件中存储的JVMDemo/EmployeeDemo这样的全限定名;

那么JVM是如何对符号引用进行翻译的呢?

首先会分两种情况:

(1)在解析阶段,会将在程序运行之前就有一个可确定的调用版本的方法的符号引用转变为直接引用,因为这类方法的调用版本在运行起间是不可改变的,即编译期可知,运行期不可变,主要包括静态方法和私有方法两大类,这类方法也称为非虚方法,这两者都不可能通过继承或其他方式对方法进行重写,因此这类方法适合在类加载阶段进行解析;

举个例子:

   24: invokespecial #17 // Method JVMDemo/Manager."<init>":(Ljava/lang/String;D)V

我们调用Manager类的<init>方法,此时就会将该方法的符号引用转变为直接引用;

(2)确定调用的方法版本的分派方法:

  1. 静态分派(重载)与动态分派(重写):
private static Employee manager1 = new Manager("CVNot++", 3000);

    public static void main(String[] args) {
        System.out.println(manager1.getSalary());
    }

我们看一下反编译后的main()方法:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: getstatic     #3                  // Field manager1:LJVMDemo/Employee;
         6: invokevirtual #4                  // Method JVMDemo/Employee.getSalary:()D
         9: invokevirtual #5                  // Method java/io/PrintStream.println:(D)V
        12: return
      LineNumberTable:
        line 10: 0
        line 11: 12

首先Employee是静态类型,而manager1是动态类型

我们看第6行,调用了符号引用为Employee.getSalary()的方法,而这样根据方法的名称,输入参数去决定调用方法的符号引用是静态分派;而通过invokevirtual调用该符号引用,则首先会查看当前的manager1这个reference的实际指向,其指向的是一个Manager类型的对象,所以最终会确定调用Manager.getSalary()方法

2.2.5 初始化:

通过<clinit>方法对之前在准备阶段赋予默认值的变量按照静态初始语句或静态代码块进行赋值

三、 总结:

至此,一个Java方法经过.java、.class、加载、分派,直至最后被虚拟机的解释引擎执行Code属性中编译好的字节码最终完成了它的使命~