JVM - 内存区域划分 与 内存溢出异常

时间:2022-08-17 15:12:10

主要介绍以下内容: Java虚拟机内存的各个区域, 这些区域的作用, 服务对象以及其中可能产生的问题

运行时数据区域

概览图

JVM - 内存区域划分 与 内存溢出异常

程序计数器(Program Counter Register)

可以看做当前线程所执行的字节码的行号指示器. 在多线程程序中, 为了线程切换后能恢复到正确的执行位置, 每条线程都需要一个独立的程序计数器, 独立存储, 我们称这类内存区域为线程私有的内存.

如果正在执行一个Java方法, 则指向正在执行的虚拟机字节码指令的地址;
如果正在执行一个Native方法(Native方法通常指的是一些系统的底层方法, 有可能是其他语言编写额), 则计数器的值为未定义(undefined)

注: 本内存区域是唯一不会出现OutOfMemoryError的区域.

Java虚拟机栈(Java Virtual Machine Stacks)

也是线程私有的, 生命周期与线程相同. 虚拟机栈描述的是Java方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表, 操作数栈, 动态链接, 方法出口等信息. 每一个方法从调用到执行完成的过程, 就对应着一个栈帧在虚拟机栈从入栈到出栈的过程.

局部变量表: 存放编译期可知的基本数据类型(boolean, byte, char, short, int, float, long, double), 对象引用和returnAddress类型. 局部变量表所需的内存空间是在编译期完成分配的, 在方法运行期间不会改变.

本区域可能出现两种异常:

  • *Error: 线程请求的栈深度大于虚拟机所允许的深度;
  • OutOfMemoryError: 栈扩展时无法申请到足够的内存.

本地方法栈(Native Method Stack)

与Java虚拟机栈非常类似, Java虚拟机栈为执行Java字节码服务, 本地方法栈为执行Native方法服务. 有的虚拟机的实现直接将二者合为一, 比如HotSpot虚拟机. 本区域可能出现的异常与Java虚拟机栈相同.

Java堆(Java Heap)

所有线程共享, 用于存放对象实例. 是垃圾收集器管理的主要区域. 很多时候也叫做GC堆(Garbage Collected Heap).

从内存回收的角度来说, 现在的收集器采用分代收集算法, 所以可以分为: 新生代和老年代, 再细分可以分为Eden空间, From Survivor空间, To Survivor空间.

从内存分配的角度来看, Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB). 所有划分方式的目的都是为了更好的回收内存, 或者更快的分配内存.

此区域只是一个逻辑上连续的内存区域, 当堆无法扩展的时候, 会抛出OutOfMemoryError异常.

方法区(Method Area)

所有线程共享, 存储被虚拟机加载的类信息(个人补充: 实际上就是类对象的存放位置), 常量, 静态变量, 即时编译器编译后的代码(也就是一段代码被多次执行后, 会被识别成热点代码, 然后被编译成机器码)等数据.是Java堆的一个逻辑部分.

通常来说, GC分代收集方法会将方法区设置为永久代(Permanent Generation), 但是容易出现内存溢出问题(比如永久代有-XX:MaxPermSize的上限). 这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载.

本区域可能出现的异常是OutOfMemoryError.

运行时常量池(Runtime Constant Pool)

是方法区的一部分, 存放类在编译期生成的各种字面量和符号引用, 在类被加载后进入这里存放. 其次, 在运行期间也可以将新的常量放入池中, 比如String类的intern()方法.

本区域可能出现的异常是OutOfMemoryError

直接内存(Direct Memory)

注意: 本区域不是虚拟机运行时数据区的一部分, 但是因为也被频繁的使用, 也可能导致OutOfMemoryError的出现.

通常是作为堆外内存的一种形式, 例如, Java的NIO(New Input/Output), 引入了一种基于通道(Channel)和缓冲区(Buffer)的I/O方式, 使用Native函数库直接分配堆外内存, 通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作. 这样能在一些场景中显著提高性能, 因为它避免了在Java堆和Native堆中来回复制数据.


内存溢出

Java堆溢出

要解决这个区域的异常, 通常先通过内存映像分析工具对Dump出来的堆转储快照进行分析, 重点是确认内存中的对象是否是必要的, 也就是要先分清楚到底是出现了内存泄露(Memory Leak)还是内存溢出(Memory Overflow).

内存泄露 指的是本来应该回收的对象没有被正确回收, 那么对象肯定存在到GC Root的引用链, 从而找到代码泄露的位置.
内存溢出 指的是内存中的对象确实都是必要的, 此时应该检查虚拟机的堆参数(-Xms-Xmx), 看是否可以调大, 不然就需要从代码上检查是否有对象的生命周期过长的情况, 尝试减少程序运行期的内存消耗.

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

在单个线程下, 无论是由于栈帧太大还是虚拟机栈容量太小, 都会抛出*Error.

在多线程的情况下, 会出现OutOfMemoryError的情况, 但是这不是虚拟机栈内存溢出, 而是进程的内存溢出. 究其原因, 操作系统分配给每个进程的内存是有限制的, 比如32bits的windows是2GB.
2GB-Xmx-MaxPermSize就约等于虚拟机栈的容量了, 如果每个线程分配到的栈容量越大, 那么可以建立的线程数量自然就越少. 需要通过:

  1. 减小Xmx
  2. 减小栈容量

来换取更多的线程. 实践经验表明, 栈深度在1000~2000完全没问题, 对于正常的方法调用(包括递归), 这个深度完全够用了.

方法区和运行时常量池溢出

在JDK 1.7之前, 运行时常量池基本上在垃圾回收中属于永久代, 所以我们可以通过设置-XX:PermSize=10M -XX:MaxPermsize=10M将它的大小限制在10M, 不能增长.

但是JDK1.7之后, 这部分就不大容易溢出了, 究其原因, 例如在遇到String类型的对象上进行intern()调用时, 不是进行赋值, 而是直接调用引用.

而方法区在使用Spring, Hibernate等框架对类进行增强的时候, 会使用到CGLib这类字节码技术, 增强的类越多, 需要越大的方法区来保证动态生成的Class可以载入内存. 其次还有以下场景会引出OutofMemoryError:

  • 基于JVM的动态语言
  • 大量JSP或者动态生成JSP文件的应用(JSP第一次运行需要编译为Java类);

本机直接内存溢出

直接内存的大小可以通过-XX:MaxDirectMemorySize指定, 默认为-Xmx的值

通常直接内存溢出抛出的异常与Java堆溢出抛出的异常类似, 不过Dump文件不会看见明显的异常, 而且文件很小, 如果符合以上特征, 而程序中又直接或者间接的使用了NIO, 那么就可能是这方面的原因.