JVM之虚拟机栈详细讲解

Java
259
0
0
2023-10-11
标签   JVM

java虚拟机 在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着 虚拟机 进程的启动而一直存在,有些区域则是依赖用户 线程 的启动和结束而建立和销毁。

如下图所示,其中灰色部分为单个线程私有的,红色部分是多个线程所共享的。

  • 每个线程:包括 程序计数器 、栈、本地方法栈
  • 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的 字节码 的行号指示器。在 java 虚拟机的 概念模型 里,字节码解释器工作时就是通过改变这个 计数器 的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的 多线程 是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于 多核处理器 来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何 OutOfMemory Error情况的区域。

虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的 生命周期 与线程相同。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的方法调用。

开发者遇到的异常

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

如果采用固定大小的Java虚拟机栈,那么每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。

如果采用动态扩展的Java虚拟机栈,当在尝试动态扩展时无法申请到足够的内存时,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈时,那么Java虚拟机会抛出一个OutOfMemoryError异常。

栈的存储单位

每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在的。在这个线程上正在执行的每个方法都各自对应一个栈帧。栈帧是一个内存区块,用于存储 局部变量 表、操作数栈、动态链接、方法返回地址等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表

局部变量表:Local Variables,又称为局部变量数组或本地变量表。

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

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

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

方法嵌套调用次数是由栈的大小决定的。一般来说,栈越大,方法嵌套调用次数就越多。对于一个函数而言,它的参数和局部变量越多,使得局部变量表越大,它对应的栈帧也就越大,从而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

局部变量表中的变量只有在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成从参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也随之销毁。

关于Slot的理解

局部变量表中最基本的存储单位是Slot(变量槽),局部变量表中存放编译期可知的各种基本类型(8种),引用类型(reference)、returnAddress 类型的变量。

在局部变量表中,32位以内的类型只占用一个位置 Slot (包括 returnAddress类型),64位的类型( long 和 double)占用两个slot。

byte 、 short 、 char 在存储前被转换为 int, boolean 也被转换为 int , 0 表示false,非0 表示 true。long 和 double 则占据两个 slot。

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

当一个实例方法被调用时,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上。如果要访问局部变量表中一个64位的局部变量值时,只需要使用前一个索引即可。

如果当前栈帧是由 构造方法 或者实例方法创建的,那么该对象引用this将会存放在index为0的Slot处,其余的参数按照参数表顺序继续排列。

Slot的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量超过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

 public class test {
    public  void  local(){
        {
            int a=;
            System.out.println(a);
        }
        //复用局部变量a的槽位
        int b=;
    }
}

操作数栈

每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出的操作数栈,也可以称之为表达式栈。

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

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

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

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

  • 32 bit的类型占用一个栈单位深度
  • 64 bit的类型占用二个栈单位深度

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

操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据分析阶段要再次验证。另外我们常说,java虚拟机的解释引擎是基于栈的执行引擎,其中的栈就是指操作数栈。

下面我们来看一个案例:

 public class test {

    public void local(){
        int i=;
        int j=;
        int k=i+j;
    }
}

我们使用javap -v test.class 进行反编译,其对应的字节码指令如下所示,我们重点关注一下local方法对应的指令。

通过观察字节码指令,bipush指令用于将15和8进行入栈操作;iadd指令用于进行加法操作,i代表的是int,所以iadd代表两个int类型的数进行相加操作。

具体的执行流程如下所示:

  1. 首先执行第一条语句,PC寄存器指向的是0 ,也就是指令的地址为0,然后使用bipush让操作数15入栈。
  2. 执行完后,让PC+1,指向下一个指令istore_1,istore_1是让操作数栈中的元素存储到局部变量表1的位置。
  3. 同理,继续向下执行,让操作数8页入栈,然后执行istore_2指令,让栈中的元素存储到局部变量表2的位置。
  4. 然后继续向下执行,执行iload_1、iload_2,将局部变量表位置1和2对应的元素加载到操作数栈中。
  5. 然后执行iadd命令,将操作数栈中两个元素执行相加操作,然后入栈。
  6. 然后执行istore_3命令,让操作数栈中的元素 23 存储到局部变量表3的位置。
  7. 最后执行return,则表示退出方法。

栈顶缓存技术

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也意味着将需要更多的执行分派次数和内存读写次数。

由于操作系统是存储在内存中的,因此频繁地执行内存的读写必然会影响执行效率。为了解决这个问题,HotSpot VM的设计者们提出了栈顶缓存技术,也就是将栈顶的元素全部缓存在物理CPU的 寄存器 中,以此降低对内存的读写次数,提升执行引擎的执行效率。

动态链接

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

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

下面给个例子。

 public class test {

    public void local() {
        int i =;
        int j =;
        int k = i + j;
    }

    public  void  local(){
        local();
    }
}

然后通过 javap -v test.class,进行反编译,可以看到所有的变量和方法引用都作为符号引用保存在常量池中。

 public class test
  minor version:
  major version:
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   # = Methodref          #4.#20         // java/lang/Object."<init>":()V
   # = Methodref          #3.#21         // test.local:()V
   # = Class              #22            // test
   # = Class              #23            // java/lang/Object
   # = Utf8               <init>
   # = Utf8               ()V
   # = Utf8               Code
   # = Utf8               LineNumberTable
   # = Utf8               LocalVariableTable
  # = Utf8               this
  # = Utf8               Ltest;
  # = Utf8               local
  # = Utf8               i
  # = Utf8               I
  # = Utf8               j
  # = Utf8               k
  # = Utf8               local1
  # = Utf8               Source File 
  # = Utf8               test.java
  # = NameAndType        #5:#6          // "<init>":()V
  # = NameAndType        #12:#6         // local:()V
  # = Utf8               test
  # = Utf8               java/lang/Object
{
  public test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=, locals=1, args_size=1
    : aload_0
    : invokespecial #1                  // Method java/lang/Object."<init>":()V
    : return
      LineNumberTable:
        line: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature       5     0  this   Ltest;

  public void local();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=, locals=4, args_size=1
    : bipush        15
    : istore_1
    : bipush        8
    : istore_2
    : iload_1
    : iload_2
    : iadd
    : istore_3
    : return
      LineNumberTable:
        line: 0
        line: 3
        line: 6
        line: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature      11     0  this   Ltest;
       8     1     i   I
       5     2     j   I
       1     3     k   I

  public void local();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=, locals=1, args_size=1
    : aload_0
    : invokevirtual #2                  // Method local:()V
    : return
      LineNumberTable:
        line: 0
        line: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature       5     0  this   Ltest;
}

这里提个问题,你知道为什么需要运行时常量池吗?

因为在不同的方法中,都有可能调用常量、方法,所以将它们放在常量池中,只存储一份,节省了空间。

方法的调用

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

链接

静态链接:

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

动态链接:

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

绑定机制

绑定是指一个字段、方法或者类在符号引用被替换为直接引用的过程。

早期绑定:

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

晚期绑定:

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

虚方法与非虚方法

如果方法在编译期就确定了具体的调用版本,并且这个版本在运行时是不可变的,那么就称此方法为非虚方法。

静态方法 、私有方法、final 方法、实例构造器、父类方法都是非虚方法,剩下的其它方法是虚方法。

JVM虚拟机提供的几个方法调用指令。

  • invoke static :调用静态方法,解析阶段确定方法的版本
  • invokespecial:调用方法、私有及父类方法,解析阶段确定方法的版本
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法
  • invokedynamic:动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而 invokedynamic 指令则支持由用户确定方法的版本。

java语言中方法重写的本质

  1. 找到操作数栈顶第一个元素所执行的对象的实际类型,记做C
  2. 如果在类型C中找到与常量中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束。如果不通过,则返回 java.lang.IllegalAccessError 异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出 java.lang .AbstractMethodError异常。

方法返回地址

用于存放调用该方法的 PC 寄存器的值。一个方法的结束,对应有两种方式。

  • 正常执行完成
  • 出现未处理的异常,非正常退出

无论通过哪种方式退出,在方法退出后都会返回到该方法被调用的位置。方法正常退出时,调用者的PC寄存器的值作为方法的返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。

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

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口。
  2. 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
  3. 在字节码指令中,返回指令包含 ireturn(当返回值类型为boolean、byte、char、short和int类型时使用),lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn。另外还有一个 return 指令声明为 void 的方法,实例初始化方法,类和接口的初始化方法使用。
  4. 在方法执行的过程中遇到异常,并且这个异常没有在方法内进行处理,也就是说只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。
  5. 方法在执行的过程中,抛出异常时的异常处理,存储在一个异常处理表中,方便在发生异常的时候找到处理异常的代码。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

一些异常信息

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

#####一道面试题

方法中定义的局部变量是否线程安全?这个得 具体问题具体分析 。

如下所示:

 public class test {
    //线程内部创建的,属于局部变量, 是线程安全的
    public static void m(){
        StringBuilder s=new  StringBuilder ();
        s. append ("a");
        s.append("b");
    }
    //线程不安全的,操作的是共享数据s
    public static void m(StringBuilder s2){
        s.append("a");
        s.append("b");
    }
    //线程不安全的,因为有返回值,有可能被其它程序所调用
    public static StringBuilder m(){
        StringBuilder s=new StringBuilder();
        s.append("a");
        s.append("b");
        return s;
    }
}