JVM - 内存区域

2021/08/23 JVM 共 3148 字,约 9 分钟
Bob.Zhu

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

虚拟机内存区域

  • 所有线程共享:
    • 方法区(非堆):类型信息、常量、静态变量、即时编译器编译后的代码缓存 等数据
    • 堆:对象实例
  • 每个线程私有:
    • 程序计数器:当前线程所执行的 行号指示器
    • 虚拟机栈:当前线程的 局部变量 表、操作数栈、动态连接、方法出口等信息
    • 本地方法栈:类似虚拟机栈,native方法所使用的栈

程序计数器

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

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

Java虚拟机栈

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

经常有人把 Java 内存区域笼统地划分为堆内存(Heap)和栈内存(Stack),这种划分方式直接继承自 传统的 C、C++程序的内存布局结构,在 Java 语言里就显得有些粗糙了,实际的内存区域划分要比这 更复杂。不过这种划分方式的流行也间接说明了程序员最关注的、与对象内存分配关系最密切的区域是 “堆”和”栈”两块。其中,”堆”在稍后笔者会专门讲述,而”栈”通常就是指这里讲的虚拟机栈,或者更多 的情况下只是指虚拟机栈中局部变量表部分。

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

本地方法栈

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

Java堆

对于 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是 被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里”几乎”所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作”GC 堆”,从回收内存的角度看,由于 现代垃圾收集器大部分都是基于分代收集理论设计的,所以 Java 堆中经常会出现”新生代”、”老年代”、 “永久代”、”Eden 空间”、”From Survivor 空间”、”To Survivor 空间”等名词,这些区域划分 仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个 Java 虚拟机具体实现的固有内存布局, 更不是《Java 虚拟机规范》里对 Java 堆的进一步细致划分。不少资料上经常写着类似于”Java 虚拟机 的堆内存分为新生代、老年代、永久代、Eden、 Survivor……“这样的内容。在十年之前(以 G1 收集器的出现为分界),作为业界绝对主流的 HotSpot 虚拟机,它内部的垃圾收集器全部都基于”经典分代” 来设计,需要新生代、老年代收集器搭配才能工作,在这种背景下,上述说法还算是不会产生太大歧义。 但是到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot 里面也出现了不采用分代设计的 新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了。

不过无论从什么角度,无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的 都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储 已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。 虽然《Java 虚拟机规范》中把 方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作”非堆”(Non-Heap),目的是与 Java 堆区分开来。

说到方法区,不得不提一下”永久代”这个概念,尤其是在 JDK 8 以前,许多 Java 程 序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为”永久代”(Permanent Generation), 或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的 HotSpot 虚拟机设计团队选择把 收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot 的垃圾收集器 能够像管理 Java 堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他 虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。原则上如何实现方法区 属于虚拟机实现细节,不受《Java 虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代 来实现方法区的决定并不是一个好主意,这种设计导致了 Java 应 用更容易遇到内存溢出的问题。

运行时常量池

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

直接内存

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

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

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机 总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制。

参考资料

文档信息

Search

    Table of Contents