Skip to content

🏃 运行时数据区

思维导图

📖 概述

​ 运行时数据区(Runtime Data Areas)是用于JVM运行Java程序时存储和管理数据的内存区域。在《Java虚拟机规范》中,运行时数据区由五部分构成,分别为程序计数器、Java虚拟机栈、本地方法栈、方法区和堆。其中,前三者区域中的数据是线程不共享的,后二者区域中的数据是线程共享的。

​ 线程不共享的区域与对应线程的生命周期紧密关联,这些区域会随着线程的创建而创建,也会随着线程的退出而销毁。但是,线程共享的区域会在JVM启动时创建,且只在JVM退出时被销毁。

组成部分

🔢 程序计数器

​ 程序计数器(Program Counter Register)也称为PC寄存器,每个线程会通过自身的的PC寄存器记录当前正在执行的Java方法中下一条虚拟机指令的地址,如果当前执行的是本地native方法,则PC寄存器的值是未定义的。

​ PC寄存器会随着当前线程运行的过程中不断切换保存的虚拟机指令的地址值,这使得虚拟机的执行引擎能够确定下一步该执行什么指令,这确保了程序逻辑(包括条件判断、循环控制、异常处理等)的正确执行。

​ 在多线程环境下,PC寄存器也能够确保线程切换后能够恢复到正确的执行位置。

程序计数器

TIP

  1. 在JVM中,PC寄存器所占的内存空间非常小,可以忽略不计。因此,这部分数据区域不会发生内存溢出。
  2. 在JVM中,PC寄存器是软件层面的构件,不同于CPU硬件中的寄存器,它不是物理存在的。

👾 Java虚拟机栈

​ Java虚拟机栈(Java Virtual Machine Stacks)是线程私有的,用于支持Java程序中的方法调用,它所占的内存空间不一定需要是连续的。每当一个方法被调用时,JVM会为该方法创建一个新的栈帧(Frame),并将其压入栈顶。而当方法退出(正常退出或异常退出)时,顶层的帧会被弹出并销毁。

​ 在任何给定时间点,对于每个线程,Java虚拟机栈中只有一个活跃的帧,即正在执行的方法所对应的帧(栈顶的帧)。该帧被称为当前帧Current Frame),对应方法称为当前方法Current Method),定义当前方法的类称为当前类Current Class)。在当前帧被弹出时,若栈中存在下一帧,则一下帧变为当前帧。而当栈中的最后一帧被弹出时,线程也将随之退出。

case

​ 以下是一段Java代码示例:

Java虚拟机栈帧示例代码

​ 当main线程执行到play方法时,其对应的Java虚拟机栈中的帧信息如下图所示。

Java虚拟机栈帧示例帧信息

​ 根据《Java虚拟机规范》,Java虚拟机栈的大小可以是固定的,也可以随着计算需求进行动态扩展和收缩。当线程运行时,若栈的容量不足以创建新的帧时,则会抛出一个StackOverflowError错误。当Java虚拟机栈支持动态扩展,并尝试扩展时,若没有足够的可用内存来创建新的Java虚拟机栈时,则会抛出一个OutOfMemoryError错误。

​ 在实现JavaSE 8的HotSpot虚拟机中,虚拟机栈的大小只能设置为固定值,不支持动态扩展和收缩。因此,该虚拟机栈一般只会产生StackOverflowError错误。

case

​ 以下是一个模拟栈内存溢出的示例代码。

栈溢出示例代码

​ 在上述代码中,我们需计算的斐波那契数n的值比较大,方法递归的深度过深,最终Java虚拟机栈中存储的帧大小总和将会超出了最大空间限制,导致无法再创建新的帧,从而引发 StackOverflowError 错误。

栈溢出示例结果

​ 虚拟机栈的大小默认是由操作系统和计算机体系结构决定的,例如,在64位的Linux系统中创建的Java虚拟机栈的默认大小为1MB。然而,开发者仍可以通过虚拟机参数-Xss来手动指定栈的大小,语法如下。

-Xss参数语法

TIP

​ 一般情况下,具体的JVM实现都对Java虚拟机栈有最大值和最小值的限制。例如,在64位的Windows操作系统中,实现Java SE 8的HotSpot最小值为180K,最大值为1024M。

​ 在线程运行过程中,每个方法从调用直至执行结束,都对应一个栈帧。帧中的数据支持着方法的调用,主要包含局部变量表操作数栈动态链接方法出口等部分,这些部分在下文将逐一进行介绍。

局部变量表

​ 每个帧都包含一个局部变量表(Local Variables),它是虚拟机在执行指令过程中用来存放所有局部变量的一块区域。局部变量表是一种数组数据结构,数组中的每一个位置称为槽(slot)。

​ 由于局部变量表是一个数组,因此其可以通过索引进行快速寻址。若当前方法存在参数,则这些参数也会存储在局部变量表中,且位置在局部变量之前。若当前方法是一个实例方法,则索引0的位置还会存放this(当前实例的引用)。

​ 局部变量表的最大槽数在编译期确定,并记录在字节码信息的方法部分,这使得JVM在运行时能够准确地分配内存大小。其中,longdouble类型的值占用两个槽位,其它类型(booleanbytecharshortintfloatreferencereturnAddress)的值占用一个槽位。

局部变量表最大槽数

​ 此外,字节码信息的方法部分还包括一个局部变量表的记录,编译器通过分析这个字节码中的局部变量表来确定最大槽数,而JVM在运行时创建的局部变量表数组也是基于它构建的。

字节码文件中的局部变量表

​ 那么局部变量表的最大槽数的计算公式是否如下图所示呢?

局部变量表最大槽数计算公式

​ 答案是否定的,为了减小运行时栈帧中局部变量表数组所申请的空间,Java编译器在编译期便会作优化。编译器会根据方法的作用域范围复用局部变量表中的槽,当一个局部变量在其作用域范围之外不再被使用时,则其原本所在的槽会被复用。

case

​ 为了验证以上观点,下面展示了一个案例。现有一个Java编写的方法suspendUser,代码如下。

局部变量表示例代码

​ 该代码编译后的字节码信息中suspendUser方法的局部变量表如下。可以看到,块级作用域中名为vipThresholdthreshold的局部变量所占用的槽被复用了。

局部变量表示例字节码

操作数栈

​ 每个帧都包含一个操作数栈(Operand Stacks),它是虚拟机在执行指令过程中用来存放中间数据的一块区域。操作数栈是一种后进先出(LIFO)的栈式数据结构,后压入的数据会被先弹出使用。

​ 操作数栈上的每个数据条目可以保存Java虚拟机中任意类型的值,包括longdouble类型。而数据值可能来自于立即数、常量池、局部变量表、字段或计算等。

​ 操作数栈的最大深度在编译期确定,并记录在字节码信息的方法部分,这使得JVM在运行时能够准确地分配内存大小。其中,longdouble类型的值对深度贡献两个单位,而其它类型的值贡献一个单位。

操作数栈最大深度

动态链接

​ 每个帧都包含对当前方法类型的运行时常量池的引用,以支持方法代码的动态链接(Dynamic Linking)。在字节码文件中,方法部分通过符号引用关联其他类的变量和方法。当方法执行时,这些符号引用可以通过动态链接被转化为具体的直接引用,从而快速定位关联信息在内存中的地址。

方法出口

​ 方法出口是指当前帧中保存的上一个帧的下一条虚拟机指令地址。当当前方法执行完成(无论是正常返回还是因异常结束),当前帧将被弹出,上一个帧将成为新的当前帧。此时,旧帧中保存的出口地址会被传递给程序计数器,从而恢复新的当前帧的执行位置。如果旧帧有返回值,该值也会被压入新的当前帧的操作数栈中,以供后续使用。

🏡 本地方法栈

​ 本地方法栈(Native Method Stacks)与Java虚拟机栈十分相似,主要区别在于前者用于执行本地native方法,后者用于执行Java方法。在《Java虚拟机规范》中指出,它们两者可以是互通的,而在HotSpot虚拟机实现中,本地方法栈和Java虚拟机栈被整合为了统一的运行栈。

🌳 堆

​ 堆(Heap)是在JVM启动时创建的,每个Java进程仅拥有一块独立的堆内存。通常情况下,堆是运行时数据区中最大的一块区域,它主要用于存储对象和数组数据。堆所占的内存空间不一定需要是连续的,且其中的数据在多个线程之间共享。

case

​ 当我们在代码中实例化一个类时,实例包含的数据存储在堆内存中,而在栈内存的局部变量表中仅保存着对该实例的引用。以下是一个代码示例。

堆中存放实例栈中存放引用

​ 在上述代码中,我们在main方法中创建了两个Student对象,这两个实例的数据会被存储在堆内存中,而栈内存中通过stu1stu2两个局部变量保存堆上两个实例的地址,从而实现了引用关系的建立。

堆中存放实例栈中存放引用结果

​ 需要注意的是,堆内存中的Student实例并不直接存储字符串数据本身,而是保存字符串对象的引用。

​ 根据《Java虚拟机规范》,堆的大小可以是固定的,也可以随着计算需求进行动态扩展和收缩。在程序运行时,如果计算所需的堆空间大小超过了最大可用的堆空间大小,则会抛出一个OutOfMemoryError错误。同时,Java虚拟机实现可以为开发者或用户提供了对“堆内存的初始分配大小”和“堆内存的最大和最小值”的精细控制。

case

​ 以下是一个模拟堆内存溢出的示例。

堆溢出示例代码

​ 在上述代码中,我们通过一个死循环,持续不断地创建大小为10M的比特数组,最终生成的比特数组的总大小将会超出堆的最大可用空间,导致在堆中无法再存放新的比特数组,从而引发OutOfMemoryError错误。需要注意的是,在模拟代码中,我们一定要把新创建的比特数组添加到一个预定的集合当中,以防止它们被垃圾回收器当成”垃圾“回收。

堆溢出示例结果

​ 在HotSpot虚拟机中,堆内存大小是动态扩展和收缩的,这个过程涉及到三个值,如下所示。

  1. used: 当前已使用的堆内存大小;
  2. total: JVM已经分配的可用堆内存大小;
  3. max: JVM可以分配的最大堆内存大小。

堆内存相关的三个值

​ 随着used不断增长,堆中的对象数据逐渐增大,如果达到一定阈值时,JVM便会向操作系统请求更多的内存分配,这意味着total值也会随之增加。需要注意的是,这个阈值并不代表used等于total,而是一种更为复杂的计算机制的结果,通常情况下used的值会小于total

​ 那是不是当used = max = total时,就会发生堆内存溢出呢?答案是否定的,堆内存溢出的判断条件也比较复杂,这点会在章节垃圾回收中详细介绍。

case

​ 我们可以使用Arthas中的memory命令来查看当前Java进程的内存占用情况,其中也包括Heap内存(堆内存)。

![查看堆内存使用情况](https://fatgod-note.oss-cn-hangzhou.aliyuncs.com/Java/JVM/md-运行时数据区/查看堆内存使%E 7%94%A8%E6%83%85%E5%86%B5.png)

​ 默认情况下,HotSpot虚拟机中的max值被设定为系统内存的1/4,而total值则被设定为系统内存的1/64。但是,开发者可以通过使用虚拟机参数-Xms-Xmx来分别手动指定totalmax的值,其语法如下。

-Xms和-Xmx参数语法

TIP

  1. -Xmx参数值必须大于2MB,-Xms参数值必须大于1MB。
  2. 当使用Java进行服务端程序开发时,建议将-Xmx-Xms设置为相同的值,这样可以避免后续的堆内存扩展和收缩,从而降低潜在的效率问题。

🔧 方法区

​ 方法区(Method Area)是在JVM启动时创建的,每个Java进程仅拥有一块独立的方法区内存,它用于存储每个类(加载后)的基本结构信息(元信息),例如字段和方法数据、运行时常量池、方法和构造函数的代码等。方法区所占的内存空间不一定需要是连续的,且其中的数据在多个线程之间共享。

方法区中类的元数据

​ 根据《Java虚拟机规范》,方法区的大小可以是固定的,也可以随着计算需求进行动态扩展和收缩。在程序运行时,如果存储类元数据所需的方法区空间大小超过了最大可用的方法区空间大小,则会抛出一个OutOfMemoryError错误。同时,Java虚拟机实现可以为开发者或用户提供了对“方法区的初始分配大小”和“方法区的最大和最小值”的精细控制。

​ 需要说明的是,《Java虚拟机规范》并没有对方法区在内存中的位置进行明确限制。在HotSpot虚拟机中,对方法区的实现主要分为两种,一种是在JDK1.7及之前版本中的永久代(PermGen Space),它位于堆内存中;另一种是在JDK1.8及之后版本中的元空间(MetaSpace),它位于操作系统管理的直接内存中。

方法区的实现

​ 那么,为什么随着JDK版本的更新,方法区的实现由永久代变更为元空间了呢?这实际上源于一些历史背景。Oracle收购了JRockit虚拟机,并计划将其与HotSpot进行整合。由于JRockit中并不包含永久代,并且JRockit的用户也不习惯配置方法区的容量,因此Oracle在Java8的HotSpot虚拟机中淘汰了永久代,转而采取了与JRockit相同的元空间机制。

​ 永久代的一大使用痛点在于容量设置困难,设得太小容易频繁触发GC,效率较低;设得太大则会造成JVM进程内存的浪费。而相比之下,元空间具有更强大的灵活性,由于它使用的是本地内存而非JVM进程内存,因此元空间的大小受到的限制更少,默认情况下只要不超过操作系统所能承受的上限,就可以一直分配内存,并且能够根据应用的实际需要进行动态调整。

Details

​ 我们可以使用Arthas中的memory命令来查看当前Java进程的内存占用情况,其中也包括方法区内存。

查看方法区内存使用情况

​ 需要注意的是,上图截取自JDK1.8版本的应用,方法区对应的区域项名称为metaspace(元空间)。而在JDK1.7及之前版本的应用中,该项名称应为ps_perm_gen(永久代)。

​ 关于HotSpot虚拟机中的元空间和永久代,开发者可以通过设置不同的虚拟机参数来调整其空间大小。对于元空间,可以使用-XX:MetaspaceSize参数来设置初始空间大小,以及-XX:MaxMetaspaceSize参数来设置最大空间大小。而对于永久代,则可以通过-XX:PermSize参数来设定初始空间大小,以及-XX:MaxPermSize参数来设定最大空间大小。以下是这四个参数对应的语法示例。

四个方法区大小相关的虚拟机参数语法

case

​ 接下来,我们将使用Java8来模拟一个方法区内存溢出的例子。为此,我们可以借助ByteBuddy框架,它是一个用于在运行时动态生成或修改Java类的字节码增强库,以下是该依赖库的Maven坐标信息。

xml
<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.12.10</version>
</dependency>

​ 本示例代码如下所示。

方法区溢出示例代码

​ 在上述代码中,我们通过一个无限循环,持续在运行时生成并加载新的类的字节码数据。随着生成的类数据量最终超过方法区的最大容量限制,导致方法区无法存储更多新的类信息,从而引发OutOfMemoryError错误。在编写该模拟代码时,需要留意以下两个注意点。

  1. 必须将运行时创建的类的Class对象收集到一个专门的集合中,以防止这些类对象在方法区中被垃圾回收器回收。
  2. 在Java8版本的HotSpot虚拟机中,方法区的实现位于直接内存中,为了直观地观察方法区内存溢出现象,应设置其最大内存限制,例如30M。

方法区溢出示例结果


​ 除了包含类的版本、字段、方法和接口的描述信息之外,字节码文件还携带了一个常量池表(Constant Pool Table),这个表用于存储编译时产生的各类字面量(Literal)和符号引用(Symbolic Reference)。其中,字面量涵盖了整数、浮点数、字符串,符号引用则主要覆盖了类、字段、方法、接口方法等。

​ 在类加载后,JVM会根据字节码文件中的静态常量池来创建一个运行时常量池(Run-Time Constant Pool),并将它存储在方法区中。此时,字面量直接以字节数据的形式存储在内存中,符号引用也会转换为直接引用。

运行时常量池


​ 字符串是应用程序中最常用的数据类型之一,在字节码文件中包含大量的字符串数据。为了提升性能并降低内存消耗,JVM专门设计了一个字符串常量池(StringTable)来存储这些字符串数据。在HotSpot虚拟机中,字符串常量池的实现细节隐藏在src/hotspot/share/classfile/stringTable.cpp源文件中。字符串常量池是一个固定大小的哈希表,它存储了字符串的字面量(作为键)与对象引用(作为值)之间的映射关系。尽管开发者可以通过虚拟机参数-XX:StringTableSize来调整其大小,但通常情况下并不建议这样做。

​ 在早期的HotSpot设计中,字符串常量池是运行时常量池的一部分。但随着后续版本的迭代,开发团队对这一设计进行了升级,最终将两者分开。具体的发展历程如下图所示。

字符串常量池发展史

TIP

​ 在JDK1.7及更早版本的HotSpot虚拟机中,方法区(即永久代)虽然物理上位于堆空间,但从逻辑上讲,它与堆是分开且独立的。

​ 在Java的java.lang.String类中,存在一个名为intern的实例方法(native修饰),它可以返回字符串的标准形式,也就是它在字符串常量池中的表示。当对一个字符串调用intern方法时,如果该字符串已经存在于常量池中,则立即返回该字符串的引用;否则,则先将该字符串添加到常量池中,然后再返回其引用。

​ 随着JDK版本的更新迭代,HotSpot虚拟机中字符串常量池的存放位置发生了变化,这导致了intern方法的内部实现细节也发生了改变。在JDK1.6及以前版本中,字符串常量池位于方法区,intern方法会将首次出现的字符串对象复制到池中,并返回该字符串对象在池中的引用。转变到JDK1.7及之后的版本,字符串常量池被迁移到了堆内存中,此时intern方法会将首次出现的字符串对象在堆中的引用记录在池中,并返回该字符串对象在堆中的引用。

case

​ 接下来,我们来看一个StringTable的示例,代码如下。

StringTable示例代码

​ 上述代码在JDK1.7及之后版本中的运行结果为true false false,分析如下。

  • 在字符串常量池初始化后,它将包含对堆中两个字符串对象"Fat""God"的引用。在第 1 行代码执行后,字符串对象"FatGod"会被创建并存储在堆中。调用字符串对象"FatGod"intern方法会使池中新增一个对其自身的引用,并返回该引用,因此第 2 行代码执行后将输出true

StringTable示例分析1

  • 在字符串常量池初始化后,它将包含对堆中字符串对象"java"的引用(每个Java应用在运行时都会关联一个"java"字符串)。在第 3 行代码执行后,新的字符串对象"java"会被创建并存储在堆中。调用新的字符串对象"java"intern方法会直接返回池中一开始就持有的字符串对象"java"的引用,因此第 4 行代码执行后将输出fasle

StringTable示例分析2

  • 在字符串常量池初始化后,它将包含对堆中字符串对象"FatBaby"的引用。在第 5 行代码执行后,新新的字符串对象"FatBaby"会被创建并存储在堆中。调用新的字符串对象"FatBaby"intern方法会直接返回池中一开始就持有的字符串对象"FatBaby"的引用,因此第 4 行代码执行后将输出fasle

StringTable示例分析3

​ 然而,该示例代码在JDK1.6及之前版本中的运行结果为false false false,这是因为相同字面量值的字符串在堆内存和字符串常量池中会分别存在独立的对象实例。

⚡ 直接内存

​ 直接内存(Direct Memory)是一种特殊的内存缓冲区,它不是Java虚拟机运行时数据区的一部分,也不被《Java虚拟机规范》定义。

​ 直接内存不在堆或方法区中分配,而是通过JNI的方式在本地内存上分配。尽管如此,直接内存也受JVM的管理,这意味着这部分内存区域在不够分配时,JVM也会抛出一个OutOfMemoryError错误。

​ 在JDK1.4中引入了一种新的IO方式,即基于通道(Channel)和缓冲区(Buffer)的NIO。NIO使用了直接内存,它可以直接使用native函数库直接分配堆外内存,并通过一个堆内存中的DirectByteBuffer对象作为该内存区域的引用进行操作。这种IO方式减少了数据复制的开销,从而显著提高了IO效率。

case

​ 我们可以使用Arthas中的memory命令来查看当前Java进程的内存占用情况,其中也包括直接内存(显示值不包含元空间大小)。

Arthas查看直接内存使用情况

​ 默认情况下,直接内存分配的上限为操作系统可以承受的上限。然而,Java应用部署的环境中可能存在其他应用程序,此时为了防止单个Java应用占用过多系统资源,导致内存耗尽的情况,我们可以通过设置虚拟机参数-XX:MaxDirectMemorySize来手动调整Java应用的直接内存上限(最大值),参数语法如下。

-XX:MaxDirectMemorySize参数语法

case

​ 以下是一个模拟直接内存溢出的示例。

直接内存溢出示例代码

​ 在上述代码中,我们通过一个死循环,持续不断地分配大小为100M的直接内存,最终分配的直接内存将超过最大容量限制,从而引发OutOfMemoryError错误。在编写该模拟代码时,需要留意以下两个注意点。

  1. 必须将创建的ByteBuffer对象收集到一个专门的集合中,以防止这类对象被回收后,其对应的直接内存也被销毁。
  2. 为了直观地观察直接内存溢出现象,应设置其最大内存限制,例如2G。

直接内存溢出示例结果

上次更新于: