02.栈
栈
一个 JVM 栈由多个帧组成,当一个方法被调用的时候,会 push 一个帧到栈顶,当方法执行完毕时(正常返回或者抛出异常),一个帧会从栈顶弹出。每个帧由两部分组成:
- 一个数组,用于存放本地变量,数组长度由编译器计算确定,一个局部变量可用于存储任意类型的值,long 和 double 值除外,它们需要两个局部变量;
- 一个操作栈,用于存放中间值,可作为指令的操作数,或者作为方法调用的参数。
值得一提的是,局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的。在方法运行期间不会改变局部变量表的大小。
JVM 虚拟机栈
JVM 虚拟机栈就是我们常说的堆栈的栈(我们常常把内存粗略分为堆和栈),和程序计数器一样,也是线程私有的,生命周期和线程一样,每个方法被执行的时候会产生一个栈帧,用于存储局部变量表、动态链接、操作数、方法出口等信息。方法的执行过程就是栈帧在 JVM 中出栈和入栈的过程。局部变量表中存放的是各种基本数据类型,如 boolean、byte、char、等 8 种,及引用类型(存放的是指向各个对象的内存地址),因此,它有一个特点:内存空间可以在编译期间就确定,运行期不在改变。这个内存区域会有两种可能的 Java 异常:StackOverFlowError 和 OutOfMemoryError。
每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法的调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。 与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执 行的时候都会同时创建一个栈帧(Stack Frame0)用于存储局部变量表、操作栈、动态 链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在 虚拟机栈中从入栈到出栈的过程。
经常有人把 Java 内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗 糙,Java 内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序 员最关注的、与对象内存分配关系最密切的内存区域是这两块。其中所指的“堆”在后 面会专门讲述,而所指的“栈”就是现在讲的虚拟机栈,或者说是虚拟机栈中的局部变 量表部分。
局部变 M 表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,根据不同的虚拟 机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或 者其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。
其中 64 位长度的 long 和 double 类型的数据会占用 2 个局部变姑空间(Slot),其余 的数据类型只占用 1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个 方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间 不会改变局部变量表的大小。
在 Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程清求的栈深度大 于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展 (当前大部分的 Java 虚拟机都可动态扩展,只不过 Java 虚拟机规范中也允许固定长度的 虚拟机栈),当扩展时无法申请到足够的内存时会拋出 OutOfMemoryError 异常。 虚拟机栈也是线程私有的内存区域。每个方法在执行的时候都会创建一个栈帧用于存储局部变量表、操作数栈、方法出口等信息,每一个方法从调用到执行完成就是一个栈帧入栈和出栈的过程。 局部变量表存放了编译时期可知的各种基本数据类型、对象引用和指向了一条字节码指令的地址。 JVM 虚拟机栈就是我们常说的堆栈的栈(我们常常把内存粗略分为堆和栈),和程序计数器一样,也是线程私有的,生命周期和线程一样,每个方法被执行的时候会产生一个栈帧,用于存储局部变量表、动态链接、操作数、方法出口等信息。方法的执行过程就是栈帧在 JVM 中出栈和入栈的过程。局部变量表中存放的是各种基本数据类型,如 boolean、byte、char、等 8 种,及引用类型(存放的是指向各个对象的内存地址),因此,它有一个特点:内存空间可以在编译期间就确定,运行期不在改变。这个内存区域会有两种可能的 Java 异常:StackOverFlowError 和 OutOfMemoryError。
栈帧
每次方法调用都会新建一个新的栈帧并把它压栈到栈顶。当方法正常返回或者调用过程中抛出未捕获的异常时,栈帧将出栈。更多关于异常处理的细节,可以参考下面的异常信息表章节。每个栈帧包含:
- 局部变量数组
- 返回值
- 操作数栈
- 类当前方法的运行时常量池引用
局部变量数组
局部变量数组包含了方法执行过程中的所有变量,包括 this 引用、所有方法参数、其他局部变量。对于类方法(也就是静态方法),方法参数从下标 0 开始,对于对象方法,位置 0 保留为 this。有下面这些局部变量:
- boolean
- byte
- char
- long
- short
- int
- float
- double
- reference
- returnAddress
除了 long 和 double 类型以外,所有的变量类型都占用局部变量数组的一个位置。long 和 double 需要占用局部变量数组两个连续的位置,因为它们是 64 位双精度,其它类型都是 32 位单精度。
操作数栈
操作数栈在执行字节码指令过程中被用到,这种方式类似于原生 CPU 寄存器。大部分 JVM 字节码把时间花费在操作数栈的操作上:入栈、出栈、复制、交换、产生消费变量的操作。因此,局部变量数组和操作数栈之间的交换变量指令操作通过字节码频繁 执行。比如,一个简单的变量初始化语句将产生两条跟操作数栈交互的字节码。
int i;
被编译成下面的字节码:
0: iconst_0 // Push 0 to top of the operand stack
1: istore_1 // Pop value from top of operand stack and store as local variable 1
更多关于局部变量数组、操作数栈和运行时常量池之间交互的详细信息,可以在类文件结构部分找到。
动态链接与运行时常量池引用
每个栈帧都有一个运行时常量池的引用。这个引用指向栈帧当前运行方法所在类的常量池。通过这个引用支持动态链接(dynamic linking)。 C/C++ 代码一般被编译成对象文件,然后多个对象文件被链接到一起产生可执行文件或者 dll。在链接阶段,每个对象文件的符号引用被替换成了最终执行文件的相对偏移内存地址。在 Java 中,链接阶段是运行时动态完成的。 当 Java 类文件编译时,所有变量和方法的引用都被当做符号引用存储在这个类的常量池中。符号引用是一个逻辑引用,实际上并不指向物理内存地址。JVM 可以选择符号引用解析的时机,一种是当类文件加载并校验通过后,这种解析方式被称为饥饿方式。另外一种是符号引用在第一次使用的时候被解析,这种解析方式 称为惰性方式。无论如何,JVM 必须要在第一次使用符号引用时完成解析并抛出可能发生的解析错误。绑定是将对象域、方法、类的符号引用替换为直接引用的过程。绑定只会发生一次。一旦绑 定,符号引用会被完全替换。如果一个类的符号引用还没有被解析,那么就会载入这个类。每个直接引用都被存储为相对于存储结构(与运行时变量或方法的位置相 关联的)偏移量。
本地方法栈
和虚拟机栈类似,存储 Native 方法的相关信息。从名字即可看出,本地方法栈就是用来处理 Java 中的本地方法的,Java 类的祖先类 Object 中有众多 Native 方法,如 hashCode()、wait()等,他们的执行很多时候是借助于操作系统,但是 JVM 需要对他们做一些规范,来处理他们的执行过程。此区域,可以有不同的实现方法,向我们常用的 Sun 的 JVM 就是本地方法栈和 JVM 虚拟机栈是同一个。
从名字即可看出,本地方法栈就是用来处理 Java 中的本地方法的,Java 类的祖先类 Object 中有众多 Native 方法,如 hashCode()、wait()等,他们的执行很多时候是借助于操作系统,但是 JVM 需要对他们做一些规范,来处理他们的执行过程。此区域,可以有不同的实现方法,向我们常用的 Sun 的 JVM 就是本地方法栈和 JVM 虚拟机栈是同一个。