我们的JVM系列已经断更好几天了,小伙伴们在后台疯狂私信阿Q,想看后续内容,今天它来了。相信大家在上篇文章中已经对类加载子系统有了清晰的认识,接下来就让我们来揭开“运行时数据区”的神秘面纱吧。(文章可能有点长,建议先收藏一波)
内存是非常重要的系统资源,是硬盘和CPU
的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。下图就是HotSpot
的经典的内存布局:
图中的CodeCache
在JVM官方文档中被归于元空间,而在阿里的官方文档中被单独摘了出来,此处区别并不影响我们对它的学习。
Java虚拟机在执行Java程序的过程中,会将涉及到的数据划分到不同的内存区域去管理,而这部分区域就是我们接下来要讲的Java虚拟机的运行时数据区。
如上图所示,我们的运行时数据区分为PC寄存器、方法区、堆、本地方法栈和虚拟机栈五个部分。其中上文中所说的元空间就是方法区的具体落地实现。估计有的老铁会问:不是还有直接内存吗?其实直接内存并不属于运行时数据区的一部分,也不是java虚拟机规范中的区域,它的大小不受java堆大小的限制,是使用Native
函数库直接分配的堆外内存,会被频繁使用。它存储着堆与本地方法相关的数据,可以避免在Java堆和Native
堆中来回复制数据,能够提高效率。
细心的老铁应该会发现,上图中阿Q用了红蓝两种颜色来区分五个部分,其中红色的方法区和堆是线程间共享的,即它们会随着虚拟机启动而创建,随着虚拟机退出而销毁;而蓝色的部分为每个线程单独享有的,即它们与线程是一一对应的,会随着线程开始和结束而创建和销毁。在HotSpot JVM
中,每个线程都与操作系统的本地线程直接映射:当一个java线程准备好执行之后,此时一个操作系统的本地线程也同时创建,java线程执行终止后,本地线程也会回收。操作系统负责所有线程的安排调度到任何一个可用的CPU
上,一旦本地线程初始化成功,它就会调用Java现成的run()
方法。
我们可以翻看官方文档了解一下Runtime
类:
Every Java application has a single instance of class Runtime that allows the application to interface with the environment in which the application is running. The current runtime can be obtained from the getRuntime method.
译: 每个Java应用程序都有一个类运行时实例,该实例允许应用程序与运行应用程序的环境交互。当前运行时可以从getRuntime
方法获得。
看到这如果大家对运行数据区还没有大致的概念的话,给大家举个小例子,大家一看便知:
如上图所示,厨师正在烹饪佳肴,我们如果把厨师炒菜比作我们的虚拟机执行代码的话,厨师就是我们后文中将要提到的执行引擎,而厨师后方的工具类和食材就相当于我们的运行时数据区。在写这篇文章的过程中发现知识点有点多,所以阿Q把它分为两部分进行讲解,该篇文章先说一下线程的私有区域:PC寄存器、本地方法栈和虚拟机栈。
这里的寄存器并不是广义上所指的物理寄存器,而是对物理寄存器的抽象模拟,把它称为PC计数器(或指令计数器)更为合适。
Java虚拟机可以一次支持多个执行线程,每个Java虚拟机线程都有其自己的PC寄存器即为线程独有。PC寄存器会随着线程的创建而创建,会随着线程的结束而死亡。正因为程序计数器记录的是指令地址,所以它占用的内存空间较少,因此它是运行速度最快的存储区域,也是唯一一个在Java虚拟机规范中没有规定任何OutOtMemoryError(内存溢出)
情况的区域。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法。如果线程当前正在执行的方法不是native
,则该pc寄存器包含当前正在执行的Java虚拟机指令的地址;如果线程当前正在执行的方法是native
,则Java虚拟机的pc寄存器值未定义undefned
。
PC寄存器的作用就是用来存储指向下一条指令的地址,也就是即将要执行的指令代码,由执行引擎读取该指令并交由cpu
执行。它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。我们可以把PC寄存器理解为一个记录着当前线程所执行的字节码的行号指示器,也可以理解为一个游标,来告诉程序按照我指定的顺序执行。接下来用例子来演示下它所处的位置与作用。
例:
如图所示,PC寄存器中存储着指向“操作指令”的“指令地址”。假如现在PC寄存器中存储的指令地址是“5”,则执行引擎会取出对应的操作指令,然后做两件事:一是操作局部变量表、操作数栈等完成数据的存、取、加减等操作;二是将操作指令翻译成CPU能识别的机器指令,最后由CPU执行;此时字节码解释器就会改变PC寄存器中的值为“6”,以此类推。
(1)为什么要使用PC寄存器记录当前线程的执行地址呢?
JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,这时候就需要PC寄存器来记录某个线程的字节码执行位置,如果虚拟机是单线程也就没必要用程序计数器记录每个线程的位置了。
(2)PC寄存器为什么会被设定为线程私有呢?
由于jvm的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此为了能够准确的记录各个线程正在执行的当前字节码指令地址,最好的办法自然就是为每一个线程都分配一个PC寄存器。这样各条线程之间计数器互不影响,独立存储。
正如我们的《JVM集合之开篇点题》中所说,由于跨平台性的设计,Java的指令都是根据栈来设计的,它遵循“先进先出、后进后出”的原则。它的优点就是跨平台、指令集小,编译器更容易实现。
在这里我们要对“栈”和“堆”做一个简单的区分,其中栈是运行时的单位,它解决的是程序运行的问题,即程序如何执行,或者说是如何处理数据;堆是存储的单位,它解决的是数据存储的问题,即数据怎么放、放在哪。我们举个简单的例子:假如你正在修理汽车,我们可以把修车的操作步骤看做是栈操作,而把汽车的零件一个个放到汽车中就可以看做是堆存储。
Java虚拟机栈,早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,所以虚拟机栈是线程私有的,当线程结束时虚拟机栈也就结束了。JVM对虚拟机栈的操作只有进栈和出栈,所以它的访问速度仅次于程序计数器,也是一种快速有效的分配存储方式。对于虚拟机栈来说它不存在垃圾回收问题,但是虚拟机栈的大小是动态的或者固定不变的,所以它会存在栈溢出或者内存溢出问题:
栈溢出:如果采用固定大小的虚拟机栈,那每一个线程的虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过虚拟机栈允许的最大容量,虚拟机栈会抛出StackOverflowError
异常。
内存溢出:如果虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那虚拟机将会抛出OutOfMemoryError
异常。栈的大小直接决定了函数调用的最大可达深度,我们可以通过-Xss
参数来配置栈内存,追加字母k或K表示KB,m或M表示MB,g或G表示GB,示例:-Xss1m
。
虚拟机栈主管Java程序的运行,保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回,那它内部到底是什么构造呢?虚拟机栈内部保存着一个一个的栈帧(Stack Frame
),每个栈帧与该线程正在执行的每个方法都是一一对应的。栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧 (Current Frame
),与当前栈帧相对应的方法就是当前方法(Current Method
),定义这个方法的类就是当前类(Current Class
)。执行引擎运行的所有字节码指令只针对当前栈帧进行操作。执行过程如下图:
程序开始执行,首先方法1入栈,为栈帧1,此时栈帧1为当前栈帧;随后方法1调用方法2,方法2入栈,为栈帧2,此时栈帧2为当前栈帧,以此类推;当方法4入栈成为栈帧4并且执行代码,在方法4返回之际,栈帧4会传回方法4的执行结果给栈帧3,接着,虚拟机会丢弃栈帧4即栈帧4出栈,使得栈帧3重新成为当前栈帧,以此类推,直到方法1执行完成,栈帧1出栈,虚拟机栈被回收。
★Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令(包含
”void
返回类型);一种是抛出异常(指的是未处理的异常,如果是try...catch
过了,算第一种)。不管使用哪种方式,都会导致栈帧出栈。不同线程中所包含的栈帧是不允许存在互相引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
如图所示,栈帧由局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息组成,接下来就让我们逐个来了解一下吧。
局部变量表也被称之为局部变量数组或本地变量表,实际上是一个“数字”数组,主要用于存储方法的参数和定义在方法体内的局部变量(包括各类基本数据类型、对象引用、returnAddress
类型),虚拟机使用局部变量表完成方法返回。因为局部变量表是建立在线程的虚拟机栈上,是线程的私有数据,所以不会存在数据安全问题。另外栈帧的大小主要受局部变量表的影响,而局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code
属性的maxmum_local_variables
数据项中,所以在方法运行期间是不会改变局部变量表的大小的,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。一般来说在,在虚拟机栈大小固定的前提下,它的局部变量表越大,它的栈帧就越大,那它的嵌套调用次数(方法调用数)也就越少,即栈的深度越浅。用几张字节码的图来说明一下局部变量表中的内容:
★局部变量表中的数据只有在当前方法中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程,当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
”
Slot
参数的存放总是在局部变量数组的索引0开始,到数组长度减1的索引结束,它最基本的存储单元就是Slot
(变量槽)。当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot
上。JVM会为局部变量表中的每个Slot
都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。其中32位以内的类型只占用一个slot
(包含returnAddress
类型,byte
、short
、char
、float
都转化为int
类型,而boolean
类型是0为false
,非0为true
),64位的类型(long
和double
)占用两个slot
。如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。
如果当前帧是由构造方法或者实例方法创建的,那么该对象的引用this
将会存放在index
为0的slot
处,其余的参数按照参数表顺序继续排列,而this
变量不存在于静态方法的局部变量表中,所以上文中的main
方法中不存在this
变量。另外Slot
是可以复用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很有可能会复用该局部变量的slot,从而达到节省资源的目的。
补充知识点:变量按照在类中的位置可以分为成员变量和局部变量,其中成员变量又分为类变量和实例变量。
成员变量在使用前,都会默认初始化赋值,其中类变量是在类加载子系统的准备阶段进行默认赋值,在初始化阶段显示赋值;
实例变量会随着对象的创建,在堆空间中分配实例变量空间,并进行默认赋值;
局部变量是不会进行默认赋值的,所以在使用前必须进行显示赋值,否则编译不通过。
★局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或者间接引用的对象都不会被回收。
”
操作数栈又称为表达式栈,在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈。操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。每一个操作数栈都会拥有一个明确的栈深度用于存储数据值,其所需要的最大深度在编译期间就定义好了,保存在方法的code
属性中,为max_stack
的值(与上边局部变量表类似)。栈中的元素可以是任意的Java数据类型,其中32bit的用一个栈单位深度,64bit的用两个栈单位深度。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。我们所说的Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。有了上述的理论,估计你会是这样的
阿Q特地制作了一张动态图来说明一下字节码指令执行时PC寄存器、局部变量表和操作数栈的运行过程:
public void test() {byte i = 15;int j = 8;int k = i + j;}
在编译期间局部变量表和操作数栈的大小已经确定了:
首先将要执行的指令地址0
存放到PC寄存器中,此时,局部变量表和操作数栈的数据为空;
当执行第一条指令bipush
时,将操作数15
放入操作数栈中,然后将PC寄存器的值置为下一条指令的执行地址,即2
;
当执行指令地址为2
的操作指令时,将操作数栈中的数据取出来,存到局部变量表的1
位置,因为该方法是实例方法,所以0
位置存的是this
的值,PC寄存器中的值变为3
;
同步骤2和3将8
先放入操作数栈,然后取出来存到局部变量表中,PC寄存器中的值也由3
->5
->6
;
当执行到地址指令为6
、7
、8
时,将局部变量表中索引位置为1
和2
的数据重新加载到操作数栈中并进行iadd
加操作,将得到的结果值存到操作数栈中,PC寄存器中的值也由6
->7
->8
->9
;
执行操作指令istore_3
,将操作数栈中的数据取出存到局部变量表中索引为3
的位置,执行return指令,方法结束。
如果被调用的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,并更新pc寄存器中下一条需要执行的字节码指令。
★栈顶缓存技术:将栈顶的元素全部缓存到物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
”
在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池中。
当字节码文件被加载到虚拟机后,字节码文件中的一些数据,如类型信息、域信息、方法信息等,就会被放置到方法区中,而字节码文件中的常量池则会进入方法区中的运行时常量池。每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。动态链接就是在“类加载”中“链接”的“解析阶段”将符号引用转化为直接引用的过程。
为什么字节码文件需要常量池?因为字节码文件需要数据支持,通常这种数据会很大,以至于不能直接存放到字节码中,换一种方式,可以将指向这些数据的符号引用存到字节码文件的常量池中,这样字节码只需使用常量池就可以在运行时通过动态链接找到相应的数据并使用。
方法返回地址是用来存放调用该方法的PC寄存器的值的。我们都知道,方法的结束有两种方式:一种是正常执行完成;一种是出现未处理的异常,非正常退出。无论哪种方式退出,在方法退出后都返回到该方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。方法正常退出时,当前线程的pc寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈,将返回值压入调用者栈帧的操作数栈,设置PC寄存器的值等,让调用者方法继续执行下去。
按照方法完成出口方式的不同又分为正常完成出口和异常完成出口:
正常完成出口的字节码指令中的返回值类型为ireturn
(boolean
、byte
、char
、short
和int
)、lreturn
(long
)、freturn
(float
)、dreturn
(double
)、areturn
(引用类型)和return
(void、实例初始化方法、类和接口的初始化方法)。
在方法执行过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常处理表中没有搜索到匹配的异常处理器,就会导致方法的退出,简称异常完成出口。异常处理表是用来存储方法执行过程中抛出异常时的异常处理的,方便在发生异常的时候找到处理异常的代码。
★两种方式的本质区别就是异常完成出口退出时不会给他的上层调用者产生任何的返回值。
”
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息,例如对程序调试提供支持的信息。
要说起本地方法栈,我们先来介绍一下本地方法。
首先本地方法是不在运行时数据区中的,它的位置如图所示:
本地方法其实就是java调用非java代码的接口,该接口由非java语言实现。本地接口的作用是融合不同的编程语言为java所用,它的初衷是融合C/C++程序。
native
可以与所有其他的java标识符连用,但是abstract
除外。
为什么要使用Native Method
?
与Java环境外交互:有时候java应用需要与java外边的环境进行交互;
与操作系统进行交互:使用本地方法,我们可以用java实现jre与底层系统的交互;
Sun's Java:Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。
本地方法栈是用来管理本地方法的调用的,也是线程私有的。他也允许被实现成固定或者可动态扩展的内存大小,在内存溢出方面与虚拟机栈类似。本地方法栈的具体做法是Native Method Stack
中登记native
方法,在Execution Engine
执行时加载本地方法库。
当某个线程调用本地方法时,他就进入了一个全新的并且不再受虚拟机限制的世界,他和虚拟机拥有同样的权限:
本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区;
可以直接使用本地处理器中的寄存区;
直接从本地内存的堆中分配任意数量的内存。
转载:20张图助你了解JVM运行时数据区,你还觉得枯燥吗?