前言
在上一篇文章中,我们提到了关于线程和JVM之间的关系,线程不是由JVM直接控制的,而是由我们的操作系统来控制。我们java程序对线程的调用,最后也是通过调用操作系统的关于线程的操作来调用。
当然,我们知道,线程的调用和我们JVM的运行时数据区也有一定的关联。在JVM内存划分区域的过程中,有些区域是随着线程的诞生和销毁而存在的,而有些区域则是一直存在于JVM中,和线程没有关系。
接下来,我们来了解一下JVM的运行时数据区。
正文
在我们正式进入JVM运行时数据区之前,我们先从看一张图。
从图中我们可以看出来,JVM将自己的 运行时数据区 分为了很多个区域,大概如下:
- 程序计数器(pc寄存器)
- java虚拟机栈
- 本地方法栈
- java堆
- 方法区(元数据)
我们意识到,JVM将运行时数据区分为了不同的区域,这样做的目的是很明显的,是为了更加有效率的处理JVM中产生的对象和数据,那么,为什么要将运行时数据区这样的区分呢?在没有深刻的了解这些运行时数据区的构造之前,我们可能很难回答这个问题,因此我们要先去了解一下运行时数据区的组成结构,深入的了解其中的构造和存储了什么样的数据结构,先知其然,然后我们再去总结知其所以然。
在这五个区域中,程序计数器,java虚拟机栈,本地方法栈是属于线程私有的,也就是说,每个线程有每个线程的程序计数器,java虚拟机栈和本地方法栈,而java堆和方法区则是线程共享的。
本文主要讲的是线程私有的程序计数器,java虚拟机栈,本地方法栈。
程序计数器(Program Counter Register)
程序计数器,也会被人称之为pc寄存器。
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个程序计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处 理、线程恢复等基础功能都需要依赖这个计数器来完成。
但是我们得知道,在cpu中也有一个叫程序计数器的东西,但是这和JVM中的程序计数器,是两个东西,当然,这两者在功能逻辑上有一定的相像性,所以最后导致了两者取了相同的名字。但是在我们的理解上,要清楚的将这两者的性质划分开来。
cpu中的程序计数器:也叫指令计数器,有的机器中也叫做指令指针IP,作用主要是在机器语言程序开始前,必须将它的起始地址,也就是机器语言程序的一条指令所在的内存单元地址送入程序计数器,也就是说,cpu通过程序计数器,知道机器语言程序运行到哪个指令了。
JVM的程序计数器:在逻辑上和cpu的程序计数器非常相近,当一个线程运行的时候,程序计数器会将这个线程运行到哪个字节码的指令的地址记录下来。也就是说,JVM通过程序计数器,可以知道这个线程运行到哪一行字节码了。
而java程序的编译过程如下:
java程序(程序员用的指令) -> 字节码(JVM用的指令,JVM程序计数器负责)-> 机器语言(CPU用的指令,cpu程序计数器负责)
因此,我们可以清楚的知道,cpu中的程序计数器,负责的是将更底层的cpu在运行程序的时候的指针存储起来,而JVM中的程序计数器,是负责将线程运行到哪里的字节码的地址存储起来。这就是两者的不同。
那么,为什么JVM需要一个程序计数器呢?
我们都知道,Java是支持多线程的,但是JVM的多线程是通过线程轮流切换,分配处理器执行时间的方式实现的,在任何时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的程序计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
在任意的时刻,一个JVM线程都只会执行一个方法的代码,这个被当前线程执行的方法称为该线程的当前方法。而这个当前线程如果是本地方法,那么程序计数器保存的就是空“undifined”。如果这个方法是java方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址。
为什么运行的是本地方法(native)方法的时候,程序计数器存储的是空呢?
因为native方法实际上都是通过C语言实现的,而且并没有编译成需要执行的字节码,所以我们在JVM层面上,是无法知道这个native方法执行到什么地步了的。
在上一篇文章《从头开始学习JVM(六):线程和JVM的关系》中,我们都知道,JVM线程的调用,实际上最终都是调用了操作系统上的线程,那么操作系统上面的线程,也就是我们理解的CPU线程,从JDK 1.3起,“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型(内核线程)。
也就是说,JVM上的线程,如果执行的是native方法,那么决定这个线程执行这个native方法到了哪一步代码的,是cpu上的程序计数器,而不是JVM的程序计数器。
java虚拟机栈(Java Virtual Machine Stack)
首先我们知道,java虚拟机栈和程序计数器一样,都是属于线程私有的,它的生命周期和线程一致。
那么,我们肯定有疑问了,这个java虚拟机栈的作用是什么呢?
我们都知道,一个线程在跑程序的时候,会执行一个又一个的方法,而每个方法被执行的时候,Java虚拟机都会同步创建一个 栈帧(Stack Frame) 用于存储关于这个方法的一些信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
也就是说,java虚拟机栈,存储的是一个又一个被当前线程执行的一个又一个方法的信息,这些信息的聚合体叫做栈帧。
调用新的方法时,新的栈帧也会随之创建,这也就是这个方法的入栈。随着程序控制权转移到新方法,新的栈帧成为了当前栈帧(位于JVM虚拟机栈栈顶的栈帧元素,即称为当前栈帧)。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧,这也就是栈帧的出栈。
栈帧主要保存了方法如下信息:
- 局部变量表
- 操作数栈
- 动态连接
- 方法出口(方法返回地址)
- 额外附加信息
java虚拟机栈的内部结构如图所示:
[图片上传失败...(image-f4f2ac-1612401918450)]
要注意的是,一个方法,它对应的栈帧的大小,不会受到运行的时候程序时的变量的大小的影响,它的对应的栈帧的大小是在编译java程序代码的时候,就被计算出来了,并且将这些计算出来的信息,放到了这个方法的方法表的Code属性中了。
换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
1.局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在这个方法的方法表的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、 byte、char、short、int、float、reference或returnAddress类型的数据。
reference:表示对一个对象实例的引用,《Java虚拟机规范》既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是一般来说,虚拟机实现至少都应当能通过这个引用做到两件事情,一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则将无法实现《Java语言规范》中定义的语法约定。 returnAddress:是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,某些很古老的Java虚拟机曾经使用这几 条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来代替了。 |
这8种数据类型,在java规定中,都可以使用32位或更小的物理内存来存储,但这种描述与明确指出“每个变量槽应占用32位长度的内存空间”是有本质差别的,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与32位虚拟机中的一致。
当然,java中除了这8个数据类型之外,还有long和double这两种64位字节的数据结构,这个时候,就会使用连续的两个变量槽来装载这两种类型的数据变量。
到这里,我突然想起了以前听过的一句话:当有一个对象你用完了之后,最好是可以将其手动设置为null(当然,我个人是不怎么同意这段话的)。
这是为什么呢?
因为为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。
也就是说,一个方法内的对象,当还处在它的作用域的时候,是无法被回收的,这是毋庸置疑的,但是当它不处于它的作用域的时候,它也无法被gc回收,除非这个时候有其他的变量来占据了它的变量槽,因为如果没有其他的变量来占据这个变量槽的时候,那么这个对象起码还有变量槽这个引用关联着它,也就是通过可达性分析来看,这个变量无法被gc判断为可回收状态,自然也就无法被gc回收了。
2.操作数栈
操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到了这个方法对应的方法表的Code属性中,只是操作数栈的最大深度是保存在max_stacks数据项,而不是max_local数据项。
那么,操作数栈的作用是什么呢?
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。
譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。
举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。
而且要注意的是,操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类加载的校验阶段的数据流分析中还要再次验证这一点。以上面的iadd指令为例,这个指令只能用于整型数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。
说白了,操作数栈的作用,就是在于为方法中的局部变量之间的加减乘除等操作提供一个内存空间。
另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。
3.动态连接
当在java程序中,一个方法要调用另外一个方法,并且获取到另外这个方法的信息,那么要做的第一件事情是什么?
是要知道这个方法的名字!
于是,JVM在编译的时候,就设置了规则,规定栈帧中会使用了一小部分内存空间,来保存一个这个栈帧对应的在方法区的方法的符号引用。
也就是说,在栈帧中保存的这个方法的符合引用,和方法区保存的这个方法的符号引用,是相同的。
当在java程序中,一个A方法,它要调用其他的方法B的时候,A会通过这个B方法对应的B的栈帧中保存的这个符号引用,然后动态转化为直接引用,这个转化就是动态连接。当A方法知道了B方法的直接引用之后,它才能在内存中准确的找到B方法的位置,获取到B方法的信息并且进行调用。
要注意的是,JVM是遵循着一定规律在转换符合引用为直接引用的,同样的一个符号引用,不管你是在编译阶段,还是在运行阶段,只要是转换为直接引用,那么转换后的结果是一样的。
当然我们也会疑惑,栈帧都形成了,为什么还需要动态连接?
这里要注意的是,动态连接的过程,是发生在栈帧完全入栈之前,也在局部变量表形成之前,也就是说,需要通过动态连接,去获取到方法的信息,从而才能形成局部变量表等信息,才能将这个栈帧完善起来。
4.方法出口(方法返回地址)
当一个方法开始执行后,只有两种方式退出这个方法。
正常调用完成:执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定。
异常调用完成:在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。
方法正常退出时,主调方法的程序计数器(PC寄存器)的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。
而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是java虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
对于一个运行中的Java程序而言,它可能会用到一些跟本地方法相关的数据区。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,但不止如此,它还可以做任何它想做的事情。
本地方法本质上还是依赖于实现的,虚拟机实现的设计者们可以自由地决定使用怎样的机制来让Java程序调用本地方法。
当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。
因为已经没有java虚拟机栈来保存本地方法的栈帧了,所以任何本地方法接口都会使用某种本地方法栈。
如果某个虚拟机实现的本地方法接口是使用C连接模型的话,那么它的本地方法栈就是C栈。当C程序调用一个C函数时,其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。同样,这就是虚拟机实现中本地方法栈的行为。
当然,很可能本地方法接口需要回调Java虚拟机中的Java方法,在这种情况下,该线程会保存本地方法栈的状态并进入到另一个Java栈。
《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
总结
本篇文章主要讲的是线程私有的三个内存区域,程序计数器,java虚拟机栈,本地方法栈。
从这三个内存区域中,我们能意识到,java虚拟机为线程做了非常丰盛的准备,不管是变量之间的计算,还是多个线程之间的变量的隔离,还是方法与方法之间的调用,某种意义上来说,java程序中,数据的的操作过程,基本上都在这三个内存区域中实现了。
而下一篇文章要讲的java堆和方法区,其实存储的就是对象和类这些类似的数据而已,是数据的存储结构。