VM 系列 (二) - JVM 内存区域和内存溢出异常详解

本贴最后更新于 2193 天前,其中的信息可能已经时过境迁

原文地址:https://mp.weixin.qq.com/s/TcG3z1z-dUmMZ8MMLcNmQg

前言

JVM 内存区域包括 PC 计数器Java 虚拟机栈本地方法栈方法区运行时常量池直接内存

本文主要介绍各个内存区域的作用和特性,同时分别阐述各个区域发生内存溢出的可能性和异常类型。

正文

(一) JVM 内存区域

Java 虚拟机执行 Java 程序的过程中,会把所管理的内存划分为若干不同的数据区域。这些内存区域各有各的用途,以及创建和销毁时间。有的区域随着虚拟机进程的启动而存在,有的区域伴随着用户线程的启动和结束而创建和销毁。

JVM 内存区域也称为 Java 运行时数据区域。其中包括:程序计数器虚拟机栈本地方法栈静态方法区静态常量池等。

注意:程序计数器、虚拟机栈、本地方法栈属于每个线程私有的;堆和方法区属于线程共享访问的

1.1. PC 计数器

程序计数器( ProgramCounterRegister)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码行号指示器

  1. 当前线程所执行的字节码行号指示器

  2. 每个线程都有一个自己的 PC 计数器。

  3. 线程私有的,生命周期与线程相同,随 JVM 启动而生, JVM 关闭而死。

  4. 线程执行 Java 方法时,记录其正在执行的虚拟机字节码指令地址

  5. 线程执行 Native 方法时,计数器记录为( Undefined)。

  6. 唯一在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况区域。

1.2. Java 虚拟机栈

线程私有内存空间,它的生命周期和线程相同。线程执行期间,每个方法执行时都会创建一个栈帧(Stack Frame) ,用于存储 局部变量表操作数栈动态链接方法出口 等信息。

  1. 局部变量表

  2. 操作数栈

  3. 动态链接

  4. 方法出口

每一个方法从调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈出栈的全过程。

下面依次解释栈帧里的四种组成元素的具体结构和功能:

1). 局部变量表

局部变量表是一组变量值的存储空间,用于存储方法参数局部变量。 在 Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量

局部变量表在编译期间分配内存空间,可以存放编译期的各种变量类型:

  1. 基本数据类型boolean, byte, char, short, int, float, long, double8 种;

  2. 对象引用类型reference,指向对象起始地址引用指针

  3. 返回地址类型returnAddress,返回地址的类型。

变量槽( VariableSlot):

变量槽局部变量表最小单位,规定大小为 32 位。对于 64 位的 longdouble 变量而言,虚拟机会为其分配两个连续Slot 空间。

2). 操作数栈

操作数栈OperandStack)也常称为操作栈,是一个后入先出栈。在 Class 文件的 Code 属性的 max_stacks 指定了执行过程中最大的栈深度。 Java 虚拟机的解释执行引擎被称为基于栈的执行引擎 ,其中所指的就是指-操作数栈

  1. 局部变量表一样,操作数栈也是一个以 32 字长为单位的数组。

  2. 虚拟机在操作数栈中可存储的数据类型intlongfloatdoublereferencereturnType 等类型 (对于 byteshort 以及 char 类型的值在压入到操作数栈之前,也会被转换为 int)。

  3. 局部变量表不同的是,它不是通过索引来访问,而是通过标准的栈操作压栈出栈来访问。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。

虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈

  1. begin

  2. iload_0 // push the int in local variable 0 onto the stack

  3. iload_1 // push the int in local variable 1 onto the stack

  4. iadd // pop two ints, add them, push result

  5. istore_2 // pop int, store into local variable 2

  6. end

在这个字节码序列里,前两个指令 iload_0iload_1 将存储在局部变量表中索引为 01 的整数压入操作数栈中,其后 iadd 指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令 istore_2 则从操作数栈中弹出结果,并把它存储到局部变量表索引为 2 的位置。

下图详细表述了这个过程中局部变量表操作数栈的状态变化(图中没有使用的局部变量表操作数栈区域以空白表示)。

3). 动态链接

每个栈帧都包含一个指向运行时常量池中所属的方法引用,持有这个引用是为了支持方法调用过程中的动态链接

Class 文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用:

  1. 静态解析:一部分会在类加载阶段或第一次使用的时候转化为直接引用(如 finalstatic 域等),称为静态解析

  2. 动态解析:另一部分将在每一次的运行期间转化为直接引用,称为动态链接

4). 方法返回地址

当一个方法开始执行以后,只有两种方法可以退出当前方法:

  1. 正常返回:当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口( NormalMethodInvocationCompletion),一般来说,调用者的 PC 计数器可以作为返回地址。

  2. 异常返回:当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口( AbruptMethodInvocationCompletion),返回地址要通过异常处理器表来确定。

当一个方法返回时,可能依次进行以下 3 个操作:

  1. 恢复上层方法局部变量表操作数栈

  2. 返回值压入调用者栈帧操作数栈

  3. PC 计数器的值指向下一条方法指令位置。

小结:

注意:在 Java 虚拟机规范中,对这个区域规定了两种异常。其一:如果当前线程请求的栈深度大于虚拟机栈所允许的深度,将会抛出 StackOverflowError 异常(在虚拟机栈不允许动态扩展的情况下);其二:如果扩展时无法申请到足够的内存空间,就会抛出 OutOfMemoryError 异常。

1.3. 本地方法栈

本地方法栈Java 虚拟机栈发挥的作用非常相似,主要区别是 Java 虚拟机栈执行的是 Java 方法服务,而本地方法栈执行 Native 方法服务(通常用 C 编写)。

有些虚拟机发行版本(譬如 SunHotSpot 虚拟机)直接将本地方法栈Java 虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出 StackOverflowErrorOutOfMemoryError 异常。

1.4. 堆

Java 堆是被所有线程共享最大的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java 中,堆被划分成两个不同的区域:新生代 ( YoungGeneration) 、老年代 ( OldGeneration) 。新生代 ( Young) 又被划分为三个区域:一个 Eden 区和两个 Survivor 区 - FromSurvivor 区和 ToSurvivor 区。

简要归纳:新的对象分配是首先放在年轻代 ( YoungGeneration) 的 Eden 区, Survivor 区作为 Eden 区和 Old 区的缓冲,在 Survivor 区的对象经历若干次收集仍然存活的,就会被转移到老年代 Old 中。

这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

1.5. 方法区

方法区和 Java 堆一样,为多个线程共享,它用于存储类信息常量静态常量即时编译后的代码等数据。

1.6. 运行时常量池

运行时常量池是方法区的一部分, Class 文件中除了有类的版本字段方法接口等描述信息外, 还有一类信息是常量池,用于存储编译期间生成的各种字面量符号引用

1.7. 直接内存

直接内存不属于虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。 JavaNIO 允许 Java 程序直接访问直接内存,通常直接内存的速度会优于 Java 堆内存。因此,对于读写频繁、性能要求高的场景,可以考虑使用直接内存。

(二). 常见内存溢出异常

除了程序计数器外, Java 虚拟机的其他运行时区域都有可能发生 OutOfMemoryError 的异常,下面分别给出验证:

2.1. Java 堆溢出

Java 堆能够存储对象实例。通过不断地创建对象,并保证 GCRoots 到对象有可达路径来避免垃圾回收机制清除这些对象。 当对象数量到达最大堆的容量限制时就会产生 OutOfMemoryError 异常。

设置 JVM 启动参数: -Xms20M 设置堆的最小内存20M-Xmx20M 设置堆的最大内存最小内存一样,这样可以防止 Java 堆在内存不足时自动扩容-XX:+HeapDumpOnOutOfMemoryError 参数可以让虚拟机在出现内存溢出异常时 Dump内存堆运行时快照。

HeapOOM.java

  1. /**

  2. * VM Args: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError

  3. */

  4. public class HeapOOM {

  5. public static class OOMObject {

  6. }

  7. public static void main(String[] args) {

  8. List<OOMObject> list = new ArrayList<>();

  9. while (true) {

  10. list.add(new OOMObject());

  11. }

  12. }

  13. }

测试运行结果:

打开 JavaVisualVM 导出 Heap 内存运行时的 dump 文件。

HeapOOM 对象不停地被创建,堆内存使用达到 99%垃圾回收器不断地尝试回收但都以失败告终。

分析:遇到这种情况,通常要考虑内存泄露内存溢出两种可能性。

  • 如果是内存泄露:

进一步使用 JavaVisualVM 工具进行分析,查看泄露对象是通过怎样的 路径GCRoots 关联而导致垃圾回收器无法回收的。

  • 如果是内存溢出:

通过 JavaVisualVM 工具分析,不存在泄露对象,也就是说堆内存中的对象必须得存活着。就要考虑如下措施:

  1. 从代码上检查是否存在某些对象生命周期过长持续状态时间过长的情况,尝试减少程序运行期的内存。

  2. 检查虚拟机的堆参数( -Xmx-Xms),对比机器的物理内存看是否还可以调大。

2.2. 虚拟机和本地方法栈溢出

关于虚拟机栈和本地方法栈,分析内存异常类型可能存在以下两种:

  • 如果现场请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。

  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,可能会抛出 OutOfMemoryError 异常。

可以划分为两类问题,当栈空间无法分配时,到底时栈内存太小,还是已使用的栈内存过大

StackOverflowError 异常

测试方案一:

  • 使用 -Xss 参数减少栈内存的容量,异常发生时打印的深度。

  • 定义大量的本地局部变量,以达到增大栈帧中的本地变量表的长度。

设置 JVM 启动参数: -Xss128k 设置栈内存的大小为 128k

JavaVMStackSOF.java

  1. /**

  2. * VM Args: -Xss128k

  3. */

  4. public class JavaVMStackSOF {

  5. private int stackLength = 1;

  6. private void stackLeak() {

  7. stackLength++;

  8. stackLeak();

  9. }

  10. public static void main(String[] args) {

  11. JavaVMStackSOF oom = new JavaVMStackSOF();

  12. try {

  13. oom.stackLeak();

  14. } catch (Throwable e) {

  15. System.out.println("Stack length: " + oom.stackLength);

  16. throw e;

  17. }

  18. }

  19. }

测试结果:

分析:在单个线程下,无论是栈帧太大还是虚拟机栈容量太小,当无法分配内存的时候,虚拟机抛出的都是 StackOverflowError 异常。

测试方案二:

  • 不停地创建线程并保持线程运行状态。

JavaVMStackOOM.java

  1. /**

  2. * VM Args: -Xss2M

  3. */

  4. public class JavaVMStackOOM {

  5. private void running() {

  6. while (true) {

  7. }

  8. }

  9. public void stackLeakByThread() {

  10. while (true) {

  11. new Thread(new Runnable() {

  12. @Override

  13. public void run() {

  14. running();

  15. }

  16. }).start();

  17. }

  18. }

  19. public static void main(String[] args) {

  20. JavaVMStackOOM oom = new JavaVMStackOOM();

  21. oom.stackLeakByThread();

  22. }

  23. }

测试结果:

  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. /**

  2. * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M

  3. */

  4. public class RuntimeConstantPoolOOM {

  5. public static void main(String[] args) {

  6. // 使用List保持着常量池的引用,避免Full GC回收常量池

  7. List<String> list = new ArrayList<>();

  8. // 10MB的PermSize在Integer范围内足够产生OOM了

  9. int i = 0;

  10. while (true) {

  11. list.add(String.valueOf(i++).intern());

  12. }

  13. }

  14. }

测试结果分析:

JDK1.6 版本运行结果:

  1. Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

  2. at java.lang.String.intern(Native Method)

JDK1.6 版本运行结果显示常量池会溢出并抛出永久带OutOfMemoryError 异常。 而 JDK1.7 及以上的版本则不会得到相同的结果,它会一直循环下去。

(二). 方法区内存溢出测试

方法区存放 Class 相关的信息,比如类名访问修饰符常量池字段描述方法描述等。 对于方法区的内存溢出的测试,基本思路是在运行时产生大量类字节码区填充方法区

这里引入 Spring 框架的 CGLib 动态代理的字节码技术,通过循环不断生成新的代理类,达到方法区内存溢出的效果。

JavaMethodAreaOOM.java

  1. /**

  2. * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M

  3. */

  4. public class JavaMethodAreaOOM {

  5. public static void main(String[] args) {

  6. while (true) {

  7. Enhancer enhancer = new Enhancer();

  8. enhancer.setSuperclass(OOMObject.class);

  9. enhancer.setUseCache(false);

  10. enhancer.setCallback(new MethodInterceptor() {

  11. @Override

  12. public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {

  13. return proxy.invokeSuper(obj, args);

  14. }

  15. });

  16. enhancer.create();

  17. }

  18. }

  19. private static class OOMObject {

  20. public OOMObject() {

  21. }

  22. }

  23. }

JDK1.6 版本运行结果:

  1. Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

  2. at java.lang.ClassLoader.defineClass1(Native Method)

  3. at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)

  4. at java.lang.ClassLoader.defineClass(ClassLoader.java:616)

测试结果分析:

JDK1.6 版本运行结果显示常量池会溢出并抛出永久带OutOfMemoryError 异常。 而 JDK1.7 及以上的版本则不会得到相同的结果,它会一直循环下去。

2.4. 直接内存溢出

本机直接内存的容量可通过 -XX:MaxDirectMemorySize 指定,如果不指定,则默认与 Java最大值(-Xmx 指定)一样。

测试场景:

直接通过反射获取 Unsafe 实例,通过反射向操作系统申请分配内存:

设置 JVM 启动参数: -Xmx20M 指定 Java 堆的最大内存, -XX:MaxDirectMemorySize=10M 指定直接内存的大小。

DirectMemoryOOM.java

  1. /**

  2. * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M

  3. */

  4. public class DirectMemoryOOM {

  5. private static final int _1MB = 1024 * 1024;

  6. public static void main(String[] args) throws Exception {

  7. Field unsafeField = Unsafe.class.getDeclaredFields()[0];

  8. unsafeField.setAccessible(true);

  9. Unsafe unsafe = (Unsafe) unsafeField.get(null);

  10. while (true) {

  11. unsafe.allocateMemory(_1MB);

  12. }

  13. }

  14. }

测试结果:

测试结果分析:

DirectMemory 导致的内存溢出,一个明显的特征是 HeapDump 文件中不会看到明显的异常信息。 如果 OOM 发生后 Dump 文件很小,并且程序中直接或者间接地使用了 NIO,那么就可以考虑一下这方面的问题。

  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖 • 2 关注
  • 内存区域
    1 引用 • 1 回帖
  • 内存溢出
    3 引用 • 6 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • 380222933

    此图片来自微信公众号平台
    未经允许不可引用

    感觉这有点 fanrenlei,看着难受