在写JVM相关文章的时候,笔者也在想,到底从哪部分内容开始讲解。看了《深入理解Java虚拟机》一书,也看到他也是从运行时数据区域来开始写的,所以,我也就从这一部分开始讲解。我个人的理解是,这部分的内容涉及到虚拟机将class文件加载到内存中,所有的数据+方法运行都在这块来进行的,这部分的内容涉及到GC,方法运行的栈等内容。
1.Java 程序的执行过程
一个java程序,首先经过javac 编译成.class 文件,然后JVM将其加载到方法区,执行引擎将会执行这些字节码。执行时,会翻译成操作系统相关的函数。
JVM作为.class文件的翻译存在,输入字节码,调用操作系统函数。过程如下:
2.JVM组成部分
本文重点介绍运行时数据区这部分中内容。
3.运行时数据区
Java 虚拟机在执行Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和小灰的时间,线程共享的区域随着虚拟机进程的启动而一直存在,线程私有的区域依赖用户线程的启动和结束而建立和销毁。根据《Java 虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图
3.1 程序计数器
程序计数器是一块较小的内存空间,唯一一块没有规定任何OOM情况的区域。它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令的。
Q:为什么需要这个程序计数器呢?
A: 因为Java支持多线程,而Java虚拟机的多线程是通过操作系统的线程轮流切换,CPU时间片轮转算法实现的。在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。也就是说,某个线程A 在执行过程中可能因为时间片耗尽而处于挂起状态,而另一个线程B此时获取到时间片开始执行,等线程B执行完成后,线程A重新获取到时间片,线程A需要从被挂起的位置继续执行。这个时候,每条线程就需要程序计数器记录当前线程的执行位置(字节码执行位置)。
如果线程正在执行的是Java方法,这个计数器记录的就是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,则这个计数器的值=undefined.
它的特点是:每条线程私有,各线程之间互不影响,独立存储,且是唯一一个没有OOM情况的区域。
上图中左边为Test.java文件,中间为Test.class 文件,右边是字节码对应的数据
程序计数器存储的就是偏移地址,假设当前程序计数器的值=9(dstore_3),那它下一步就要执行的指令应该就是10(iload_1)。
3.2 虚拟机栈
在开始介绍栈之前,我觉得有必要就栈和堆做一个比较。
堆 | 栈 | |
---|---|---|
作用 | 存储单元 | 运行单元 |
范围 | 线程共享 | 线程私有 |
- 堆堆解决的是数据存储的问题,数据怎么放,放哪里。
- 栈栈解决的是程序运行的问题,程序怎么运行,数据怎么处理,方法怎么执行。
举个🌰: 假如我们厨师在炒菜,厨师炒菜的步骤看成是栈操作(洗菜,切菜,开火,加油,放菜,翻炒,放盐,出锅)。而存放油、盐、菜的盘子碗相当于是堆。
从这个例子我们实际上也可以看出堆是线程共享的:放在厨房里的油、盐、菜,每个厨师都可以拿;而栈是线程私有的:一个厨师在炒菜的过程当中,别人是不能来跟他抢锅来炒菜的。抢的话,会打死你的😂
与程序计数器一样,Java虚拟机栈也是线程私有的,随着线程的启动而创建,随着线程的结束而销毁。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈 、动态链接、方法出口等信息。每一个方法被执行的过程就对应着一个栈帧在虚拟机中的入栈到出栈的过程。
在《Java虚拟机规范》中,对这个内存区域规定了两类异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。我们可以使用-Xss 参数减少栈内存容量,追加字母k或K表示KB,m或M表示MB,g或G表示GB,示例:
-Xss1m
从上图中介绍一下栈帧的运行原理:虚拟机栈是程序的执行单元,它的职责是解决程序的运行问题,数据怎么处理,方法怎么执行的问题。虚拟机内部保存着一个一个的栈帧(Stack Frame)。每个栈帧与该线程正在执行的每个方法一一对应的。在当前运行的线程中,一个时间点只有一个运行的栈帧,也就是一个运行的方法,这个栈帧称为当前栈帧,该栈帧所代表的方法就是当前方法,执行引擎运行的所有字节码指令只对当前栈帧进行操作。 如上图:
-
程序开始执行,首先调用的是方法1,然后方法1入栈--->栈帧1 入栈;
-
方法1 调用方法2,接着方法2入栈----> 栈帧2 入栈;
-
方法2 接着调用方法3,此时方法3入栈---> 栈帧3入栈。 此时栈中的内容为 栈帧1(栈底)--->栈帧2---->栈帧3(当前栈帧)。
-
此时就运行栈帧3中的代码,在栈帧3返回的时候,由于栈帧中有一个方法返回地址(存放调用方 的程序计数器),栈帧3会将执行结果回传到栈帧2中的方法返回地址所指向的位置,执行完毕后,栈帧3 出栈,此时栈中:栈帧1(栈底)--->栈帧2(当前栈帧)
-
执行栈帧2的代码,然后将执行结果返回给栈帧1中适当的位置,栈帧2执行完毕后,栈帧2出栈,目前栈中只有栈帧1(当前栈帧)
-
栈帧1 执行,执行完毕后,栈帧1 出栈,栈为空,虚拟机栈被回收。
3.3.1局部变量表
局部变量表也称为局部变量数组或者本地变量表。定义为一个数字数组,主要用于存储方法参数和方法内定义的局部变量。其存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、short、int、float、long、double),对象引用(引用指针或者句柄)和returnAddress类型(指向了一条字节码指令的地址)。
局部变量表所需要的内存空间在编译期就完成了分配,当进入一个方法时,这个方法需要多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。并保存在方法的Code属性的max_locals数据项中。方法嵌套调用次数由栈的大小决定,局部变量表决定了栈帧的大小。因此在栈的大小固定的前提下,局部变量表越大,则方法嵌套调用次数就越小(栈的深度越小)
局部变量表的内容借用jclasslib 插件来看编译后的class文件。
下图中的最大变量槽怎么得出的,我这边介绍一下。
首先我们先要解释下变量槽Slot 这个概念
变量槽Slot: 变量槽是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、short、boolean、int 和returnAddress、reference等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double 和long 这两种64位的数据类型则需要两个变量槽来存放,JVM会为每一个slot 分配一个访问索引。通过这个索引即可访问到局部变量表中所指向的局部变量值,32位的直接通过索引访问,64位由于需要两个变量槽,所以只需要使用其该变量所对应的变量槽的起始位置所以即可。
注: 局部变量表中slot是可以重用的,如果一个局部变量过了其他作用域,那么其作用域之后声明的新 的局部变量有可能会复用这个slot,以便于节省资源。
小结: 局部变量表是通过slot 来方法参数和定义方法体内的局部变量。在方法执行的时候,虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程(即实参到形参的传递),当方法调用结束后,随着方法栈帧的销毁,这个局部变量表也随之销毁。
3.3.2操作数栈
操作数栈(Operand Stack)也被称为操作栈,它是一个先入后出栈,操作数栈的最大深度也是在编译的时候写入到Code属性的max_stacks数据中的。在方法执行的任何时候,操作数栈的深度都不会超过max_stacks数据项中设定的最大值。
操作数栈的每一个元素可以是任意的java数据类型,32位的数据类型所占的栈容量为1,64位的数据类型所占的栈容量为2。操作数栈中的远视眼的数据类型必须与字节码指令的序列严格匹配。
当一个方法在执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。
如上图,就是一个整型数据相加的字节码指令。
- 图1 首先要将指令地址0 放到PC计数器中,此时PC计数器=0, 此时,局部变量表和操作数栈为空。
2. 图2当执行第一条指令bipush 时,将操作数10写入到操作数栈中,然后PC计数器就存入下一条指令地址2
3. 图3接着执行istore_1 指令,它的意思是将操作数栈中的栈顶元素取出来,然后放入到局部变量表中,此时局部变量表中的索引1指的就是10。接着更新PC计数器的值为3
4. 图4 步骤如2,就是将20 放入到操作数栈中,更新PC计数器=5
5. 图5 步骤如3,就是将操作数栈中的20 放入到局部变量表中去,并更新PC计数器=6
6. 图6指令iload_1,就是根据iload_1 中的1 找到局部变量表中的索引为1的值=10,然后放入到操作数栈中。此时操作数栈顶元素为10,接着更新PC计数器=7
7. 图7如步骤6,将局部变量表中的索引值为2的数据20放入到操作数栈中,此时操作数栈中的栈顶为20,有两个数据了。接着更新PC计数器=8
8. 图8 iadd指令就是将操作数栈中的最上面的两个整型数值出栈,然后相加得出结果,然后入栈,此时栈中的元素为30,PC计数器=9
9. 图9istore_3 将操作数栈中的栈顶元素取出放入到局部变量表的索引为3的地方,然后更新PC计数器=10.
10. 图10iload_3 就是将局部变量表中索引值为3所指向的数据30放入到操作数栈中。更新PC计数器=11。
11. 图11从操作数栈中取出栈顶元素的值,然后返回到调用该方法的地方。
至此,该方法执行完毕。 这个操作指令所代表的含义见docs.oracle.com/javase/spec…
3.3.3动态链接 Dynamic Linking
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
在Java 文件被编译成class 文件时,这些符号引用就存储在Class 文件中的常量池中。字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分是在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。还有一部分是在每一次运行期间都转化为直接引用,这部分称为动态链接
这段来自引用“为什么字节码文件需要常量池?因为字节码文件需要数据支持,通常这种数据会很大,以至于不能直接存放到字节码中,换一种方式,可以将指向这些数据的符号引用存到字节码文件的常量池中,这样字节码只需使用常量池就可以在运行时通过动态链接找到相应的数据并使用。”
3.3.4方法返回地址
我们都知道,在方法退出的时候,都必须返回到最初方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。
一般来说方法退出有两种方式:正常退出和遇到异常退出。 在正常退出的时候,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器的值。而异常退出时,就需要异常处理表来决定返回的位置。
正常返回时
异常退出时
方法的退出过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
3.3 本地方法栈
本地方法栈同虚拟机栈所发挥的作用差不多。本地方法栈执行的是Native方法。也是线程私有的。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区;
- 可以直接使用本地处理器中的寄存区;
- 直接从本地内存的堆中分配任意数量的内存。
这篇文章主要介绍了运行时数据区的线程私有区域的几个知识点,涉及到线程共享的堆和方法区,笔者打算放到下篇去单独讲解。 如果你觉得这篇文章有帮助到你对JVM运行时数据区域的理解,还希望你能点个赞+关注。
参考文献:
《深入理解Java虚拟机》