DirectMemory 的回收与 PhantomReference

Posted on Sun, Aug 15, 2021 Java

DirectMemory 并不属于 Java 虚拟机规范的一部分,而是由于 JDK1.4 中引入了 nio,一种基于 channel 与 buffer 的 I/O 方式,可以通过操作 JVM 中的 DirectByteBuffer 对象,调用 Native 函数库在堆外分配堆外内存(DirectMemory)并进行操作,可以避免堆外到堆内对象来回拷贝的开销,以及堆内 GC 的负载。

这部分内存也是常见的导致 OOM 的原因之一,最明显的一个特征就是当 JVM OOM 时,dump 出来的文件却很小

JVM 可以通过以下以下参数 MaxDirectMemorySize 限定堆外内存的最大用量。在 JDK8 版本,如果不指定 MaxDirectMemorySize,则最大不超过堆内存大小。

在不主动释放的前提下,DirectMemory 往往到了 Full GC 才会回收。其实在调用 ByteBuffer.allocateDirect(int capacity) 分配堆外内存时会添加堆外内存的释放逻辑,如下:

     public static ByteBuffer allocateDirect(int capacity) {
         return new DirectByteBuffer(capacity);
     }
 
     DirectByteBuffer(int cap) {                   // package-private
         // ...
         // 方法里会调用 System.gc() 尝试回收堆外内存
         Bits.reserveMemory(size, cap);
         // ...
         // 将自身的释放逻辑放入 Cleaner 中,利用 PhantomReference 特性释放堆外内存
         cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
         att = null;
     }

其中 Deallocator 是一个 Runnable,释放堆外内存的逻辑就在其中:

 public void run() {
     if (address == 0) {
         // Paranoia
         return;
     }
     unsafe.freeMemory(address);
     address = 0;
     Bits.unreserveMemory(size, capacity);
 }

综上,可以看到 JDK 对于 DirectMemory 的回收煞费苦心,光是分配堆外内存时就做了两个保险措施:

  1. 显示调用 System.gc() ,但一旦设置了 XX:+DisableExplicitGC 则会失效;
  2. 借助 PhantomReference 机制执行回收逻辑,,一旦该 buffer 没有强引用时会被 JVM 回收。
NOTE:从这里也可以看出,为什么 JDK 很多地方有着将变量置为 null 的操作,可以协助更好的 GC。

DirectBuffer 也提供了一个 cleaner 接口,可以让我们获取 Cleaner 对象主动执行清理动作:

 ((DirectBuffer)byteBuffer).cleaner().clean();

关于 JDK8 里的 Cleaner

Cleaner 继承自 PhantomReference,这是 《深入理解Java虚拟机》对 PhantomReference 的一个描述:

虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供 了 PhantomReference 类来实现虚引用。

从定义可以看出,PhantomReference 并不影响对象的生命周期,并且无法通过 PhantomReference 获得任何对象引用。

创建一个 Reference 对象时,还需要创建一个 ReferenceQueue。GC 经过可达性分析后如果发现 Reference 是一个已注册的对象且满足回收条件,则会直接将其追加到 ReferenceQueue 中,父类 Reference 的实现中就可以看到会起一个 ReferenceHandler 线程去处理队列里的对象。

编码方式:自定义对象 + Reference 队列 = PhantomReference 包装:

 ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
 Object obj = new Object();
 PhantomReference<Object> phantomobj = new PhantomReference(obj, referenceQueue)

结合 DirectByteBuffer 对 Cleaner 的使用可以看出 PhantomReference 的一个经典应用场景:一个对象确定不再使用后,能够在回收时产生一个通知并执行指定操作

NOTE: JVM 虽然提供了 finalized 的机制,但执行该逻辑的是一个低优先级线程,并不保证一定会执行该操作,并且在其中放入开销较大的逻辑还会影响 GC,因此借助 PhantomReference 来实现对象回收时执行期望操作是一个更优的选择。

JDK 提供了 sun.misc.Cleaner ,该类继承了 PhantomReference 接口,支持传入一个对象以及一个 Runnable 对象:

 List<Object> objs = new ArrayList<>();
 for (int i = 0; i < 10; ++i) {
     Object obj = new Object();
     objs.add(obj);
     // 传入需要跟踪的对象以及不再引用时执行的动作
     Cleaner.create(obj, () -> System.out.println("clearing..."));
 }
 objs.clear();
 System.gc();

在发生 GC 时,如果 Cleaner 跟踪的对象被回收,那么 Cleaner 会被挪到 Reference 队列中,执行 Runnable 的逻辑:

 // sun.misc.Cleaner#clean
 public void clean() {
     if (remove(this)) {
         try {
             // 执行 Runnable 逻辑
             this.thunk.run();
         // ...
 }

 // java.lang.ref.Reference#tryHandlePending
 // Reference 会启动一个 ReferenceHandler 线程不断循环调用 Cleaner 逻辑
 static boolean tryHandlePending(boolean waitForNotify) {
     Reference<Object> r;
     Cleaner c;
     // ...
     // Fast path for cleaners
     if (c != null) {
         c.clean();
         return true;
     }
     // ...
 }

关于 JDK9 及之后的 Cleaner

从 JDK9 开始,一个 Cleaner 可以注册多个对象及 action:

 public class CleanerTest implements AutoCloseable {
 
 
     // A cleaner, preferably one shared within a library
     private static final Cleaner CLEANER = Cleaner.create();
 
     static class State implements Runnable {
 
         State() {
             // initialize State needed for cleaning action
         }
 
         @Override
         public void run() {
             // cleanup action accessing State, executed at most once
             System.out.println("clearing...");
         }
     }
 
     private final Cleaner.Cleanable cleanable;
 
     public CleanerTest() {
         State state = new State();
         this.cleanable = CLEANER.register(this, state);
     }
 
     @Override
     public void close() {
         cleanable.clean();
     }
 
 }

一个 Cleaner 对应一个线程,如果创建过多的 cleaner,可能会有不必要的线程开销。

上述官方例子中还用到了 AutoCloseable 接口,这样一来就可以在 try-with-resource 语法中使用。官方文档的说法是回收资源最高效的方式时主动去调用清理操作,并且该操作直到变为 phantom reachable 时最多只会被执行一次(即使显示调用)。

注意事项

需要注意的是,Cleaner 的 action 不可以指向注册/包装的对象及其内部,会导致对象无法变为 phantom reachable,从而无法自动调用 action。

虽然注册时也可以传递一个 lambda 表达式,但这样往往很容易引用到注册对象,导致上面提到的问题,使用一个静态内部类可以从编码上比较好的防止这类事情发生,即使有外部引用,也只能访问到与类相关的静态外部变量,不至于影响到实例对象。

在执行 action 的过程中,出现的异常都会被忽略,执行 action 的线程及其他 action 都不受影响。

System.exit 时无法保证所有的 action 一定会被执行。

总结

  1. 如果不对不再使用 DirectMemory 主动释放,那么会在 Full GC 时才回收。
  2. 每次调用 ByteBuffer 分配 Direct Memory 时,都会采取以下两个措施回收 DirectMemory:
    • 主动调用 System.gc()
    • (JDK8)将一个 DirectMemory 的 PhantomReference 引用加入 Cleaner
  3. 传入 Cleaner 的 Runnable 方法, 不能持有对清理对象及其内部非静态变量的引用,否则会导致无法变为 Phantom Reachable。