一、虚拟机栈简介
1.1 虚拟机栈在 JVM 中所处的位置
1.2 官方说明
Oracle 官方规范文档地址:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.2
1.3 栈管运行,堆管存储
- 栈解决程序如何运行的问题,即方法如何执行,数据如何处理的问题。
- 堆解决的是数据存储的问题, 对象如何分配,如何回收。JVM 主要的数据都在堆中存放。
1.4 虚拟机栈的特点
- 虚拟机栈是线程私有的,随线程创建而创建,其内部保存一个个的栈帧。与线程生命周期相同。
- 虚拟机栈是是基于栈实现的,只有入栈(方法调用)和出栈(方法执行结束)操作。
1.5 异常
JVM 允许栈的大小是动态的或固定不变的:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError。
- 如果虚拟机栈设置为可以动态扩展,当扩展时无法申请掉足够的内存会抛出 OutOfMemoryError。
1.6 设置栈大小
可以使用 -Xss 设置线程最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
设置不同 Xss 时,程序运行结果:
- -Xss100k deep of calling = 1096
- -Xss100m deep of calling = 4954492
二、虚拟机栈与方法调用
2.1 栈运行原理
- 方法和栈帧一一对应。
- 方法的调用会创建新的栈帧,并将栈帧入栈。
- 方法退出(正常执行完成或异常退出),对应的栈帧出栈。
一个活动的线程中在同一时间点上,只有一个活动的栈帧即当前栈帧(current frame), 当前栈帧对应的方法就是当前方法(current method)。定义这个方法的类称为当前类(current class)
2.2 方法调用
所有方法调用的目标方法在 Class 文件中都是常量池的符号引用。这些符号常量需要转换成直接引用,才能实现方法的调用, 这种转换分为两种:
- 解析
静态过程, 调用关系在编译期就确定,在类加载的解析阶段完成转换 - 分派
动态过程, 调用关系在编译期不能确定,又分为动态分派和静态分派
2.2.1 解析
方法在真正运行前就有一个可确定的调用版本,并且这个版本在运行期不可改变。换句话说目标方法的调用在编码完成就已经确定。
- 不可变方法包括:静态方法、私有方法、实例构造器和父类方法(super 调用),另外还包括 final 修饰的方法。
- 调用字节码指令包括: invokestatic、invokespecial、invokevirtual(final 方法)
2.2.2 分派
2.2.2.1 静态类型和动态类型
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
类 Human 是类 Man、Woman 的父类。在上面的实例中:
- Human 就是静态类型,也叫外观类型,定义后其类型不会发生变化,编译期可知。
- Man 和 Woman 是动态类型,也叫运行时类型,是变量所指的具体类型。编译器并不确定其类型。
2.2.2 2 静态分派
使用静态类型来决定方法执行哪个版本,最典型的应用就是重载。因为静态类型编译期可知。因此静态分派也在编译期确定。
// 一个重载类
public class StaticDispatch{
public void sayHello(Human guy){
system.out.println("hello, guy");
}
public void sayHello(Man guy){
system.out.println("hello, man");
}
public void sayHello(Woman guy){
system.out.println("hello, woman");
}
}
// 调用代码
Human man = new Man();
Human woman = new Woman();
// 无论动态类型是 Man 还是 Woman, 都会输出“hello guy”
new StaticDispatch().sayHello(man);
new StaticDispatch().sayHello(woman);
上面示例演示了重载,并且在 sayHello 方法调用中,使用的是 Human 这个静态类型来判断目标方法的版本。
2.2.2.3 动态分派
使用动态类型来决定方法执行的版本,最典型的应用就是重写。因为动态类型在编译期不可知,所以在运行期才能确定。
// 类 Man
public Class Man extends Human{
...
// 重写 Human 类的 sayHello 方法
public void sayHello(){
System.out.println("hello, man");
}
...
}
public Class Woman extends Human{
...
// 重写 Human 类的 sayHello 方法
public void sayHello(){
System.out.println("hello, woman");
}
...
}
// 方法调用
Human man = new Man();
Human woman = new Woman();
man.sayHello(); // 输出 hello, man
woman.sayHello(); // 输出 hello, woman
以上示例看出, 实际类型的不同(man or woman)调用了不同的重写方法。
2.2.2.4 invokevirtual 指令决定目标方法的过程
- 找到操作数栈顶第一个元素所指向对象的实际类型,记为 C
- 如果在 C 中找到与常量描述符和简单名都相符的方法,就进行权限校验。如果通过,就返回这个方法的直接引用,查找结束;否则抛出 IllegalAccessError
- 否则, 按继承关系依次对 C 的父类进行第二步的搜索和验证过程
- 如果始终没找到相符的方法,抛出 AbstractMethodError
2.2.2.5 invokedynamic 指令
invokedynamic 指令是 JDK 1.7 中出现, 是 JDK 1.8 中 lambda 实现的基础,是 Java 语言具备部分动态语言的能力,不详细介绍。
2.3 方法退出
一个方法开始执行后,如果正常执行结束(遇到 return 类指令) 或遇到了异常但方法内没有进行处理,都会导致方法退出。虚拟机栈通过返回地址通知执行引擎在方法返回后继续执行调用方法的代码。
2.3.1 正常执行完成
一个方法正常调用完成后,究竟使用哪个 return 指令需要根据返回类型来决定,包括:ireturn,lreturn,freturn,dreturn,areturn 和没有返回值时的 return 指令。
2.3.2 异常退出
方法执行过程中的异常处理信息,存储在一个异常处理表中,方便在异常产生时找到异常处理的代码。下图为一个异常处理表示例(jclasslib 查看字节码)
三、栈帧内部结构
- 每个线程都有自己的虚拟机栈,栈里的数据都是以栈帧(Stack Frame)的格式存在
- 在线程上正在执行的每个方法都对应着一个栈帧
- 栈帧是一个内存区域,一个数据集,维系着方法执行过程中的各种数据信息
3.1 本地变量表
本地变量表也称为局部变量表,主要用于存放方法参数和局部变量(方法体内部定义的变量)。
- 这些变量包括基本数据类型(boolean,byte,char,short,int,long,float,double)、引用类型(指向对象)和 返回地址。
- 局部变量表是一个数值型数组,其基本存储单元是 Slot, long 和 double 类型占 2 个 Slot,其他的类型各占用一个 Slot。
示例
一些重要的点
- 局部变量表的容量是在编译器确定的,保存在 Code 的 maximum local variables 属性中,并且在执行过程中不会改变。
- 局部变量表中的 Slot 是可以重复利用的, 如果一个变量的作用域已经结束,那么它曾使用过的 Slot 可能会被其后定义的变量使用。
- 当一个方法被调用时,它的方法参数和局部变量会按照顺序复制到局部变量表中。
- 如果当前帧是由构造方法或实例方法构建的, 那么该对象的引用指针 this 会被放置在 index 为 0 的 Slot 中。
3.2 操作数栈
3.2.1 基本信息
操作数栈是 JVM 执行引擎的一个工作区, 当栈帧被创建时,操作数栈是空的。在方法执行过程中,操作数栈主要用于保存计算过程的中间结果,同时也作为计算变量的临时存储区。
操作数栈的最大深度也是在编译期就定义好了,保存在 Code 的 max_stack 属性中。
3.2.2 一个示例
演示一个简单的加法执行过程。
3.3 动态链接
也叫指向运行时常量池的方法引用。
Java 源文件被编译成字节码的过程中, 所有变量和方法引用都作为符号引用保存在 class 文件的常量池中,比如描述一个方法调用另外一个方法时,就是通过常量池中的符号引用来表示,动态链接的作用就是为了将这些符号引用转换成调用方法的直接引用。
3.4 方法返回地址
存放调用该方法的 PC 寄存器的值,方法正常退出时,调用者的 PC 寄存器的值将作为返回地址压入调用者的操作数栈,让调用方法继续执行下去。
3.5 一些附加信息
栈帧中还允许携带一些与 Java 虚拟机相关的附加信息,例如,对程序调试提供支持的信息。