JVM:从JVM角度观察实际问题

JVM方法调用的内部机制

JVM作为一款虚拟机,必然要涉及计算机核心的3大功能。

  • 方法调用。方法作为程序组成的基本单元,作为原子指令的初步封装,计算机必须能够支持方法的调用。同样,Java语言的原子指令是字节码,Java方法是对字节码的封装,因此,JVM必须支持对Java方法的调用。
  • 取值。计算机进入方法后,最终需要逐条取出指令并逐条执行。Java方法也不例外,因此JVM进入方法后,也要模拟硬件CPU,能够从Java方法中逐条去除字节码指令。
  • 运算。计算机取出指令后,就要根据指令进行相应的逻辑运算,实现指令的功能。JVM作为虚拟机,也需要具备对Java字节码的运算能力。

即方法调用与方法执行

提出问题

  • JVM到底是如何run起来的。即计算机的核心3大功能
  • 在JVM的方法调用中,其变量、方法的执行是一个什么样的角色,在一个什么位置。

概述

是什么

Java源代码需要编译成字节码文件,由JVM解释执行。

分类

  • 方法调用
    • 静态
    • 动态
  • 方法执行

为什么要理解

内存管理

虚拟机栈

Java虚拟机在运行时会为每一个线程在内存中分配了一个虚拟机栈,来表示线程的运行状态和信息,虚拟机栈中的元素称之为栈帧(JVM stack frame),每一个栈帧表示这对一个方法的调用信息。如下所示:

1564709178169

方法调用大致过程

1
2
3
4
5
6
7
8
9
10
11
public class Bootstrap {
public static void main(String[] args) {
String name = "Louis";
greeting(name);
}

public static void greeting(String name){
System.out.println("Hello,"+name);
}

}
  1. 首先JVM会先将这个Bootstrap.class信息加载到 内存中的方法区(Method Area)中。Bootstrap.class中包含了常量池信息,方法的定义 以及编译后的方法实现的二进制形式的机器指令,所有的线程共享一个方法区,从中读取方法定义和方法的指令集。

  2. 接着,JVM会在Heap堆上为Bootstrap.class 创建一个Class\<Bootstrap>实例用来表示Bootstrap.class 的 类实例。

  3. JVM开始执行main方法,这时会为main方法创建一个栈帧,以表示main方法的整个执行过程;

  4. main方法在执行的过程之中,调用了greeting静态方法,则JVM会为greeting方法创建一个栈帧,推到虚拟机栈顶。

  5. 当greeting方法运行完成后,则greeting方法出栈,main方法继续运行;

1564709442096

JVM方法调用的过程是通过栈帧来实现的,那么,方法的指令是如何运行的呢?弄清楚这个之前,我们要先了解对于JVM而言,方法的结构是什么样的。我们知道,class文件是JVM能够识别的二进制文件,其中通过特定的结构描述了每个方法的定义。

JVM在编译Bootstrap.java的过程中,在将源代码编译成二进制机器码的同时,会判断其中的每一个方法的三个信息:

  • 在运行时会使用到的局部变量的数量(作用是:当JVM为方法创建栈帧的时候,在栈帧中为该方法创建一个局部变量表,来存储方法指令在运算时的局部变量值)
  • 其机器指令执行时所需要的最大的操作数栈的大小(当JVM为方法创建栈帧的时候,在栈帧中为方法创建一个操作数栈,保证方法内指令可以完成工作)
  • 方法的参数的数量

子类方法与父类方法的关系

如果子类中不显式调用父类的方法,即不super.method,则不会执行父类的方法。

因此synchronized关键字在子类中是需要显式声明的。

JVM对一个方法执行的基本策略

一般地,对于java方法的执行,在JVM在其某一特定线程的虚拟机栈(JVM Stack) 中会为方法分配一个 局部变量表,一个操作数栈,用以存储方法的运行过程中的中间值存储。

由于JVM的指令是基于栈的,即大部分的指令的执行,都伴随着操作数的出栈和入栈。所以在学习JVM的机器指令的时候,一定要铭记一点:每个机器指令的执行,对操作数栈和局部变量的影响,充分地了解了这个机制,你就可以非常顺畅地读懂class文件中的二进制机器指令了。

如下是栈帧信息的简化图,在分析JVM指令时,脑海中对栈帧有个清晰的认识:

1564709607140

方法调用的字节码指令

JVM里面提供了4条方法调用字节码指令。分别如下:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法(super(),super.method())
  • invokevirtual:调用所有的虚方法(静态方法、私有方法、实例构造器、父类方法、final方法都是非虚方法)
  • invokeinterface:调用接口方法,会在运行时期再确定一个实现此接口的对象

invokestatic和invokespecial指令调用的方法都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载阶段就会把符号引用解析为该方法的直接引用。直接引用就是一个指针或偏移量,可以让JVM快速定位到具体要调用的方法。

invokevirtual和invokeinterface指令调用的方法是在运行时确定具体的方法地址,接口方法和实例对象公有方法可以用这两个指令来调用。

1
2
3
4
5
6
7
8
9
10
11
12
public class Test {
private void run() {
List<String> list = new ArrayList<>(); // invokespecial 构造器调用
list.add("a"); // invokeinterface 接口调用
ArrayList<String> arrayList = new ArrayList<>(); // invokespecial 构造器调用
arrayList.add("b"); // invokevirtual 虚函数调用
}
public static void main(String[] args) {
Test test = new Test(); // invokespecial 构造器调用
test.run(); // invokespecial 私有函数调用
}
}

反编译字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Object init
4: return

private void run();
Code:
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1 //初始化一个字符串a,并存入常量池
8: aload_1 //加载一个字符串
9: ldc #4 // String a
11: invokeinterface #5, 2 // 执行list.add
16: pop
17: new #2 // class java/util/ArrayList
20: dup
21: invokespecial #3 //init ArrayList
24: astore_2
25: aload_2
26: ldc #6 // String b
28: invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
31: pop
32: return

public static void main(java.lang.String[]);
Code:
0: new #8 // class Test
3: dup
4: invokespecial #9 // Method List init
7: astore_1
8: aload_1
9: invokespecial #10 // Method run:()V
12: return
}

从上面的字节码可以看出,每一条方法调用指令后面都带一个Index值,JVM可以通过这个索引值从常量池中获取到方法的符号引用。

每个class文件都有一个常量池,主要是关于类、方法、接口等中的常量,也包括字符串常量和符号引用。方法的符号引用是唯一标识一个方法的信息结构体,包含类名,方法名和方法描述符,方法描述符又包含返回值、函数名和参数列表。这些字符值都存放到class文件的常量池中,通过整型的Index来标识和索引。

动态链接

Java中的实例方法默认是虚方法,因此父类引用调用被子类覆盖的方法时能体现多态性。方法调用阶段的唯一任务是确定被调用方法的版本,即调用哪一个方法。而Class文件的编译过程是不涉及传统编译器中的连接步骤,一切方法调用在Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用),即需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。

动态链接

即Java当中的多态特性,而到JVM层面即当JVM遇到invokevirtual或invokeinterface时,需要运行时根据方法的符号引用查找到方法地址。具体过程如下:

  • 在方法调用指令之前,需要将对象的引用压入操作数栈
  • 在执行方法调用时,找到操作数栈顶的第一个元素所指向的对象实际类型,记作C
  • 在类型C中找到与常量池中的描述符和方法名称都相符的方法,并校验访问权限。如果找到该方法并通过校验,则返回这个方法的引用;
  • 否则,按照继承关系往上查找方法并校验访问权限;
  • 如果始终没找到方法,则抛出java.lang.AbstractMethodError异常;

可以看到,JVM是通过继承关系从子类往上查找的对应的方法的,为了提高动态分派时方法查找的效率,JVM为每个类都维护一个虚函数表。

虚函数表

JVM里引入虚函数表的目的是加快虚方法的索引。JVM 会在链接类的过程中,给类分配相应的方法表内存空间。每个类对应一个方法表。这些都是存在于方法区中的。Java中每个对象的对象头有一个类型指针,可以索引到对应的类,在对应的类数据中对应一个方法表。

一个类的方法表包含类的所有方法入口地址,从父类继承的方法放在前面,接下来是接口方法和自定义的方法。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同的方法的入口地址一致。如果子类重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

方法执行

参考

  1. JVM方法调用的内部机制