微信公众号/CSDN/知乎同名:Java传家宝
虚拟机栈超详解
Java虚拟机栈作为Java虚拟机运行时数据区的一部分,是线程私有的,描述了Java方法的内存模型。结构如图所
image-20231023191243969
当方法调用时,会在虚拟机栈中创建一个栈帧,在栈帧中保存方法的局部变量表,操作数栈,动态连接和返回地址。以下对其详解
局部变量表
在局部变量表中,分为一个个Slot,方法执行时先bipush指令将变量放在操作数栈中,通过istore指令存储在slot中。存储了方法在编译期可知的各种基本数据类型和对象引用(非对象本身,相当于一个指针指向Java堆中的对象实例)。
image-20231023194820907
操作数栈
操作数栈存储当前时刻的操作数,在方法运行会进行一系列的入栈出栈操作。这部分比较抽象,结合一个例子来看,比如调用如下方法
public static void main(String[] args){
int i = 100;
int j = 200;
int j += i;
}
结合上文局部变量表的理解,操作数栈内容和局部变量表变化如图
image-20231023201413337
先解释其中的一些字节码指令
字节码指令 | 效果 |
---|---|
bipush | 将整型变量推入操作数栈 栈顶 |
istore_n | 将操作数栈栈顶整型变量出栈并保存在局部变量表第n个slot中 |
iload_n | 将局部变量表第n个slot整型变量复制到操作数栈栈顶中 |
iadd_n | 将操作数栈头两个栈元素出栈并做整型加法,结果入栈 |
由图示应该能理解操作数栈的作用了。首先,操作数栈是空的,当方法开始执行,会进行不断入栈出栈过程,实现一系列的方法操作。
动态连接
首先,在每个栈帧中包含一个指向运行时常量池中该栈帧所属方法的引用,用于支持动态连接。其次动态连接指的是只有在运行期间才能确定方法调用版本的方法,在方法调用时,将常量池中的符号引用替换为直接引用的过程。这句话包含的信息较多,我们挨个进行解析。
常量池
当我们的程序通过javac编译为Class字节码后,常量池指的就是Class文件中保存的字面量和符号引用。属于编译期的概念,Class文件记录的部分数据结构如图
image-20231023205333873
字面量接近于常量的概念,比如final的常量,文本字符串等。
符号引用相当于是一种类似标志的概念?,此时并不能通过它获得真正的内存入口,主要包括有:
-
类和接口的全限定名
-
字段和方法的名称和描述符
image-20231023211612173
运行时常量池
首先,它属于方法区的一部分,在类加载之后,常量池的内容将会存放至运行时常量池中,此外,还存放了将符号引用解析后的直接引用。(方法区和类加载在方法区超详解细讲)
image-20231023211842892
方法调用
方法调用即字面意思,就是方法的调用,但是如何选定正确的版本是一个问题。对于在程序写好,编译时就能够确定版本的方法,调用时称为解析调用,反之称为分派调用,分派调用又分为静态分派和动态分派。下文依次解析
image-20231023212827398
解析调用
解析调用指的是,在类加载的解析阶段,对于在程序写好,编译时就能够确定版本的方法,会将其符号引用转化为直接引用。一般为包括静态方法、私有方法、实例构造器和父类方法,他们也被称为非虚方法(直接就确定类型了,一点都不‘虚’)。
在字节码层面讲,能够被invokestatic和invokespecial调用的方法都可以在解析阶段确定唯一的调用版本。
字节码指令 | 效果 |
---|---|
invokestatic | 调用静态方法 |
invokespecial | 调用实例构造器、私有方法和父类方法 |
invokevirtual | 调用所有的虚方法 |
分派调用
分派调用就指的时,在程序写好,编译时不能够确定版本的方法。又分为静态分派和动态分派。
静态分派
依赖于静态类型来定位方法执行版本的称为静态分派。最常见的就是方法的重载,发生在编译阶段。比如如下代码
public class StaticDispatch {
static abstract class Human{}
static class Man extends Human{}
static class Woman extends Human{}
public void sayHello(Man man){
System.out.println("Hello, man");
}
public void sayHello(Woman woman){
System.out.println("Hello, woman");
}
public void sayHello(Human human){
System.out.println("Hello, human");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch staticDispatch = new StaticDispatch();
staticDispatch.sayHello(man);
staticDispatch.sayHello(woman);
}
}
对于上述代码,最终输出
Hello, human
Hello, human
即,根据静态类型调用对应的方法。所谓静态类型就如下代码中,man的静态类型就是Human,实际类型是Man。最终方法调用的是重载后Human对应得版本。
Human man = new Man();
动态分派
动态分派就是通过实际类型确定方法调用的版本。常见的就是方法的重写。比如如下代码
class DynamicDispatch {
static interface Human{
public void sayHello();
}
static class Man implements Human{
@Override
public void sayHello() {
System.out.println("Hello, Man");
}
}
static class Woman implements Human{
@Override
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();
}
}
最终运行结果为根据实际类型调用的对应的方法。
Hello, Man
Hello, Woman
如何实现动态分派的呢?
我们主要关注invokevirtual,它主要分为三步骤:
-
首先匹配对象的实际类型
-
根据实际类型找到在虚方法表中与常量池中的描述符和名称都匹配的方法
- 匹配成功后根据访问权限校验
-
通过就返回方法的直接引用(栈帧从而拿到了该方法的直接引用)
-
不通过就抛出异常
-
虚方法表指的是存放了各个方法的实际入口地址:意思就是如果子类未重写父类方法,那么虚方法表内指向的就是父类方法地址,如果重写了,就指向子类方法地址。
image-20231023225813435
之前动态分派的实现是参考JVM虚拟机的描述,下面我根据上图用自己的话总结一下:
首先,会在操作数栈中创建方法所有者,根据方法所有者的实际类型在虚方法表中找到与常量池中的符号引用一致的方法的直接引用,然后返回给栈帧保存在动态连接中。
返回地址
方法退出一般分为两种方式:
-
方法正常退出,此时返回地址可以为方法调用者的程序计数器值。(有点像方法执行前的程序计数器指向的字节码地址)
-
方法异常退出时,此时返回地址不保存在栈帧中,而是通过异常处理表来决定的。
方法退出过程其实就是栈帧出栈的过程,如果有返回值,就将返回值压入调用方法者的操作数栈的栈顶,通过调整程序计数器的值,以指向方法调用后的下一条字节码指令。
image-20231024100227931
觉得文章有用可关注我的同名微信公众号:Java传家宝