JVM系列之:你知道Java有多少种内存溢出吗

语言: CN / TW / HK

theme: cyanosis

本文为《深入学习 JVM 系列》第二十五篇文章

Java内存区域

关于这部分内容大多来源于《深入理解Java虚拟机》一书。

Java 运行时数据区域(JDK8)如下图所示:

img

关于上述提到的线程共享和线程隔离区域,下图做详细讲解:

img

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。

另外,在多线程的情况下,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

在 JVM 规范中规定,如果线程执行的是非 native 方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是 native 方法,则程序计数器中的值是 undefined。由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,此内存区域是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)

img

①局部变量表

主要存放了基本类型数据、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。

这些数据类型的存储在之前的JVM系列之:聊聊Java的数据类型 一文中有详细介绍过。

②操作数栈

想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的

③动态链接

因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。

④方法出口信息

当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的 Java 栈,互不干扰。也就解释了栈是线程私有的。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 异常。

扩展:那么方法/函数如何调用?

当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。

Java 方法有两种返回方式:return 语句和抛出异常。不管哪种返回方式都会导致栈帧被弹出。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

关于堆的详细介绍可以参考之前的文章,如果堆空间不足,则会抛出 OutOfMemoryError 异常。

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

方法区也被称为永久代。很多人都会分不清方法区和永久代的关系,为此我也查阅了文献。

(1)方法区和永久代的关系

《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

(2)常用参数

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

-XX:PermSize=N //方法区(永久代)初始大小 -XX:MaxPermSize=N //方法区(永久代)最大大小,超过这个值将会抛出OutOfMemoryError异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

JDK 1.8 的时候,方法区(HotSpot的永久代)被彻底移除了(JDK1.7就已经开始了),取而代之是元空间,元空间使用的是本地内存。

下面是一些常用参数:

-XX:MetaspaceSize=N //设置Metaspace的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置Metaspace的最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

(3)为什么要将永久代(PermGen)替换为元空间(MetaSpace)呢?

  • 官方文档:移除永久代是为融合 HotSpot JVM与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,不需要配置永久代
  • 永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,并且永远不会得到 java.lang.OutOfMemoryError。你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池( Constant pool table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入运行时常量池中存放。运行时常量池相对于 class 文件常量池的另外一个特性是具备动态性,Java 语言并不要求常量一定只有编译器才产生,也就是并非预置入 class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。

推荐阅读:Java 中方法区与常量池

扩展:运行时常量池位置变化

在 JDK1.7 之前运行时常量池和字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代

在 JDK1.7 字符串常量池从方法区移到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池还在方法区, 也就是 hotspot 中的永久代。

在 JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 JVM 规范中定义的内存区域。但这部分内存也被频繁的使用,而且也可能导致 OutOfMemoryError 异常出现。

JDK1.4 中新引入了 NIO 机制,它是一种基于通道与缓冲区的新 I/O 方式,可以直接从操作系统中分配直接内存,即直接堆外分配内存,这样能在一些场景中提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

内存溢出

内存溢出(OutOfMemory,简称OOM)是一个令人头疼的问题,它通常出现在某一块内存空间耗尽的时候。在 Java 程序中,导致内存溢出的原因有很多,其中最常见的有:堆溢出、直接内存溢出、方法区溢出等。

堆溢出

堆是 JVM 内存管理的最大的一块区域,此内存区域的唯一目的就是存放对象的实例,所有对象实例与数组都要在堆上分配内存。它是垃圾收集器的主要管理区域,也就称为最可能发生溢出的区间。

堆溢出的原因是因为大量空间被对象占用,而这些对象都持有强引用,即无法被回收,当对象占用空间之和大于由 Xmx 参数指定的堆空间大小时,最终导致溢出错误。

如下案例所示:

```java //-Xmx60M -Xms60M public class SimpleHeapOOM {

public static void main(String[] args) { List list = new ArrayList<>(); for (int i = 0; i < 2000; i++) { list.add(new String[1024*1024]); } } } ```

执行上述代码报错:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at com.msdn.java.hotspot.gc.SimpleHeapOOM.main(SimpleHeapOOM.java:16)

由于堆空间不可能无限增长,所以我们需要借助前文提到的 MAT 或 VIsualVM 工具,分析 dump 文件(使用-XX:+HeapDumpOnOutOfMemoryError命令生成 dump文件),弄清楚到底是出现了内存泄漏(Memory Leak) 还是内存溢出(Memory Overflow)。(此处占个坑,之后结合实际案例分析一波)

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

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

栈溢出

由于 HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈,栈容量只能由-Xss参数来设定。 关于虚拟机栈和本地方法栈, 在《Java虚拟机规范》 中描述了两种异常:

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

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

栈溢出抛出 StackOverflowError 错误,出现此种情况是因为方法运行的时候栈的深度超过了虚拟机容许的最大深度所致。一般情况下是程序错误所致的,比如写了一个死递归,就有可能造成此种情况。下面我们通过一段代码来模拟一下此种情况的内存溢出。

```java public class StackOOM {

private int len = 1;

public void stackOverFlowMethod() { len++; stackOverFlowMethod(); }

public static void main(String[] args) { StackOOM stackOOM = new StackOOM(); try { stackOOM.stackOverFlowMethod(); } catch (Throwable e) { System.out.println("stack length: " + stackOOM.len); throw e; } } } ```

执行结果为:

stack length: 17850 Exception in thread "main" java.lang.StackOverflowError

那么 HotSpot 虚拟机中是否可以抛出 OutOfMemoryError 呢?我们尝试如下两种方案:

方案一:使用-Xss 参数减少栈内存容量,JDK8 要求栈内存容量至少为 160K,针对上述代码设置如下参数:-Xss160k,执行结果变为:

stack length: 774 Exception in thread "main" java.lang.StackOverflowError

经过测试发现,栈内存容量变小,仍然会抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。

方案二:在方法中多定义一些局部变量,增大方法栈中拘捕变量表的长度。

修改代码如下:

java public void stackOverFlowMethod() { long l1, l2, l3, l4, l5, l6, l7, l8, l9; l1 = l2 = l3 = l4 = l5 = l6 = l7 = l8 = l9 = 100L; double d1, d2, d3, d4, d5, d6, d7, d8, d9; d1 = d2 = d3 = d4 = d5 = d6 = d7 = d8 = d9 = 100.0; len++; stackOverFlowMethod(); }

执行结果为:

stack length: 4817 Exception in thread "main" java.lang.StackOverflowError

根据结果可知,同样抛出 StackOverflowError 异常, 异常出现时输出的堆栈深度相应缩小。

关于这句话“在 HotSpot 虚拟机上是不会由于虚拟机栈无法扩展而导致 OutOfMemoryError 异常——只要线程申请栈空间成功了就不 会有 OOM, 但是如果申请时就失败, 仍然是会出现 OOM 异常的”是什么意思呢?我们上面尝试的两个方案没有出现 OOM 异常,那是因为程序执行过程中线程数是固定的,所以只要线程申请空间成功,即开始执行,就不会有 OOM。那么我们尝试在代码中不断创建新的线程,看看结果如何。

每个线程的开启都要占用栈内存,如果线程数量过大,栈空间使用完毕,也有可能导致 OOM。如下案例所示:

```java //-Xmx1g public class MultiThreadOOM {

public static class SleepThread implements Runnable {

@Override
public void run() {
  try {
    Thread.sleep(1000000);
  } catch (InterruptedException e) {
    e.printStackTrace();
  }
}

}

public static void main(String[] args) { for (int i = 0; i < 5000; i++) { new Thread(new SleepThread(), "Thread" + i).start(); System.out.println("Thread" + i); } }

} ```

执行结果报错:

Thread4067 Thread4068 Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread at java.lang.Thread.start0(Native Method)

注意,执行上述代码记得先保存当前的工作,Java 的线程是映射到操作系统的内核线程上,创建线程数过多可能对操作系统带来压力,经本人测试,在 Mac 上因为创建线程数量过多会导致系统自动关机。

针对上述 OOM 异常,即使使用 -Xss 参数减少栈内存容量,仍然会报相同的错误,又或者使用 -Xmx 降低堆内存大小,结果也一样。说明当前系统仅支持创建 4069 个线程。

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

通过前文我们可知运行时常量池属于方法区的一部分,所以这两个区域的溢出可以一起来学习。

本文基于 JDK8,所以此时方法区的实现已经改为元空间,元空间使用的是本地内存,下面是一些常用参数:

-XX:MetaspaceSize=N //设置Metaspace的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置Metaspace的最大大小,默认为-1,即不限制, 或者说只受限于本地内存大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

那么如何演示方法区溢出呢?首先我们明确方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、 方法描述等。对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止。本文借助 CGLib 直接操作字节码运行时生成了大量的动态类。

```java //-XX:MaxMetaspaceSize=10M public class RuntimeConstantPoolOOM { static class OOMObject{ }

public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OOMObject.class); enhancer.setUseCache(false); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { return methodProxy.invokeSuper(o, args); } }); enhancer.create(); } } } ```

执行结果为:

Exception in thread "main" org.springframework.cglib.core.CodeGenerationException: java.lang.OutOfMemoryError-->Metaspace at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:557) at org.springframework.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:363)

那么在实际应用中都有哪些场景可能出现方法区溢出呢?

1、关于上述 CGLib 操作字节码生成动态类的代码,在当前很多主流框架,如 Spring、 Hibernate 对类进行增强时, 都会使用到 CGLib 这类字节码技术,当增强的类越多,就需要越大的方法区以保证动态生成的新类型可以载入内存。

2、除此之外,很多运行于Java虚拟机上的动态语言(例如Groovy等)通常都会持续创建新类型来支撑语言的动态性,随着这类动态语言的流行,方法区溢出的情况也会更加频繁。

3、大量 JSP 或动态产生 JSP 文件的应用(JSP第一次运行时需要编译为Java类)、基于 OSGi 的应用(即使是同一个类文件, 被不同 的加载器加载也会视为不同的类) 等。

直接内存溢出

直接内存

在学习 Java 对象的内存布局时,除了堆空间,我们知道本地内存中还有一块区域叫做直接内存。直接内存的申请速度一般要比堆内存慢,但是其访问速度要快于堆内存。

对于直接内存来说,JVM 将会在 IO 操作上具有更高的性能,因为它直接作用于本地系统的 IO 操作。而非直接内存,也就是堆内存中的数据,如果要做 IO 操作,会先复制到直接内存,再利用本地 IO 处理。

从数据流转的角度来讲,直接内存比堆访问更快。

本地IO-->直接内存-->堆-->直接内存-->本地IO 本地IO-->直接内存-->本地IO

在 Java 程序中,我们可以使用 Unsafe 或 ByteBuffer 分配直接内存。

直接内存(DirectMemory)容量可以通过 -XX:MaxDirectMemorySize 指定,如果不指定,则默认为与 Java 堆的最大值(-Xmx指定)一样。但是,在 OSX 上的最新版本的 JVM,对直接内存的默认大小进行修订,改为“在不指定直接内存大小的时默认分配的直接内存大小为64MB”,可以通过 -XX:MaxDirectMemorySize 来显示指定直接内存的大小。

直接内存的分配与释放

接下来我们通过一个案例来演示直接内存的分配与释放。

java //-Xmx100M -XX:+PrintGCDetails //或者 -XX:MaxDirectMemorySize=100m public static void main(String[] args) { List<ByteBuffer> list = new ArrayList<>(); int i = 0; while (true) { ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 20); list.add(buffer); i++; System.out.println(i); } }

执行一段时间,程序就会因为内存溢出而退出,部分打印信息如下:

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory at java.nio.Bits.reserveMemory(Bits.java:695) at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123) at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311) at com.msdn.java.hotspot.gc.DirectBufferOOM.main(DirectBufferOOM.java:18)

另外根据输出结果可知,在整个执行过程中,只有最后才输出了一次 GC 日志。但是如果没有 list 与 buffer 之间的强引用,即注释掉 list.add(buffer) 语句,输出结果中会有很多 Full GC 日志,那么就不会出现直接内存溢出的问题。

为什么会出现这么多的 Full GC 日志呢?我们知道 System.gc() 会触发 Full GC,那么是否是程序执行了 System.gc() 呢?

我们来看一下 ByteBuffer 的源码,查看ByteBuffer 源码可知 ByteBuffer.allocateDirect()创建 DirectByteBuffer 实例,DirectByteBuffer通过 Unsafe 分配内存,下面具体看一下执行步骤。

1、调用 ByteBuffer.allocateDirect(int cap)

2、创建 DirectByteBuffer:主要分三步,第一步调用Bits.reserveMemory(long size, int cap)) ;第二步,调用Unsafe.allocateMemory(long var )方法分配内存;第三步,调用Cleaner.create(Object var0, Runnable var1) 创建 Cleaner对象,用于回收内存。

```java DirectByteBuffer(int cap) { // package-private super(-1, 0, cap, cap); boolean pa = VM.isDirectMemoryPageAligned(); int ps = Bits.pageSize(); long size = Math.max(1L, (long)cap + (pa ? ps : 0)); Bits.reserveMemory(size, cap);

long base = 0; try { base = unsafe.allocateMemory(size); } catch (OutOfMemoryError x) { Bits.unreserveMemory(size, cap); throw x; } unsafe.setMemory(base, size, (byte) 0); if (pa && (base % ps != 0)) { // Round up to page boundary address = base + ps - (base & (ps - 1)); } else { address = base; } cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); att = null; } ```

是否注释 list.add(buffer);这段代码对应着两种处理逻辑,我们首先来看一下没有注释(抛出异常)的逻辑, Bits.reserveMemory()方法的逻辑

```java static void reserveMemory(long size, int cap) {

if (!memoryLimitSet && VM.isBooted()) { maxMemory = VM.maxDirectMemory(); memoryLimitSet = true; }

// optimist!此方法用于在直接内存中分配空间,更改直接内存的大小,最后判断直接内存大小是否还有剩余,如果没有,会返回false if (tryReserveMemory(size, cap)) { return; }

final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

// retry while helping enqueue pending Reference objects // which includes executing pending Cleaner(s) which includes // Cleaner(s) that free direct buffer memory // 第一次调用时会返回 false,当执行System.gc()后,在后续的循环体中会再次调用jlra.tryHandlePendingReference()方法 while (jlra.tryHandlePendingReference()) { if (tryReserveMemory(size, cap)) { return; } }

//可以确认的是该方法触发Full GC,清除堆空间 // trigger VM's Reference processing System.gc();

// a retry loop with exponential back-off delays // (this gives VM some time to do it's job) boolean interrupted = false; try { long sleepTime = 1; int sleeps = 0; while (true) { if (tryReserveMemory(size, cap)) { return; } //如果代码中包括list.add(buffer);意味着list和buffer之间存在强引用,Full GC无法回收,然后进入该循环体,直到超时break if (sleeps >= MAX_SLEEPS) { break; } //循环体进行到 if (!jlra.tryHandlePendingReference()) { try { Thread.sleep(sleepTime); sleepTime <<= 1; sleeps++; } catch (InterruptedException e) { interrupted = true; } } }

//从循环体中break出来后,会抛出下述异常
// no luck
throw new OutOfMemoryError("Direct buffer memory");

} finally { if (interrupted) { // don't swallow interrupts Thread.currentThread().interrupt(); } } } ```

关键在于 jlra.tryHandlePendingReference()方法,该方法位于 Reference 文件的 tryHandlePending()方法中,通过下图可知,pending 对象的类型为 Finalizer,而不是 Cleaner。

因为 c 为 null,所以下面没有执行 clean 方法。所以最终直接内存因为无法释放内存而溢出。

java if (c != null) { c.clean(); return true; }

这里我们看一下 pending 对象的类型,为 Reference,我们再来看一下 Finalizer 和 Cleaner 的关系,下面用 UML 类图来表示:

但是从开始查看源码可知,在创建 DirectByteBuffer 时,方法最后会创建 Cleaner 对象,调试过程中也发现确实创建成功了。

但是在 Bits.reserveMemory()方法中第一次执行 jlra.tryHandlePendingReference()时,结果为 false,因为 pending 为 null。后续又执行 System.gc(),在循环中会再次调用 jlra.tryHandlePendingReference(),这次进来后发现 pending 对象的值变为了 Finalizer。那么看来只有一个原因,在执行 System.gc()时系统做了某种处理手段,关于这点暂时没有找到好的解释。

至此,关于为何会抛出“Direct buffer memory”异常,我们基本了解了背后的原理。

接下来继续看一下注释掉 list.add(buffer)代码,程序是如何处理的?还是重点分析 Bits.reserveMemory()方法,调试过程中发现,当执行 System.gc()后,直接内存会释放一部分内存。等到执行循环体中的 tryReserveMemory(size, cap)方法时,因为返回结果为 true,直接 return 了。

Bits.reserveMemory()方法结束后,之后再次因创建 DirectByteBuffer 进入该方法时,会再次调用 Reference.tryHandlePending()方法,此时的 pending 对象因为值为 Cleaner,会调用 Cleaner.clean()方法,释放直接内存。

所以这也是在注释掉 list.add(buffer)代码后,程序能够一直运行的原因,而且没有抛出异常。

另外,这里需要重点介绍一下 Cleaner,Cleaner 类继承自 PhantomReference< Object>,在此处保留 Cleaner对象的虚引用。此类中还包含一个静态 DirectByteBuffer 引用队列用于得知那些虚引用所指向的对象已回收,这是一个很棒的设计,因为 JVM 不知道堆外内存的使用情况,通过 DirectByteBuffer 对象的回收来间接控制堆外内存的回收。

综上所述,Java 虚拟机虽然无法直接管理直接内存,但是 Java 开发人员通过另一种方式,即 Cleaner 的实现,间接控制直接内存的回收。那么在实际应用中,我们不可能只分配直接内存,而不去使用。为避免直接内存溢出,在不浪费空间的前提下,设置合理的 -XX:MaxDirectMemorySize 来避免直接内存溢出。

参考文献

Java直接内存分配和释放方式

《深入理解Java虚拟机》