JVM 运行时数据区

1 简介

根据《Java 虚拟机规范》,JVM 管理的内存被划分为以下运行时数据区域:程序计数器、虚拟机栈、本地方法栈、堆、方法区。

JVM 运行时数据区域

2 程序计数器

程序计数器 (Program Counter Register) 是内存中很小的一块空间,用于指示当前线程正在执行的字节码行号。在 Java 虚拟机中,字节码解释器使用程序计数器来确定下一条指令,它控制着程序的执行流程,包括分支、循环、跳转、异常处理和线程恢复等基本功能。

JVM 通过时间片分配和线程轮转来实现多线程。每个线程都需要一个独立的程序计数器,用于恢复正确的执行位置。这些计数器是线程私有的,独立存储,互不影响。

  • 如果线程正在执行 Java 方法,程序计数器记录正在执行的虚拟机字节码指令的地址;
  • 如果正在执行本地方法,则计数器的值为 undefined

此内存区域是《Java 虚拟机规范》中唯一没有规定任何 OutOfMemoryError 情况的区域。

3 Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈 (Java Virtual Machine Stack) 也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,JVM 都会同步创建一个栈帧 (Stack Frame) 用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表包括基本数据类型、对象引用和 returnAddress 类型。每种类型使用局部变量槽 (Slot) 表示,longdouble 类型使用两个槽,其他类型使用一个槽。编译期分配所需空间并在方法进入时确定局部变量表大小 1,方法运行期间不会改变。该内存区域没有规定 OutOfMemoryError 情况。

对象引用并不等同于对象本身,可能是指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置。

《Java 虚拟机规范》对虚拟机栈规定了两类异常状况:

  • 如果栈深度过大抛出 StackOverflowError 异常;
  • 如果栈容量可以扩展但无法申请到足够内存,则抛出 OutOfMemoryError 异常。

HotSpot 虚拟机栈容量不可扩展,因此不会因为栈无法扩展而导致 OutOfMemoryError,但如果线程在申请栈空间时失败,则仍可能出现 OOM 异常。

4 本地方法栈

本地方法栈 (Native Method Stacks) 类似于虚拟机栈,不同之处在于本地方法栈是为本地方法服务的。

《Java 虚拟机规范》没有对其使用的语言、使用方式与数据结构做强制规定,因此虚拟机可以根据需要自由实现它,有的虚拟机会将本地方法栈和虚拟机栈合二为一。

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

5 Java 堆

对于 Java 应用程序而言,Java 堆(Heap)是虚拟机管理的内存中最大的一块。它是被所有线程共享的内存区域,在虚拟机启动时就被创建,唯一的目的是存放对象实例,几乎 2 所有的对象实例都在这里分配内存。为了提高对象分配效率,Java 堆内部有多个线程私有的 TLAB(Thread Local Allocation Buffer)分配缓冲区。

尽管 Java 堆可以在物理内存中是不连续的,但从逻辑上来看应该被视为连续的,就像磁盘存储文件一样。Java 堆可以被实现成固定大小的,也可以是可扩展的。当前主流的 JVM 都是按照可扩展来实现的,通过参数 -Xmx-Xms 设定。当 Java 堆中没有足够的内存完成实例分配,并且无法再扩展时,虚拟机会抛出 OutOfMemoryError 异常。

6 方法区

方法区 (Method Area) 与 Java 堆一样,是多个线程共享的内存区域,存储已加载的类型信息、常量、静态变量和即时编译器编译后的代码缓存。尽管《Java 虚拟机规范》将方法区描述为堆的一个逻辑部分,但它通常被称为 “非堆” (Non-Heap),以区别于 Java 堆。

回顾历史,虽然很多国内互联网从业者将方法区称为“永久代 (Permanent Generation)”,但是这种说法是不准确的。永久代是 HotSpot 虚拟机使用的一种实现方式,它使用永久代来实现方法区,将垃圾回收器的分代概念扩展到了方法区。

使用永久代实现方法区的设计会导致一些问题 3,比如更容易遇到内存溢出的问题和一些不兼容问题,因此在 JDK 8 中,HotSpot 放弃了永久代的设计,采用元空间代替 (Meta-space) 。与此同时,字符串常量池、静态变量等内容也被移到了本地内存中。虚拟机实现方法区的方式并不受《Java 虚拟机规范》的约束。

《Java 虚拟机规范》对方法区的限制非常宽松,它不需要连续的内存,可以选择固定大小或可扩展,甚至可以选择不实现垃圾收集。方法区主要用于常量池的回收和类型的卸载,但这个区域的回收效果往往难以令人满意,特别是类型的卸载条件相当苛刻。

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

6.1 运行时常量池

运行时常量池 (Runtime Constant Pool) 是一种在类加载期间创建的数据结构,它属于方法区的一部分,用于存储编译时生成的各种字面量 (literal) 和符号引用 (symbolic references)。在程序运行期间,JVM 可以从常量池中快速地获取需要使用的字面量或符号引用,从而提高程序的运行效率。

常量池主要包括两种类型的常量:

  • 字面量常量

    字面量常量是一些固定的值,例如字符串、数字和布尔值等。在编译时,Java 编译器会将这些字面量直接嵌入到程序代码中,并存储在常量池中。这样,在程序运行时,JVM 就可以直接从常量池中获取这些值,而不需要再次计算或创建。

  • 符号引用常量

    符号引用常量是一些需要在程序运行时才能确定的值,例如类和方法的名称、描述符和方法句柄等。在编译时,Java 编译器会将这些符号引用转化为一些特殊的常量,并存储在常量池中。在程序运行时,JVM 可以根据这些常量来获取对应的类或方法的信息,从而正确地执行程序代码。

常量池的主要作用是优化程序的性能。由于常量池可以缓存常用的字面量和符号引用,因此程序运行时可以更快地获取这些值,从而提高程序的运行效率。同时,常量池也可以减少程序的内存占用,因为相同的字面量和符号引用只需要存储一次。

需要注意的是,常量池虽然可以缓存常用的值,但是在程序中过度使用字面量常量会导致程序的可维护性降低。因为这些常量可能散布在代码的各个位置,难以进行统一管理和修改。因此,在程序设计中,应该尽量避免过度使用字面量常量,而是使用常量变量或枚举类型等方式来管理常量。

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

7 直接内存

JVM 直接内存 (Direct Memory) 是一种在 Java 程序中可以直接访问的内存,与 Java 堆不同,直接内存是由操作系统管理的,不受 JVM 垃圾回收机制的控制。

直接内存的分配和释放是通过 Unsafe 类实现的。Unsafe 类提供了一组用于分配和释放直接内存的方法,例如 allocateMemory()freeMemory() 等。直接内存的分配和释放操作不受 JVM 垃圾回收机制的影响,因此可以更灵活地管理内存,但也更容易出现内存泄漏等问题。

直接内存不需要通过 Java 堆来进行中转,因此读写性能更高,但是分配和释放的代价比在 Java 堆中大得多。使用直接内存时,尽量重用已经分配的内存,而不是频繁地进行分配和释放操作。同时,也可以通过调整 JVM 参数来限制直接内存的使用量,以防止内存泄漏等问题。


  1. 这里的“大小”指的是变量槽的数量,不同虚拟机实现具体使用多少比特来实现一个变量槽则不同。 ↩︎

  2. 按照《Java 虚拟机规范》所述,所有的对象实例以及数组都应当在堆上分配,但目前栈上分配、标量替换等优化手段已经导致一些微妙的变化,使得 Java 对象实例都分配在堆上不再绝对。 ↩︎

  3. 永久代有一个 -XX:MaxPermSize 的上限,即使不设置也有默认大小。相比之下,J9 和 JRockit 只要没有触及进程可用内存的上限,就不会出现问题。此外,由于永久代的原因,极少数方法(例如 String::intern())会导致不同虚拟机之间表现不同。鉴于 HotSpot 未来的发展,JDK 6 时,HotSpot 开发团队计划放弃使用永久代,逐步采用本地内存 (Native Memory) 来实现方法区。 ↩︎


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容