JVM之Java内存区域与内存溢出异常
Java虚拟机在执行Java程序的过程中,会把它所管理的内存划分为若干个不同的数据区域。这些区域各有各的用途,以及创建和销毁的时间。理解这些区域的划分、作用、生命周期以及可能发生的内存溢出异常,是进行JVM调优和线上问题排查的基础。
第一部分:JVM内存区域划分(运行时数据区)
根据《Java虚拟机规范(Java SE 8版)》的规定,JVM在执行Java程序的过程中,会管理以下几种运行时数据区域。我们可以将其分为两大类:线程共享区和线程私有区。
A. 线程共享区
这类区域是所有线程都可以访问的,它们随着JVM进程的启动而创建,随着JVM进程的结束而销毁。
1. 堆
作用:Java世界中最大的一块内存区域,几乎所有的对象实例以及数组都在这里分配内存。这也是垃圾收集器管理的主要区域,因此有时也被称为“GC堆”。
特点:
物理上不连续,逻辑上连续:堆的物理内存空间可以是不连续的,但在逻辑上它被视为一个连续的地址空间。
动态扩展:堆的大小可以是固定的,也可以是动态扩展的(通过 -Xms 和 -Xmx 参数设置初始和最大值)。
线程共享:所有线程共享堆内存,因此多线程在堆上创建对象时需要考虑线程安全问题。
分代设计:为了更高效地进行垃圾回收,现代垃圾收集器通常将堆划分为新生代和老年代。
新生代:又细分为一个 Eden区 和两个 Survivor区(From/To)。绝大多数新创建的对象首先被分配在Eden区。
老年代:在新生代中经过多次Minor GC仍然存活的对象,会被“晋升”(Promote)到老年代。
2. 方法区
作用:用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
特点:
线程共享:所有线程共享方法区。
内存回收目标:虽然方法区的垃圾回收(如对废弃常量和无用的类的卸载)效率不高,但并非完全不进行回收。
实现差异:这是JVM规范中的一个概念,不同虚拟机的实现方式不同。
在JDK 7及之前:HotSpot虚拟机使用“永久代”(Permanent Generation, PermGen)来实现方法区。永久代是堆的一部分,有自己的内存上限(可通过 -XX:MaxPermSize 设置)。
在JDK 8及之后:HotSpot虚拟机移除了永久代,改用元空间来实现方法区。元空间使用的是本地内存,而不是JVM堆内存。这使得方法区的大小不再受JVM设定的最大堆大小的限制,而是受限于本地可用内存。
3. 运行时常量池
作用:是方法区的一部分。用于存放编译期生成的各种字面量和符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中。
特点:
动态性:运行时常量池具备动态性,并非只有预置入Class文件中常量池的内容才能进入,运行期间也可以将新的常量放入池中,例如 String 类的 intern() 方法。
位置变迁:在JDK 7之前,字符串常量池也存放在方法区(永久代)中。从JDK 7开始,字符串常量池被移到了堆中。JDK 8之后,方法区整体由元空间实现,运行时常量池也随之移至元空间。
B. 线程私有区
这类区域是每个线程独立拥有的,它们的生命周期与线程的生命周期相同。
1. 程序计数器
- 作用:一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
- 特点:
- 线程私有:为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器。
- 唯一无OOM的区域:如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法,这个计数器值则为空。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何
OutOfMemoryError
情况的区域。
2. Java虚拟机栈
- 作用:描述的是Java方法执行的线程内存模型。每个方法在执行时,JVM都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 特点:
- 线程私有:生命周期与线程相同。
- 栈帧:每个方法从调用到执行完毕,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 局部变量表:存放了编译期可知的各种基本数据类型、对象引用和
returnAddress
类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配。
3. 本地方法栈
- 作用:与虚拟机栈非常相似,其区别在于:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的本地方法服务。
- 特点:
- 线程私有。
- 实现灵活:《Java虚拟机规范》对本地方法栈的实现方式没有强制规定,具体的虚拟机可以自由实现它。例如,HotSpot虚拟机直接就把本地方法栈和虚拟机栈合二为一。
第二部分:内存溢出异常
内存溢出,即 OutOfMemoryError
,是指程序在申请内存时,没有足够的内存空间供其使用。当JVM在各个内存区域中无法分配到足够的内存,并且也无法再扩展时,就会抛出该异常。
1. Java堆溢出
现象:java.lang.OutOfMemoryError: Java heap space
原因:
内存泄漏:程序中存在无用但未被回收的对象,导致它们持续占用堆内存。随着时间的推移,可用内存越来越少,最终耗尽。这是最常见的原因。
内存溢出:程序确实需要分配一个非常大的对象(如巨大的数组),超出了堆的最大容量限制。
排查与解决:
排查:
通过 -Xms 和 -Xmx 参数设置堆的初始和最大大小为相同值,避免堆自动扩展。
启动应用,使用内存分析工具(如 Eclipse MAT, JProfiler, VisualVM)分析堆转储快照。
在快照中,首先确认是内存泄漏还是内存溢出。如果是内存泄漏,查看导致泄漏的对象是通过什么引用链与GC Roots关联并导致无法被回收的。
解决:
如果是内存泄漏,修复代码中的泄漏点(如未关闭的资源、未取消的监听器、静态集合持有对象引用等)。
如果是内存溢出,检查业务逻辑是否合理,或者考虑增加堆内存大小(-Xmx),优化数据结构(如使用更节省内存的结构),或者分批处理大数据。
2. 虚拟机栈和本地方法栈溢出
由于HotSpot虚拟机不区分虚拟机栈和本地方法栈,因此 -Xoss
参数(设置本地方法栈大小)对于HotSpot是无效的,栈容量只由 -Xss
参数设定。
现象:
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError。
如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出 OutOfMemoryError。
原因:
StackOverflowError:通常是由于无限递归调用导致的。每次方法调用都会在栈中压入一个栈帧,如果递归没有出口,栈帧会一直压入,直到超过栈的最大深度。
OutOfMemoryError:当不断建立新线程时,可能导致栈内存溢出。每个线程都需要分配独立的栈空间,如果系统内存不足以创建新的线程,就会抛出此异常。
排查与解决:
StackOverflowError:检查代码中是否存在无限递归或循环调用层级过深的情况。如果是业务逻辑需要很深的调用栈,可以适当增大栈大小(-Xss)。
OutOfMemoryError:检查是否创建了过多的线程。如果是,考虑使用线程池来复用线程,而不是无限制地创建新线程。或者减少单个线程的栈大小(-Xss),但这可能导致 StackOverflowError,需要权衡。
3. 方法区(或元空间)溢出
现象:
JDK 7及之前 (永久代):java.lang.OutOfMemoryError: PermGen space
JDK 8及之后 (元空间):java.lang.OutOfMemoryError: Metaspace
原因:
加载了过多的类:在应用中动态生成大量的类(如使用CGLIB、反射、JSP等),或者部署了大量的应用,导致类信息耗尽了方法区/元空间。
运行时常量池溢出:例如,在JDK 6或更早版本中,String.intern() 方法会将其内容复制到永久代的字符串常量池中,如果调用 intern() 的字符串过多,也会导致永久代溢出。
排查与解决:
排查:检查应用是否使用了动态生成类的技术,或者是否存在类加载器泄漏(例如,在Web容器中重复部署应用时,旧的类加载器及其加载的类没有被卸载)。
解决:
对于永久代,可以尝试增大 -XX:MaxPermSize。
对于元空间,可以增大 -XX:MaxMetaspaceSize(默认没有上限,受限于本地内存)。
更根本的解决方案是优化代码,避免不必要的类加载,或修复类加载器泄漏问题。
4. 本地直接内存溢出
现象:java.lang.OutOfMemoryError: Direct buffer memory
原因:
直接内存并不是JVM运行时数据区的一部分,但它也会导致 OutOfMemoryError。它通过 java.nio.ByteBuffer.allocateDirect() 方法分配,使用的是 Unsafe 类的 allocateMemory() 方法,直接在堆外分配内存。虽然直接内存的分配不受JVM堆大小的限制,但它会受到本机总内存(包括RAM和SWAP区)的限制。如果 -XX:MaxDirectMemorySize 参数被设置,则受此参数限制。
当应用程序频繁申请直接内存,而回收不及时(依赖于 System.gc() 或 Cleaner 机制),就可能耗尽直接内存。
排查与解决:
排查:检查代码中是否大量使用了 NIO 的 DirectBuffer。
解决:
合理使用 DirectBuffer,及时释放不再需要的 DirectBuffer 对象(通过设置其引用为null,使其可被GC回收,从而触发 Cleaner 释放本地内存)。
可以通过 -XX:MaxDirectMemorySize 参数来限制直接内存的大小,防止其无限制地消耗系统内存。
总结
内存区域 | 线程共享/私有 | 可能的异常 | 异常原因 | 解决思路 |
---|---|---|---|---|
堆 | 共享 | OutOfMemoryError: Java heap space | 对象创建过多且无法回收(内存泄漏),或单个对象过大 | 分析堆快照,修复泄漏,优化代码,或增大堆大小 |
方法区/元空间 | 共享 | OutOfMemoryError: PermGen space (JDK 7)OutOfMemoryError: Metaspace (JDK 8+) | 加载的类信息过多,或运行时常量池过大 | 检查动态类生成,修复类加载器泄漏,或增大方法区/元空间大小 |
虚拟机栈/本地方法栈 | 私有 | StackOverflowError | 线程请求的栈深度过大(如无限递归) | 检查递归代码,优化调用深度,或增大栈大小 |
虚拟机栈/本地方法栈 | 私有 | OutOfMemoryError | 创建线程过多,导致栈内存耗尽 | 减少线程数量,使用线程池,或减小单个线程栈大小 |
程序计数器 | 私有 | 无 | - | - |
直接内存 | - | OutOfMemoryError: Direct buffer memory | NIO中直接内存分配过多,回收不及时 | 合理使用DirectBuffer,及时释放,或设置最大直接内存大小 |