在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM) 异常的可能。

下面我们来谈谈实际工作中出现内存溢出异常时, 我们应该如何根据异常的提示信息迅速得知是哪个区域的内存溢出,以及怎样的代码可能会导致这些区域内存溢出,当出现这些异常后该如何处理?

Java堆溢出

异常原因

Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。

参数说明:

  1. 将堆的最小值​​-Xms​​​参数与最大值​​-Xmx​​参数设置为一样即可​避免堆自动扩展
  2. 通过参数​​-XX:+HeapDumpOnOutOf-MemoryError​​可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。

异常现象

Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。 出现Java堆内存溢出时, 异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“​Java heap space​”,具体如下所示:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid3404.hprof ...
Heap dump file created [22045981 bytes in 0.663 secs]

解决方案

要解决这个内存区域的异常, 常规的处理方法是首先通过内存映像分析工具(如​Eclipse Memory Analyzer​)对Dump出来的堆储快照进行分析。

首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了​内存泄漏​(Memory Leak)还是​内存溢出​(Memory Overflow)。

  • 如果是内存泄漏, 可进一步通过工具查看泄漏对象到GC Roots的引用链, 找到泄漏对象是通过怎样的引用路径、 与哪些GC Roots相关联, 才导致垃圾收集器无法回收它们, 根据泄漏对象的类型信息以及它到GC Roots引用链的信息, 一般可以比较准确地定位到这些对象创建的位置, 进而找出产生内存泄漏的代码的具体位置。

  • 如果不是内存泄漏, 换句话说就是内存中的对象确实都是必须存活的,那就应当​检查Java虚拟机的堆参数(-Xmx与-Xms) 设置,与机器的内存对比,看看是否还有向上调整的空间​。 再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、 存储结构设计不合理等情况,​尽量减少程序运行期的内存消耗​。

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

由于​HotSpot虚拟机中并不区分虚拟机栈和本地方法​栈,因此对于HotSpot来说,​​-Xoss​​​参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由​​-Xss​​参数来设定。

参数说明:

  • -Xss​:设置​每个线程​的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大小进行调整。​在相同物理内存下,减小这个值能生成更多的线程​。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。对于不同版本的Java虚拟机和不同的操作系统,​栈容量最小值​可能会有所限制,这主要取决于操作系统内存分页大小。譬如上述方法中的参数-Xss128k可以正常用于32位Windows系统下的JDK 6,但是如果用于64位Windows系统下的JDK 11,则会提示栈容量最小不能低于180K,而在Linux下这个值则可能是228K,如果低于这个最小限制,HotSpot虚拟器启动时会给出如下提示:​​The Java thread stack size specified is too small. Specify at least 228k​

异常原因

关于虚拟机栈和本地方法栈, 在《Java虚拟机规范》 中描述了两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出​​StackOverflowError​​异常。
  2. 如果虚拟机的栈内存允许​动态扩展​, 当​扩展栈容量​无法申请到足够的内存时, 将抛出​​OutOfMemoryError​​异常。

《Java虚拟机规范》 明确允许Java虚拟机实现自行选择是否支持栈的动态扩展, 而​HotSpot虚拟机的选择是不支持扩展​,所以除非在创建线程​申请内存时​就因无法获得足够内存而出现​​OutOfMemoryError​​异常,否则在线程​运行时​是不会因为扩展而导致内存溢出的。​只会因为栈容量无法容纳新的栈帧而导致​​StackOverflowError​​异常​。

异常现象

在单个线程内,无论是由于​栈帧太大​还是​虚拟机栈容量太小​(通过​​-Xss​​设置),当​新的栈帧​内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常,如下所示:

stack length:2402
Exception in thread "main" java.lang.StackOverflowError
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:20)
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21)
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21)
……

如果在允许动态扩展栈容量大小的虚拟机(如:Classic虚拟机)上,相同代码则会导致不一样的情况。

如果测试时不限于单线程, ​通过不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常的​,如下所示:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread

但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态。甚至可以说,​在这种情况下,给每个线程的栈分配的内存越大, 反而越容易产生内存溢出异常​。

原因​:操作系统分配给每个进程的内存是有限制的,譬如32位Windows的​单个进程最大内存限制为2GB​。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制) 减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

解决方案

出现StackOverflowError异常时, 会有明确错误堆栈可供分析, 相对而言比较容易定位到问题所在。

如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的, 所以只能说大多数情况下) 到达​​1000~2000​​是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。

但是,如果是​建立过多线程导致的内存溢出​,在不能减少线程数量或者更换64位虚拟机的情况下,就只能​通过减少最大堆和减少栈容量​来换取更多的线程。

这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验, 一般比较难以想到,这一点读者需要在开发32位系统的多线程应用时注意。

由于这种问题较为隐蔽,从JDK 7起, 以上提示信息中“​unable to create native thread​”后面, 虚拟机会特别注明原因可能是“​possibly out of memory or process/resource limits reached​”。

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

由于运行时常量池是方法区的一部分,所以把这两个区域的溢出放到一起。由于 HotSpot 从JDK 7开始逐步“去永久代”的计划, 并在JDK 8中完全使用元空间来代替永久代,那么​使用“永久代”还是“元空间”来实现方法区​, 对程序有什么实际的影响呢。

字符串常量池内存溢出异常

具体示例代码如下,其中:​​String::intern()​​是一个本地方法,它的作用是如果​字符串常量池​中已经包含一个等于此String对象的字符串,则返回​代表池中这个字符串的String对象​的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回​此String对象​的引用。

/**
* VM Args: -XX:PermSize=6M -XX:MaxPermSize=6M
* @author zzm
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用Set保持着常量池引用, 避免Full GC回收常量池行为
Set<String> set = new HashSet<String>();
// 在short范围内足以让6MB的PermSize产生OOM了
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}

JDK 6 字符串常量池内存溢出

在JDK 6或更早之前的HotSpot虚拟机中, ​常量池都是分配在永久代​中, 我们可以通过​​-XX:PermSize​​​和​​-XX:MaxPermSize​​限制永久代的大小,即可间接限制其中常量池的容量。

运行时常量池导致的内存溢出异常如下所示:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18)

在运行时常量池溢出时, 在OutOfMemoryError异常后面跟随的提示信息是“​PermGen space​”,这也说明运行时常量池的确是属于方法区(即JDK 6的HotSpot虚拟机中的永久代) 的一部分。

JDK 7 以后字符串常量池内存溢出

自JDK 7起,原本存放在永久代的​字符串常量池被移至Java堆​之中,所以在JDK 7及以上版本,限制​方法区的容量​(在JDK 7中继续使用​​-XX: MaxPermSize​​​参数,JDK 8及以上使用​​--XX:MaxMeta-spaceSize​​)来测试字符串常量池溢出是毫无意义的。

这时候使用​​-Xmx​​参数限制最大堆到6MB就能够看到以下两种运行结果之一, 具体取决于​哪里的对象分配时产生了溢出​。

// OOM异常一:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.lang.Integer.toString(Integer.java:440)
at java.base/java.lang.String.valueOf(String.java:3058)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)

// OOM异常二:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.HashMap.resize(HashMap.java:699)
at java.base/java.util.HashMap.putVal(HashMap.java:658)
at java.base/java.util.HashMap.put(HashMap.java:607)
at java.base/java.util.HashSet.add(HashSet.java:220)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java from InputFile-Object:14)

关于这个字符串常量池的实现在永久代还是元空间, 还可以引申出一些更有意思的影响,示例代码如下:

public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
String str1 = new StringBuilder().append("String").append("Test").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
}

这段代码在JDK 6中运行, 会得到两个false, 而在JDK 7中运行, 会得到一个true和一个false。

产生差异的原因是,在JDK 6中,​​intern()​​方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是​永久代里面这个字符串实例​的引用,而​由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用​,结果将返回false。

浅析JVM几种常见OOM异常原因及解决方案_内存溢出

而JDK 7(以及部分其他虚拟机, 例如JRockit)的​​intern()​​方法实现​就不需要再拷贝字符串的实例到永久代​了,既然字符串常量池已经移到Java堆中,那只需要在​常量池里记录​一下首次出现的​实例引用​即可,因此​​intern()​​返回的引用和由StringBuilder创建的那个字符串实例就是同一个。

浅析JVM几种常见OOM异常原因及解决方案_内存溢出_02

而对str2比较返回false,这是因为“java”(它是在加载​​sun.misc.Version​​​这个类的时候进入常量池的)这个字符串在执行​​StringBuilder.toString()​​之前就已经出现过了,​字符串常量池中已经有它的引用​(因此,它指向的是其他对象实例的应用,而不是str2对象实例的应用),不符合​​intern()​​方法要求“首次遇到”的原则。

通过​​java -version​​​确实是可以看到JVM中这个的​​java​​字符串确实已存在字符串常量池:

java version "11.0.10" 2021-01-19 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.10+8-LTS-162)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.10+8-LTS-162, mixed mode)

而“StringTest”这个字符串则是首次出现的,因此,结果返回true。

​String​​​的​​intern()​​方法的使用总结:

  1. JDK6中,如果串池中有,则不会放入,返回已有的串池中的对象地址;如果串池中没有,会把此对象复制一份,类似于new,放入串池,并返回串池中的对象地址
  2. JDK7/8中,如果串池中有,则不会放入,返回已有的串池中的对象地址;如果串池中没有,会把对象的引用地址复制一份,放入串池,并返回串池中的对象地址

方法区内存溢出

方法区的主要职责是用于存放类型的相关信息, 如类名、 访问修饰符、 常量池、 字段描述、 方法描述等。

异常原因

该区域内存溢出通常是​运行时产生大量的类去填满了方法区​。比如:CGLib直接操作字节码运行时生成了大量的动态类。另外,很多运行于Java虚拟机上的动态语言(例如Groovy等)通常都会持续创建新类型来支撑语言的动态性,随着这类动态语言的流行,类似的溢出场景也越来越容易遇到。

异常现象及预防措施

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。

在JDK 7中的运行结果如下所示:

Caused by: java.lang.OutOfMemoryError: PermGen space
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)
at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
... 8 more

这类场景除了之前提到的程序使用了​CGLib字节码增强​和​动态语言​外,常见的还有:​大量JSP或动态产生JSP文件的应用​(JSP第一次运行时需要编译为Java类) 、 ​基于OSGi的应用​(即使是同一个类文件, 被不同的加载器加载也会视为不同的类)等。

在JDK 8以后, 永久代便完全退出了历史舞台, 元空间作为其替代者登场。在默认设置下, ​前面列举的那些正常的动态创建新类型已经很难再迫使虚拟机产生方法区的溢出异常了​。 不过为了让使用者有​预防实际应用里出现类似具有破坏性的操作​, HotSpot还是提供了一些参数作为元空间的防御措施,主要包括:

  • ​-XX:MaxMetaspaceSize​​:设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
  • ​-XX:MetaspaceSize​​​:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值; 如果释放了很少的空间,那么在不超过​​-XX: MaxMetaspaceSize​​(如果设置了的话) 的情况下,适当提高该值。
  • ​-XX:MinMetaspaceFreeRatio​​:作用是在​垃圾收集之后​控制​最小的元空间剩余容量的百分比​, 可​减少因为元空间不足导致的垃圾收集的频率​。类似的还有​​-XX:Max-MetaspaceFreeRatio​​, 用于控制最大的元空间剩余容量的百分比。

本机直接内存溢出

直接内存(​​Direct Memory​​​)的容量大小可通过​​-XX:MaxDirectMemorySize​​​参数来指定, 如果不去指定, 则默认与Java堆最大值(由​​-Xmx​​指定) 一致。

本机直接内存溢出异常演示

下面演示使用unsafe分配本机内存,出现内存溢出,代码如下所示:

/**
* VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
* @author zzm
*/
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}

上面的代码越过了​​DirectByteBuffer​​类直接​通过反射获取​​Unsafe​​实例进行内存分配​(Unsafe类的​​getUnsafe()​​​方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能, 在JDK 10时才将Unsafe的部分功能通过VarHandle开放给外部使用), 虽然使用​​DirectByteBuffer​​分配内存也会抛出内存溢出异常,但​它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常​, 真正申请分配内存的方法是​​Unsafe::allocateMemory()​​。

以上代码的运行结果如下:

Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)

解决方案

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况, 如果发现​内存溢出之后产生的Dump文件很小, 而程序中又直接或间接使用了​​DirectMemory​​(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。

总结

虽然Java有垃圾收集机制, 但内存溢出异常离我们并不遥远。因此,我们需要熟悉JVM各个内存可能发生的内存溢出异常及其解决方法。

参考文档