文章目录
Java 运行时数据区
1. 概述
对于从事 C、C++ 程序开发的人员来说,在内存管理领域,他们既是拥有最高权利的“皇帝”,又是从事最基础工作的劳动人民——既拥有每一个对象的“所有权”,又担负着每一个对象生命从开始到终结的维护责任。
对于 Java 程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个 new 操作去写配对的 delete/free 代码,不容易出现内存泄露和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不过,也正是因为 Java 程序员把控制内存的权利交给了 Java 虚拟机,一旦出现内存泄露和内存溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。
2. 运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有的区域则是依赖用户线程的启动和结束而建立和销毁。
Java 虚拟机所管理的内存将会包含以下几个运行时数据区域:
2.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在 Java 虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序流程控制的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
**由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。**因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间程序计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。
如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined 未定义的)。
此内存区域是唯一一个在《Java 虚拟机规范》中没有规定 OutOfMemoryError 情况的区域。
2.2 Java 虚拟机栈(Java Virtual Machine Stack)
与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候都会同步创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
2.2.1 运行时栈帧的结构
Java 虚拟机以方法作为最基本的执行单元。”栈帧“ 则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
每一个栈帧都包括了局部变量表 、操作数栈、动态连接和方法返回地址和一些额外的附加信息。在编译 Java 程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的 Code 属性之中。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
一个线程中的方法调用链可能会很长,以 Java 程序的角度来看,同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,**只有位于栈顶的栈帧才是生效的,称其为“当前栈帧”,这个栈帧所关联的方法被称为“当前方法”。**执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
a. 局部变量表(Local Variables Table)
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序被编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量以变量插槽(Variable Slot)为最小单位,《Java 虚拟机规范》中并没有明确规定一个变量插槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据。这 8 种数据类型,都可以使用 32 位或更小的物理内存来存储,但这种描述与明确指出每个变量插槽应占用 32 位长度的内存空间是有本质差别的,它允许变量插槽的长度是可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在 64 位虚拟机中使用了 64 位的物理内存空间去实现一个变量插槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与 32 位虚拟机中的一致。
Java 虚拟机的数据类型,作简介。一个变量槽可以存放一个 32 位以内的数据类型, Java 中占用不超过 32 位存储空间的数据类型有 boolean、byte、char、short、int、float、reference 和 returnAddress 这 8 种类型(Java 语言和 Java 虚拟机中的基本数据类型是存在本质差别的),reference 类型表示对一个对象实例的引用,《Java 虚拟机规范》既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。(一般来说,虚拟机实现至少都应当能通过这个引用做到两件事)
- 一是从根据引用直接或间接地查找到对象在 Java 堆中的数据存放的起始地址或索引。
- 二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。
returnAddress 类型目前已经很少见了,它是为字节码指令 jsr、jsr_w 和 ret 服务的,指向了一条字节码指令的地址,某些很古老的 Java 虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来代替了。
对于 64 位的数据类型, Java 虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。 Java 语言中明确的 64 位的数据类型只有 long 和 double 两种。这里把 long 和 double 数据类型分割存储的做法与“ long 和 double 的非原子性协定”中允许把一次 long 和 double 数据类型读写分割为两次 32 位读写的做法有些类似。由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的变量槽数量。如果访问的是 32 位数据类型的变量,索引 N 就代表了使用第 N 个变量槽,如果访问的是 64 位数据类型的变量,则说明会同时使用第 N 个和第 N + 1 个变量槽。对于两个相邻的共同存放一个 64 位数据类型的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个。《Java 虚拟机规范》中明确要求了如果遇到了进行这种操作的字节码序列,虚拟机就应该在类加载的校验阶段中抛出异常。
当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被 static 修饰的方法),那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超过了某个变量的作用域,那这个变量对应的变量槽就可以交给其它变量来重用。下面演示在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为:
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
// 这样是没有回收掉垃圾的
System.gc();
}
public static void main(String[] args) {
{
// 更改了 placeholder 的作用域
byte[] placeholder = new byte[64 * 1024 * 1024];
}
// 这样也是没有回收掉垃圾的
System.gc();
}
public static void main(String[] args) {
{
// 更改了 placeholder 的作用域
byte[] placeholder = new byte[64 * 1024 * 1024];
}
// 这样回收掉垃圾了,这里 int a = 0; 复用了变量槽
int a = 0;
System.gc();
}
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
// 这样回收掉垃圾了,在这里手动将引用置空
placeholder = null;
System.gc();
}
b. 操作数栈(Operand Stack)
操作数栈也常被称为操作栈,它是一个后入先出(Last In First Out。LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性的 max_stacks 数据项之中。操作数栈的每一个元素都可以是包括 long 和 double 在内的任意 Java 数据类型。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。Javac 编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。举个例子,例如整数加法的字节码指令 iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行这个指令时,会把这两个 int 值出栈并相加,然后将相加的结果重新入栈。
操作数栈中元素的数据类型必须是与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的 iadd 指令为例,这个指令只能用于整数型的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为 int 型,不能出现一个 long 和一个 float 使用 iadd 命令相加的情况。
另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了,重叠过程如图所示:
Java 虚拟机的解释执行引擎被称为 “基于栈的执行引擎”,里面的 “栈” 就是操作数栈。
c. 动态连接(Dynamic Linking)
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道 **Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。**另一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
d. 方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为 “正常调用完成” (Normal Method Invocation Completion)。
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow
字节码指令产生的异常,只要在本方法的异常表中没有搜到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为 “异常调用完成” (Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的 PC 计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
2.3 本地方法栈(Native Method Stacks)
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机到的本地方法(native,一般为调用本地的 dll,在 Linux 上是 so)服务。
《Java 虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的 Java 虚拟机(譬如 HotSpot)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常。
2.4 Java 堆(Java Heap)
对于 Java 程序员来说,Java 堆是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里 “几乎” 所有的对象实例都在这里分配内存。在《Java 虚拟机规范》中对 Java 堆的描述是:“所有的对象实例及数组都应当在堆上分配”,这里的 “几乎” 是指从实现角度看,随着 Java 语言的发展,现在已经能看见些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说 Java 对象实例都分配在堆上也渐渐变得不是那么绝对了。
Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称为 “GC 堆”(Garbage Collected Heap),从回收内存的角度来看,由于现代垃圾收集器大部分是基于分代收集理论设计的,所以 Java 堆中经常会出现 “新生代” ,“老年代” ,“永久代”,“Eden 空间”,“From Survivor 空间”,“To Survivor 空间” 等名词。不少资料上经常会写着类似于 “Java 虚拟机的堆内存分为新生代、老年代、永久代、Eden、Survivor…” 这样的内容。在十年之前(以 G1 收集器的出现为分界),作为业界绝对主流的 HotSpot 虚拟机,它内部的垃圾收集器全部都基于 “经典分代” 来设计,需要新生代、老年代收集器搭配才能工作,在这种背景下,上述说法还算是不会产生太大歧义。但是到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot 里面也出现了不采用分代设计的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了。
如果从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
根据《Java 虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
Java 堆既可以被实现固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数 -Xmx 和 -Xms 设定,堆内存最大与最小)。如果在 Java 堆中没有内存完成实例分配,并且堆也无法在扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。
2.5 方法区(Method Area)
方法区与 Java 堆一样,是各个线程共享的内存区域,**它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。**虽然《Java 虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作 “非堆”(Non-Heap),目的是与 Java 堆区分开来。
说到方法区,不得不提一下 “永久代” 的概念,尤其是在 JDK 8 以前,许多 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 未来的发展,在 JDK 6 的时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了 JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全放弃了永久代的概念,改用 JRockit、J9 一样在本地内存中实现的元空间(Meta-space)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
《Java 虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确比较少出现的,但并非数据进入了方法区就如永久代的名字一样 “永久” 存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄露。
根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。
2.5.1 运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分。Class 文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
Java 虚拟机对于 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java 虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需求来实现这个内存区域,不过一般来说,除了保存 Class 文件描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。