Java 虚拟机中的运行时数据区分析
本文基于 JDK1.8 阐述分析
运行过程
我们都知道 Java 源文件通过编译器编译后,能产生相应的 .Class 文件,也就是字节码文件。而字节码文件通过 Java 虚拟机中的解释器,编译成特定机器上的机器码。
跨平台的特性
Java 能跨平台的原因是因为:不同的平台有不同的 JVM 版本,一个 Java 源文件被编译成字节码文件,被不同平台的 JVM 翻译成特定平台下的机器码从而运行。
Java 虚拟机组成
Java 虚拟机由三个子系统构成,分别是类加载子系统、JVM 运行时数据区和执行引擎,本文的重点是在 JVM 运行时数据区。
类加载子系统将硬盘上的字节码文件加载进内存,JVM 运行内存有一套自己的结构划分如图所示,最终程序要运行,需要操作系统分配相应的时间调度,由执行引擎去执行,才能得到最终结果。
线程共享数据:允许被所有线程共享访问的一块内存区域。
线程私有数据:本线程私有的一块内存区域
虚拟机栈(JVM Stacks)
-
Java 虚拟机栈是线程私有的,它的生命周期与线程相同,线程启动而产生,线程结束而消亡。
-
Java 虚拟机栈是描述 Java 方法执行的内存模型,用于存储栈帧。
-
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。
-
虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
-
除了 native 方法,几乎所有的 Java 方法都是通虚拟机栈来实现方法的调用和执行(需要程序计数器、堆、方法区的配合)。
-
栈帧(Stack Frame)
- 每个方法执行的同时会创建一个栈帧,它是虚拟机栈的基本元素。
- 一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
- 在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
- 栈帧随着方法调用而创建,随着方法结束而销毁。
- 每一个栈帧包含的内容有局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。
-
局部变量表(Local Variable Table)
- 一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。
- 该方法所需要分配的局部变量表的最大容量在将 Java 编译为 Class 文件时已经确定。
- 一个局部变量表保存的是编译期可知的各种基本数据类型、对象引用和 returnAddress 类型(它指向了一条字节码指令的地址)。
- 局部变量表的容量以变量槽为最小单位,每个变量槽可以存储32位长度的内存空间。对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的变量糙空间。
- 局部变量表所需的内存空间在编译期间就能完成分配,在运行期间不会改变其大小。
- 虚拟机通过索引定位的方法查找相应的局部变量
-
操作数栈(Operand Stack)
- 虚拟机栈中的一个用于计算的临时数据存储区。
- 随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。
-
动态链接(Dynamic Linking)
- 在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于运行时常量池。
- 每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用。
- 这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。
-
方法返回
- 一个方法开始执行后,只有两种退出方式:正常完成出口和异常完成出口
- 正常完成出口指方法正常完成并退出,根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。
- 异常完成出口指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。
- 无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,方法返回时可能需要在栈帧中保存一些信息。
- 一般来说,方法正常退出时,调用者的程序计数器的值可以作为返回地址,栈帧中会保存这个计数器值。而方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中一般不保存这部分信息。
-
附加信息
- 虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧之中,例如和调试相关的信息,这部分信息完全取决于不同的虚拟机实现。
- 在实际开发中,一般会把动态连接,方法返回地址与其他附加信息一起归为一类,称为栈帧信息。
程序计数器(Program Counter Register)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
-
程序计数器是线程私有的
JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,在同一时刻一个处理器内核只会执行一条线程,处理器切换线程时并不会记录上一个线程执行到哪个位置,所以为了线程切换后依然能恢复到原位,每条线程都需要有各自独立的程序计数器。
-
JVM 规范中唯一没有规定 OutOfMemoryError 情况的区域
程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存。
-
执行 Native 方法时计数器值为空
当执行 Java 方法时,程序计数器存放 Java 字节码的地址。实现上可能有两种形式,一种是相对该方法字节码开始处的偏移量,叫做 bytecode index(简称 bci)。另一种是该 Java 字节码指令在内存的地址,叫做 bytecode pointer(简称 bcp)。
Native 方法大多通过 C 实现,它的方法体不是由 Java 字节码构成,无法应用上述 Java 字节码地址的概念,也就不需要存储字节码文件的行号。
-
Native 方法的实际执行
Java 线程总是需要以某种形式映射到 OS 线程上,HotSpot VM 目前在大多数平台上都使用 1:1 模型(原生线程模型),也就是每个 Java 线程直接映射到一个 OS 线程上执行。此时 native 方法由原生平台直接执行。
本地方法栈(Native Method Stacks)
本地方法栈为虚拟机使用到的 Native 方法服务。Native 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用 C/C++ 方法。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
-
Java 程序调用本地方法
不同于虚拟机栈的入/出栈,当线程调用 native 方法时,虚拟机只是简单地动态连接并直接调用指定的 native 方法。
-
本地方法接口回调 JVM 中的 Java 方法
如果某个虚拟机实现的本地方法接口是使用 C 连接模型的话,那个他的本地方法栈就是 C 栈,当一个 C 函数调用另一个 C 函数时,它的栈操作是确定的。如果本地方法接口需要回调JVM 中的 Java 方法,该线程会保存本地方法栈的状态并进入到另一个Java栈。
-
不同虚拟机的不同实现
虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。常用的 HotSpot 虚拟机选择合并了虚拟机栈和本地方法栈。
堆(Heap)
堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。
-
分代概念
- JVM 中堆空间由新生代和老年代两个区组成
- 新生代可以划分为三个区,Eden 区,两个 Survivor 区
- Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。
- JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。
-
常用参数配置
参数 说明 -Xms 堆内存初始大小 -Xmx 堆内存最大允许大小 -Xss 每个线程的 Stack 大小 -XX:NewSize(-Xns) 新生代初始大小 -XX:MaxNewSize(-Xmn) 新生代最大允许大小 -XX:NewRatio 设置新生代与老年代比值 -XX:SurvivorRatio 设置 Survivor 与 Eden 比值 -XX:PermSize 设置持久代初始内存大小(JDK8 以前) -XX:MaxPermSize 设置持久代最大内存(JDK8 以前) -XX:MetaspaceSize 设置元空间初始内存大小(JDK8 以后) -XX:MaxMetaspaceSize 设置元空间最大内存(JDK8 以后) -
堆 GC
在堆中分配的内存,由 JVM 自动垃圾回收器来管理。关于 GC 详情,之后再补充。
方法区(Method Area)
方法区是一种规范,不同的虚拟机的实现也不一样。从 JDK 1.8 开始,元空间(Metaspace)取代了永久代(PermGen)成为 HotSpot VM 对方法区的实现。方法区存储加载进来的每一个类的结构信息,可以看做是将类(Class)的模板信息,保存在方法区里
-
元空间属于本地内存
JDK8 以前,永久代是堆的一部分,和新生代、老年代的地址是连续的。JDK8 以后,元空间属于本地内存,不再属于堆的一部分,它还有一个别名叫非堆(Non-Heap),所以元空间不存在 OOM 内存溢出的情况。
-
方法区是线程共享的
当多个线程用到同一个类,而这个类还未被加载,则应该只有一个线程去加载类,其他线程等待。