JVM规范定义运行时数据区剖析-Java快速进阶教程
一、前言
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。Java 虚拟机规范定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
扩展阅读
java垃圾回收机制GC(Garbage Collection)-Java快速进阶教程
本文基于作者2014年发布文章<JVM规范定义运行时数据区详解-Java快速进阶教程>创作。相对旧文而言,本文加强实例池、句柄池、实例指针、类型指针、对象引用 、对象类型数据等概念的解释。
下图是 Java 虚拟机规范中定义的各种运行时数据区域。
![](https://img2023.cnblogs.com/blog/14085/202212/14085-20221204140226360-931726504.png)
二、程序计数器-Program Counter
Java 虚拟机可以支持多个线程同时执行,每个 Java 虚拟机线程都有自己的(program counter)寄存器。在任何时候,每个Java 虚拟机线程都只能执行单个方法的代码。这个正被执行的方法称为该线程当前方法(current method)。如果该方法不是native调用,则寄存器里存放的内容就是Java 虚拟机当前正在执行的指令的地址 。如果这个方法是native调用,那么Java 虚拟机的寄存器存放的内容就是undefined。同时Java 虚拟机寄存器容量至少能够存放得下与平台相关指针或者规范中定义的returnAddress类型的尺寸。
三、虚拟机栈-Java Virtual Machine Stacks
3.1 栈定义
Java 虚拟机栈(Java Virtual Machine Stacks),早期也叫 Java 栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。
与传统语言(例如C语言) 中的栈非常类似, 用于存储局部变量与一些尚未算好的结果。另外, 它还可以出现在方法调用和返回。
栈因为除了栈帧的出栈和入栈之外,Java虚拟机栈不会再受其他因素的影响, 所以栈帧可以在堆中分配Java虚拟机栈所使用的内存不需要保证是连续的。另外规范既允许Java虚拟机栈被实现成固定大小,也允许根据计算动态来扩展和收缩。 如果采用固定大小的Java虚拟机栈, 那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。
栈中可能出现的异常:
Java 虚拟机规范允许 Java虚拟机栈的大小是动态的或者是固定不变的
- 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
- 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个OutOfMemoryError异常
3.2 栈的存储单位-栈帧(Stack Frame)
栈中存储什么?
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都各自有对应的一个栈帧
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
每个栈帧(Stack Frame)中存储什么?
-
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(或称为表达式栈)
- 动态链接(Dynamic Linking):指向运行时常量池的方法引用
- 方法返回地址(Return Address):方法正常退出或异常退出的地址
- 一些附加信息
3.2.1 局部变量表
- 局部变量表也被称为局部变量数组或者本地变量表
- 是一组变量值存储空间,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和returnAddress类型(指向了一条字节码指令的地址,已被异常表取代),以下是reference类型的示例
- 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
- 局部变量表所需要的容量大小是编译期确定下来的,并保存在方法的 Code 属性的
maximum local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的 - 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
- 参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束
槽 Slot
-
局部变量表最基本的存储单元是 Slot(变量槽),大小为32位.
-
在局部变量表中,32 位以内的类型只占用一个 Slot(包括returnAddress类型),64 位的类型(long和double)占用两个连续的 Slot
- byte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 true
- long 和 double 则占据两个 Slot
-
JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,索引值的范围从 0 开始到局部变量表最大的 Slot 数量
-
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上
-
如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或double 类型变量,不允许采用任何方式单独访问其中的某一个 Slot)
-
如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列(这里就引出一个问题:静态方法中为什么不可以引用 this,就是因为this 变量不存在于当前方法的局部变量表中)
-
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
- 在栈帧中,与性能调优关系最为密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
3.2.2 操作数栈
-
每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称为表达式栈(Expression Stack)
-
操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
-
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作
- 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的
max_stack
数据项中
栈中的元素可以是任意的 Java 数据类型
- 32bit 的类型占用一个栈单位深度
- 64bit 的类型占用两个栈单位深度
- 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
- 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
3.2.3 动态链接(指向运行时常量池的方法引用)
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
- 在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
JVM 是如何执行方法调用的
方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关,常用方式有2种:
- 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
- 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
- 晚期绑定:如果被调用的方法在编译器无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式就被称为晚期绑定。
虚方法和非虚方法
- 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法
- 其他方法称为虚方法
虚方法表
在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。
3.2.4 方法返回地址(return address)
用来存放调用该方法的 PC 寄存器的值。
一个方法的结束,有两种方式
- 正常执行完成
- 出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
-
执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口
一个方法的正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定
在字节码指令中,返回指令包含 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。
-
在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
3.2.5 附加信息
栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。
本文内容来自于Java® 1.7 语言规范 的理解.
四、运行时常见概念剖析
4.1句柄池
4.1.1定义
句柄池是一种管理对象句柄的机制,它可以缓存对象的句柄并在需要时重复使用它们,从而避免频繁创建和销毁句柄的开销,提高系统的性能和效率。在java中句柄池也是堆中的一部分,它存储着对象句柄的引用。Java虚拟机通过句柄池来管理对象句柄的创建和销毁,以提高系统的性能和效率。当程序需要引用一个对象时,虚拟机会先从句柄池中获取该对象的句柄,然后再通过句柄来访问对象实例。
4.1.2作用
减少对象句柄的创建和销毁次数,提高系统的性能和效率。
4.1.3原因
创建和销毁对象句柄需要消耗大量的系统资源,尤其是在多线程和高并发的环境下,频繁创建和销毁对象句柄会导致系统性能下降
4.1.4构成
句柄池用于存储Java对象的引用,它是实例池的一种优化方式。在句柄池中,每个Java对象都有一个句柄,句柄包含了对象的实际地址和类型信息。引用变量实际上是指向句柄而不是对象本身。句柄池的构成包括以下几个方面:
- 句柄
在句柄池中,每个Java对象都有一个句柄。句柄本身是一个固定长度的结构体,用于存储Java对象的实际地址和类型信息等。句柄本身的组成如下:
对象引用地址 对象引用地址是句柄中最重要的部分,它指向了Java对象的实际地址。在Java虚拟机中,对象引用地址是一个指针,指向Java对象在堆内存中的实际地址。
类型信息指针 类型信息指针是句柄中保存的Java对象的类型信息。在Java虚拟机中,每个Java对象都有一个类型信息指针,用于存储对象的类型信息。类型信息指针指向的是一个类型信息对象,包含了Java对象的类型信息,例如类的名称、父类的类型信息、实现的接口信息等。
其他信息 句柄中还可能包含一些其他的信息,例如锁信息、GC状态等。锁信息用于存储Java对象的锁状态,GC状态用于标记Java对象是否需要进行垃圾回收等。
总的来说,Java句柄是一种优化方式,可以减少Java虚拟机的内存使用,提高程序的性能和效率。句柄本身由对象引用地址、类型信息指针和其他信息等组成,可以有效地管理Java对象的内存空间。
4.2实例池
4.2.1定义
实例池是一种管理实例对象的机制,它可以缓存已经创建的对象并在需要时重复使用它们,从而避免频繁创建和销毁对象的开销,提高系统的性能和效率。在Java中实例池是堆中的一部分,它存储着已经创建的对象实例。Java虚拟机通过实例池来管理对象实例的创建和销毁,以提高系统的性能和效率。当程序需要创建一个新的对象时,虚拟机会先查找实例池中是否存在相同类型的对象实例,如果存在,则直接返回该对象实例的引用,否则就创建一个新的对象实例,并将其加入到实例池中。
4.2.2作用
减少对象的创建和销毁次数,提高系统的性能和效率。
4.2.3原因
创建和销毁对象需要消耗大量的系统资源,尤其是在多线程和高并发的环境下,频繁创建和销毁对象会导致系统性能下降。
4.2.4构成
实例池用于存储Java对象的实例,包括数组和类实例。实例池中的对象可以通过引用变量来访问和操作。实例池的构成包括以下几个方面:
- 对象头
每个Java对象在实例池中都有一个对象头,用于存储对象的元数据信息,包括对象的哈希码、对象的GC状态、对象的类型信息等。 - 实例数据
实例数据是对象的实际数据,包括对象的字段和数组元素等。实例数据可以通过对象头中的指针来访问和操作。 - 对齐填充
为了保证对象在内存中的对齐,Java虚拟机会在实例数据之后添加一些字节的填充。
4.3实例指针
4.3.1定义
实例指针是一种指向对象实例的指针,它可以在程序中引用和操作对象实例。
4.3.2作用
指向对象实例,方便程序引用和操作对象。
4.3.3原因
对象实例是程序中重要的数据结构,需要能够方便地引用和操作。
4.4类型指针
4.4.1定义
类型指针是一种指向对象类型的指针,它可以在程序中引用和操作对象类型。
4.4.2作用
指向对象类型,方便程序引用和操作对象。
4.4.3原因
对象类型是程序中重要的数据结构,需要能够方便地引用和操作。
4.5对象引用
4.5.1定义
对象引用是一种指向对象的引用,它可以在程序中引用和操作对象。
4.5.2作用
引用和操作对象。
4.5.3原因
对象是程序中重要的数据结构,需要能够方便地引用和操作。
4.6对象类型数据定义
4.6.1定义
对象类型数据定义是指对象的类型和属性的定义,包括对象的类名、实例变量、方法等。
4.6.2作用
定义对象的类型和属性,方便程序操作和管理对象。
4.6.3原因
对象是程序中重要的数据结构,需要定义其类型和属性。
4.7方法区
方法区是Java堆中的一部分,主要用于存储已经被加载的类的信息、常量、静态变量、即时编译器编译后的代码等数据。在Java虚拟机的内存区域划分中,方法区位于堆内存的逻辑上方,是一块单独的内存区域。方法区的构成主要包括以下几个方面:4.7.1类型信息
方法区主要用于存储已经被加载的类的信息,包括类的基本信息(如类名、父类名、接口、字段、方法等)、类的访问修饰符、方法的访问权限等。这些信息可以通过Java反射机制来访问和修改。
4.7.2运行时常量池
方法区还包括一个运行时常量池,用于存储类文件中的常量信息,包括字符串、数字、方法、字段等。在类加载时,Java虚拟机会将常量池中的常量加载到运行时常量池中,并进行解析和替换,以便于程序在运行时使用。4.7.3静态变量
方法区还用于存储类的静态变量,这些变量在类加载时被初始化,并一直保存在方法区中,直到程序结束或类被卸载。静态变量可以被所有实例共享,因此它们通常用于保存和管理全局数据。
4.7.4即时编译器编译后的代码
方法区还包括即时编译器编译后的代码,用于提高程序的性能和效率。即时编译器会将Java字节码编译成本地机器码,并保存在方法区中,以供程序调用。
4.7.5方法描述符(Method Descriptor)
方法描述符描述了方法的参数类型和返回值类型等信息。Java虚拟机通过方法描述符来确定方法的参数和返回值类型,以便于方法的调用和执行。
4.7.6动态代理的信息
动态代理类的字节码、代理类的接口列表、代理类的方法调用处理器等。
4.7.7字节码增强技术的信息
类加载器、字节码增强器、类修改器等。
4.7.8内部运行的信息
Java虚拟机内部的异常处理器、线程调度器、内存管理器等
总的来说,方法区是Java虚拟机中非常重要的一部分,它包括了类的信息、常量、静态变量、即时编译器编译后的代码、方法描述符、动态代理信息、字节码增强信息和Java虚拟机内部运行信息等内容。了解方法区的构成和作用,有助于我们更好地理解Java虚拟机的内部运行机制。