前言
JVM内存区域包括 PC计数器、Java虚拟机栈、本地方法栈、堆、方法区、运行时常量池和 直接内存。
本文主要介绍各个内存区域的作用和特性,同时分别阐述各个区域发生内存溢出的可能性和异常类型。
正文
(一). JVM内存区域
Java虚拟机执行Java程序的过程中,会把所管理的内存划分为若干不同的数据区域。这些内存区域各有各的用途,以及创建和销毁时间。有的区域随着虚拟机进程的启动而存在,有的区域伴随着用户线程的启动和结束而创建和销毁。
JVM内存区域也称为Java运行时数据区域。其中包括:程序计数器、虚拟机栈、本地方法栈、堆、静态方法区、静态常量池等。
注意:程序计数器、虚拟机栈、本地方法栈属于每个线程私有的;堆和方法区属于线程共享访问的。
1.1. PC计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码行号指示器。
- 当前线程所执行的字节码行号指示器。
- 每个线程都有一个自己的
PC计数器。 - 线程私有的,生命周期与线程相同,随
JVM启动而生,JVM关闭而死。 - 线程执行
Java方法时,记录其正在执行的虚拟机字节码指令地址。 - 线程执行
Native方法时,计数器记录为空(Undefined)。 - 唯一在
Java虚拟机规范中没有规定任何OutOfMemoryError情况区域。
1.2. Java虚拟机栈
线程私有内存空间,它的生命周期和线程相同。线程执行期间,每个方法执行时都会创建一个栈帧(Stack Frame) ,用于存储 局部变量表、操作数栈 、动态链接 、方法出口 等信息。
- 局部变量表
- 操作数栈
- 动态链接
- 方法出口
每一个方法从调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈和出栈的全过程。
下面依次解释栈帧里的四种组成元素的具体结构和功能:
1). 局部变量表
局部变量表是一组变量值的存储空间,用于存储方法参数和局部变量。 在 Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量。
局部变量表在编译期间分配内存空间,可以存放编译期的各种变量类型:
- 基本数据类型 :
boolean,byte,char,short,int,float,long,double等8种; - 对象引用类型 :
reference,指向对象起始地址的引用指针; - 返回地址类型 :
returnAddress,返回地址的类型。
变量槽(Variable Slot):
变量槽是局部变量表的最小单位,规定大小为
32位。对于64位的long和double变量而言,虚拟机会为其分配两个连续的Slot空间。
2). 操作数栈
操作数栈(Operand Stack)也常称为操作栈,是一个后入先出栈。在 Class 文件的 Code 属性的 max_stacks 指定了执行过程中最大的栈深度。Java虚拟机的解释执行引擎被称为基于栈的执行引擎 ,其中所指的栈就是指-操作数栈。
- 和局部变量表一样,操作数栈也是一个以
32字长为单位的数组。 - 虚拟机在操作数栈中可存储的数据类型:
int、long、float、double、reference和returnType等类型 (对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int)。 - 和局部变量表不同的是,它不是通过索引来访问,而是通过标准的栈操作 — 压栈和出栈来访问。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
1
|
begin
|
在这个字节码序列里,前两个指令
iload_0和iload_1将存储在局部变量表中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量表索引为2的位置。
下图详细表述了这个过程中局部变量表和操作数栈的状态变化(图中没有使用的局部变量表和操作数栈区域以空白表示)。
3). 动态链接
每个栈帧都包含一个指向运行时常量池中所属的方法引用,持有这个引用是为了支持方法调用过程中的动态链接。
Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用:
- 静态解析:一部分会在类加载阶段或第一次使用的时候转化为直接引用(如
final、static域等),称为静态解析, - 动态解析:另一部分将在每一次的运行期间转化为直接引用,称为动态链接。
4). 方法返回地址
当一个方法开始执行以后,只有两种方法可以退出当前方法:
- 正常返回:当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(
Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址。 - 异常返回:当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(
Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。
当一个方法返回时,可能依次进行以下3个操作:
- 恢复上层方法的局部变量表和操作数栈。
- 把返回值压入调用者栈帧的操作数栈。
- 将
PC计数器的值指向下一条方法指令位置。
小结:
注意:在Java虚拟机规范中,对这个区域规定了两种异常。
其一:如果当前线程请求的栈深度大于虚拟机栈所允许的深度,将会抛出StackOverflowError异常(在虚拟机栈不允许动态扩展的情况下);其二:如果扩展时无法申请到足够的内存空间,就会抛出OutOfMemoryError异常。
1.3. 本地方法栈
本地方法栈和Java虚拟机栈发挥的作用非常相似,主要区别是Java虚拟机栈执行的是Java方法服务,而本地方法栈执行Native方法服务(通常用C编写)。
有些虚拟机发行版本(譬如
Sun HotSpot虚拟机)直接将本地方法栈和Java虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
1.4. 堆
Java堆是被所有线程共享的最大的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
在Java中,堆被划分成两个不同的区域:新生代 (Young Generation) 、老年代 (Old Generation) 。新生代 (Young) 又被划分为三个区域:一个Eden区和两个Survivor区 - From Survivor区和To Survivor区。
简要归纳:新的对象分配是首先放在年轻代 (
Young Generation) 的Eden区,Survivor区作为Eden区和Old区的缓冲,在Survivor区的对象经历若干次收集仍然存活的,就会被转移到老年代Old中。
这样划分的目的是为了使JVM能够更好的管理堆内存中的对象,包括内存的分配以及回收。
1.5. 方法区
方法区和Java堆一样,为多个线程共享,它用于存储类信息、常量、静态常量和即时编译后的代码等数据。
1.6. 运行时常量池
运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法和接口等描述信息外,
还有一类信息是常量池,用于存储编译期间生成的各种字面量和符号引用。
1.7. 直接内存
直接内存不属于虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。Java NIO允许Java程序直接访问直接内存,通常直接内存的速度会优于Java堆内存。因此,对于读写频繁、性能要求高的场景,可以考虑使用直接内存。
(二). 常见内存溢出异常
除了程序计数器外,Java虚拟机的其他运行时区域都有可能发生OutOfMemoryError的异常,下面分别给出验证:
2.1. Java堆溢出
Java堆能够存储对象实例。通过不断地创建对象,并保证GC Roots到对象有可达路径来避免垃圾回收机制清除这些对象。
当对象数量到达最大堆的容量限制时就会产生OutOfMemoryError异常。
设置JVM启动参数:-Xms20M设置堆的最小内存为20M,-Xmx20M设置堆的最大内存和最小内存一样,这样可以防止Java堆在内存不足时自动扩容。-XX:+HeapDumpOnOutOfMemoryError参数可以让虚拟机在出现内存溢出异常时Dump出内存堆运行时快照。
HeapOOM.java
1
|
/**
|
打开Java VisualVM导出Heap内存运行时的dump文件。

HeapOOM对象不停地被创建,堆内存使用达到99%。垃圾回收器不断地尝试回收但都以失败告终。
分析:遇到这种情况,通常要考虑内存泄露和内存溢出两种可能性。
-
如果是内存泄露:
进一步使用
Java VisualVM工具进行分析,查看泄露对象是通过怎样的路径与GC Roots关联而导致垃圾回收器无法回收的。 -
如果是内存溢出:
通过
Java VisualVM工具分析,不存在泄露对象,也就是说堆内存中的对象必须得存活着。就要考虑如下措施:- 从代码上检查是否存在某些对象生命周期过长、持续状态时间过长的情况,尝试减少程序运行期的内存。
- 检查虚拟机的堆参数(
-Xmx与-Xms),对比机器的物理内存看是否还可以调大。
2.2. 虚拟机和本地方法栈溢出
关于虚拟机栈和本地方法栈,分析内存异常类型可能存在以下两种:
- 如果现场请求的栈深度大于虚拟机所允许的最大深度,将抛出
StackOverflowError异常。 - 如果虚拟机在扩展栈时无法申请到足够的内存空间,可能会抛出
OutOfMemoryError异常。
可以划分为两类问题,当栈空间无法分配时,到底时栈内存太小,还是已使用的栈内存过大。
StackOverflowError异常
测试方案一:
- 使用
-Xss参数减少栈内存的容量,异常发生时打印栈的深度。 - 定义大量的本地局部变量,以达到增大栈帧中的本地变量表的长度。
设置JVM启动参数:-Xss128k设置栈内存的大小为128k。
JavaVMStackSOF.java
1
|
/**
|
测试结果:
分析:在单个线程下,无论是栈帧太大还是虚拟机栈容量太小,当无法分配内存的时候,虚拟机抛出的都是
StackOverflowError异常。
测试方案二:
- 不停地创建线程并保持线程运行状态。
JavaVMStackOOM.java
1
|
/**
|
测试结果:
1
|
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
|
上述测试代码运行时存在较大的风险,可能会导致操作系统假死,这里就不亲自测试了,引用作者的测试结果。
2.3. 方法区和运行时常量池溢出
(一). 运行时常量池内存溢出测试
运行时常量和字面量都存放于运行时常量池中,常量池又是方法区的一部分,因此两个区域的测试是一样的。
这里采用String.intern()进行测试:
String.intern()是一个native方法,它的作用是:如果字符串常量池中存在一个String对象的字符串,那么直接返回常量池中的这个String对象;
否则,将此String对象包含的字符串放入常量池中,并且返回这个String对象的引用。
设置JVM启动参数:通过-XX:PermSize=10M和-XX:MaxPermSize=10M限制方法区的大小为10M,从而间接的限制其中常量池的容量。
RuntimeConstantPoolOOM.java
1
|
/**
|
测试结果分析:
JDK1.6版本运行结果:
1
|
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
|
JDK1.6版本运行结果显示常量池会溢出并抛出永久带的OutOfMemoryError异常。
而JDK1.7及以上的版本则不会得到相同的结果,它会一直循环下去。
(二). 方法区内存溢出测试
方法区存放Class相关的信息,比如类名、访问修饰符、常量池、字段描述、方法描述等。
对于方法区的内存溢出的测试,基本思路是在运行时产生大量类字节码区填充方法区。
这里引入Spring框架的CGLib动态代理的字节码技术,通过循环不断生成新的代理类,达到方法区内存溢出的效果。
JavaMethodAreaOOM.java
1
|
/**
|
JDK1.6版本运行结果:
1
|
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
|
测试结果分析:
JDK1.6版本运行结果显示常量池会溢出并抛出永久带的OutOfMemoryError异常。
而JDK1.7及以上的版本则不会得到相同的结果,它会一直循环下去。
2.4. 直接内存溢出
本机直接内存的容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。
测试场景:
直接通过反射获取Unsafe实例,通过反射向操作系统申请分配内存:
设置JVM启动参数:-Xmx20M指定Java堆的最大内存,-XX:MaxDirectMemorySize=10M指定直接内存的大小。
DirectMemoryOOM.java
1
|
/**
|
测试结果:
测试结果分析:
由DirectMemory导致的内存溢出,一个明显的特征是Heap Dump文件中不会看到明显的异常信息。
如果OOM发生后Dump文件很小,并且程序中直接或者间接地使用了NIO,那么就可以考虑一下这方面的问题。
欢迎关注技术公众号: 零壹技术栈
本帐号将持续分享后端技术干货,包括虚拟机基础,多线程编程,高性能框架,异步、缓存和消息中间件,分布式和微服务,架构学习和进阶等学习资料和文章。









