探秘 JVM:运行时数据区

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

image

一. 线程共享区域

1.1 方法区

  • 调整指令:-XX:PermSize 和 -XX:MaxPermSize
  • 异常类型:OutOfMemoryError

方法区是线程共享的一块区域,用于存储已被虚拟机加载的 类信息、常量、静态变量,以及即时编译器编译后的代码 等数据。既然是线程共享的,就需要一定的同步策略来保证线程安全,对于方法区来说一个类只能被一个线程加载,且只能被加载一次,从而保证方法区中存储的各个类的类信息只有一份。此区域垃圾收集效果不佳,所以 JVM 规范对此区域的垃圾收集不强制要求,一些虚拟机选择在这一区域设置永久代。

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

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

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

  • 运行时常量池

运行时常量池是每一个类或接口的常量池表的运行时表示形式,包括直接常量(String、Integer 等)和对其他类型、字段和方法的符号引用,从编译期可知的数值字面量到必须在运行期解析后才能获得的方法或字段的引用。常量池中的数据项类似数组一样通过索引进行访问,因为存储了类所用到的所有类型、字段和方法的符号引用,所以在动态链接中起着核心作用。

  • 字段信息

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

1
private String name;

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

  • 方法信息

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

总之,方法区中存储的是一个类的基本定义,个人觉得改名叫 “类信息区” 更加直观。方法区的大小可以通过参数 -XX:PermSize-XX:MaxPermSize 设置,前者设置初始值,后者设置最大值。当内存不足时,则抛 OutOfMemoryError 异常,并且后面会跟 “PermGen space”。

1.2 堆

  • 调整指令:-Xms 和 -Xmx
  • 异常类型:OutOfMemoryError

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

1.2.1 堆内存分配策略

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

  • 指针碰撞法

如果堆中内存是规整的,即使用中的内存放在一边,而空闲的内存放在另外一边,中间维护一个指针作为分界指示器,这种情况下如果需要为一个对象分配内存,只要按需将指示器向空闲区域移动相应大小即可。堆内存是否规整主要依据采用的垃圾收集器是否具备压缩整理功能。

  • 空闲列表法

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

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

  1. 本地线程分配缓冲(TLAB),虚拟机采用 CAS 配合失败重试的方式保证更新操作的原子性。
  2. 把堆内存分配成多块,每块由一个线程维护分配。
1.2.2 对象的内存布局

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

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

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

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

1.2.3 对象的访问定位

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

image

如上图所示,左图描绘了基于句柄的定位策略,右图描绘了基于直接指针的定位策略。两种定位的区别在于基于句柄的策略需要在堆中专门分配一块句柄池,用于记录堆中对象实例和方法区中对象类型的真实地址信息,而栈中保存的则是对应堆中的对象句柄地址。而基于直接指针的策略则是在栈中保存堆中对象的真实地址信息。两种策略各有优略,基于句柄策略的优势在于当对象的地址变更时只要修改句柄池中对应的地址即可,缺点就是每次定位一个对象需要访问两次堆内存;基于直接指针的优缺点则正好相反。HotSpot 采用的是基于直接地址的访问策略。

对于堆内存大小,我们可以通过 -Xmx-Xms 参数进行控制(将这两个参数值设为相同则可避免堆自动扩展内存大小,我们可以附加 -XX:+HeapDumpOnOutOfMemoryError 参数,用于当出现 OOM 时 dump 出当前的内存堆转储快照,便于分析),如果内存不足,则抛 OutOfMemoryError 异常,并且后面会跟 “Java heap space”。

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

1.3 直接内存

  • 调整指令:-XX:MaxDirectMemorySize
  • 异常类型:

首先需要声明 直接内存并不是运行时数据区的一部分,也不是 JVM 规范中定义的内存区域。 Java 的 NIO 可以通过使用 native 函数库直接分配堆外内存,然后通过一个存储在 java 堆中的 DirectByteBuffer 对象作为这块内存对象的引用,这样可以避免在 java 堆和 native 堆间来回复制数据。直接内存大小的分配往往受制于宿主机总内存,如果我们在设置 JVM 参数的时候,忽略了直接内存大小的设置,导致总的内存大小上限超过了宿主机总内存,就会导致 OOM,这个时候我们需要通过 -XX:MaxDirectMemorySize 指定直接内存上限,否则直接内存默认最大值与 -Xmx 一样。

直接内存溢出的一个明显特征就是堆内存转储文件中没有明显异常,而且一般都是 NIO 引发的,如果发现堆内存 dump 文件很小,且程序中直接或间接使用了 NIO,则可以考虑是不是直接内存溢出。

二. 线程私有区域

2.1 程序计数器(PC 寄存器)

  • 调整指令:无
  • 异常类型:无

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

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

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

2.2 Java 虚拟机栈

个人觉得命名为 java 方法栈会更加已于理解。

  • 调整指令:-Xss
  • 异常类型:StackOverflowError 和 OutOfMemoryError

如果粗略的对虚拟机内存进行分类,可以分为 两大类,其中堆就是指前面所述的 java 堆,而栈就是指这里的 java 虚拟机栈。Java 虚拟机栈描述的是 java 方法执行的内存模型,它与线程同生命周期,当然也是线程私有的。Java 的每个方法在执行时都会创建一个 栈帧(Frame),用来存储参数、局部变量,以及中间运算结果等数据,而 一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程,这也是 JVM 对虚拟机栈仅有的两类操作。

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

JVM 规范既允许 java 虚拟机栈被实现成固定大小,也允许依据计算对其进行动态扩展和收缩。如果实现成固定大小,那么每一个线程对应的 java 虚拟机栈可以在线程创建时独立选定。

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

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

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

2.2.1 栈帧

栈帧用于存储数据和部分过程结果,同时也用来处理动态链接、方法返回值和异常分派。栈帧随着方法的被调用而创建,并随着方法的结束运行(不管是正常结束还是异常结束)而被销毁。每一个栈帧都有自己的局部变量表、操作数栈,以及指向当前方法所属类的运行时常量池的引用。栈帧是线程本地私有的数据,不可能在一个栈帧中引用另外一个线程的栈帧。

  • 局部变量表

局部变量表用于 存放方法参数和方法内部定义的局部变量,是一个以字长为单位,从 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) {
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

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

  • 帧数据区

帧数据区用来支撑运行时常量池解析所需数据、方法正常返回,以及异常派发机制。每当虚拟机执行某个需要用到常量池的指令时,都会通过帧数据区中指向常量池的指针来进行访问。对于方法的正常返回,虚拟机必须恢复发起调用的方法的栈帧,包括设置程序计数器指向发起调用的方法中的具体指令,如果当前方法有返回值,则需要将返回值压入调用方法的操作数栈中。而对于异常返回来说,帧数据区需要保存一个对此方法异常表的引用。

2.3 本地方法栈

  • 调整指令:-Xoss
  • 异常类型:StackOverflowError 和 OutOfMemoryError

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

有些虚拟机(比如 HotSpot)并不区分虚拟机栈和本地方法栈,所以就算设置 -Xoss 参数也是无效的。

参考

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

转载声明 : 版权所有,商业转载请联系作者,非商业转载请注明出处
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议
Powered by hexo & Theme by hiero   Copyright © 2015-2019 浙ICP备 16010916  号,指 · 间 All Rights Reserved.