本文从概念上介绍 Java 虚拟机内存的各个区域,讲解这些区域的作用、服务对象以及其中可能产生的问题。

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

根据《Java 虚拟机规范》的规定, Java 虚拟机所管理的内存将会包括以下几个运行时数据区域:程序计数器、Java 虚拟机栈、本地方法栈、Java 堆、方法区。

image-20230221174739144.png

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,程序计数器可以看作是当前线程所执行的字节码的行号指示器。在 Java 虚拟机的概念模型里,字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。

“概念模型”这个词会经常被提及,它代表了所有虚拟机的统一外观,但各款具体的 Java 虚拟机并不一定要完全照着概念模型的定义来进行设计,具体的 Java 虚拟机可能会通过一些更高效率的等价方式去实现它。


由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻, 一个处理器(对于多核处理器来说是一个内核)都只会执行一个线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间的程序计数器互不影响,独立存储,我们称这类内存区域为 “线程私有” 的内存。

如果线程正在执行的是一个 Java 方法, 程序计数器记录的是正在执行的虚拟机字节码指令的地址; 如果线程正在执行的是本地(Native) 方法,程序计数器值则应为空(Undefined)。

程序计数器内存区域是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stack)与程序计数器一样,也是线程私有的内存区域,Java 虚拟机栈的生命周期与线程相同。

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

每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化, 但在基于概念模型的讨论里,大体上可以认为是编译期可知的)


局部变量表

局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型(boolean、 byte、 char、 short、 int、float、 long、 double) 、对象引用(reference 类型,对象引用并不等同于对象本身,对象引用可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示, 其中 64 位长度的 long 和 double 类型的数据会占用两个变量槽,其余的数据类型只占用一个变量槽。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时, 这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间局部变量表的大小不会改变。

请读者注意,这里说的 “大小” 指的是变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照 1 个变量槽占用 32 个比特、 64 个比特, 或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。


在《Java 虚拟机规范》中, 对 Java 虚拟机栈内存区域规定了两类异常状况:StackOverflowError、OutOfMemoryError

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

通过参数 -Xss 来设定单个线程栈的大小,栈的大小直接决定了函数调用的最大深度。

HotSpot 虚拟机的栈容量是不可以动态扩展的,以前的 Classic 虚拟机倒是可以。所以在 HotSpot 虚拟机上是不会由于虚拟机栈无法扩展而导致 OutOfMemoryError 异常。只要线程申请栈空间成功了就不会有 OOM,但是如果线程申请栈空间失败了,仍然是会出现 OOM 异常的。

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用非常相似,它们两个的区别是:虚拟机栈为虚拟机执行 Java 方法(也就是字节码) 服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

《Java 虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的 Java 虚拟机(譬如 HotSpot 虚拟机) 直接就将本地方法栈和虚拟机栈合二为一。

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

Java 堆

Java 堆是一块被所有线程共享的内存区域,Java 堆在虚拟机启动时被创建。

Java 堆内存区域的唯一目的就是存放对象实例,Java 世界里 “几乎” 所有的对象实例都在 Java 堆分配内存。

在《Java 虚拟机规范》中对 Java 堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,而这里笔者写的“几乎”是指从实现角度来看,随着 Java 语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说 Java 对象实例都分配在堆上也渐渐变得不是那么绝对了。

根据《Java 虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。


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

固定大小的 Java 堆指的是:只在虚拟机启动时,向操作系统申请固定大小的堆内存空间。

可扩展的 Java 堆指的是:在虚拟机启动时,向操作系统申请固定大小的初始堆内存空间。在空闲的 Java 堆内存空间无法满足新的内存分配需求时,再向操作系统申请堆内存空间。

方法区

方法区(Method Area)与 Java 堆一样, 也是被所有线程共享的内存区域。

方法区用于存储已被虚拟机加载的类型信息(如类名、访问修饰符、字段描述、方法描述等)、常量、静态变量、即时编译器编译后的代码缓存等数据。

虽然《Java 虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是方法区它却有一个别名叫作 “非堆”(Non-Heap) ,目的是与 Java 堆区分开来。

根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,Java 虚拟机将会抛出 OutOfMemoryError 异常。


《Java 虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在方法区这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样 “永久” 存在了。方法区这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载, 一般来说方法区这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是方法区这个区域的回收有时又确实是必要的。 以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对方法区这个区域未完全回收而导致内存泄漏。

永久代

说到方法区,不得不提一下 “永久代” 这个概念,尤其是在 JDK8 以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人都更愿意把方法区称为 “永久代”(Permanent Generation),或者将这两者(方法区、永久代)混为一谈。
本质上这两者(方法区、永久代)并不是等价的
,因为仅仅是当时的 HotSpot 虚拟机设计团队选择把垃圾收集器的分代设计扩展至方法区,或者说
使用永久代来实现方法区
而已, 这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理方法区这部分内存,省去专门为方法区编写内存管理代码的工作。但是
对于其他的虚拟机实现, 譬如 BEA JRockit、IBM J9 等来说,是不存在永久代这个概念的


原则上如何实现方法区属于虚拟机的实现细节,不受《Java 虚拟机规范》管束, 并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了 Java 应用更容易遇到内存溢出的问题(永久代有 -XX:MaxPermSize 的上限,即使不设置也有默认大小,而 J9 和 JRockit 只要没有触碰到进程可用内存的上限, 例如32位系统中的4GB限制, 就不会出问题) ,而且有极少数方法(例如String::intern()) 会因永久代的原因而导致不同虚拟机下有不同的表现。

当 Oracle 收购 BEA 获得了JRockit 的所有权后, 准备把 JRockit 中的优秀功能,譬如 Java Mission Control 管理工具, 移植到 HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。

考虑到 HotSpot 未来的发展,在 JDK6 的时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了 JDK7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出, 而到了 JDK8 , 终于完全废弃了永久代的概念, 改用与 JRockit、J9 一样在本地内存中实现的元空间(Metaspace)来代替,把 JDK7 中永久代还剩余的内容(主要是类型信息) 全部移到元空间中。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。

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

Java 虚拟机对于 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java 虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。

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

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区域的一部分,也不是《Java 虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现,所以我们放到这里一起讲解。

在 JDK1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式, NIO 它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置 -Xmx 等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

总结

运行时数据区域

程序计数器

程序计数器是一块较小的内存空间。程序计数器是“线程私有”的数据区域。

如果一个线程正在执行的是一个 Java 方法, 程序计数器记录的是正在执行的虚拟机字节码指令的地址。

在 Java 虚拟机的概念模型里,字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。


Java 虚拟机栈、本地方法栈

HotSpot 虚拟机将本地方法栈和虚拟机栈合二为一。

  • Java 虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在 Java 虚拟机栈中从入栈到出栈的过程。
  • 本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用非常相似,它们两个的区别是:虚拟机栈为虚拟机执行 Java 方法(也就是字节码) 服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。


【Java 堆】
内存区域的唯一目的就是存放对象实例,Java 世界里 “几乎” 所有的对象实例都在【Java 堆】区域分配内存。

【方法区】
内存区域用于存储已被虚拟机加载的类型信息(如类名、访问修饰符、字段描述、方法描述等)、常量、静态变量、即时编译器编译后的代码缓存等数据。

“线程私有” 的区域

“线程私有” 的内存区域:每个线程都有一个独立的内存区域,各个线程之间的内存区域互不影响, 独立存储,我们称这类内存区域为 “线程私有” 的内存区域。

  • “线程私有” 的内存区域有:程序计数器、Java 虚拟机栈、本地方法栈;
  • 被所有线程共享的内存区域有:Java 堆、方法区。

垃圾收集的区域

程序计数器、Java 虚拟机栈、本地方法栈这三个运行时数据区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着入栈和出栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这三个运行时数据区域的内存分配和回收都具备确定性,在这三个运行时数据区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

而 Java 堆和方法区这两个运行时数据区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样, 一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分(Java 堆、方法区)内存的分配和回收是动态的。垃圾收集器所关注的正是这部分(Java 堆、方法区)内存该如何管理。

内存区域的异常状况

【程序计数器】内存区域是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。


【Java 虚拟机栈】、【本地方法栈】内存区域
:在【Java 虚拟机栈】、【本地方法栈】内存区域中,可能出现的异常状况有:OutOfMemoryError、StackOverflowError:

  • 创建线程时,需要申请栈空间。如果线程申请栈空间失败了,那么 Java 虚拟机就会抛出 OutOfMemoryError 异常。
  • 线程申请栈空间成功后,如果线程请求的栈深度大于虚拟机所允许的深度,那么 Java 虚拟机就会抛出 StackOverflowError 异常。


【Java 堆】内存区域
:如果 Java 堆无法满足新的内存分配需求,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

【方法区】内存区域
:如果方法区无法满足新的内存分配需求时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

【直接内存】
:如果各个内存区域的总和大于物理内存限制(包括物理的和操作系统级的限制),Java 虚拟机将会抛出 OutOfMemoryError 异常。

参考资料

《深入理解 Java 虚拟机》第 2 章:Java 内存区域与内存溢出异常 2.2 运行时数据区域

标签: none

添加新评论