深入理解Java虚拟机-Java虚拟机内存领域

33 阅读13分钟

1、 概述

        Java虚拟机在执行Java程序的过程中,会将其所管理的内存划分成若干个不同的数据区域。这些区域有各自的用途和生命周期。有的区域随着虚拟机进程的启动而一直存在,有的区域依赖于线程的启动和结束而建立和销毁。

        JVM所管理的内存区域主要包括以下运行时数据区域,如图1-1所示。

JVM运行时数据区.jpg

图 1-1  JVM虚拟机运行时数据区

2、 运行时数据区域

2.1、 程序计数器

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

        由于JVM的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何确定的时刻,一个处理器(或者多核处理器的一个内核)都只会执行一条线程中的指令。因此,为了保证线程的上下文切换正常进行,每个线程都要有自己独立的程序计数器。我们成这种独立的内存区域为“线程私有”。

        更为细分的说,当线程执行的是Java方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址;当执行的方法是Native方法时,程序计数器值应当为空。

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

2.2、 Java虚拟机栈

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

        Java的内存区域像传统的C、C++一样笼统的划分为堆内存(Heap)和栈内存(Stack)是不太合适的,Java程序实际的内存区域划分要比着更复杂。在这里对于“堆”的部分稍后再论述,而 “栈”通常指的就是虚拟机栈,或者说再更多情况下知识虚拟机栈中的局部变量表部分

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

        这些数据类型在局部变量表中的存储空间以局部变量槽(slot) 来表示,其中64位的double和long类型的数据会占用两个变量槽,其余均只占用一个。局部变量表所需的内存在编译期完成分配,当进入一个方法时,这个方法在栈帧中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。这里的"大小"指的是变量槽的数量,具体要用多大的内存空间(一个变量槽占用32比特、64比特或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事。

        在《Java虚拟机规范》中,虚拟栈帧这个内存区域规定了两种异常情况:当线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈的容量可以动态扩展,当栈扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

2.3、 本地方法栈

        本地方法栈(Native Method Stacks)与虚拟机栈的作用是类似的,其区别只是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则是虚拟机使用到的本地(Native)方法服务

        《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以自由的实现本地方法栈,甚至有的JVM直接将本地方法栈和虚拟机栈合二为一,例如Hot-Spot虚拟机。同样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowErrorOutOfMemoryError异常。

2.4、 Java堆

        对于Java程序而言,Java堆(Java Heap) 是虚是拟机所管理的内存中最大的一块,是被所有线程共享的区域,在虚拟机启动时创建。Java堆的唯一目的就是存放对象实例,《Java虚拟机规范》中对堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,但随着即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换等优化手段的出现,使得Java对象实例都分配在堆中不那么绝对了。

        Java堆是由垃圾收集器管理的内存区域,因此也被称为“GC堆”(Garbage Collected Heap)。从回收内存的角度来看,现代垃圾回收器大部分都是基于分代收集理论设计的,因此常常有新生代、老年代、永久代、Eden、From Survivor、To Survivior等名词出现在相关资料中。在G1收集器出现前,业界主流的HotSpot虚拟机内部的垃圾收集器全是基于“基本分代”来设计,需要新生代、老年代收集器搭配才能工作,此时“JVM的堆内分为新生带、老年代、永久代、Eden、Survivor......”这种说法是没什么问题的。但是到了今天,垃圾收集器技术早已更新,HotSpot中也出现了不采用分代设计的新垃圾收集器。

        从分配内存内存的角度来看,线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer),以提升对象分配的效率。不过无论是怎样划分,无论是哪个区域,存储的都只能是对象的实例。将堆细分成多个区域的目的是为了更好的回收内存,或则和更快的分配内存。

        在《Java虚拟机规范》中,Java堆可以处于物理上不连续的空间,但在逻辑上应该被视为连续的。但对于诸如数组的大对象,基于实现简单、存储高效的考虑,大多数都会要求连续的数据空间。

        Java堆的大小可以被实现为固定大小的,也可以是可扩展的。目前主流的JVM都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。当堆中没有内存完成实例分配且堆也无法扩展时,JVM会抛出OutOfMemoryError异常。

2.5、  方法区

        方法区(Method Area)与Java堆一样,是各个线程共享的区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。它有一个别名 "非堆"(Non-Heap)

        在JDK8以前,很多Java程序员都更愿意将方法区称为永久代,实际上这两者是不等价的,仅仅是因为HotSpot虚拟机设计团队选择将收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器可以像管理Java堆那样管理这部分内存,进而减少专门编写内存管理代码的工作。

        原则上如何实现方法区属于JVM的具体实现细节,各个虚拟机之间并不统一。现在来看,HotSpot使用永久代来实现方法区并不是一个好主意,这会导致Java应用更容易遇到内存溢出的问题(永久代-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的限制,例如32位4GB,就不会出问题)。而且有极少数方法(String::intern())会因为永久代的原因在不同虚拟机下会有不同的表现,为了移植其他虚拟机的优秀功能,HotSpot团队在JDK6的时候就有放弃永久代,逐步使用本地内存(Native Memory)来实现方法区,在JDK7时,已经将原本放在永久代的字符串常量池、静态变量移出,到了JDK8彻底放弃了永久代,二十和JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替,把JDK7中还剩余的内容(主要是类型信息)全部移到元空间中。

        《Java虚拟机规范》中对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至可以不选择垃圾收集。但这不意味着进入方法区就会永久存在了。方法区的内存回收主要是针对常量池的回收和对类型的卸载,其中对于类型的卸载条件是非常苛刻的,但同时对方法区的回收又是十分必要的。

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

2.6、 运行时常量池

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

        Java虚拟机对于Class文件的每一部分(包括常量池)的格式都有严格规定,如每个字节用于存储那种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行。但对运行时常量池,并没有做任何细节的需求。一般而言,除了保存Class文件中描述的符号引用外,还会将符号引用翻译出来的直接引用也存储下来。

        运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,Java语言中常量并不是只有编译期才会产生。简而言之,并非预置入Class文件中常量池的内容才能进入方法去运行时常量池,运行期间也可以将新的常量放到池中,就像String::intern()方法。

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

2.7、 直接内存

        直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,但这部分内存也被频繁的使用,也可能导致OutOfMemoryError异常出现。

        在JDK1.4中新增了NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,可以使用Native函数直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

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

3、 总结

        在 Java 中,虚拟机(JVM)负责管理和分配内存。Java 的内存空间大致可以划分为以下几个部分:

  1. 程序计数器(Program Counter Register): 这是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  2. Java虚拟机栈(JVM Stack): 每个线程都有一个私有的栈,它存放的是基本类型的变量数据和对象的引用,但对象本身不存放在栈中。也就是说,栈中主要存放的是局部变量。栈中的内存分配运行速度很快。
  3. 本地方法栈(Native Method Stack): 类似于虚拟机栈,但本地方法栈不执行 Java 方法(字节码),而是执行本地方法。
  4. Java堆(Java Heap): 这个区域用于存储所有的对象实例以及数组。它是 JVM 所管理的最大一块内存区。堆被所有线程共享,在虚拟机启动时创建。
  5. 方法区(Method Area): 也被称为静态区,用来存放已被加载的类信息、常量、静态变量等数据。就像堆一样,方法区也是被所有线程共享的。
  6. 运行时常量池(Runtime Constant Pool): 它是每一个类或接口定义的常量池的运行时表示形式。它包含了几种不同的常量,从编译期可知的字面量(如文本字符串)到必须在运行期解析之前的符号引用。运行时常量池为 Java 的动态链接提供了基本的设施。
  7. 直接内存(Direct Memory): 直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。在 JDK 1.4 新加入的 NIO 类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作,避免了在 Java 堆和 Native 堆之间来回复制数据。