一:虚拟机栈的概述
1:虚拟机栈的概念:
Java虚拟机栈(Java virtual Machine stack) ,早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(stack Frame) (下文有详细介绍),对应着一次次的Java方法调用。
2:虚拟机栈的基本性质:
- 线程私有
- 生命周期和线程一致
- 主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
3:虚拟机栈的优点和缺点:
- 优点:跨平台,指令集小,编译器容易实现
- 缺点: 性能下降,实现同样的功能需要更多的指令。
4:虚拟机栈构成的基本栈帧介绍:
- 栈帧是用于虚拟机执行是方法调用和方法执行是的数据结构,它是虚拟机栈的基本元素。每一个方法从调用到方法结束都对应着一个栈帧入栈、出栈的过程,最顶部的栈帧称为当前栈帧,栈帧所关联的方法为当前方法,定义这个方法的类称为当前类,该线程中,虚拟机有且只会对当前栈帧进行操作
- 栈帧的作用:存储数据,部分过程处理,处理动态链接,方法返回值和异常分派
- 栈帧的构造图
- 每一个栈帧包含的内容有局部变量表、操作数栈、动态链接、方法返回值和一些附加信息(下文会进行详细介绍),在编译代码时,栈帧需要多大的局部变量表和操作数栈都可以完全确定,并且写入到code属性中,如图:
5:虚拟机如果执行一个方法(在文章的最后会详细讲方法调用):
首先我们的方法被编译成了字节码,并生成了可执行的命令。通过程序计数器,虚拟机会一行一行的执行命令,直到进入一个新的方法入口,对应虚拟机栈也就是新的栈帧入栈,当前栈帧改变,又或者遇到返回指令或出现异常结束了方法,对应虚拟机也就是出栈。
二:局部变量表:
- 局部变量表定义为一个数字数组,用于存放 方法参数和局部变量。在class文件的方法表的code属性的max_locals指定了该方法所需局部变量表的最大容量
- 局部变量表的基本大卫为变量槽(slot,下文有详细介绍);虚拟机是通过索引定位的方式使用局部变量表
- 当调用方法是非static 方法时,局部变量表中第0位索引的 Slot 默认是用于传递方法所属对象实例的引用(reference),即 “this” 关键字指向的对象。分配完方法参数后,便会依次分配方法内部定义的局部变量。
- 为了节省栈帧空间,局部变量表中的 Slot 是可以重用的。因为即使是一个方法内,也是存在作用域的,当离开了某些变量的作用域之后,这些变量对应的 Slot 空间就可以交给其他变量使用。但是这种机制有时候会影响垃圾回收行为,原因很简单,当离开某个作用域时,如果没有新的变量值覆盖之前作用域内的变量(指reference)空间,那么当垃圾回收时,则该引用对应的java堆中的内存则不允许被回收,因为局部变量表中还存在该引用。所以问题在于虚拟机并没有主动清理局部变量表中离开作用域的变量值,而是采用新盖旧的方法被动清理。
main方法代码
public static void main(String[] args) {
int a =2 ;
{
int b =3 ;
b +=a ;
}
int c = 5 ;
Son son = new Son();
son.show();
}
- 所以很明显,局部变量表的作用就是记录执行该方法时会使用到的变量值,它可以说这个方法的数据池,是我们方法中变量的化身,相当于把我们方法中所需要的变量整合成一个数组对象或集合对象,这个对象的名称就叫做局部变量表。
三:变量槽(variable slot)
一个变量槽大小一般为32位,其实《Java虚拟机规范》中并没有明确指出 一个变量槽应占用的内存空间大小,如在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,但是虚拟机仍要使用对齐和补白的手段 让变量槽在外观上看起来与32位虚拟机中的一致,也就是说64位的变量槽实际上只用到了前32位来存储数据,其余32位是不能使用的。对于boolean、byte、char、short、int、 float、reference 和returnAddress这8种数据类型,可以使用32位或更小的物理内存来存储,而64位的数据类型(double、long),Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间(64位的虚拟机分配的也是两个变量槽)。
四:操作数栈
- 操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项 之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
- Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。
- 举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int 值出栈并相加,然后将相加的结果重新入栈。
代码段:
int a=1;
int b=2;
int c=a+b;
对应的指令
iload_0 // 将局部变量表0号索引的值入操作数栈
iload_1 // 将局部变量表1号索引的值入操作数栈
iadd // 操作数栈去除前两位相加,放入栈顶
istore_2 // 操作数栈顶元素出栈,放入局部变量表2号索引
五:动态连接
-
每一个栈帧内部都包含一个指向当前方法所在类型的运行时常量池的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(dynamic Linking)。比如: invokedynamic指令
-
在java源文件被编译到字节码文件时,所有的变量和方法引用都为符号引用,保存在class文件的常量池中,比如:描述一个方法调用了另外的其他方法时,就是常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
这里补充一个方法的调用:
1:在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
- 静态链接:放一个字节码文件被装载进jvm内部时,如果被调用的目标方法在编译器可知且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
- 动态链接:如果被调用的方法在编译期无法确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接
2:对应的方法绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定:指定调用的目标方法在编译期可知,且运行期保持不变,可将这个方法也所属类型进行绑定
- 晚期绑定:只被调用的目标方法在编译期无法被确定下来,只能够在程序运行期间根据实际的类型绑定相关的方法
3:虚方法与非虚方法:
- 非虚方法:如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可改变的,这样的方法称为非虚方法。静态方法,私有方法、final方法、实例构造器、父类方法都是非虚方法
- 虚方法:除了上述说的虚方法,其他都是虚方法(多态,父类引用指向子类对象)
4:虚拟机中提供了以下几条方法调用指令:
- 普通调用指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
- 动态调用指令:
- invokedynamic:动态解析出需要调用的方法,然后执行
- 直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式
- 前四条指令固化在虚拟机内部,方法调用执行不可认为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
5:方法重写的本质
- 找到操作数栈栈顶的第一个元素所执行的对象的实际类型,记做C
- 如果在类型C中找到与常量中描述符合简单名称都相符的方法,则进行访问权限校验
- 如果通过则返回这个发放的直接引用,查找过程结束
- 如果不通过,则返回java.lang.IllegalAccessError 异常
- 否则按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
6:虚方法表:JVM采用在类的方法区建立一个虚方法表(virtual method table) (非虚方法不会出现在表中)来实现。使用索引表来代替查找。每个类中都有一个虚方法表,表中存放着各个方法的实际入口。虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后, JVM会把该类的方法表也初始化完毕。
六:方法返回地址
- 方法返回地址:存放调用该方法的PC寄存器的值
- 当一个方法开始执行以后,只有两种方法可以退出当前方法:
- 当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址。
- 当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。
- 当方法返回时,可能进行3个操作:
- 恢复上层方法的局部变量表和操作数栈
- 把返回值压入调用者调用者栈帧的操作数栈
- 调整 PC 计数器的值以指向方法调用指令后面的一条指令
7:附加信息
sdd
sdasd:《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。
8:面试题:
-
举例栈溢出的情况
-
StackOverflowError 通过设置-XSS设置栈的大小:OOM。如果线程请求的栈深度大于虚拟机所允许的深度,会抛出StackOverflowErroer异常,如果虚拟机栈扩展是无法申请到足够的内存,就会抛出OutOfMenioryError
-
-
调整栈大小,就能保证不出现溢出吗?
-
不能,如果一个递归没有退出条件,就死翘翘了
-
-
分配栈的内存越大越好吗
-
不是,因为整个虚拟机是确定下来的内存,如果栈大的话其他就得小
-
-
垃圾回收是否涉及到虚拟机栈
-
不会的
-
-
方法 中定义的局部变量是否线程安全
-
具体问题具体分析 。如果只有一个线程才可以操作此数据,则必是线程安全的。如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
-
例子代码:
public class StringBuilderTest {
int num = 10;
//s1的声明方式是线程安全的
public static void method1(){
//StringBuilder:线程不安全
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
//...
}
//sBuilder的操作过程:是线程不安全的 参数是StringBuilder
public static void method2(StringBuilder sBuilder){
sBuilder.append("a");
sBuilder.append("b");
//...
}
//s1的操作:是线程不安全的 返回了StringBuilder
public static StringBuilder method3(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1;
}
//s1的操作:是线程安全的
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(() -> {
s.append("a");
s.append("b");
}).start();
method2(s);
}
}