1.虚拟机栈的概述
1. 虚拟机栈出现的背景
由于跨平台性的设计, Java的指令都是根据栈来设计的 , 不同的平台CPU架构不同, 所以不能基于CPU寄存器架构来设计
优点是跨平台, 指令集小, 编译器容易实现 , 缺点是性能下降 , 实现同样的功能需要更多的指令
2.内存中的堆和栈
栈是运行时单位 , 而堆是存储单位
即: 栈解决程序的运行问题, 即程序如何执行, 或者说如何处理数据 , 堆解决的是数据存储问题
3.虚拟机栈基本内容
- Java虚拟机栈是什么
Java虚拟机栈,早期也叫Java栈,每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧, 对应一次次的Java调用
- 生命周期
生命周期与线程一致
- 作用
主管Java程序的运行, 它保管方法的局部变量 , 部分结果 , 并参与方法的调用和返回.
- 栈是一种快速有效的分配存储方式 , 访问速度仅次于程序计数器.
- JVM直接对Java栈的操作只有两个
- 每个方法执行, 伴随着进栈(入栈和压栈)
- 执行结束之后出栈的工作
- 对于栈来说不存在垃圾回收机制
4.栈里面可能出现的异常
- 常见的栈异常
Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的
如果是固定大小的Java虚拟机栈 , 那每一个线程的Java虚拟机栈容量 , 可以在线程创建的时候独立选定.如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量, Java虚拟机将会抛出一个StackOverflowError异常.
如果Java虚拟机栈可以动态扩展 , 并且在尝试扩展的时候无法申请到足够的内存 , 或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机将会抛出一个OutofMemoryError异常.
- 设置栈内存大小
我们可以使用参数-Xss 选项来设置线程最大栈空间,栈的大小直接决定了函数调用的最大可达深度.
2.栈的存储单位
1.栈中存储什么?
- 每个线程中都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都各自对应一个栈帧
- 栈帧是一个内存区块 , 是一个数据集, 维系着方法执行过程中的各种数据信息
2.栈的运行原理
- JVM直接对Java栈的操作只有两个, 就是对栈帧的压栈和出栈,遵循”先进后出” / “后进先出”原则
- 在一条活动线程中, 一个时间点上,只会有一个活动的栈帧 , 即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的 , 这个栈帧被称之为当前栈帧(Current Frame) , 与当前栈帧相对应的方法就是当前方法(Current Method), 定义这个方法的类就是当前类(Current Class)
- 执行引擎运行的所有字节码指令只针对于当前栈帧进行操作
- 如果该方法调用了其他方法, 对应的新的栈帧会被创建出来, 放在栈的顶端,成为新的当前栈
- 不同线程中所包含的栈帧是不允许存在相互利用的 , 即不可能在一个栈帧之中引用另外一个线程的栈帧
- 如果当前方法调用了其他的方法,方法返回之际 , 当前栈帧会传回此方法的执行结果给前一个栈帧, 接着, 虚拟机会丢弃当前的栈帧, 使得前一个栈帧重新成为当前栈帧
Java方法有两种返回函数的方式, 一种是正常的函数返回 , 使用return 指令, 另外一种就是抛出异常, 不管使用那种方式, 都会导致栈帧被弹出
public class StackTest {
public static void main(String[] args) {
StackTest stackTest = new StackTest();
stackTest.method1();
}
public void method1() {
method2();
System.out.println("method1-------");
}
public int method2() {
method3();
return 10;
}
public String method3() {
throw new RuntimeException("抛出异常");
}
}
3.栈的内部结构
局部变量表(Local Variables)
- 操作数栈(Operand Stack) (或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Method Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
3.局部变量表
- 局部变量表也被称之为局部变量数组或者本地变量表
- 定义一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量表, 这些数据类型包括各类基本类型数据 , 引用对象 , (reference) , 以及returnAddress类型.
- 由于局部变量表是建立在线程之上的, 是线程的私有数据,因此不存在数据安全问题
- 局部变量表所需要的的容量大小是在编译时期确定大小的,并保存在方法的Code属性的maximum local variables数据项中,在方法运行期间是不会改变局部变量表的大小的
- 方法嵌套调用次数的由栈的大小决定的,一般来说,栈越大,方法嵌套调用次数越多.对于一个函数而言,它的参数和局部变量表膨胀,它的栈帧就越大,以满足方法调用所需要的传递的信息大的需求,进而函数调用就会占用会更多的栈空间,导致其嵌套调用次数就会减少.
局部变量表中的变量只在当前方法调用中生效 . 在方法执行中 , 虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程. 当方法调用结束之后,随着方法栈帧的销毁 , 局部变量表也会随之销毁.
1.关于slot理解
参数值的存放总是在布局变量数组的index0开始的,到数组的长度-1的索引结束
- 局部变量表,最基本的的存储单元是slot(变量槽)
- 局部变量表里, 32位以内的类型只占用一个slot(包括returnAddress类型) , 64位的类型(long和double)占用两个slot
- byte , short , char在存储前被转换为int,boolean也被转换为int, ,0表示false,非0表示true
- long和double,占据两个slot
- JVM会为局部变量表中的每一个Slot都分配一个访问索引 , 通过这个索引即可以成功访问到局部变量表中指定的局部变量值
- 当一个实例方法被调用的时候 , 它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上.
- 如果需要访问局部变量表中一个64bit的局部变量值时, 只需要使用前一个索引即可.(比如: 访问long或者double类型变量)
如果当前栈是由构造方法或者实例方法创建的, 那么该对象引用this将会存放在index为0slot上 , 其余的参数按照参数表顺序继续排列
2.Slot的重复利用
栈帧中的局部变量表中的槽位是可以重复利用,如果一个局部变量表过了其作用域,那么在其作用域之后申明的新的局部变量就很可能会复用之前存在过期的局部变量的槽位,从而达到节省资源的目的 ```java public void init1() {
int a = 0;
System.out.println(a);
int b = 0;
}
public void init2() {
{
int a = 0;
System.out.println(a);
}
// 此时a的槽位就会被b占用
int b = 0;
}
![image.png](https://cdn.nlark.com/yuque/0/2022/png/25783451/1650930347102-16fdd08a-9785-425c-a4c9-7a3e9cfddf48.png#clientId=u9a673c48-d50a-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=137&id=u174d3d6b&margin=%5Bobject%20Object%5D&name=image.png&originHeight=274&originWidth=1936&originalType=binary&ratio=1&rotation=0&showTitle=false&size=61220&status=done&style=none&taskId=uff52652b-4f74-4fbe-97ec-746d120727b&title=&width=968)<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/25783451/1650930369354-8f3a74ac-87d0-4e66-a2da-0a0b21f7c24c.png#clientId=u9a673c48-d50a-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=185&id=uc2d3943d&margin=%5Bobject%20Object%5D&name=image.png&originHeight=370&originWidth=1956&originalType=binary&ratio=1&rotation=0&showTitle=false&size=77799&status=done&style=none&taskId=u74f94aeb-6714-45e5-93d8-5316959d52e&title=&width=978)
<a name="uHKeM"></a>
## 3.静态变量和局部变量的对比
```java
变量的分类: 按照数据类型分:
那些不会再生成<clinit>
1. 一个类中没有没有定义任何类变量,也没用使用静态代码块
2. 一个类中声明了类变量,但是没有对其进行赋值,以及使用静态代码块进行赋值
3. 一个类中使用static final进行修饰基本类型数据字段类型
- 参数分配完毕之后, 在根据方法体内定义的变量的顺序和作用域分配的
- 我们知道类变量表有两次初始化的机会,第一次是在”准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在”初始化”阶段,赋予程序员在代码中定义的初始值
- 和类变量初始化不同的是, 局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量表则必须人为的初始化,否则无法使用.
- 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表,而在方法执行时,虚拟机使用局部变量表完成方法的传递
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
4.操作数栈
- 每一个独立的栈帧中除了包含局部变量表以外,还包含一个先进后出的操作数栈(Last In First Out), 而可以称之为表达式栈
- 操作数栈, 在方法执行过程中, 根据字节码指令, 往栈中写入数据或者提取数据, 即入栈(push)/出栈(pop)
- 某些字节码指令将值压入操作数栈, 其余的字节码指令将操作数栈取出栈 . 使用它们后再把结果压入栈
- 比如: 执行复制, 交换 , 求和等操作.
- 如果被调用的方法带有返回值的话, 其返回值将会被压入当前栈的操作数栈中, 并更新PC寄存器中下一条需要执行的字节码指令
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段再次验证.
- Java虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈.
- 操作数栈 , 主要用于保存计算过程的中间结果, 同时作为计算过程变量临时的存储空间.
- 操作数栈就是JVM执行引擎的一个工作区 , 当一个方法刚开始执行的时候, 一个新的栈帧也会随之被创建出来, 这个方法的操作数栈是空的
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值, 其所需要的最大深度在编译期就定义好了. 保存在方法的Code属性中, 为max_stack的值.
- 栈中的任何一个元素都可以是任意的Java数据类型.
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
- 操作数栈并非采用访问索引的方式, 来进行数据的访问的, 而是通过标准的出栈(push)和入栈(pop)来完成一次数据访问的
5.代码追踪
/**
* 0 bipush 15
* 2 istore_1
* 3 bipush 20
* 5 istore_2
* 6 iload_1
* 7 iload_2
* 8 iadd
* 9 istore_3
* 10 return
* @param args
*/
public void test(){
int integer = 15;
int test_1 = 20;
int test_2 = integer + test_1;
}
- 0 bipush 15
- 2 istore_1
- 3 bipush 20
- 5 istore_2
- 6 iload_1
- 7 iload_2
- 8 iadd
- 9 istore_3
- 10 return
6.栈顶缓存技术(Top of Stack Cashing)
基于栈式架构的虚拟机所使用的零地址指令更加紧凑, 但是完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也意味着需要将更多的指令分派(instruction dispatch)次数和内存读/写次数
由于操作数是存储在内存中的, 因此频繁地执行内存读/写操作必然会影响执行速度,为了解决这个问题 , HotSpotJVM的设计者们提出了栈顶缓存技术(Tos Top of Stack Cashing) 技术, 将栈顶元素全部缓存在物理CPU的寄存器中, 一此降低对内存的读/写次数, 提升执行引擎效率.
7.动态链接(Dynamic - Linking)
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用 . 包含这个引用的目的就是为了支持当前方法的代码能够动态链接(Dynamic Linking), 比如invokedynamic指令
- 在Java源文件被编译到字节码文件中,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池. 比如: 描述一个方法调用另外一个的其他方法时, 就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用用转换为调用方法的直接引用
8.方法调用
在JVM中, 将符号引用转换为调用方法的直接引用与方法的绑定机制相关
- 动态链接
如果被调用的方法在编译期无法被确定下来, 也就是说, 只能够在程序运行期将调用方法的符号引用转换为直接引用, 由于这种引用转换过程具备动态性, 因此也就被称之为动态链接
- 静态链接
当一个字节码文件被装载进JVM内部时, 如果被调用的目标方法在编译时期就可知, 而且运行期保持不变,这种情况下将调用方法和符号引用转换为直接引用,这个过程被称之为静态链接
对应的方法的绑定机制为: 早期绑定(Early Binding)和晚期绑定(Late Binding), 绑定是一个字段 , 方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次
- 晚期绑定
如果被调用的方法在编译期无法被确定下来, 只能够在程序期间运行根据实际的类型绑定相关的方法, 这种绑定方式也被称之为晚期绑定 (多态)
- 早期绑定
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定 这样一来, 由于明确了被调用的目标方法究竟是那一个 , 因此也就可以使用静态链接的方式将符号引用转换为直接引用(构造器)
- 非虚方法
- 如果方法在编译期间就确定了调用的具体版本,该版本在运行期间是不可变的,这样的方法称之为非虚方法
- 静态方法, 私有方法, final方法, 实例构造器, 父类方法都是非虚方法
- 其他方法称之为虚方法
- 虚方法
继承父类或者实现类的方法
虚拟机中提供了以下几条方法调用指令
- 普通调用指令
- invokestatic: 调用静态方法, 解析阶段确定唯一方法版本
- invokespecial: 调用
方法, 私有以及父类方法,解析阶段确定唯一方法版本 - invokevirtual: 调用所有的虚方法
- invokeinterface: 调用所有的接口方法
- 动态调用指令
- invokedynamic: 动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部 , 方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本. 其中invokestatic指令和invokespecial指令调用的方法称之为非虚方法,其余的(final修饰的除外)称之为虚方法
public class Father {
static void showStatic() {
System.out.println("father static method");
}
public void showCommon() {
System.out.println("father common method");
}
public final void showFinal() {
System.out.println("father final method");
}
}
class Son extends Father {
public static void showStatic() {
System.out.println("son static method");
}
@Override
public void showCommon() {
System.out.println("son common method");
}
private void showPrivate() {
System.out.println("son private method");
}
public void info() {
System.out.println("son method");
}
public void show() {
showStatic();// invokestatic
super.showStatic(); //invokestatic
showPrivate();//invokespecial
showCommon();//invokevirtual
super.showCommon();//invokespecial
super.showFinal();// invokespecial // 此处如果不使用super进行调用,就会出现invokevirtual
info(); // invokevirtual
}
public static void main(String[] args) {
Son son = new Son();
son.show();
}
}
9.方法返回地址(Return Address)
- 存放调用该方法的PC寄存器的值.
- 一个方法的结束 , 有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
- 无论通过那种方式退出, 在方法退出之后都返回到该方法被调用的位置. 方法正常退出时 , 调用者的PC计数器
的值作为返回值,即调用该方法的指令的下一条指令的地址. 而通过异常退出的,返回地址要通过异常表来确定,栈帧中一般不会保存这部分信息.
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 执行引擎遇到任意一个方法返回的字节码指令(return) , 会有返回值传递给上层的方法调用者,简称正常完成出口
- 一个方法在正常调用完成之后究竟需要使用那一个返回指令还需要根据方法返回值的实际数据类型而定
- 在字节码指令中,返回指令包括ireturn(当返回值是boolean/char/int/short/byte类型使用时), lreturn, freturn,dreturn,以及areturn,另外还有一个return指令供声明为void方法 , 实例初始化方法 , 类和接口的初始化方法使用
- 在方法执行的过程中遇到了异常(Exception) , 并且这个异常没有在方法内进行处理, 也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口
方法执行过程中抛出异常时的异常处理, 存储在一个异常处理表 , 方便在发生异常的时候找到处理异常的代码.
public class ExceptionDemo {
public static void main(String[] args) {
ExceptionDemo exceptionDemo = new ExceptionDemo();
exceptionDemo.test();
}
public void test() {
try {
System.out.println("13213");
int i = 10 + 1;
} catch (Exception e) {
}
}
}
10.虚拟机栈的面试题
- 举例栈溢出的情况?(StackOverFlowError)
- 通过设置-Xxs设置栈的大小;OOM
- 递归的情况,递归次数超过栈本身大小
- 调整栈的大小,就能保证不出现溢出吗?
- 不能, 有可能出现递归死循环,造成栈的溢出
- 分配栈内存越大越好吗?
- 不是 , 栈的大小会影响线程创建的数量
- 垃圾回收机制是否会涉及到虚拟机栈?
- 不会,垃圾回收机制主要是针对堆的回收, 栈空间随着方法被销毁而结束
- 方法中定义的局部变量是否线程安全?
- 局部定义的变量是安全的 , 如果布局定义的变量如果返回,就不变得不安全, 可能存在多个线程争抢一个返回值