深入理解JVM 运行时数据区

作者: 撸代码的大白 | 来源:发表于2020-03-28 20:03 被阅读0次

    郑重声明,盗图一时爽,一直盗图一直爽,开玩笑的哈,图片大部分来源于网络,如有侵权,请联系我,我自己再画一份。

      今天我们终于开始了运行时数据区了。我们回忆一下我们之前所介绍的内容,我们先讲了类的初始化和类加载器也就是传说中的类加载子系统(JVM第二部分内容),然后比较详细的介绍了由源码到字节码的过程和字节码结构(JVM第一部分内容)。

      那么既然从源码到了字节码,而且字节码也被类加载子系统加载了,那么加载之后jvm是怎么处理的呢?那么就牵扯到运行时了,我们讲解的运行时数据区,算是JVM第三部分的内容。

    运行时数据区的结构图(盗图):

    总结构图

    我们可以看出,分了五部分:程序计数器、虚拟机栈、本地方法栈、方法区、堆。分了两类,灰色是线程共享的、青色部分是线程隔离的。我们每个部分详细介绍一下。

    1、程序计数器

      我们知道程序是多线程的,当发生线程调度时,需要记录一下当前线程运行到哪儿了,然后其他线程执行,当之前的线程得以继续运行时,要通过程序计数器拿到之前停止在哪里了,然后从那个指令 开始继续执行。程序计数器负责记录当前线程正在执行的字节码指令的地址,如果正在执行的是 Native 方法,这个计数器值则为空。程序计数器占用的空间很小,而且是线程独有的。它是唯一不会发生内存溢出的区域。

    2、栈

    我们知道,jvm是采用基于栈的指令集,基于栈和基于寄存器的区别,我们在文章后面再介绍,现在我们先关注栈的结构。

    栈结构

    每个java线程都对应一个虚拟机栈,栈内是一个一个线程需要调用的方法。每个方法对应一个数据结构,我们称之为栈帧。方法调用到执行的过程,就对应一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

    当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常;栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。

    本来栈桢作为虚拟机栈的一个单元,应该是栈桢之间完全独立的。但是,大多虚拟机的实现里进行了一些优化:为了避免过多的方法间参数的复制传递、方法返回值的复制传递等一些操作,就让一部分数据进行栈桢间共享。

    栈帧内存储了局部变量表、操作数栈、 动态链接、 方法返回地址等信息。

    2-1 栈帧:局部变量表

    是一片逻辑连续的内存空间,最小单位是Slot,用来存放方法参数和方法内部定义的局部变量。虚拟机通过索引定位的方式使用局部变量表,索引值范围从0开始至局部变量表最大的Slot数量。其内存储着三种数据类型。

    2-1-1 基本类型:boolean、byte、char、short、int、float占用一个Slot。long和double类型占用两个Slot。

    2-1-2 引用类型:占用一个Slot。表示对一个对象实例的引用。通过这个引用可以获取一个对象和对象的元数据信息(主要是类型信息)。引用可能指向直接用用或者指向句柄。直接引用和句柄的区别,下图说明:

    引用指向句柄 引用指向对象地址

    我们可以比较清楚的比较两种方式的优点和缺点。运行时,句柄比直接引用多一次地址路由。而当发生垃圾回收,对象地址随之发生改变。句柄要做的是更新其对象地址,而不需要改变引用和句柄的关联关系。而直接引用需要改变引用,使其能重新找到对象。

    2-1-3 返回地址类型:指向了一条字节码指令的地址。

    执行实例方法,局部变量表中第0位索引的Slot默认是方法所属对象实例的引用,方法中可以通过关键字“this”来访问。 方法参数则按照参数表顺序,占用从1开始的局部变量Slot。之后再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

    2-2 栈帧:操作数栈

    每个栈帧都包含一个叫做操作数栈(操作栈)的后进先出的栈。方法刚开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。向其他方法传参的参数,也存在操作数栈中。其他方法返回的结果,返回时存在操作数栈中。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。

    这里通过一个简单的运算,帮助理解一下操作数栈,也顺便解释一下基于栈的指令集和基于寄存器的指令集的区别。以下两个图,做的是(1+2)*5 ,源码:

    源码

    下图动态的解释了基于栈的运行过程:

    基于栈

    如果看过之前反编译字节码那一篇文章、这些就看起来很简单了,我再解释一下这个过程:

    程序计数器指向第0行,将整数1压入栈顶

    程序计数器指向第1行,将栈顶的1出栈并赋值给局部变量表的第0个变量,也就是a

    程序计数器指向第2行,将证书2压入栈顶

    程序计数器指向第3行,将栈顶的2出栈并赋值给局部变量表的第1个变量,也就是b

    程序计数器指向第4行,加载第0个变量并将其压入栈顶,也就是a

    程序计数器指向第5行,加载第1个变量并将其压入栈顶,也就是b

    程序计数器指向第6行,执行加法运算,将1 和 2 出栈,并将结果3压入栈顶。

    程序计数器指向第7行,将整数5压入栈顶

    程序计数器指向第8行,执行乘法运算,5和3出栈,将结果15压入栈顶。

    程序计数器指向第9行,将栈顶的15赋值给第2个变量,也就是c

    程序计数器指向第a行,return

    下图动态的解释了基于寄存器的运行过程:

    基于寄存器

    通过这两个图,我们理解了操作数栈的作用,也知道了怎么跟程序计数器和本地变量表配合。同时也看出了,基于栈的指令集相对复杂,频繁的入栈出栈,效率上比基于寄存器要低。但是为什么java选择了基于栈的操作呢?我的理解是,基于寄存器受限于硬件资源,平台无关系差。

    2-3 栈帧:动态链接

    Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

    2-4 栈帧:方法返回地址

    当一个方法被执行后有两种方式退出这个方法:

    正常完成出口:执行引擎遇到任意一个方法返回的字节码指令。

    异常完成出口:遇到异常,并且这个异常没有在方法体里得到处理。

    无论采用何种退出方式,在方法退出后,都需要返回方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用于恢复它的上层方法的执行状态。一般来说方法正常退出,调用者的 PC 计数器的值就可作为返回地址,栈帧中很可能保存的就是这个计数器的值。而方法异常退出时,返回地址要通过异常处理器来确定,这时候栈帧中一般不会保存这部分信息。

    方法退出的过程实际上等同于将当前栈帧出栈。因此退出时的操作可能有:恢复上层方法的局部变量表和操作数栈,如果有返回值则把他压入调用者栈帧的操作数栈中。调整 PC 计数器的值以指向方法调用的指令的后一条指令。

    3、本地方法栈

    本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。

    4、堆

    所有对象都在这里分配内存,是垃圾收集的主要区域("GC 堆")。

    对象分配空间失败会抛出 OutOfMemoryError 异常。

    可以通过 -Xms 和 -Xmx 两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

    这一部分的具体内容,我们放在分析垃圾回收机制时再详细讲解。

    5、方法区

    用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

    和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。

    对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。

    HotSpot 虚拟机把它当成永久代来进行垃圾回收。但是很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。

    在JDK1.7及以前版本的 HotSpot 虚拟机中,是采用永久代(PermGen space)来实现 JVM 规范中的方法区的。到了JDK1.8方法区的实现变成了元空间(Metaspace)。

    其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用转移到了 native heap;字面量、类的静态变量转移到了java heap。

    永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

    5-1 运行时常量池

    运行时常量池是方法区的一部分。这有一次印证了反编译字节码文件的收获。

    Class 文件中的常量池(编译器生成的各种字面量(Literal)和符号引用)会在类加载后被放入这个区域。

    除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()。

     6、直接内存

    在 JDK 1.4 中新加入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存(Native 堆),然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。unsafe类。

    这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

    相关文章

      网友评论

        本文标题:深入理解JVM 运行时数据区

        本文链接:https://www.haomeiwen.com/subject/bmjsuhtx.html