JVM-虚拟机栈

基于栈而非特定寄存器。这种设计使得Java程序可以在不同的CPU架构上运行,避免了依赖特定硬件的限制。尽管这种做法带来了跨平台的优势,并且简化了编译器的实现,但也导致了性能上的一定下降。因为相比于基于寄存器的指令集,栈架构需要更多的指令来完成相同的任务。

内存中的栈与堆

栈是运行时的单位,堆是存储单位。

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储问题,即数据怎么放,放在哪儿。
栈的定义

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(stack Frame),对应着一次次的Java调用。生命周期和线程一致,是线程私有的。主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回

栈的特点(优点)
  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
  • JVM直接对栈的操作只有两个:每个方法执行,伴随着进栈(入栈、压栈);执行结束后的出栈工作。
  • 对于栈来说不存在垃圾回收问题,即不存在GC,存在OOM

栈中可能出现的异常

Java虚拟机规范允许Java栈的大小可以是动态的,也可以是固定的。对于固定大小的Java虚拟机栈,每个线程在创建时可以独立选择其栈容量。如果线程请求的栈容量超过Java虚拟机栈的最大容量,则会抛出StackOverflowError异常。另一方面,如果Java虚拟机支持动态扩展,并且在尝试扩展时无法申请足够的内存,或者在创建新线程时没有足够的内存来创建对应的虚拟机栈,则会抛出OutOfMemoryError异常

栈内存储的内容

每个线程都拥有自己的栈,其中的数据以栈帧(Stack Frame)的形式存在。每个正在执行的方法在该线程上都对应着一个栈帧,栈帧是一个内存区块,承载着方法执行过程中的各种数据信息。

栈的运行原理

  • JVM 对 Java 栈的操作仅限于栈帧的压栈和出栈,遵循先进后出(LIFO)的原则。
  • 在单个活动线程中,任何时刻只有一个活动的栈帧,即当前正在执行的方法的栈帧(栈顶栈帧),称为当前栈帧(Current Frame),对应的方法为当前方法(Current Method),定义该方法的类为当前类(Current class)
  • 执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
  • 当方法调用其他方法时,会创建新的栈帧并置于栈顶,成为新的当前栈帧。
  • 不同线程中的栈帧不允许相互引用,即一个栈帧不可能引用另一个线程的栈帧。
  • 当前方法调用其他方法后,方法返回时,当前栈帧将传递执行结果给前一个栈帧,并丢弃当前栈帧,使前一个栈帧重新成为当前栈帧。
  • Java 方法有两种返回方式:正常返回(使用 return 指令)和异常抛出。无论采用哪种方式,都会导致栈帧被弹出

栈帧的内部结构
局部变量表(Local Variables):用于存储方法参数和方法内部定义的局部变量。
操作数栈(Operand Stack)或表达式栈:用于执行方法中的操作,存储方法执行过程中的临时数据。
动态链接(Dynamic Linking):指向运行时常量池的方法引用,用于动态链接。
方法返回地址(Return Address):记录方法正常退出或异常退出的定义,用于返回到方法调用点。
附加信息:可能包括一些额外的元数据,如异常处理信息等。

局部变量表(Local Variables)
  • 也称为局部变量数组或本地变量表。
  • 定义为一个数字数组,主要用于存储方法参数和方法体内定义的局部变量,包括基本数据类型、对象引用和returnAddress类型。
  • 由于局部变量表建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题
  • 局部变量表的容量大小在编译期确定,并保存在方法的 Code 属性的 maximum local variables 数据项中,在方法运行期间不会改变。
  • 方法嵌套调用次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。函数参数和局部变量越多,局部变量表膨胀,栈帧越大,方法调用占用的栈空间增加,导致嵌套调用次数减少。
  • 局部变量表中的变量仅在当前调用方法中有效。虚拟机通过局部变量表完成参数值到参数变量列表的传递过程。方法调用结束后,随着方法栈帧的销毁,局部变量表也随之销毁。

Slot
参数值的存放始于局部变量数组的索引0,结束于数组长度减1。
局部变量表的基本存储单元是槽(Slot)。
局部变量表中存放编译期可知的各种基本数据类型(8种)、引用类型(reference)和 returnAddress 类型的变量。
在局部变量表中,32位以内的类型只占用一个槽(包括 returnAddress 类型),而64位的类型(如 long 和 double)占用两个槽。byte、short、char 在存储前被转换为 int,boolean 也被转换为 int,其中 0 表示 false,非 0 表示 true。而 long 和 double 占用两个槽。
JVM为局部变量表中的每个槽分配一个访问索引,通过该索引可以成功访问到局部变量表中指定的局部变量值。
当实例方法被调用时,其方法参数和方法体内定义的局部变量按顺序复制到局部变量表中的每个槽上。
如果需要访问局部变量表中64位的局部变量值,只需使用前一个索引即可(例如:访问 long 或 double 类型的变量)。
如果当前帧是由构造方法或实例方法创建的,则该对象引用 this 将存放在索引为0的槽上,而其余参数则按参数顺序继续排列。

Slot的重复利用

在栈帧中,局部变量表中的槽位是可重用的。当一个局部变量超出其作用域后,后续声明的新局部变量很可能会复用已经失效的槽位,以节省资源。

变量的分类

静态变量与局部变量
参数表分配完成后,根据方法体内变量的顺序和作用域进行分配。静态变量有两次初始化机会:第一次是在“准备阶段”,执行系统初始化,将静态变量设置为0值;另一次是在“初始化”阶段,赋予程序员在代码中定义的初始值。
不同于静态变量初始化的是,局部变量表没有系统初始化的过程。这意味着一旦定义了局部变量,则必须人为地进行初始化,否则将无法继续使用。
// 以下代码是错误的,没有赋值不能够使用public void test() {     int i;     System.out.println(i);}
补充说明
在栈帧中,与性能调优关系最为密切的部分是前面提到的局部变量表。在方法执行期间,虚拟机利用局部变量表来完成方法的参数传递和数据存储。

局部变量表中的变量也是重要的垃圾回收根节点,任何被局部变量表直接或间接引用的对象都不会被回收。

操作数栈(Operand Stack)
在每个独立的栈帧中,除了包含局部变量表外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也称为表达式栈(Expression Stack)。
操作数栈在方法执行过程中根据字节码指令,执行入栈(push)和出栈(pop)操作。某些字节码指令会将值推入操作数栈,而其他指令则会从栈中取出操作数,执行相应的操作,并将结果重新推入栈中。这些操作包括复制、交换、求和等。

操作数栈主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈是 JVM 执行引擎的一个工作区。当一个方法开始执行时,一个新的栈帧会被创建,此时方法的操作数栈为空。
每个操作数栈都有一个明确的栈深度,用于存储数值。其最大深度在编译期就被定义,保存在方法的 code 属性中,以 max_stack 的值表示。
栈中的每个元素都可以是任意的 Java 数据类型。32 位类型占用一个栈单位深度,而 64 位类型则占用两个栈单位深度。
操作数栈不采用访问索引的方式来进行数据访问,而是通过标准的入栈(push)和出栈(pop)操作来完成数据访问。
如果被调用的方法有返回值,其返回值会被加入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须严格匹配字节码指令序列,这由编译器在编译期间进行验证,并在类加载过程中的类检验阶段的数据流分析阶段进行再次验证。

此外,Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

栈顶缓存技术(Top-Of-Stack-Cashing)
前面提到,基于栈式架构的虚拟机使用的零地址指令更加紧凑,但执行一项操作时需要更多的入栈和出栈指令,导致指令分派次数和内存读/写次数增加。

由于操作数存储在内存中,频繁的内存读/写操作会影响执行速度。为了解决这个问题,HotSpot JVM 的设计者们提出了栈顶缓存技术(TOS,Top-of-Stack Cashing),将栈顶元素缓存在物理 CPU 的寄存器中,减少对内存的读写次数,提升执行引擎的效率。

动态链接(Dynamic Linking)
动态链接是指向运行时常量池中方法引用的指针。
每个栈帧内部都包含一个指向该栈帧所属方法的运行时常量池的引用。这个引用的存在是为了支持当前方法的代码能够进行动态链接,如 invokedynamic 指令。
在 Java 源文件编译为字节码文件时,所有变量和方法引用都以符号引用(Symbolic Reference)的形式保存在 class 文件的常量池中。例如,当描述一个方法调用其他方法时,会使用常量池中指向方法的符号引用。动态链接的目的就是将这些符号引用转换为调用方法的直接引用。

为什么需要常量池?

常量池的作用就是为了提供一些符号和常量,便于指令识别。

方法的调用

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

静态链接

当一个字节码文件被加载到 JVM 内部时,如果调用的目标方法在编译期可知,并且在运行时保持不变,这种情况下将调用方法的符号引用转换为直接引用的过程称为静态链接。

动态链接

如果调用的方法在编译期无法确定,即只能在程序运行时将调用方法的符号引用转换为直接引用,由于这种转换过程具有动态性,因此称之为动态链接。

绑定机制
  • 方法绑定机制包括早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是指在符号引用被替换为直接引用的过程,仅发生一次。
  • 早期绑定指被调用的目标方法在编译期可知且在运行期保持不变时,将该方法与其所属的类型进行绑定。由于目标方法明确,可以使用静态链接将符号引用转换为直接引用。
  • 晚期绑定发生在被调用方法无法在编译期确定时。在程序运行期根据实际类型绑定相关方法,这称为晚期绑定。

虚函数
在Java中,每个普通方法都具备虚函数的特征,类似于C++语言中的虚函数(在C++中需使用关键字virtual显式定义)。若不希望某方法具备虚函数特征,可使用final标记该方法。

非虚方法指在编译期确定具体调用版本,在运行时不可变的方法。这包括静态方法、私有方法、final方法、实例构造器以及父类方法。而编译期无法确定的方法则称为虚方法。

方法调用指令

普通调用指令固化在虚拟机内部,方法的调用执行不可认为干预,而动态调用指令则支持由用户确定版本方法。其中invokestatic和invokespecial指令调用方法称为非虚方法,其余的(final修饰除外)称为虚方法。

invokedynamic指令
JVM字节码指令集一直稳定至Java 7,此后引入了invokedynamic指令,以支持动态类型语言。然而,Java 7并未提供直接生成invokedynamic指令的方式,需要依赖底层字节码工具如ASM来实现。直到Java 8引入Lambda表达式后,才为生成invokedynamic指令提供了直接的方式。

Java 7中对动态语言类型支持的改进实质上是对Java虚拟机规范的修改,而非Java语言规则的修改。这一领域较为复杂,主要增强了虚拟机中的方法调用机制,直接受益者是运行在Java平台上的动态语言编译器。

动态类型语言 & 静态类型语言

动态类型语言和静态类型语言的主要区别在于类型检查时机。静态类型语言在编译期进行类型检查,而动态类型语言在运行期进行类型检查。简言之,静态类型语言根据变量自身的类型信息进行检查,而动态类型语言根据变量值的类型信息进行检查。这突显了动态语言的一个重要特征:变量没有类型信息,变量值才有类型信息。

Java方法重写的本质

查找操作数栈顶元素的实际类型(记为C),并与常量中的描述符和简单名称匹配的方法。若权限验证通过,则返回该方法的直接引用,结束搜索过程;否则,抛出java.lang.IllegalAccessError异常。如果未找到匹配的方法,则按照继承关系逐级向上搜索C的父类,并重复上述验证过程。若始终未找到合适的方法,则抛出java.lang.AbstractMethodError异常。

IllegalAccessError

程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

虚方法表
  • 在面向对象编程中,频繁使用动态分派可能导致在类的方法元数据中反复搜索适当的目标,影响执行效率。为提高性能,JVM采用在类的方法区建立虚方法表(virtual method table)的方式来解决这一问题。虚方法表存储着各个方法的实际入口,并使用索引表代替查找。每个类都有自己的虚方法表,其中记录了该类及其父类的所有可继承方法的实际入口。非虚方法不会出现在虚方法表中。
  • 虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成后,JVM会把该类的方法表也初始化完毕。
方法返回地址
在方法执行过程中,调用该方法的PC寄存器的值被存储。方法结束有两种情况:正常执行完成或出现未处理异常而非正常退出。无论何种退出方式,方法结束后都返回到调用该方法的位置。对于正常退出,调用者的PC计数器值即为返回地址,指向调用该方法的指令的下一条地址。异常退出的返回地址通过异常表确定,通常不在栈帧中保存。
方法结束只有两种途径
  • 执行引擎遇到返回指令(如ireturn、lreturn、freturn、dreturn、areturn用于不同返回值类型,以及void方法的return指令),将返回值传递给调用者,称为正常完成出口。返回指令的选择根据返回值的数据类型确定。
  • 方法执行过程中遇到未处理异常,即在方法的异常表中找不到匹配的异常处理器,导致方法退出,称为异常完成出口。异常处理信息存储在异常处理表中,便于异常发生时找到相应的处理代码。
  • 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
  • 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

附加信息
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。
面试题

举例栈溢出的情况

栈溢出(StackOverflowError)的一个例子是当栈空间无法容纳更多的栈帧时发生。这可能是由于递归调用或深度函数调用导致的。通过调整栈大小参数(如-Xss),可以设置栈的大小,可以选择固定大小或动态变化。如果栈大小是固定的,当栈空间耗尽时,会触发StackOverflowError;如果栈大小是动态变化的,并且整个内存空间不足以满足扩容需求时,会触发OutOfMemoryError。

调整栈的大小,就能保证不出现溢出吗?

调整栈大小并不能保证完全避免溢出的发生。增加栈空间可以延缓StackOverflowError的发生时间,但并不能保证永远不会发生溢出。这就好比给你额外的钱让你多用几天,但并不能解决你永远不需要钱的问题。溢出的发生取决于程序的执行情况,如递归深度或者函数调用的层次,无法仅靠调整栈大小来完全避免。

分配的栈内存越大越好么

不是的,增大栈的大小会导致栈内存占用更多的系统资源,可能会影响到系统中其他部分的内存使用情况,从而增加了其他类型的内存相关异常的风险。因此,即使通过增加栈大小延缓了StackOverflowError的出现,但也会增加系统整体的资源消耗,可能引发其他方面的问题。优化栈大小需要综合考虑系统的整体资源使用情况和需求,不能单纯依赖于增大栈大小来解决问题。

垃圾回收是否会涉及到虚拟机栈

不会

方法中定义的局部变量是否是线程安全的

具体问题具体分析:变量是在内部产生,且在内部消亡的则是线程安全的,否则不是线程安全的。

栈的调优参数
-Xss:我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。 

原创文章,作者:速盾高防cdn,如若转载,请注明出处:https://www.sudun.com/ask/76928.html

(0)
速盾高防cdn's avatar速盾高防cdn
上一篇 2024年5月23日 下午4:48
下一篇 2024年5月23日 下午5:06

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注