OOM 异常通俗点儿说,就是 JVM 内存不够用了。Javadoc 中对 OutOfMemoryError 的解释是,没有空闲内存并且垃圾收集器也无法提供更多内存。这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。

当然,也不是在任何情况下垃圾收集器都会被触发的,比如我们分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以会直接抛出 OutOfMemoryError。

Java 堆溢出

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

下面的代码中限制了 Java 堆的大小为 10MB 且不可扩展(将堆的最小值 -Xms 与最大值 -Xmx 设置为一样即可避免堆自动扩展),通过参数 -XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机在出现内存溢出异常的时候 Dump 出当前的内存堆转储快照以便进行事后分析。和它配合使用的还有 -XX:HeapDumpPath,可以指定导出堆转储快照的存放路径。

  1. /**
  2. * VM Args: -Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
  3. */
  4. public class HeapOOM {
  5. 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. }

运行结果:
image.png
要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具对 Dump 出来的堆转储快照进行分析。首先应确认内存中导致 OOM 的对象是否是必要的,也就是要先分清楚到底是出现了 内存泄漏(Memory Leak)还是 内存溢出(Memory Overflow)。下图展示了使用 JProfilerl 打开的堆转储快照文件。
image.png
如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链,找到泄漏对象是通过怎样的引用路径、与哪些 GC Roots 相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到 GC Roots 引用链的信息,可以准确定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
image.png如果不是内存泄漏,那就应当检查 Java 虚拟机的堆参数(-Xmx 与 -Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

Java 栈溢出

由于 HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈,因此对于 HotSpot 来说,-Xoss 参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由 -Xss 参数来设定。关于虚拟机栈和本地方法栈,在《Java 虚拟机规范》中描述了两种异常:

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

HotSpot 虚拟机不支持栈的动态扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError 异常,否则在线程运行时是不会因扩展而导致内存溢出的,只会因栈容量无法容纳新的栈帧而导致 StackOverflowError 异常。

为了验证这点,我们可以做两个实验,先将实验范围限制在单线程中操作,尝试下面两种行为能否让 HotSpot 虚拟机产生 OutOfMemoryError 异常。注意对于不同版本的 Java 虚拟机和不同的操作系统,栈容量最小值可能会有所限制,这主要取决于操作系统内存分页大小。

  • 使用 -Xss 参数减少栈内存容量。结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
  • 定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。

首先,对第一种情况进行测试,具体代码如下:

  1. /**
  2. * VM Args: -Xss180k
  3. */
  4. public class StackSOF {
  5. private int stackLength = 1;
  6. public void stackLeak() {
  7. stackLength++;
  8. stackLeak();
  9. }
  10. public static void main(String[] args) {
  11. StackSOF stackSOF = new StackSOF();
  12. try {
  13. stackSOF.stackLeak();
  14. } catch (Throwable t) {
  15. System.out.println("Stack Length: " + stackSOF.stackLength);
  16. throw t;
  17. }
  18. }
  19. }

运行结果:
image.png
我们继续验证第二种情况,这次代码就显得有些“丑陋”了,为了多占局部变量表空间,我们不得不定义了一长串变量,具体代码如下:

  1. /**
  2. * VM Args: -Xss180k
  3. */
  4. public class StackSOFTwo {
  5. private static int stackLength = 1;
  6. public static void test() {
  7. long unused1, unused2, unused3, unused4, unused5,
  8. unused6, unused7, unused8, unused9, unused10,
  9. unused11, unused12, unused13, unused14, unused15,
  10. unused16, unused17, unused18, unused19, unused20,
  11. unused21, unused22, unused23, unused24, unused25,
  12. unused26, unused27, unused28, unused29, unused30,
  13. unused31, unused32, unused33, unused34, unused35,
  14. unused36, unused37, unused38, unused39, unused40,
  15. unused41, unused42, unused43, unused44, unused45,
  16. unused46, unused47, unused48, unused49, unused50;
  17. stackLength++;
  18. test();
  19. unused1 = unused2 = unused3 = unused4 = unused5 =
  20. unused6 = unused7 = unused8 = unused9 = unused10 =
  21. unused11 = unused12 = unused13 = unused14 = unused15 =
  22. unused16 = unused17 = unused18 = unused19 = unused20 =
  23. unused21 = unused22 = unused23 = unused24 = unused25 =
  24. unused26 = unused27 = unused28 = unused29 = unused30 =
  25. unused31 = unused32 = unused33 = unused34 = unused35 =
  26. unused36 = unused37 = unused38 = unused39 = unused40 =
  27. unused41 = unused42 = unused43 = unused44 = unused45 =
  28. unused46 = unused47 = unused48 = unused49 = unused50 = 0;
  29. }
  30. public static void main(String[] args) {
  31. try {
  32. test();
  33. } catch (Error t) {
  34. System.out.println("Stack Length: " + stackLength);
  35. throw t;
  36. }
  37. }
  38. }

运行结果:
image.png
实验结果表明:无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot 虚拟机抛出的都是 StackOverflowError 异常。

如果测试时不限于单线程,通过不断建立线程的方式,在 HotSpot 上也是可以产生内存溢出异常的。但这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态。甚至可以说,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

出现 StackOverflowError 异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。如果使用 HotSpot 虚拟机默认参数,栈深度在大多数情况下(每个方法压入栈的帧大小并不是一样的)到达 1000 ~ 2000 是完全没有问题,对于正常的方法调用,这个深度应该完全够用了。

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

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到 HotSpot 从 JDK 7 开始逐步“去永久代”的计划,并在 JDK 8 中完全使用元空间来代替永久代的背景故事,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么实际的影响。

1. JDK 6

String::intern() 是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象的引用;否则,会将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 6 或更早之前的 HotSpot 虚拟机中,常量池都是分配在永久代中,我们可以通过 -XX:PermSize 和 -XX:MaxPermSize 限制永久代的大小,即可间接限制其中常量池的容量,具体实现代码如下,请测试时首先以 JDK 6 来运行代码。

  1. /**
  2. * VM Args: -XX:PermSize=4M -XX:MaxPermSize=4M
  3. */
  4. public class RuntimeConstantPoolOOM {
  5. public static void main(String[] args) {
  6. Set<String> set = new HashSet<String>();
  7. short i = 0;
  8. while (true) {
  9. set.add(String.valueOf(i++).intern());
  10. }
  11. }
  12. }

运行结果:
image.png
从运行结果中可以看到,运行时常量池溢出时,在 OutOfMemoryError 异常后面跟随的提示信息是“PermGen space”,说明运行时常量池的确是属于方法区(即 JDK 6 的 HotSpot 虚拟机中的永久代)的一部分。

2. JDK 7+

而使用 JDK 7 或更高版本的 JDK 来运行这段程序并不会得到相同的结果,无论是在 JDK 7 中继续使用 -XX:MaxPermSize 参数或在 JDK 8 及以上版本使用 -XX:MaxMetaspaceSize 参数把方法区容量同样限制在 6MB,也都不会重现 JDK 6 中的溢出异常。

出现这种变化,是因为自 JDK 7 起,原本存放在永久代的字符串常量池被移至 Java 堆之中,所以在 JDK 7 及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。此时使用 -Xmx 参数限制最大堆到 6MB 就能够看到内存溢出了。
image.png

3. 方法区溢出

方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。

在 JDK 8 以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,已经很难再迫使虚拟机产生方法区的溢出异常了。不过 HotSpot 还是提供了一些参数作为元空间的防御措施,主要包括:

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

    直接内存溢出

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

下面代码中越过了 DirectByteBuffer 类直接通过反射获取 Unsafe 实例进行内存分配,因为虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是 Unsafe 的 allocateMemory() 方法。

  1. /**
  2. * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
  3. */
  4. public class DirectMemoryOOM {
  5. private static final int _1MB = 1024 * 1024 * 1024;
  6. public static void main(String[] args) throws IllegalAccessException {
  7. Field field = Unsafe.class.getDeclaredFields()[0];
  8. field.setAccessible(true);
  9. Unsafe unsafe = (Unsafe) field.get(null);
  10. unsafe.allocateMemory(_1MB);
  11. }
  12. }

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

Unsafe 类的 getUnsafe() 方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里的类才能使用 Unsafe 的功能,在 JDK 10 时才将 Unsafe 的部分功能通过 VarHandle 开放给外部使用。