vlambda博客
学习文章列表

20张图助你了解JVM运行时数据区,你还觉得枯燥吗?

运行时数据区总览

内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。下图就是HotSpot的经典的内存布局:图中的CodeCache在JVM官方文档中被归于元空间,而在阿里的官方文档中被单独摘了出来,此处区别并不影响我们对它的学习。

Java虚拟机在执行Java程序的过程中,会将涉及到的数据划分到不同的内存区域去管理,而这部分区域就是我们接下来要讲的Java虚拟机的运行时数据区。20张图助你了解JVM运行时数据区,你还觉得枯燥吗?如上图所示,我们的运行时数据区分为PC寄存器、方法区、堆、本地方法栈和虚拟机栈五个部分。其中上文中所说的元空间就是方法区的具体落地实现。估计有的老铁会问:不是还有直接内存吗?其实直接内存并不属于运行时数据区的一部分,也不是java虚拟机规范中的区域,它的大小不受java堆大小的限制,是使用Native函数库直接分配的堆外内存,会被频繁使用。它存储着堆与本地方法相关的数据,可以避免在Java堆和Native堆中来回复制数据,能够提高效率。20张图助你了解JVM运行时数据区,你还觉得枯燥吗?

细心的老铁应该会发现,上图中阿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方法获得。

看到这如果大家对运行数据区还没有大致的概念的话,给大家举个小例子,大家一看便知:20张图助你了解JVM运行时数据区,你还觉得枯燥吗?如上图所示,厨师正在烹饪佳肴,我们如果把厨师炒菜比作我们的虚拟机执行代码的话,厨师就是我们后文中将要提到的执行引擎,而厨师后方的工具类和食材就相当于我们的运行时数据区。在写这篇文章的过程中发现知识点有点多,所以阿Q把它分为两部分进行讲解,该篇文章先说一下线程的私有区域:PC寄存器、本地方法栈和虚拟机栈。

PC寄存器(程序计数器)

这里的寄存器并不是广义上所指的物理寄存器,而是对物理寄存器的抽象模拟,把它称为PC计数器(或指令计数器)更为合适。

介绍

作用

面试题分析

JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,这时候就需要PC寄存器来记录某个线程的字节码执行位置,如果虚拟机是单线程也就没必要用程序计数器记录每个线程的位置了。

(2)PC寄存器为什么会被设定为线程私有呢?

虚拟机栈

栈的介绍

正如我们的中所说,由于跨平台性的设计,Java的指令都是根据栈来设计的,它遵循“先进先出、后进后出”的原则。它的优点就是跨平台、指令集小,编译器更容易实现。

在这里我们要对“栈”和“堆”做一个简单的区分,其中栈是运行时的单位,它解决的是程序运行的问题,即程序如何执行,或者说是如何处理数据;堆是存储的单位,它解决的是数据存储的问题,即数据怎么放、放在哪。我们举个简单的例子:假如你正在修理汽车,我们可以把修车的操作步骤看做是栈操作,而把汽车的零件一个个放到汽车中就可以看做是堆存储。

虚拟机栈介绍

Java虚拟机栈,早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,所以虚拟机栈是线程私有的,当线程结束时虚拟机栈也就结束了。JVM对虚拟机栈的操作只有进栈和出栈,所以它的访问速度仅次于程序计数器,也是一种快速有效的分配存储方式。对于虚拟机栈来说它不存在垃圾回收问题,但是虚拟机栈的大小是动态的或者固定不变的,所以它会存在栈溢出或者内存溢出问题:

  • 栈溢出:如果采用固定大小的虚拟机栈,那每一个线程的虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过虚拟机栈允许的最大容量,虚拟机栈会抛出 StackOverflowError异常。
  • 内存溢出:如果虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那虚拟机将会抛出 OutOfMemoryError异常。栈的大小直接决定了函数调用的最大可达深度,我们可以通过 -Xss参数来配置栈内存,追加字母k或K表示KB,m或M表示MB,g或G表示GB,示例: -Xss1m

栈帧的运行原理

Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令(包含void返回类型);一种是抛出异常(指的是未处理的异常,如果是try...catch过了,算第一种)。不管使用哪种方式,都会导致栈帧出栈。不同线程中所包含的栈帧是不允许存在互相引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。

局部变量表 Local Variables

局部变量表也被称之为局部变量数组或本地变量表,实际上是一个“数字”数组,主要用于存储方法的参数和定义在方法体内的局部变量(包括各类基本数据类型、对象引用、returnAddress类型),虚拟机使用局部变量表完成方法返回。因为局部变量表是建立在线程的虚拟机栈上,是线程的私有数据,所以不会存在数据安全问题。另外栈帧的大小主要受局部变量表的影响,而局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maxmum_local_variables数据项中,所以在方法运行期间是不会改变局部变量表的大小的,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。一般来说在,在虚拟机栈大小固定的前提下,它的局部变量表越大,它的栈帧就越大,那它的嵌套调用次数(方法调用数)也就越少,即栈的深度越浅。用几张字节码的图来说明一下局部变量表中的内容:20张图助你了解JVM运行时数据区,你还觉得枯燥吗?20张图助你了解JVM运行时数据区,你还觉得枯燥吗?20张图助你了解JVM运行时数据区,你还觉得枯燥吗?20张图助你了解JVM运行时数据区,你还觉得枯燥吗?

局部变量表中的数据只有在当前方法中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程,当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

Slot

参数的存放总是在局部变量数组的索引0开始,到数组长度减1的索引结束,它最基本的存储单元就是Slot(变量槽)。当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上。JVM会为局部变量表中的每个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。其中32位以内的类型只占用一个slot(包含returnAddress类型,byteshortcharfloat都转化为int类型,而boolean类型是0为false,非0为true),64位的类型(longdouble)占用两个slot。如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。20张图助你了解JVM运行时数据区,你还觉得枯燥吗?如果当前帧是由构造方法或者实例方法创建的,那么该对象的引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列,而this变量不存在于静态方法的局部变量表中,所以上文中的main方法中不存在this变量。20张图助你了解JVM运行时数据区,你还觉得枯燥吗?另外Slot是可以复用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很有可能会复用该局部变量的slot,从而达到节省资源的目的。20张图助你了解JVM运行时数据区,你还觉得枯燥吗?

补充知识点:变量按照在类中的位置可以分为成员变量和局部变量,其中成员变量又分为类变量和实例变量。

  1. 成员变量在使用前,都会默认初始化赋值,其中类变量是在类加载子系统的准备阶段进行默认赋值,在初始化阶段显示赋值;
  2. 实例变量会随着对象的创建,在堆空间中分配实例变量空间,并进行默认赋值;
  3. 局部变量是不会进行默认赋值的,所以在使用前必须进行显示赋值,否则编译不通过。

局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或者间接引用的对象都不会被回收。

操作数栈 Operand Stack

操作数栈又称为表达式栈,在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈。操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。每一个操作数栈都会拥有一个明确的栈深度用于存储数据值,其所需要的最大深度在编译期间就定义好了,保存在方法的code属性中,为max_stack的值(与上边局部变量表类似)。栈中的元素可以是任意的Java数据类型,其中32bit的用一个栈单位深度,64bit的用两个栈单位深度。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。我们所说的Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。有了上述的理论,估计你会是这样的20张图助你了解JVM运行时数据区,你还觉得枯燥吗?阿Q特地制作了一张动态图来说明一下字节码指令执行时PC寄存器、局部变量表和操作数栈的运行过程:

 public void test() {
      byte i = 15;
      int j = 8;
      int k = i + j;
  }

20张图助你了解JVM运行时数据区,你还觉得枯燥吗?在编译期间局部变量表和操作数栈的大小已经确定了:

  1. 首先将要执行的指令地址 0存放到PC寄存器中,此时,局部变量表和操作数栈的数据为空;
  2. 当执行第一条指令 bipush时,将操作数 15放入操作数栈中,然后将PC寄存器的值置为下一条指令的执行地址,即 2
  3. 当执行指令地址为 2的操作指令时,将操作数栈中的数据取出来,存到局部变量表的 1位置,因为该方法是实例方法,所以 0位置存的是 this的值,PC寄存器中的值变为 3;
  4. 同步骤2和3将 8先放入操作数栈,然后取出来存到局部变量表中,PC寄存器中的值也由 3-> 5-> 6
  5. 当执行到地址指令为 678时,将局部变量表中索引位置为 12的数据重新加载到操作数栈中并进行 iadd加操作,将得到的结果值存到操作数栈中,PC寄存器中的值也由 6-> 7-> 8-> 9
  6. 执行操作指令 istore_3,将操作数栈中的数据取出存到局部变量表中索引为 3的位置,执行return指令,方法结束。

如果被调用的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,并更新pc寄存器中下一条需要执行的字节码指令。

栈顶缓存技术:将栈顶的元素全部缓存到物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

动态链接 Dynamic Linking

在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池中。20张图助你了解JVM运行时数据区,你还觉得枯燥吗?当字节码文件被加载到虚拟机后,字节码文件中的一些数据,如类型信息、域信息、方法信息等,就会被放置到方法区中,而字节码文件中的常量池则会进入方法区中的运行时常量池。每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。动态链接就是在“类加载”中“链接”的“解析阶段”将符号引用转化为直接引用的过程。20张图助你了解JVM运行时数据区,你还觉得枯燥吗?

为什么字节码文件需要常量池?因为字节码文件需要数据支持,通常这种数据会很大,以至于不能直接存放到字节码中,换一种方式,可以将指向这些数据的符号引用存到字节码文件的常量池中,这样字节码只需使用常量池就可以在运行时通过动态链接找到相应的数据并使用。

方法返回地址 Return Address

按照方法完成出口方式的不同又分为正常完成出口和异常完成出口:

  • 正常完成出口的字节码指令中的返回值类型为 ireturnbooleanbytecharshortint)、 lreturnlong)、 freturnfloat)、 dreturndouble)、 areturn(引用类型)和 return(void、实例初始化方法、类和接口的初始化方法)。
  • 在方法执行过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常处理表中没有搜索到匹配的异常处理器,就会导致方法的退出,简称异常完成出口。异常处理表是用来存储方法执行过程中抛出异常时的异常处理的,方便在发生异常的时候找到处理异常的代码。

两种方式的本质区别就是异常完成出口退出时不会给他的上层调用者产生任何的返回值。

一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息,例如对程序调试提供支持的信息。

本地方法栈

要说起本地方法栈,我们先来介绍一下本地方法。

本地方法 Native Method

首先本地方法是不在运行时数据区中的,它的位置如图所示:

本地方法其实就是java调用非java代码的接口,该接口由非java语言实现。本地接口的作用是融合不同的编程语言为java所用,它的初衷是融合C/C++程序。native可以与所有其他的java标识符连用,但是abstract除外。

为什么要使用Native Method

  1. 与Java环境外交互:有时候java应用需要与java外边的环境进行交互;
  2. 与操作系统进行交互:使用本地方法,我们可以用java实现jre与底层系统的交互;
  3. Sun's Java:Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。

本地方法栈 Native Method Stack

本地方法栈是用来管理本地方法的调用的,也是线程私有的。他也允许被实现成固定或者可动态扩展的内存大小,在内存溢出方面与虚拟机栈类似。本地方法栈的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。

当某个线程调用本地方法时,他就进入了一个全新的并且不再受虚拟机限制的世界,他和虚拟机拥有同样的权限:

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区;
  • 可以直接使用本地处理器中的寄存区;
  • 直接从本地内存的堆中分配任意数量的内存。

觉得还不错?记得一键四连呦👇