Java虚拟机—栈帧、操作数栈和局部变量表

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域有其各自的用途,以及创建销毁的时间,其中有些会一直存在,即随着虚拟机的启动而创建、随着虚拟机的退出而销毁;另外一些则是与虚拟机中的单个线程一一对应,这些与线程相对应的数据区域会随着线程的开始而创建、随着线程的结束即销毁。

image.png

1.程序计数器

程序计数器(Program Counter Register),是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖于此计数器。JVM中的程序计数器也是在Java虚拟机规范中唯一一个没有规定任何OutOfMemoryError情况的区域。在任意时刻一条JVM线程只能执行一个方法的代码,方法可以是Java方法,或者是native方法。

此处还有2点需注意:

1.Java虚拟机中的程序计数器仅仅是虚拟机中的,存在于内存之上的“虚拟”计数器,而不是电脑中的实体程序计数器。

2.JVM线程中执行的方法有2种类型:普通Java方法和由其他语言实现的native方法。如果当前执行的是普通Java方法,则程序计数器记录的是虚拟机字节码指令的地址。如果当前执行的是native方法,则计数器的值为空(Undefined)。

(native方法多由C和C语言实现,譬如java.lang.Object类中的hashCode()方法就是native方法,其底层是通过C实现的。)

public native int hashCode();

2.Java虚拟机栈

和程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,即生命周期和线程相同。Java虚拟机栈和线程同时创建,用于存储栈帧。每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

Java虚拟机规范中Java虚拟机栈内存的大小既可以被实现成固定大小,也可以根据计算动态拓展或收缩,当前大部分的JVM实现是支持动态拓展的。Java虚拟机栈可能发生的异常:

1.线程请求分配的栈容量>Java虚拟机最大栈容量,则JVM会抛出StackOverFlowError异常。

2.如果Java虚拟机可动态拓展,则如果在拓展的过程中无法申请到足够的内存,就会抛出OutOfMemoryError异常。

3.本地方法栈

本地方法栈(Native Method Stack)和Java虚拟机栈类似,区别在于Java虚拟机栈是为了Java方法服务的,而本地方法栈是为了native方法服务的。在虚拟机规范中并没有对本地方法实现所采用的编程语言与数据结构采取强制规定,因此不同的JVM虚拟机可以自己实现自己的native方法。此处需要说明:Sun HotSpot虚拟机就直接将本地方法栈和Java虚拟机栈合二为一了。

4.Java堆

前面所说的程序计数器、Java虚拟机栈、本地方法栈通常只占很小一部分的内存空间,对与大多数应用来说,Java堆(Java Heap)才是JVM管理的内存空间中最大的一块。此区域存在的唯一目的就是存放对象实例,几乎所有的对象实例都会在这被分配内存,而且Java堆是被所有线程共享的一块内存区域

Java堆是Java中垃圾收集器管理的主要区域,因此也被称为GC堆—Garbage Collected Heap.

关于垃圾堆的问题O不,GC堆的问题,我们以后会经常见到,著名的垃圾回收算法我们也会再之后的文章中详细探讨。

(根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存之中,只要逻辑上是连续的即可。且和栈一样,不同的JVM实现可以有不同的内存分配策略,Java堆的内存既可以设计成固定大小,也可以是可动态拓展的。)

5.方法区

方法区(Method Area),与Java堆一样是各个线程共享的内存区域。用于存储被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范将方法区描述为堆的一个逻辑部分,但是它却有个别名叫做Non-Heap(非堆),目的就是和Java堆区分开来。

Java虚拟机规范对方法区的限制十分宽松,除了和Java堆一样不需要连续的内存空间分配和可选择固定大小或可拓展内存以外,方法区也可以被垃圾回收器管理或不受其管理。

首先,要明确一个「概念」——方法区,是一个概念,是Java虚拟机规范中定义的概念,一个「非堆」的运行时数据区域,用于存放被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,运行时常量池也是存放于方法区中。逻辑上的「非堆」表示和Java堆独立,那物理上呢?Java虚拟机规范中定义了方法区这个概念,但是并没有规定此区域的是否需要垃圾收集。

在Java7以前,HotSpot虚拟机中,方法区也被称为“永久代”,因为在物理上,方法区使用的是由JVM开辟的堆内存,由于和Java堆共享内存且内存空间由垃圾收集器统一分配和管理,自然的垃圾收集也拓展到了方法区上。此时,Java堆中分区为青年代Young Generation和老年代Old Generation,而方法区自然地被称为永久代Permanent Generation 。

(JVM虚拟机有不同的实现,比较主流的是sun公司的HotSpot虚拟机,在此才有“永久代的概念”,其他虚拟机不存在“永久代”这个概念) 在Java8中,HotSpot虚拟机改变了原有方法区的物理实现,将原本由JVM管理内存的方法区的内存移到了虚拟机以外的计算机本地内存,并将其称为元空间(Metaspace)。这样一来,现在的方法区实际存储在于元空间,再也不用和Java堆共享内存了,“永久代”也就永久地被撤销了。

尽管永久代撤销了,方法区这个逻辑上的空间一直是存在的,所以在java8以后,方法区的垃圾回收在物理上就是对元空间的垃圾回收。由于元空间用的是计算机本地内存,所以理论上来说只要内存足够大,方法区就能有多大,实际上Metaspace的大小是可以通过参数设定的,如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和类的加载器。常用的G1和CMS垃圾收集器都能很好地回收Metaspace区。

5.1运行时常量池

运行时常量池(Runtime Constant Pool)是.class文件中每一个类或接口的常量池表(constant pool table)的运行时表示形式,属于方法区的一部分。每一个运行时常量池都在Java虚拟机的方法区中分配,在加载类和接口道虚拟机后,就创建对应的运行时常量池。常量池的作用是:

存放编译器生成的各种字面量和符号引用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析、翻译到具体的内存地址之中。

字面量(Literal),通俗理解就是Java中的常量,如文本字符串、声明为final的常量值等。

符号引用(Symbolic References)则是属于编译原理中的概念,包括了下面三类常量:

1.类和接口的全限定名

2.字段的名称和描述符

3.方法的名称和描述符

Java虚拟机栈和运行时栈帧结构

Java虚拟机是基于「栈」架构的,如图所示:

image.png

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

一个线程中方法的调用链可能会很长,很多方法都同时处于执行状态。对于JVM执行引擎来说,在在活动线程中,只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法,定义这个方法的类叫做当前类。

执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。

调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。

栈帧是线程本地的私有数据,不可能在一个栈帧中引用另外一个线程的栈帧。 在概念模型上,典型的栈帧结构如下:

image.png

关于「栈帧」,我们再看看《Java虚拟机规范》中的描述:

栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态连接、方法返回值和异常分派。 栈帧随着方法调用而创建,随着方法结束而销毁——无论方法正常完成还是异常完成都算作方法结束。 栈帧的存储空间由创建它的线程分配在Java虚拟机栈之中,每一个栈帧都有自己的本地变量表(局部变量表)、操作数栈和指向当前方法所属的类的运行时常量池的引用。

接下来,详细讲解一下栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的数据结构和作用。

1.局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。

在Java程序编译为Class文件时,就在方法的Code属性中的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。(最大Slot数量) 一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference和returnAddress类型的数据。reference类型表示对一个对象实例的引用。returnAddress类型是为jsr、jsr_w和ret指令服务的,目前已经很少使用了。

虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型),则会连续使用两个连续的Slot来存储。

2.操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中。

操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。

当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

3.动态连接

在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。

Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。

这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。

4.方法返回

当一个方法开始执行时,可能有两种方式退出该方法:

正常完成出口 异常完成出口 正常完成出口是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定。

异常完成出口是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论是Java虚拟机抛出的异常还是代码中使用athrow指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。 无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态

方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压如调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。 一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

5.附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧之中,例如和调试相关的信息,这部分信息完全取决于不同的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其他附加信息一起归为一类,称为栈帧信息。

摘自:https://zhuanlan.zhihu.com/p/45354152

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×