1.1 运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来讲,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,栈帧的概念结构如下图所示:
接下来我们将详细了解一下栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。
1.1.1局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应用占用空间大小,只是很有“导向性”地说明每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这种描述与明确指出“”有一些差别,它允许Slot的长度随着处理器、操作系统或虚拟机的不同而发生变化。不过无论如何,即使在64位虚拟机中使用了64位长度的内存空间来实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中和一致。
既然前面提到了数据类型,在此顺便说一下,一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference和returnAddress八种类型。前面六种不需要多解释,大家都认识,而后面的reference是对象的引用。虚拟机规范既没有说明它的长度,也没有明确指出这个引用应有怎样的结构,但是一般来说,虚拟机实现至少都应当能从此引用中直接或间接地查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据。而returnAddress是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。
对于64位的数据类型,虚拟机会以高位在前的方式为其分配两个连续的Slot空间。Java语言中明确规定的64位的数据类型只有long和double两种(reference类型则可能是32位也可能是64位)。值得一提的是,这里把long和double数据类型读写分割为两次32读写的做法类似,读者阅读到Java内存模型时可以对比一下。不过,由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否是原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的Slot数量。如果是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明要使用第n和第n+1两个Slot。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static的方法),那么局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省栈空间,在某些情况下Slot的复用会直接影响到系统的垃圾收集行为,请看如下代码清单的演示:
publicclass LocalVariableTableTest {
publicstaticvoid main(String[]args) {
byte[] placeholder = newbyte[64 * 1024 * 1024];
System.gc();
}
}
如上代码清单中的代码很简单,向内存填充了64MB的数据,然后通知虚拟机进行垃圾收集。我们在虚拟机运行参数中加上“-verbose:gc”来看看垃圾收集的过程,发现在System.gc()运行后并没有回收掉这64MB的内存,下面是运行的结果:
[Full GC 66933K->66064K(126720K), 0.0097959 secs]
没有回收掉placeholder所占的内存能说得过去,因为在执行System.gc()时,变量placeholder还处于作用域内,虚拟机自然不敢回收掉placeholder的内存。我们把代码修改一下,变成如下清单中的样子:
publicclass LocalVariableTableTest1 {
publicstaticvoid main(String[]args) {
{
byte[] placeholder = newbyte[64 * 1024 * 1024];
}
System.gc();
}
}
加入了花括号之后,placeholder的作用域被限制在花括号之内,从代码逻辑上讲,在执行System.gc()的时候,placeholder已经不可能再被访问了,但执行一下这段程序,会发现运行结果如下,还是有64MB的内存没有被回收掉,这又是为什么呢?
[Full GC 66933K->66064K(126720K), 0.0070839 secs]
在解释为什么之前,我们先对这段代码进行第二次修改,在调用System.gc()之前加入一行代码“int a = 0;”,变成如下代码清单的样子:
publicstaticvoid main(String[]args) {
{
byte[] placeholder = newbyte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
这个修改看起来很莫名其妙,但运行一下程序,却发现这次内存真的被正确回收了(这样的场景并不多见):
[Full GC 66933K->528K(126720K), 0.0067665 secs]
上面三段代码清单中,placeholder能否被回收的根本原因就是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再被使用的变量,手动将其设置为null值(用来代替“int a = 0;”,把变量对应的局部变量表Slot清空)就不是一个毫无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件)下的“奇技”来使用。但不应当对赋null值操作有过多的依赖,也没有必要把它当做一个普遍的编码方法来推广。以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法。
另外,赋null值的操作在经过虚拟机JIT编译器优化之后会被消除掉,这时候将变量设置为null实际上是没有意义的。字节码被编译为本地代码后,对GC Roots的枚举也与解释执行时期有所差别。
关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在“准备阶段”。通过前一章的讲解,我们已经知道变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。所以不要认为Java中任何情况下都存在诸如整型变量默认为0、布尔型变量默认为false之类的默认值。如下代码清单所示:
publicstaticvoid main(String[]args) {
int a;
System.out.println(a);
}
这段代码其实并不能运行,所幸编译器能在编译期间检查到并提示这一点。即便编译器能通过手动生成字节码的方式制造出下面的代码效果,字节码检验的时候也会被虚拟机发现,从而导致类加载失败。
1.1.2操作数栈
操作数栈也常被称为操作栈,它是一个后入先出(Last In First Out, LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈出栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
举个例子,整数加法的字节码指令iadd在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值和并相加,然后将相加的结果入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例,这个指令用于整型数加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。
另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的。但是大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,而无须进行额外的参数复制传递了,重叠的过程如下图所示:
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
1.1.1动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池有存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
1.1.2方法返回地址
当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
1.1.3附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
相关推荐
深入理解Java内存结构对于开发高效、稳定的Java应用程序至关重要,它涉及到内存管理、垃圾收集、并发编程等多个方面。本文将对Java内存结构进行深入探讨,帮助读者建立坚实的理论基础,从而提升在实际开发中的编程...
运行时栈帧结构是Java虚拟机(JVM)中的一种数据结构,用于支持虚拟机进行方法调用和方法执行。每个方法的调用开始到结束都对应着一个栈帧在虚拟机栈中的入栈到出栈的过程。栈帧中包括了局部变量表、操作数栈、动态...
### Java内存结构详解 #### 一、概述 Java内存模型主要分为以下几个部分:堆(Heap)、栈(Stack)、方法区(Method Area)、程序计数器(Program Counter Register)以及本地方法栈(Native Method Stack)。本文...
#### 二、Java内存结构概述 Java程序在运行时,其内存空间被划分为多个不同的区域,以满足不同的数据管理和计算需求。下面详细介绍这些内存区域: 1. **程序计数器(Program Counter Register)** - **定义**:这是...
Java内存模型定义了线程如何访问和修改共享变量,确保多线程环境下的正确性。它涉及到 volatile、synchronized、final 等关键字的语义。 理解并掌握JVM内存结构有助于我们编写高效、稳定的Java代码,避免出现性能...
Java虚拟机(JVM)内存结构是理解Java应用程序性能和内存管理的关键。本文将详细介绍JVM内存的不同组件,包括它们的功能和重要性。 首先,Java虚拟机规范中的内存管理主要涉及Runtime Data Area,这是一个用于存储...
1. **Java内存结构** Java内存主要分为堆内存(Heap)和栈内存(Stack),堆是存储对象实例的主要区域,而栈主要用于存储基本类型和对象引用。除此之外,还有方法区(Method Area)、程序计数器(PC Register)和...
【JAVA内存结构】 Java内存结构主要分为五个主要区域:堆(JAVA HEAP)、虚拟机栈(JAVA VIRTUAL MACHINE STACKS)、本地方法栈(NATIVE METHOD STACK)、方法区(METHOD AREA)和程序计数器(RUNTIME CONSTANT ...
在深入探讨Java内存结构前,需要明确的是Java虚拟机(JVM)内存模型是Java程序运行的基石。JVM运行Java程序时,会将其内存划分成若干个不同的数据区域,包括程序计数器、虚拟机栈、本地方法栈、堆以及方法区。 程序...
JVM内存结构的理解对于优化Java程序的性能至关重要。JVM内存主要分为以下几个区域: 1. **程序计数器(Program Counter Register)**:每个线程都有一个独立的程序计数器,用于存储当前线程正在执行的字节码指令...
标题和描述中提及的知识点主要围绕Java内存...总之,深入理解Java内存模型和JVM内存结构对于Java开发者来说至关重要,它不仅有助于编写更高效、更安全的代码,还能在遇到性能瓶颈或内存问题时,快速定位并解决问题。
Java 实现内存动态分配主要涉及Java内存模型以及内存管理机制,包括堆内存和栈内存的分配,以及垃圾回收等概念。下面将详细解释这些知识点。 1. **Java内存模型** Java程序运行时,内存分为堆内存(Heap)和栈内存...
Java运行内存分析涉及了Java虚拟机(JVM)内存结构和内存管理的各个方面。JVM内存主要分为以下几个部分:栈内存(Stack)、堆内存(Heap)、方法区(Method Area)、运行时常量池(Runtime Constant Pool)以及直接...
Java虚拟机(JVM)是Java程序运行的核心,它的内存结构是理解Java性能优化和内存管理的关键。在Java虚拟机中,内存分为多个区域,主要包括以下几个部分: 1. **原始类型**:Java语言中,原始类型包括数值类型(如...
Java运行内存,也被称为Java内存模型(Java Memory Model),是Java程序执行时内存分配的主要结构。它分为三个主要区域:堆内存(Heap)、虚拟机栈(JVM Stack)和方法区(Method Area)。理解这些区域的工作原理...
总结,深入理解Java内存分配原理意味着深入理解Java运行机制,这将帮助开发者编写出更高效、更健壮的代码,提升系统的整体性能和稳定性。通过学习和实践,开发者可以更好地应对内存溢出、性能瓶颈等问题,从而成为...
第95讲 运行时栈帧结构 00:08:46 第96讲 局部变量表 00:20:48 第97讲 操作数栈 00:08:36 第98讲 动态连接 00:02:56 第99讲 方法返回地址和附加信息 00:03:24 第100讲 方法调用-解析调用 00:09:49 ...
Java内存管理是Java编程中的核心概念,它涉及到程序运行时如何高效地分配、使用和回收内存。在Java中,内存管理主要由JVM(Java Virtual Machine)负责,其中包括了垃圾收集机制,确保程序不会因为内存泄漏而导致...