Java 虚拟机运行时数据区域划分详解

Java 虚拟机(Java Virtual Machine,简称 JVM)是 Java 程序(字节码)的运行环境,其主要提供 Java 字节码执行(解释执行或者即时编译为本地机器码执行)、内存管理(内存分配和垃圾回收等)、多线程支持和安全控制等功能,是 Java 语言「一次编写,到处运行」口号得以实现的基石。

Java 虚拟机运行时数据区域指的是在 Java 字节码运行期间,Java 虚拟机管理的各种内存区域。划分不同的数据区域是为了不同用途的内存分配,从而更好地支持 Java 程序的运行。

其中一些数据区域是随着 Java 虚拟机的启动和终止而创建和销毁的,另一些数据区域是线程专有的,即随着线程的创建和销毁而创建和销毁。

运行时数据区域划分

(运行时数据区域划分)

本文即关注这些数据区域是如何划分的,然后重点对栈和堆这两块区域从分配和功用上进行一下对比。

1 运行时数据区域划分

程序计数器(Program Counter Register)

程序计数器是一块较小的内存区域,是线程私有的(每个线程都有一个独立的程序计数器),它保存着当前线程所执行的字节码指令的地址。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

当线程执行 Java 方法时,程序计数器保存的是当前正在执行的指令的地址。当线程执行 Native 方法时,程序计数器的值为空(Undefined)。

Java 虚拟机栈(Java Virtual Machine Stacks)

与程序计数器一样,Java 虚拟机栈也是线程私有的,其与线程的生命周期相同。每个线程在创建时都会创建一个对应的虚拟机栈,用于存储局部变量、方法参数、返回值以及方法调用的记录。每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

栈帧结构:

  • 局部变量表(Local Variable Array):用于存储方法的局部变量和传递参数。
  • 操作数栈(Operand Stack):用于存储方法执行过程中的操作数。
  • 动态链接(Dynamic Linking):用于指向运行时常量池中该方法的引用。
  • 返回地址(Return Address):用于保存方法调用后的返回地址。

功能作用:

  • 方法调用:虚拟机栈记录了方法的调用顺序和调用关系,通过栈帧的入栈和出栈操作来实现方法的调用和返回。
  • 局部变量存储:虚拟机栈的局部变量表用于存储方法中的局部变量和传递参数。
  • 传递参数:方法调用时,参数会被压入栈帧的局部变量表中,供方法使用。
  • 异常处理:当方法抛出异常时,虚拟机栈可以用于定位异常处理器的位置。

可能抛出的异常:

  • 如果线程请求的栈深度超过虚拟机所允许的深度,将抛出 StackOverflowError 异常。
  • 如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。

堆(Heap)

堆(Heap)是 Java 虚拟机管理的最大的一块内存区域,用于存储类的实例和数组,堆这块区域在虚拟机启动时创建,被所有线程所共享。

堆是垃圾收集器(Garbage Collector)管理的内存区域,当堆中没有足够的内存完成对象的分配时,会触发垃圾收集器进行垃圾回收。

为了更好地回收内存以及更快地分配内存,堆又通常会被分为如下几个区域:

堆的划分

(堆内的区域划分)
  • 新生代(Young Generation)

    新生代分 Eden 空间和 Survivor 空间。新创建的对象首先被分配到 Eden 空间。当 Eden 空间满时,部分存活的对象会被移动到 Survivor 空间。Survivor 空间分为两个大小相等的区域,一般称为 From 区和 To 区。存活时间较长的对象会被复制到 To 区,同时年龄增加。经过多次垃圾回收之后,仍然存活的对象会被移动到老年代。

  • 老年代(Old Generation)

    存活时间较长的对象会被移动到老年代。老年代的对象会经历更长时间的垃圾回收周期。当老年代的空间不足时,会触发 Full GC 进行垃圾回收。

  • 元空间(Metaspace)

    元空间使用本地内存来存储类的元数据,不受限于堆的大小。

总的来讲,新生代用于存放新创建的对象,通过复制算法进行垃圾回收。老年代用于存放存活时间较长的对象,经历更长时间的垃圾回收周期。元空间用于存放 Java 类的元数据和一些不容易被回收的对象。

可能抛出的异常:

  • 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数 -Xmx-Xms 设定)。如果在堆中没有内存来完成实例分配,并且堆也无法再扩展时,Java 虚拟机会抛出 OutOfMemoryError 异常。

方法区(Method Area)

方法区与堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量和即时编译器编译后的代码等数据。方法区在 Java 虚拟机启动时被创建,并在 Java 虚拟机退出时被销毁。

方法区包含的内容:

  • 类信息

    类的完整结构,包括类的名称、父类、接口、字段、方法等存放在方法区中。类的静态变量和常量也存放在方法区中。

  • 运行时常量池

    存放类的常量,如字符串常量、基本类型常量等。

  • 字段和方法数据

    存储类的字段和方法的相关信息,包括字段的名称、类型、访问修饰符等,方法的名称、参数列表、返回值类型和访问修饰符等。

  • 即时编译器编译后的代码

    Java 虚拟机的即时编译器(Just-In-Time Compiler)将热点代码(经常被执行的代码)编译成本地机器码,以提高执行效率。而编译后的代码也存放在方法区中。

可能抛出的异常:

  • 如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

运行时常量池(Run-Time Constant Pool)

运行时常量池是方法区的一部分,是线程共享的。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。

可能抛出的异常:

  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

本地方法栈(Native Method Stacks)

本地方法栈是与虚拟机栈类似的线程私有内存区域,用于支持 Java 程序与本地方法(如 C、C++ 等编写的方法)的交互。它由栈帧组成,类似于虚拟机栈的栈帧结构,用于存储本地方法的局部变量、操作数栈、动态链接和返回地址等信息。本地方法栈在本地方法调用、本地方法执行、调用约定等方面起着重要的作用。

可能抛出的异常:

  • 与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowErrorOutOfMemoryError 异常。

直接内存(Direct Memory)

直接内存并不是 Java 虚拟机运行时数据区域的一部分,但是它与 Java NIO 密切相关。直接内存是通过堆外内存分配方式分配的内存,它不受堆的限制,可以在一些场景下提供更高的性能。

2 对比栈和堆

根据上面的划分,我们知道栈和堆是 Java 虚拟机运行时数据区域中的关键部分,它们在内存管理和数据存储方面有着不同的特点和用途。

下面从各方面对两者进行一下对比:

  • 分配方式

    栈内存是自动分配和释放的。局部变量、方法参数、返回值等都存放在栈上,栈上的内存会在方法执行完毕后自动释放。而堆内存是动态分配的,通过垃圾收集器进行管理。类的实例和数组都在堆上分配内存,通过 new 关键字创建对象时,会在堆上分配内存并返回对象的引用。

  • 内存空间

    栈的大小是固定的(由 -Xss 参数进行调整),并且每个线程都有独立的栈空间,用于存储局部变量和方法调用的记录。而堆是 Java 虚拟机中最大的一块内存区域,用于存储类的实例和数组。堆的大小可以通过 -Xmx-Xms 参数进行调整。

  • 内存分配速度

    栈的内存分配速度相对较快,因为只需要移动栈指针即可完成内存分配和释放。堆的内存分配速度相对较慢,因为需要在堆上进行动态分配和垃圾回收。

  • 对象的生命周期

    栈上的数据的生命周期与方法的执行周期相同,当方法执行完毕后,栈上的数据会被自动释放。而堆上的对象的生命周期比较长,直到垃圾收集器判断其不再被引用时,才会被回收。

  • 内存碎片

    栈内存是按照先进后出的原则进行分配和释放的,不存在内存碎片问题。而因堆是动态分配的,所以可能会产生内存碎片,需要垃圾收集器进行内存整理。

  • 内存使用

    栈的内存使用是静态的,编译时就确定了大小。而堆的内存使用是动态的,可以根据需要进行增加或减少。

总的来讲,栈和堆在内存管理、分配方式、内存空间、内存分配速度、对象生命周期、内存碎片和内存使用等方面存在明显的差异。了解栈和堆的特点和用途,有助于更合理地使用内存,从而提高程序的性能和稳定性。

3 小结

综上,本文首先介绍了 Java 虚拟机运行时数据区域是如何划分的,然后对其中的两块区域(栈和堆)从各个维度进行了对比。

参考资料

[1] The Java Virtual Machine Specification: Run-Time Data Areas - https://docs.oracle.com/javase/specs/jvms/se22/html/jvms-2.html#jvms-2.5

[2] 周志明.(2019). 深入理解 Java 虚拟机(第 3 版). 机械工业出版社, 北京. - https://item.jd.com/12607299.html

[3] CSDN 博客:JVM 的运行时内存区域划分详细讲解 - https://blog.csdn.net/qq_39939541/article/details/131795626

评论

正在加载评论......