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