这篇文章的素材来自周志明的《深入理解Java虚拟机》。
作为Java开发人员,一定程度了解JVM虚拟机的的运作方式非常重要,本文就一些简单的虚拟机的相关概念和运作机制展开我自己的学习过程,是这个系列的第三篇。
虚拟机运行活化的内存数据中的指令:程序的执行
前面我们说明了java源码被编译成为了二进制字节码,二进制字节码转为内存中方法区里存储的活化对象,那么最重要的程序执行就做好了基础:当方法区里的字段和方法按照虚拟机规定的数据结构排好,常量池中的符号引用数据在加载过程中最大限度地转为了直接引用,那么这个时候虚拟机就可以在加载主类后创建新的线程按步执行主类的main函数中的指令了。
java虚拟机执行程序的基础是特定的二进制指令集和运行时栈帧:
二进制指令集是java虚拟机规定的一些指令,在编译后二进制字节码的类方法里的字节码就是这种指令,所以只要找到方法区里的类方法就可以依照这套指令集去执行命令。
运行时栈帧是虚拟机执行的物理所在,在这个栈帧结构上,方法的局部变量、操作数栈、动态链接和返回地址依序排列,依照命令动态变换栈帧上的数据,最终完成所有的这个方法上的指令。
栈帧的进一步划分:
局部变量表:包括方法的参数和方法体内部的局部变量都会存在这个表中。
操作数栈:操作数栈是一个运行中间产生的操作数构成的栈,这个栈的栈顶保存的就是当前活跃的操作数。
动态链接:我们之前提到这个方法中调用的方法和类在常量池中的符号引用转换为的直接引用就保存在这里,只要访问到这些方法和类的时候就会根据动态链接去直接引用所指的地址加载那些方法。
返回地址:程序正常结束恢复上一个栈帧的状态的时候需要知道上一个指令的地址。
现在我们使用一个综合实例来说明运行的整个过程:
源代码如下,逻辑很简单:
public class TestDemo {
public static int minus(int x){
return -x;
}
public static void main(String[] args) {
int x = 5;
int y = minus(x);
}
}
我们可以分析它的二进制字节码,当然这里我们借助javap工具进行分析:
jinhaoplus$ javap -verbose TestDemo
Classfile /Users/jinhao/Desktop/TestDemo.class
Last modified 2015-10-17; size 342 bytes
MD5 checksum 4f37459aa1b3438b1608de788d43586d
Compiled from "TestDemo.java"
public class TestDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."":()V
#2 = Methodref #3.#16 // TestDemo.minus:(I)I
#3 = Class #17 // TestDemo
#4 = Class #18 // java/lang/Object
#5 = Utf8
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 minus
#10 = Utf8 (I)I
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 TestDemo.java
#15 = NameAndType #5:#6 // "":()V
#16 = NameAndType #9:#10 // minus:(I)I
#17 = Utf8 TestDemo
#18 = Utf8 java/lang/Object
{
public TestDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 1: 0
public static int minus(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iload_0
1: ineg
2: ireturn
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_5
1: istore_1
2: iload_1
3: invokestatic #2 // Method minus:(I)I
6: istore_2
7: return
LineNumberTable:
line 6: 0
line 7: 2
line 8: 7
}
SourceFile: "TestDemo.java"
这个过程是从固化在class文件中的二进制字节码开始,经过加载器对当前类的加载,虚拟机对二进制码的验证、准备和一定的解析,进入内存中的方法区,常量池中的符号引用一定程度上转换为直接引用,使得字节码通过结构化的组织让虚拟机了解类的每一块的构成,创建的线程申请到了虚拟机栈中的空间构造出属于这一线程的栈帧空间,执行主类的main方法:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_5
1: istore_1
2: iload_1
3: invokestatic #2 // Method minus:(I)I
6: istore_2
7: return
LineNumberTable:
line 6: 0
line 7: 2
line 8: 7
}
首先检查main的访问标志、描述符描述的返回类型和参数列表,确定可以访问后进入Code属性表执行命令,读入栈深度建立符合要求的操作数栈,读入局部变量大小建立符合要求的局部变量表,根据参数数向局部变量表中依序加入参数(第一个参数是引用当前对象的this,所以空参数列表的参数数也是1),然后开始根据命令正式执行:
0: iconst_5
将整数5压入栈顶
1: istore_1
将栈顶整数值存入局部变量表的slot1(slot0是参数this)
2: iload_1
将slot1压入栈顶
3: invokestatic #2 // Method minus:(I)I
二进制invokestatic方法用于调用静态方法,参数是根据常量池中已经转换为直接引用的常量,意即minus函数在方法区中的地址,找到这个地址调用函数,向其中加入的参数为栈顶的值
6: istore_2
将栈顶整数存入局部变量的slot2
7: return
将返回地址中存储的PC地址返到PC,栈帧恢复到调用前
现在我们分析调用minus函数的时候进入minus函数的过程:
public static int minus(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iload_0
1: ineg
2: ireturn
LineNumberTable:
line 3: 0
同样的首先检查minus函数的访问标志、描述符描述的返回类型和参数列表,确定可以访问后进入Code属性表执行命令,读入栈深度建立符合要求的操作数栈,读入局部变量大小建立符合要求的局部变量表,根据参数数向局部变量表中依序加入参数,然后开始根据命令正式执行:
0: iload_0
将slot0压入栈顶,也就是传入的参数
1: ineg
将栈顶的值弹出取负后压回栈顶
2: ireturn
将返回地址中存储的PC地址返到PC,栈帧恢复到调用前
这个过程结束后对象的生命周期结束,因此开始执行GC回收内存中的对象,包括堆中的类对应的java.lang.Class对象,卸载方法区中的类。
方法的解析和分派
上面这个例子中main方法里调用minus方法的时候是没有二义性的,因为从二进制字节码里我们可以看到invokestatic方法调用的是minus方法的直接引用,也就说在编译期这个调用就已经决定了。这个时候我们来说说方法调用,这个部分的内容在前面的类加载时候提过,在能够唯一确定方法的直接引用的时候虚拟机会将常量表里的符号引用转换为直接引用,这样在运行的时候就可以直接根据这个地址找到对应的方法去执行,这种时候的转换才能叫做我们当时提到的在连接过程中的解析。
但是如果方法是动态绑定的,也就是说在编译期我们并不知道使用哪个方法(或者叫不知道使用方法的哪个版本),那么这个时候就需要在运行时才能确定哪个版本的方法将被调用,这个时候才能将符号引用转换为直接引用。这个问题提到的多个版本的方法在java中的重载和多态重写问题息息相关。
重载(override)
public class TestDemo {
static class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public void sayHello(Human human) {
System.out.println("hello human");
}
public void sayHello(Man man) {
System.out.println("hello man");
}
public void sayHello(Woman woman) {
System.out.println("hello woman");
}
public static void main(String[] args) {
TestDemo demo = new TestDemo();
Human man = new Man();
Human woman = new Woman();
demo.sayHello(man);
demo.sayHello(woman);
}
}
javap结果:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #7 // class TestDemo
3: dup
4: invokespecial #8 // Method "":()V
7: astore_1
8: new #9 // class TestDemo$Man
11: dup
12: invokespecial #10 // Method TestDemo$Man."":()V
15: astore_2
16: new #11 // class TestDemo$Woman
19: dup
20: invokespecial #12 // Method TestDemo$Woman."":()V
23: astore_3
24: aload_1
25: aload_2
26: invokevirtual #13 // Method sayHello:(LTestDemo$Human;)V
29: aload_1
30: aload_3
31: invokevirtual #13 // Method sayHello:(LTestDemo$Human;)V
34: return
LineNumberTable:
line 21: 0
line 22: 8
line 23: 16
line 24: 24
line 25: 29
line 26: 34
重写(overwrite)
public class TestDemo {
static class Human{
public void sayHello() {
System.out.println("hello human");
}
}
static class Man extends Human{
public void sayHello() {
System.out.println("hello man");
}
}
static class Woman extends Human{
public void sayHello() {
System.out.println("hello woman");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
}
}
javap结果:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class TestDemo$Man
3: dup
4: invokespecial #3 // Method TestDemo$Man."":()V
7: astore_1
8: new #4 // class TestDemo$Woman
11: dup
12: invokespecial #5 // Method TestDemo$Woman."":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method TestDemo$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method TestDemo$Human.sayHello:()V
24: return
LineNumberTable:
line 20: 0
line 21: 8
line 22: 16
line 23: 20
line 24: 24
我们可以看出来无论是重载还是重写,都是二进制指令invokevirtual调用了sayHello方法来执行的。
在重载中,程序调用的是参数实际类型不同的方法,但是虚拟机最终分派了相同外观类型(静态类型)的方法,这说明在重载的过程中虚拟机在运行的时候是只看参数的外观类型(静态类型)的,而这个外观类型(静态类型)是在编译的时候就已经确定的,和虚拟机没有关系。这种依赖静态类型来做方法的分配叫做静态分派。
在重写中,程序调用的是不同实际类型的同名方法,虚拟机依据对象的实际类型去寻找是否有这个方法,如果有就执行,如果没有去父类里找,最终在实际类型里找到了这个方法,所以最终是在运行期动态分派了方法。在编译的时候我们可以看到字节码指示的方法都是一样的符号引用,但是运行期虚拟机能够根据实际类型去确定出真正需要的直接引用。这种依赖实际类型来做方法的分配叫做动态分派。得益于java虚拟机的动态分派会在分派前确定对象的实际类型,面向对象的多态性才能体现出来。
对象的创建和堆内存的分配
前面我们提到的都是类在方法区中的内存分配:
在方法区中有类的常量池,常量池中保存着类的很多信息的符号引用,很多符号引用还转换为了直接引用以使在运行的过程能够访问到这些信息的真实地址。
那么创建出的对象是怎么在堆中分配空间的呢?
首先我们要明确对象中存储的大部分的数据就是它对应的非静态字段和每个字段方法对应的方法区中的地址,因为这些东西每个对象都是不一样的,所以必须通过各自的堆空间存储这些不一样的数据,而方法是所有同类对象共用的,因为方法的命令是一样的,每个对象只是在各自的线程栈帧里提供各自的局部变量表和操作数栈就好。
这样看来,堆中存放的是真正“有个性”的属于对象自己的变量,这些变量往往是最占空间的,而这些变量对应的类字段的地址会找到位于方法区中,同样的同类对象如果要执行一个方法只需要在自己的栈帧里面创建局部变量表和操作数栈,然后根据方法对应的方法区中的地址去寻找到方法体执行其中的命令即可,这样一来堆里面只存放有限的真正有意义的数据和地址,方法区里存放共用的字段和方法体,能最大程度地减小内存开销。