zl程序教程

您现在的位置是:首页 >  Java

当前栏目

JVM运行时数据区知多少

2023-02-18 16:46:04 时间

前言

java引以为豪的就是内存自动化管理,不需要像C、C++等一样需要开发者手动获取内存、释放内存,对内存进行操作等,java在这方面做的非常好、非常方便。所以,了解java内存区域是怎么划分的是非常有必要的,面试的时候也是经常会问到的。

运行时数据区

Java 虚拟机定义了在程序执行期间使用的各种运行时数据区域,有些区域是随着虚拟机的创建而创建,随着虚拟机的退出而销毁。有些区域是随着线程的创建而创建,随着线程的退出而销毁。

运行时数据区

运行时数据区分为线程共享区和线程私有区。 线程共享区是所有线程共享的内存区域包括方法区和堆区。 线程私有区是每个线程独有的一份内存区域,分为虚拟机栈、本地方法栈、程序计数器。

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响, 独立存储, 我们称这类内存区域为“线程私有”的内存。

因为JVM内部有完整的指令与执行的一套流程,所以在运行 Java 方法的时候需要使用程序计数器(记录字节码执行的地址或行号),如果是遇到本地方法(native方法),这个方法不是JVM来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器,这个会记录本地代码的执行的地址,所以在执行native方法时,JVM 中程序计数器的值为空(Undefined)。另外程序计数器也是JVM中唯一不会 OOM(OutOfMemory)的内存区域。

虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。虚拟机栈是一个栈结构,属于后进先出(FILO)的数据结构。 JVM规范允许Java虚拟机栈具有固定大小或根据需求动态扩展和收缩。如果Java虚拟机栈具有固定大小,则每个Java虚拟机栈的大小可以在创建堆栈时独立选择。 Java虚拟机实现可以让开发者控制Java虚拟机栈的初始大小,以及在动态扩展或收缩虚拟机栈的情况下,控制虚拟机栈的最大值和最小值大小。如果没有空间来创建新对象,它会抛出java.lang.StackOverFlowError异常

虚拟机栈

栈帧

栈帧用于存储局部变量表、操作数栈、动态连接、返回地址等信息。栈帧中需要多大的局部变量表,需要多深的操作数栈在编译期间就已经被分析计算出来,并且写入到方法表的Code属性之中不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

局部变量表

栈帧包含一个变量数组,称为局部变量表。局部变量表顾名思义就是局部变量的表,局部变量表存放着8大基本类型、对象引用和returnAddress类型。

局部变量表是通过索引来寻址的,索引从0开始。基本类型long和double占用局部变量表中的两个局部变量,也就是局部变量数组中的连续两个空间,它们是通过使用最小的一个索引来寻址的。比如double存储的下标是n,但实际上它是占用了索引为n和n+1两个局部变量的,它通过索引n进行寻址。索引n+1处的局部变量理论上也是能够加载并重新存入值,但这样索引n处的局部变量就无效了。

局部变量表存储的是基本类型、对象引用和returnAddress类型,所以在编译期局部变量表需要多大的内存空间都是确定的,在运行期不会改变局部变量表大小。一般来说,局部变量表32位长度已经完全够用,但是这个长度会随着处理器、 操作系统或虚拟机实现的不同而发生变化,如果是64位的话,虚拟机会采用高低位对齐的方式让64位看起来像32位一样。

Java虚拟机使用局部变量在方法调用时传递参数。在类方法调用中,任何参数都在从局部变量0开始的连续局部变量中传递。在实例方法调用时,局部变量0始终用于传递对正在调用实例方法的对象的引用(也就是this)。随后向局部变量1开始的连续局部变量中传递参数。

操作数栈

每个栈帧都包含一个后进先出 (LIFO) 栈,称为其操作数栈。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。操作数栈上的每个条目都可以保存任何Java虚拟机类型的值,操作数栈中的值必须以适合其类型的方式进行操作,例如整数加法的字节码指令iadd,它在执行时,最接近栈顶的两个元素的数据类型必须为int类型,不能出现一个long类型和一个float类型使用iadd命令相加的情况。 操作数栈本质上是JVM执行引擎的一个工作区,也就是方法在执行时才会对操作数栈进行操作,如果代码不不执行,操作数栈其实就是空的。

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

返回地址

方法调用完成分为两种方式:方法正常调用完成和方法异常调用完成。

  • 方法正常调用完成:调用程序计数器中的地址作为返回。如果当前方法的调用正常完成,则可能会向调用方法返回一个值。这发生在被调用的方法执行返回指令之一时,选择的返回指令必须适合返回值的类型(如果有)。在这种情况下,当前帧栈用于恢复调用者的状态,包括其局部变量和操作数栈,调用者的程序计数器会适当增加以跳过方法调用指令。然后在调用方法的帧中正常继续执行,并将返回值(如果有)推送到该帧栈的操作数栈中。
  • 方法异常调用完成:如果在方法内执行Java虚拟机指令导致Java虚拟机抛出异常,并且该异常不在方法内处理,则方法调用会突然完成。执行athrow指令 也会导致显式抛出异常,如果当前方法未捕获到异常,则会导致方法调用突然完成。突然完成的方法调用永远不会向其调用者返回值。

方法完成的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值(如果有的话)压入调用者栈帧的操作数栈中
  • 调整PC计数器的值以指向方法调用指令后面的一条指令等

栈的优化技术——栈帧之间数据的共享

在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的 JVM 在实现中会进行一些优化,使得两个栈帧出现一部分重叠(主要体现在方法中有参数传递的情况),让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。

栈帧之间数据的共享

本地方法栈

本地方法栈和虚拟机栈的作用差不多是一样的,只不过虚拟机栈是为java方法提供服务,而本地方法栈是为除了java外的native方法提供服务。你甚至可以认为虚拟机栈和本地方法栈是同一个区域,JVM规范无强制规定,各版本虚拟机自由实现,HotSpot直接把本地方法栈和虚拟机栈合二为一 。

JVM规范允许本地方法堆栈具有固定大小或根据计算要求动态扩展和收缩。Java虚拟机实现可以为开发者提供对本地方法栈的初始大小的控制,以及使用在不同大小的本地方法栈的情况下,控制本地方法栈最大值和最小值大小。 以下异常情况与本机方法堆栈相关:

  • 如果线程中的计算需要比允许的更大的本机方法堆栈,Java 虚拟机将抛出一个StackOverflowError.
  • 如果本地方法堆栈可以动态扩展并尝试本地方法栈扩展,但内存不足,或者如果内存不足,无法为新线程创建初始本地方法栈,Java 虚拟机将抛出OutOfMemoryError.

堆区

Java虚拟机有一个在所有Java虚拟机线程之间共享的堆,堆是为所有类实例和数组分配内存的运行时数据区域,它是在虚拟机启动的时候创建的。Java堆是虚拟机所管理的内存中最大的一块,我们常说的垃圾回收操作的区域就是堆。堆是为所有类实例和数组分配内存的运行时数据区域,如果是普通对象并且是局部变量,那么在局部变量表中存放的只是对象的引用,也就是存储的是对象的地址,实例还是存放在堆区。

堆可以是固定大小的,也可以根据计算的需要进行扩展,如果不需要更大的堆,则可以收缩。堆的内存在物理上不需要是连续的,逻辑上是连续的即可,可通过参数-Xms(设置堆内存初始值或最小值)和-Xmx(设置堆内存最大值)来对堆内存大小进行扩展。 Java虚拟机实现可以让开发者控制堆的初始大小。如果对象没有足够的内存去分配的话,Java虚拟机会抛出一个java.lang.OutOfMemoryError

由于现代垃圾回收器大部分采用分代收集,所以jdk8堆空间也是采用分代结构设计的。

堆空间分代划分

方法区

方法区是可供各个线程共享的运行时内存区域。它存储了每一个类的结构信息,例如运行时常量池字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。虚拟机的规范中方法区是堆中的一个逻辑部分,但是它却拥有一个叫做非堆(Non-Heap)的别名。

方法区是JVM对内存的“逻辑划分” ,在 JDK1.7 及之前很多开发者都习惯将方法区称为“永久代”,是因为在HotSpot虚拟机中,设计人员使用了永久代来实现了JVM 规范的方法区。在JDK1.8 及以后使用了元空间来实现方法区。 如果方法区域中的内存无法满足分配请求,Java 虚拟机将抛出一个java.lang.OutOfMemoryError(OOM).

元空间

元空间用于储存类的元数据,它是方法区的实现。

在HotSpot虚拟机、Java7版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在JVM的非堆内存中,而Java8 版本已经将方法区中实现的永久代去掉了, 并用元空间代替了之前的永久代, 并且元空间的存储位置是本地内存,因为元空间占用的是本地内存,所以只要本地内存足够大,就不会出现OutOfMemoryError,但是也可以通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来分别配置元空间初始值和最大值,例如:-XX:MaxMetaspaceSize=256m

Java8 为什么使用元空间替代永久代,这样做有什么好处呢?官方给出的解释是:移除永久代是为了融合HotSpot JVM与JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。永久代内存经常不够用或发生内存溢出, 抛出异常java.lang.OutOfMemoryError:PermGen。这是因为在 JDK1.7 版本中, 指定的 PermGen 区大小为8M,由于PermGen中类的元数据信息在每次FullGC的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有为PermGen分配多大的空间很难确定,PermSize的大小依赖于很多因素,比如,JVM加载的class总数、常量池的大小和方法的大小等。

运行时常量池

运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式, 它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。 运行时常量池是方法区的一部分,它相对于Class常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

如果构建运行时常量池需要的内存超出JVM方法区域可用的内存,JVM将抛出错误java.lang.OutOfMemoryError

总结

本文主要简单介绍了JVM运行时数据区的一些概念,下一篇文章将通过工具和示例来加深一下对这些概念的理解。除了JVM运行时数据区还有一个直接内存的概念,本地内存不属于运行时数据区也不受JVM的内存限制,受本机内存限制,感兴趣的可以了解一下。

参考书籍:《深入了解JVM虚拟机》