探秘 JVM:运行时数据区

JVM 内存区域从概念模型上主要分为 堆、元空间、java 虚拟机栈、本地方法栈、程序计数器 五大模块,其中前两者属于线程共享,而后三者属于线程私有,如下图(以 HotSpot 虚拟机为例):

image

说明:元空间在 java 8 中引入,替换之前的方法区。

各个区域的基本介绍如下:

区域 线程共享 调整参数 异常类型 功能描述
-Xms, -Xmx OutOfMemoryError 主要用于存放对象实例和数组
方法区 -XX:PermSize, -XX:MaxPermSize OutOfMemoryError 存储已被虚拟机加载的类型信息、常量、静态变量,以及 JIT 编译后的代码缓存,运行时常量池位于此区域
虚拟机栈 -Xss StackOverflowError, OutOfMemoryError 存储局部变量表、操作数栈、动态连接,以及方法出口信息
本地方法栈 -Xoss StackOverflowError, OutOfMemoryError 与虚拟机栈功能类似,但是服务于 native 方法
程序计数器 用于指定下一条待执行的指令,控制代码的执行流程

线程共享区域

Java 堆是线程共享的,在虚拟机启动时创建,是存放对象实例和数组的地方,垃圾收集器的主战场。虽然 JVM 规范要求所有的对象实例都要在堆上分配,但是随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换等技术让对象实例必须在堆上分配逐渐变得不那么绝对。

堆内存分配策略

JVM 规范只要求堆在逻辑上连续即可,但是具体是逻辑上连续还是物理上连续要看具体的内存分配算法。

  • 指针碰撞法

如果堆中内存是规整的,即使用中的内存放在一边,空闲的内存放在另外一边,中间维护一个指针作为分界指示器。这种情况下如果需要为一个对象分配内存,只要按需将指示器向空闲区域移动相应大小即可。

  • 空闲列表法

对于不规整的内存区域,只有在堆中维护一个空闲内存列表,用于标记哪些内存区域是空闲的,然后分配的时候从空闲列表中找到一块对应的足够大的内存区域予以分配。

堆内存是否规整主要依据采用的垃圾收集器是否具备压缩整理功能。例如使用 Serial、ParNew 等带压缩整理的垃圾收集器时,系统采用的堆内存分配算法是指针碰撞算法;如果使用的是 CMS 这类清除算法垃圾收集器,一般就只能使用空闲列表算法执行堆内存分配。

除了考虑具体的内存分配算法,我们还需要考虑内存分配的 线程安全问题 ,毕竟 new 操作是相当频繁的。解决线程安全主要有两种方法:

  1. 采用 CAS 配合失败重试的方式保证分配操作的原子性。
  2. 把堆内存分配成多块(TLAB: Thread Local Allocation Buffer),每块由一个线程维护分配,这样就能够避免线程之间的竞争,只有在分配 TLAB 时才需进行同步。
对象的内存布局

在 HotSpot 中,对象的内存布局分为 3 块:对象头、实例数据,以及对齐填充。

对象头 包含两部分数据,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID,偏向时间戳等);另外一部分则是类型指针,用于定位当前对象是哪个类的实例,但是这部分不是必须的,视具体定位策略。此外,如果对象是一个数组,那么还需要在对象头中保存当前数组的长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|----------------------------------------------------------------------------------------|--------------------|
| Object Header (64 bits) | State |
|-------------------------------------------------------|--------------------------------|--------------------|
| Mark Word (32 bits) | Klass Word (32 bits) | |
|-------------------------------------------------------|--------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Normal |
|-------------------------------------------------------|--------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Biased |
|-------------------------------------------------------|--------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | OOP to metadata object | Lightweight Locked |
|-------------------------------------------------------|--------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | OOP to metadata object | Heavyweight Locked |
|-------------------------------------------------------|--------------------------------|--------------------|
| | lock:2 | OOP to metadata object | Marked for GC |
|-------------------------------------------------------|--------------------------------|--------------------|

引用自 https://gist.github.com/arturmkrtchyan/43d6135e8a15798cc46c

实例数据 部分记录了对象的有效信息,即各种类型的字段内容,包括继承的和自定义的。这部分的存储顺序由虚拟机分配策略参数与字段定义顺序决定。

对齐填充 不是必须的,仅仅起到占位符的作用,一般来说对象的大小必须是 8 字节的整数倍,所以在不满足时需要进行填充。

对象的访问定位

引用(reference)类型是 JVM 所支持的类型之一,一般而言,虚拟机至少应该基于引用类型做到两件事情:

  1. 根据引用直接或间接的定位对象在 java 堆中数据存放的起始地址或索引。
  2. 根据引用直接或间接的定位对象所属类型在方法区中存储的类型信息。

在 java 栈中通过引用记录着堆上具体对象的引用,如何基于引用来定位具体的对象是本小节需要讨论的问题,目前主流的定位策略主要分为 句柄直接指针 两类。

image

如上图所示,左图描绘了基于句柄的定位策略,右图描绘了基于直接指针的定位策略。两种定位的区别在于基于句柄的策略需要在堆中专门分配一块句柄池,用于记录堆中对象实例和方法区中对象类型的真实地址信息,而栈中保存的则是对应堆中的对象句柄地址。基于直接指针的策略则是在栈中保存堆中对象的真实地址信息。

两种策略各有优略,基于句柄策略的优势在于当对象的地址变更时只要修改句柄池中对应的地址即可,缺点就是每次定位一个对象需要访问两次堆内存;基于直接指针的优缺点则正好相反。HotSpot 虚拟机采用的是基于直接地址的访问策略。

控制参数
参数 默认值 说明
-Xms 设置堆内存初始大小,ms 是 memory start 的缩写
-Xmx 设置堆内存大小上限,mx 是 memory max 的缩写
-XX:+HeapDumpOnOutOfMemoryError 当出现 OOM 时 dump 出当前堆内存快照
-XX:HeapDumpPath 用于指定堆内存快照文件的存储路径,文件名以 .hprof 后缀结尾

说明:-X 前缀代表这是一个 JVM 运行时参数。

生产环境通常建议将 -Xms-Xmx 这两个参数值设为相同以避免堆内存的伸缩所带来的性能开销。

如果内存不足该区域会抛出 OutOfMemoryError 异常,并且后面会跟“Java heap space”字样。下面的示例会导致堆内存 OOM:

1
2
3
4
List<byte[]> bytes = new ArrayList<>();
while (true) {
bytes.add(new byte[1024 * 1024]);
}

当出现堆内存 OOM 时不应该马上调大堆内存,而是应该通过内存映像分析工具(比如 MAT: Eclipse Memory Analyzer)对 dump 出来的堆内存转储快照进行分析,以确定是 内存泄露 ,还是 内存溢出 ,如果是前者则调再大也无济于事,此时需要进一步查看对象到 GC Roots 的引用链。

方法区

方法区同样是线程共享的一块区域,用于存储已被虚拟机加载的 类型信息、常量、静态变量,以及即时编译器编译后的代码缓存 等数据。既然是线程共享的,就需要一定的同步策略来保证线程安全,对于方法区来说一个类只能被一个线程加载,且只能被加载一次,从而保证方法区中存储的各个类的类信息只有一份。

此区域垃圾收集效果不佳,所以 JVM 规范对此区域的垃圾收集不强制要求,一些虚拟机选择在这一区域设置永久代。需要注意的一点是永久代这一概念正在逐步被废弃,因为其设计容易导致内存溢出问题。以 HotSpot 虚拟机为例,在 java 8 中已经完全废弃了永久代的概念,取而代之的是在 本地内存 中实现的元空间(Metaspace),这点上基本向 JRockit 和 J9 看齐。

对于每个被装载的类,虚拟机都会在方法区内记录如下类信息:

  1. 类的全限定名称
  2. 类的直接父类的全限定名称
  3. 类或接口类型标识信息
  4. 类的访问修饰符
  5. 类实现接口的全限定名称有序列表
  6. 类的运行时常量池
  7. 字段信息
  8. 方法信息
  9. 静态(类)变量
  10. 类加载器引用
  11. 类 Class 引用

下面针对上述列表中的一些名词作进一步解释:

  • 运行时常量池

运行时常量池用于存放类或接口中定义的常量,包括直接常量和对其它类型、字段和方法的符号引用,从编译期可知的数值字面量到需要在运行期解析后才能获得的方法或字段的引用。既然命名为运行时常量池,就说明这一块区域是动态的,允许运行时写入,一个典型的示例就是当我们调用 String#intern 方法将字符串写入运行时常量池。

常量池中的数据项类似数组一样通过索引进行访问,因为存储了类所用到的所有类型、字段和方法的符号引用,所以在动态连接中起着核心作用。

  • 字段信息

字段信息包括:字段名称、字段类型,以及字段访问修饰符。比如:

1
private String name;

需要注意的是,我们一般也称其为属性,但是实际上两者还是存在一些不同的。通常属性是通过 getter 和 setter 方法推断出来的,比如 getAge(),我们可以认为有一个名为 age 的属性,但是字段就是上面我们真实定义的。除了字段信息之外,字段声明的顺序也记录在方法区中。

  • 方法信息

方法信息包括:方法名称、参数列表、返回类型、访问修饰符。对于非 abstract 和非 native 方法,还必须包含:方法字节码、操作数栈和栈帧中局部变量表的大小、异常表。除了方法信息,方法声明的顺序也记录在方法区中。

控制参数
参数 默认值 说明
-XX:PermSize 设置方法区初始内存大小
-XX:MaxPermSize 设置方法区内存大小上限

当方法区内存不足时会抛出 OutOfMemoryError 异常,并且后面跟“PermGen space”字样。

直接内存

首先需要声明 直接内存并不是运行时数据区的一部分,也不是 JVM 规范中定义的内存区域 。Java 的 NIO 可以通过使用 native 函数库直接分配堆外内存,然后通过一个存储在 java 堆中的 DirectByteBuffer 对象作为这块内存对象的引用,这样可以避免在 java 堆和 native 堆间来回复制数据。

控制参数
参数 默认值 说明
-XX:MaxDirectMemorySize -Xmx 值相同 设置直接内存大小上限

直接内存大小的分配往往受制于宿主机总内存,如果我们在设置 JVM 参数时忽略了直接内存大小的设置,导致总的内存大小上限超过了宿主机总内存,就会导致 OOM。直接内存溢出的一个明显特征就是堆内存转储文件中没有明显异常,而且一般都是 NIO 引发的。如果发现堆内存 dump 文件很小,且程序中直接或间接使用了 NIO,则可以考虑是不是直接内存溢出。

线程私有区域

程序计数器(PC 寄存器)

程序计数器是线程私有的一块内存区域,大小是一个字长(至少应该能够保存一个 returnAddress 类型的数据,或一个与平台相关的本地指针的值),用于控制线程执行代码的流程。我们可以直观上将其理解为线程所执行的字节码的行号指示器,用于指定下一条待执行的指令。

说明:returnAddress 类型目前已经很少使用,该类型主要为字节码指令 jsr、jsr_w 和 ret 服务,指向一条字节码指令的地址,一些较老的 JVM 曾经使用这几条指令来实现异常处理时的跳转,但现在基本都采用变量表予以替换。

JVM 中线程的运行需要依赖于 CPU 分配时间片,拿到时间片的线程切换到运行态。多线程程序在执行时会出现多个线程间切换上下文的情况,所以我们需要一个程序计数器以存储线程下一条需要执行的指令。当线程拿到 CPU 时间片的时候,可以知道该执行什么,从而最终实现 分支、循环、跳转、异常处理,以及线程恢复等 基础逻辑。所以程序计数器只能是线程私有的,不然就乱套了,此外程序计数器也是 唯一一个没有 OOM 的区域

注意 :对于 java 方法,计数器存储的是正在执行的虚拟机字节码指令的地址,如果是 native 方法则计数器为空(undefined)。

Java 虚拟机栈

个人觉得命名为 java 方法栈会更加已于理解,与本地方法栈相呼应。

如果粗略的对虚拟机内存区域进行分类,可以分为 两大块,其中堆就是指前面所介绍的 java 堆,而栈就是指这里的 java 虚拟机栈。Java 虚拟机栈描述的是 java 方法执行的内存模型,它与线程同生命周期,当然也是线程私有的。Java 的每个方法在执行时都会创建一个 栈帧(Stack Frame) ,用来存放局部变量表、操作数栈、动态连接,以及方法出口等信息。 一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程

之所以存在栈帧的概念,是因为 java 编译器输出的指令流基本上是一种 基于栈的指令集架构 ,因为依赖于栈进行操作,所以这类指令流中的大部分指令都是 零地址指令 。与基于栈的指令集架构相对应的是 基于寄存器的指令集架构 ,相对于该指令集架构而言,基于栈的指令集架构具备可移植性(因为不依赖于寄存器)、代码相对紧凑,以及编译器实现更加简单的优点,但因为执行过程中需要频繁出栈入栈,且执行相同逻辑需要的指令更多,所以在执行速度上要逊色于寄存器指令架构。

栈帧

栈帧用于存储局部变量表、操作数栈、动态连接,以及方法出口信息。栈帧随着方法的被调用而创建,并随着方法的结束运行(不管是正常结束还是异常结束)而被销毁。栈帧是线程本地私有的数据,不可能在一个栈帧中引用另外一个线程的栈帧。

  • 局部变量表

局部变量表(Local Variables Table)用于存放方法参数和方法内部定义的局部变量,其容量在编译期确定(因为只存 8 种基本类型变量和对象的引用地址,所以容量可以事先计算),记录在 Code 属性的 max_locals 字段中。当一个方法被调用时,JVM 会使用局部变量表来完成实参到形参的传递。在变量类型层面,支持存放编译期可知的各种基本数据类型、引用类型,以及 returnAddress 类型数据,是一个以字长为单位,从 0 开始计数的数组。一个局部变量表可以保存一个类型为 boolean、byte、char、short、int、folat、reference,或 returnAddress 类型的数据,两个连续的局部变量表可以保存一个类型为 long 或 double 类型的数据,其中 byte、short、char,以及 boolean 都会被转成 int 类型进行存储(只有在堆和方法区中才会以原类型存储)。一个方法的局部变量表所占据的内存空间大小可以在编译期确定,当进入一个方法时会依据该大小值为局部变量表申请分配内存空间,且在方法运行期间不会发生改变。

1
2
3
4
5
6
7
8
9
// 类方法
public static int classMethod(int i, long l, float f, double d, Object obj, byte b) {
return 0;
}

// 实例方法
public int instanceMethod(char c, double d, short s, boolean b, float f) {
return 0;
}

假设某个类包含上面代码块中的两个方法,其中 classMethod 是类方法,而 instanceMethod 是实例方法,则这两个方法在局部变量表中的存储结构如下图所示:

image

所有的参数都严格按照声明的顺序存储在局部变量表中,对于定义在方法内部的局部变量来说,其存储顺序则不一定按照声明的顺序,甚至在前面声明的局部变量生命周期已经结束的情况下,后面声明的局部变量可以覆盖掉该局部变量在变量表中对应的索引位置。其中需要注意的有 3 点:

  1. 所有的 byte、short、char、boolean 类型都转换成了 int 类型进行存储;
  2. 实例方法的 0 号索引位置是对 this 指针的引用,实例方法是属于具体类实例的,需要通过 this 指针来知晓当前隶属的对象;
  3. 参数 Object 类型在局部变量表中是以 reference 类型进行存储,该引用指向堆中的具体对象, 在局部变量表和操作数栈中永远都不会直接存储对象(包括堆中对象的拷贝)
  • 操作数栈

操作数的定义可以简单理解为当前执行计算所操作的对象,在 java 虚拟机栈中没有寄存器的概念,所以计算操作的存储位置基本上都是基于操作数栈来完成的。之所以取名为栈是因为对于操作数栈的操作是完全按照栈的操作出栈、入栈,而不是像局部变量表那样基于索引来定位。

每一个操作数栈都会拥有一个明确的栈深度用于存储数值,一个 32bit 的数值可以用一个单位的栈深度来存储,而 2 个单位的栈深度则可以保存一个 64bit 的数值。操作数栈所需的容量大小同样可以在编译期被完全确定下来,并保存在方法的 Code 属性中(max_stacks 数据项)。

虚拟机在操作数栈中存储数据的方式和在局部变量表中是一样的,对于 byte、short、boolean,以及 char 类型的值在压入到操作数栈之前,也会被转换为 int 类型。虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据,执行运算,最后把结果压回操作数栈。

假设我们现有下面这样一段简单的求和代码:

1
2
3
public void add(int a, int b) {
int c = a + b;
}

对应的字节码如下:

1
2
3
4
5
6
7
public void add(int, int);
Code:
0: iload_1
1: iload_2
2: iadd
3: istore_3
4: return

整段字节码指令的执行过程如下图所示:

image

执行过程如图示的非常清楚,不再多做描述,可以看到操作数栈就相当于一个栈结构的数据缓存区域,栈顶永远存储着当前操作所需要的操作数。

  • 动态连接

每个栈帧都持有一个指向运行时常量池中该栈帧所属方法的引用,用于支持方法调用过程中的动态连接(Dynamic Linking)。字节码中的方法调用指令以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或首次使用时被解析成直接引用,这种转换被称为 静态解析 ;另外一部分将在每次运行期间被转换成直接引用,称为 动态连接

  • 方法返回地址

方法的退出分为正常调用完成和异常调用完成两种:

  • 正常退出 :执行引擎遇到任意的方法返回指令,退出当前方法,并将返回值传递给上层方法调用者。
  • 异常退出 :方法执行期间遇到未被妥善处理的异常,此时不会给上层方法调用者传递返回值。

不论何种形式的退出,当一个方法退出执行后必须回到最初方法被调用的位置。

控制参数
参数 默认值 说明
-Xss 设置每个线程的栈内存大小

该区域包含两种异常类型:

  • StackOverflowError:线程请求的栈深度超过 java 虚拟机栈所允许的最大深度。
  • OutOfMemoryError:执行动态扩容时申请不到足够多的内存,或者在创建新的线程时没有足够多的内容予以分配。

当我们在递归调用的时候,如果设计不当就很容易触发 StackOverflowError 异常。此外对于多线程应用来说,如果我们将每个线程的栈内存设置得越大,就越容易出现 OOM,因为每个线程都消耗一份内存。当出现这类情况的时候,如果不能减少线程数或者更换 64 位虚拟机(更换 64 位虚拟机可以增大单个进程使用的内存上限,一个 JVM 启动起来就是一个进程,所以 JVM 运行时数据区所使用的内存受制于该上限),则应该减少栈内存。

JVM 规范既允许 java 虚拟机栈被实现成固定大小,也允许对其进行动态扩容和收缩,HotSpot 虚拟机的栈容量是不允许动态扩容的。

本地方法栈

本地方法栈和 java 虚拟机栈的作用是相似的,区别在于前者服务于 native 方法,而后者服务于 java 方法(是不是叫 java 方法栈更加容易理解一些)。与 java 虚拟机栈一样,本地方法栈也存在 StackOverflowError 和 OutOfMemoryError 两类异常。

控制参数
参数 默认值 说明
-Xoss 设置每个线程的栈内存大小

有些虚拟机并不区分虚拟机栈和本地方法栈,例如 HotSpot 就直接将二者合二为一,所以就算设置 -Xoss 参数也是无效的。

参考

  1. Java 虚拟机规范(Java SE 8 版)
  2. 深入理解 java 虚拟机(第 2 版)
  3. 深入理解 java 虚拟机(第 3 版)