vlambda博客
学习文章列表

java 程序员应该了解的 JVM - 虚拟机栈篇

JVM 虚拟机栈

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

  • java 的虚拟机栈是什么?

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

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

JVM 直接对 Java 栈的操作只有两个:

  1. 每个方法执行,伴随着进栈(入栈,压栈)

  2. 执行结束后的出栈工作

对于栈来说不存在垃圾回收问题。

  • 栈中可能出现的异常 

Java 虚拟机规范允许 Java 栈的大小是动态的或者是固定不变的。

  1. 固定大小的栈,容易 StackOverflowError 异常。

  2. 如果 Java 虚拟机栈可以动态拓展,并且在尝试拓展的时候无法申请到足够内存,后者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError异常。



  • 调整栈的大小

-Xss 可以设置栈的大小

  • 栈中存储的是什么?

  1. 每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在的

  2. 在这个线程上正在执行的每个方法都各自对应一个栈帧

  3. 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈帧的内部结构图

java 程序员应该了解的 JVM - 虚拟机栈篇

局部变量表(local variables)

是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括基本数据类型、对象引用,以及 returnAddress 类型。

由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全的问题

局部变量表所需要的容量大小是在编译期确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变变量表的大小的。

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

  • 关于 Slot 的理解

JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值

当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上

如果需要访问局部变量表中一个 64 bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 和 double 类型的变量)

如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 slot 处,其余的参数按照参数表顺序继续排列。java 程序员应该了解的 JVM - 虚拟机栈篇

操作数栈(Operand Stack)

每一个独立的栈帧中除了包含局部变量表以外,还包含了一个后进先出的(Last - In -First -Out ) 的操作数栈,也可以称之为表达式栈(Expression Stack)。

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push) 和出栈(pop).

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

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

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。

每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需要的最大深度在编译期就定义好了,保存方法的 Code 属性中,为 Max_Stack的值。

栈中的任何一个元素都是可以任意的 Java 数据类型。

  1. 32 bit 的类型占用一个栈单位深度

  2. 64 bit 的类型占用两个栈单位深度

操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push) 和出栈 (pop) 操作来完成一次数据访问。

public void testAddOperation(){
byte i = 15;
int j = 8;
int k = i + j;
}

使用 javap 命令反编译 class 文件: javap -v 类名.class

public void test AddOperation();
Code:
0:bipush 15
2:istore_1
3:bipush 8
5:istore_2
6:iload_1
7:iload_2
8:iadd
9:istore_3
10: return

下面的几张图展示寄存器,操作数栈和局部变量表的执行过程:

  1. 寄存器记录的是代码执行到哪里,如下图所示,bipush 是把 15 压入操作数栈 ,然后出栈,然后 istore_1 把15 放入局部变量表中java 程序员应该了解的 JVM - 虚拟机栈篇

  2. 然后 bipush 8 入栈,然后出栈,然后 istore_2 把 8 放入局部变量变量表中

java 程序员应该了解的 JVM - 虚拟机栈篇

  1. 依次执行iload_1 和 iload_2 从局部变量表中加载 15 和 8 到操作数栈。

java 程序员应该了解的 JVM - 虚拟机栈篇

  1. 执行 iadd 指令 23 进入栈顶,然后出栈,istore 保存 23 进入局部变量。然后函数执行 return 指令。

java 程序员应该了解的 JVM - 虚拟机栈篇

栈顶缓存技术(了解)

由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 的设计者们提出了栈顶缓存(Tos,Top_of_Stack Cashing) 技术,将栈顶元素全部缓存在物理 CPU 的积存器中,以此降低堆内存的读/写次数,提示执行引擎的执行效率。

动态链接(Dynamic Linking):指向运行时常量池的方法引用

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking). 比如 invokedynamic 指令

在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference) 保存在 class 文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了讲这些符号引用转换为调用方法的直接引用。

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

方法的调用

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

  • 静态链接

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

  • 动态链接

如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期调用方法的符号引用转换为直接引用,由于这种引用转化过程具备动态性,因此也就被称之为动态链接。

对应的方法的绑定机制为:早期绑定(Early Binding) 和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  1. 早期绑定

早期绑定就是被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样以来,由于明确了被调用的目标方法究竟是哪一个,一次也就可以使用静态链接的方式将符号引用转换为直接引用。

  1. 晚期绑定

如果被调用的方法在编译期无法被确定下来,只能在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也被称之为晚期绑定。

随着高级语言的横空出市,类似于 Java 一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是他们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种方式。

Java 中任何一个普通的方法其实都具备虚函数的特征,他们相当于 C++ 语言中的虚函数(C++ 中则需要使用关键字 virtual 来显示定义)。如果 Java 程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字 final 来标记这个方法。

  • 非虚方法

  1. 如果方法在编译器就确定了具体的调用版本,这个版本在运行时时不可变的/这样的方法称为非虚方法。

  2. 静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法。

  3. 其他方法都是虚方法。

  • 虚拟机中提供了以下几条方法的调用指令:

  1. 普通调用指令:

    • invokestatic : 调用静态方法,解析阶段确定唯一方法版本

    • invokespecial: 调用<init> 方法、私有及父类方法,解析阶段确定唯一方法版本

    • invokevirtual :调用所有虚方法

    • invokeinterface :调用接口方法

  2. 动态调用指令:

    • invokedynamic : 动态解析出需要调用的方法,然后执行

Invokedynamic 指令则支持由用户确定方法版本。其中 invokestatic 指令和 invokespecial 指令调用的方法称为非虚方法,其余的(final 修饰的除外)称为虚方法。

  • 方法重写的本质

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C。

  2. 如果过程结束;如果类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,不通过,则返回 java.lang.IllegalAccessError 异常。

  3. 否则,按照继承关系从下往上依次对 C 的父类进行第 2 步的搜索和验证过程。

  4. 如果始终没有找到合适的方法,则抛出 Java.lang AbstractMethodError 异常。

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

方法返回地址

一个方法的结束,有两种方式:

  1. 征程执行完成

  2. 出现未处理的异常,非正常退出

当一个方法执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;

    • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际类型而定。

    • 在字节码指令中,方法的指令包含 ireturn ( 当返回值时 boolean、byte、 char、short和int 类型时使用)、lreturn、freturn、dreturn、areturn,另外还有一个 return指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。


栈帧中的一些附加信息

栈帧中还允许携带与java 虚拟机相关的一些附加信息。例如多程序调试提供支持的信息

本地方法栈 

  1. 什么是本地方法?

简单地讲,一个 Native Method 就是一个 Java 调用非 Java 代码的接口。一个 Native Method 是这样一个 Java 方法:该方法的实现由非 Java 语言实现,比如 C 。这个特征并非 Java 所特有,很多其他的编程语言都有这一机制,比如在 C

“ A native method is a java method whose implementation is provided by non-java code"

在定义一个 native method 时,并不提供实现体(有些像定义一个 Java interface ), 因为其实现体是由非 java 语言实现的。

  1. 什么是本地方法栈

Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。本地方法栈,也是线程私有的。允许被实现成固定或者是可动态扩展内存大小。本地方法使用 C 语言实现的。它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库。

虚拟机栈的几道面试题

1.举例栈溢出的情况?(StackOverflowError)通过 -Xss 设置栈的大小;OOM( OutOfMemoryError )

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

3.分配的栈内存越大越好吗?不是,资源有限,应该合理分配。

4.垃圾回收是否会涉及到虚拟机栈?不会的!5.方法中定义的局部变量是否线程安全?具体问题具体分析,方法内部定义的变量通常是线程安全的。




zempty 笔记

生活|写作|程序员代码