详解JVM堆外内存的分配和回收机制

前言

写这篇文章的契机是前段时间在Flink社区大群里详细解答了一个问题。

195230-454d3671ac0afefa.png

我们每天都会与JVM堆打交道(之前哪篇文章的开头也是这个来着)。但作为大数据工程师,我们对JVM的堆外内存(off-heap memory,英文资料中也常称为native memory)应该也是非常熟悉的,Spark、Flink、Kafka等这些鼎鼎大名的大数据组件都会积极地使用堆外内存,更底层的Netty之类就更不必说了。

使用堆外内存的好处主要有以下两个:

  • 避免堆内内存Full GC造成的stop-the-world延迟,当然也可以降低OOM风险;
  • 绕过用户态到内核态的切换,实现高效数据读写,如零拷贝和内存映射

以Flink为例,打开正在运行的Flink作业Web UI中某个TaskManager的Metrics页,就可以看到堆外内存的使用情况,如“Outside JVM”一块所示。其中Direct即直接内存,对应NIO中的DirectByteBuffer;Mapped即映射内存,对应NIO中的MappedByteBuffer。

195230-cfefcea77b9ae57e.png

堆外内存毕竟也是内存,而服务器的内存量总是有限的,所以堆外内存也面临着回收的问题,并且不像堆内内存一样有垃圾收集器负责GC,而是需要自己实现。本文就以DirectByteBuffer为例探究堆外内存是如何回收的,当然在回收之前,先看看是如何分配的。

分配堆外内存

我们通过调用ByteBuffer.allocateDirect()方法分配堆外内存。

  public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
  }

主要逻辑位于DirectByteBuffer类的构造方法中。

  DirectByteBuffer(int cap) { 
    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)) {
      address = base + ps - (base & (ps - 1));
    } else {
      address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
  }

该方法的执行流程如下:

  1. 调用VM.isDirectMemoryPageAligned()方法获取是否需要进行页对齐,在JDK 7及以后默认不需要。
  2. 调用Bits.pageSize()方法获取内存页的大小,同样是为了对齐使用。
  3. 调用Bits.reserveMemory()方法试图预留指定大小的内存的配额,如果能够预留,就继续执行,否则直接抛出OOM。
  4. 调用Unsafe.allocateMemory()方法正式地分配内存,查看HotSpot的native代码,容易发现是调用了C语言的malloc()函数。
  5. 调用Unsafe.setMemory()方法将分配到的内存区域初始化为全0,自然是对应C语言的memset()函数。
  6. 对内存基地址进行可能的对齐操作。
  7. 调用Cleaner.create()方法创建一个sun.misc.Cleaner实例(其中包含有DirectByteBuffer的内部类Deallocator),该实例具体负责后面的堆外内存回收,后面细说。

Bits.reserveMemory()方法比较重要,值得看一眼。

  static void reserveMemory(long size, int cap) {
    if (!memoryLimitSet && VM.isBooted()) {
      maxMemory = VM.maxDirectMemory();
      memoryLimitSet = true;
    }
    if (tryReserveMemory(size, cap)) {
      return;
    }

    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    while (jlra.tryHandlePendingReference()) {
      if (tryReserveMemory(size, cap)) {
        return;
      }
    }

    System.gc();

    boolean interrupted = false;
    try {
      long sleepTime = 1;
      int sleeps = 0;
      while (true) {
        if (tryReserveMemory(size, cap)) {
          return;
        }
        if (sleeps >= MAX_SLEEPS) {
          break;
        }
        if (!jlra.tryHandlePendingReference()) {
          try {
            Thread.sleep(sleepTime);
            sleepTime <<= 1;
            sleeps++;
          } catch (InterruptedException e) {
            interrupted = true;
          }
        }
      }

      throw new OutOfMemoryError("Direct buffer memory");
    } finally {
      if (interrupted) {
        Thread.currentThread().interrupt();
      }
    }
  }

不再逐行解释,只说三个重要的点:

  • JVM能使用的最大堆外内存量可以由参数-XX:MaxDirectMemorySize显式指定。如果没有指定,翻HotSpot代码可以得知,默认堆外内存大小是-Xmx减去一个Survivor区的内存量,翻代码的过程就略去了。
  • 如果首次调用tryReserveMemory()方法未能申请到指定大小的预留内存,就会主动调用System.gc()方法来通知进行GC,试图释放一些内存。因此在堆外内存使用频繁的场合,不要擅自开启-XX:+DisableExplicitGC开关进行“优化”,废掉System.gc()可能会适得其反。
  • 首次申请未成功的话,就会循环调用tryReserveMemory()重试申请,一共会尝试MAX_SLEEPS(常量,为9)次,按指数退避规则从1ms开始设定重试的延迟。也就是说,Bits.reserveMemory()方法在最终失败的情况下,最多过511ms(约半秒)就抛出OOM。

回收堆外内存

DirectByteBuffer是一个轻量级的对象,主要的信息都维护在静态内部类Deallocator中,包括内存基地址、大小等。Cleaner是单独维护的。由以下代码可见,Deallocator还实现了Runnable接口,当它被执行时,会调用Unsafe.freeMemory()方法(对应C语言的free()函数)释放掉它持有的堆外内存。

  private static class Deallocator implements Runnable {
    private static Unsafe unsafe = Unsafe.getUnsafe();

    private long address;
    private long size;
    private int capacity;

    private Deallocator(long address, long size, int capacity) {
      assert (address != 0);
      this.address = address;
      this.size = size;
      this.capacity = capacity;
    }

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

  private final Cleaner cleaner;

  public Cleaner cleaner() {
    return cleaner;
  }

DirectByteBuffer的东西虽然不多(也就是占用堆空间是很少的),但是它背后可能是一大片不受JVM直接控制的堆外内存,因此JVM必须保证在DirectByteBuffer对象实例被GC掉时,它背后的堆外内存也同步被回收。这个机制就靠Cleaner来实现,因为它本质上是个虚引用(Phantom Reference)。借用阿里开发手册中的图来回忆一下Java中的四种引用。

195230-05f99ddc95384da6.png

Java中不同级别的引用实际上代表了GC根搜索机制中不同的可达性(reachability)。虚引用是最弱的一级引用,只有一个作用,就是跟踪对象的回收。也就是说对一个对象而言,如果除了与它关联的PhantomReference之外再无其他引用,那么在GC触发时,该对象就马上准备被回收。

来看下Cleaner这个类的源码,不长。

public class Cleaner extends PhantomReference {
    private static final ReferenceQueue dummyQueue = new ReferenceQueue();

    static private Cleaner first = null;

    private Cleaner
        next = null,
        prev = null;

    private static synchronized Cleaner add(Cleaner cl) {
        if (first != null) {
            cl.next = first;
            first.prev = cl;
        }
        first = cl;
        return cl;
    }

    private static synchronized boolean remove(Cleaner cl) {
        if (cl.next == cl)
            return false;

        if (first == cl) {
            if (cl.next != null)
                first = cl.next;
            else
                first = cl.prev;
        }
        if (cl.next != null)
            cl.next.prev = cl.prev;
        if (cl.prev != null)
            cl.prev.next = cl.next;

        cl.next = cl;
        cl.prev = cl;
        return true;

    }

    private final Runnable thunk;

    private Cleaner(Object referent, Runnable thunk) {
        super(referent, dummyQueue);
        this.thunk = thunk;
    }

    public static Cleaner create(Object ob, Runnable thunk) {
        if (thunk == null)
            return null;
        return add(new Cleaner(ob, thunk));
    }

    public void clean() {
        if (!remove(this))
            return;
        try {
            thunk.run();
        } catch (final Throwable x) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                    public Void run() {
                        if (System.err != null)
                            new Error("Cleaner terminated abnormally", x)
                                .printStackTrace();
                        System.exit(1);
                        return null;
                    }});
        }
    }
}

该类中虽然有一个引用队列ReferenceQueue,但只是PhantomReference的强制需求,实际上并未用到。在今后有空的时候会详细说说ReferenceQueue的。

Cleaner类的实现非常聪明,除了使用虚引用之外,还有一点:使用双向链表维护所有Cleaner,防止Cleaner本身在它们对应的DirectByteBuffer之前被回收。而传入的Runnable就相当于回调函数,在Cleaner被GC掉时调用,因此在Deallocator.run()方法中释放掉堆外内存,就可以随着DirectByteBuffer的清理而清理了。

那么Cleaner.clean()方法是何时被调用的呢?我们需要简单看看ReferenceHandler,它本质上是Java引用的超类Reference的内部类,并且是一个线程。Reference类中有一个pending队列,用于保存已经注册到引用队列但尚未加入的引用,而ReferenceHandler就负责将这些引用加入不同的ReferenceQueue中,来看部分代码。

  private static Reference<Object> pending = null;

  private static class ReferenceHandler extends Thread {
    // 部分略去...
    public void run() {
      while (true) {
        tryHandlePending(true);
      }
    }
  }

  static boolean tryHandlePending(boolean waitForNotify) {
    Reference<Object> r;
    Cleaner c;
    try {
      synchronized (lock) {
        if (pending != null) {
          r = pending;
          c = r instanceof Cleaner ? (Cleaner) r : null;
          pending = r.discovered;
          r.discovered = null;
        } else {
          if (waitForNotify) {
            lock.wait();
          }
          return waitForNotify;
        }
      }
    } catch (OutOfMemoryError x) {
      Thread.yield();
      return true;
    } catch (InterruptedException x) {
      return true;
    }

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

    ReferenceQueue<? super Object> q = r.queue;
    if (q != ReferenceQueue.NULL) q.enqueue(r);
    return true;
  }

tryHandlePending()方法对pending队列的操作是加锁的,这是考虑到GC线程可能会并发执行,比如CMS。这段代码的细节就先不提,从中我们可以看出,如果判断出引用的类型为Cleaner,就会特殊处理一下,调用它的clean()方法并直接返回。如果不为Cleaner,就加入ReferenceQueue,用户代码进而可以对这些引用采取替代Finalizer的操作,这就是后话了。

结束了?

还差点事儿。根据JVM堆的分代GC机制,DirectByteBuffer这种小对象在经过-XX:MaxTenuringThreshold次的Young GC之后,很容易晋升到老生代。如果堆内内存的状况良好,余量充足,没有超大对象进入,那么可能很久都不会触发Full GC,造成堆外内存迟迟不被回收。为了避免这种情况,在前面的tryReserveMemory()方法中才会主动调用System.gc()方法。重要的话再说一遍:在堆外内存使用频繁的场合,不要擅自开启-XX:+DisableExplicitGC开关进行“优化”

当然,我们也可以不等JVM,而是主动调用DirectByteBuffer.getCleaner().clean()方法,就可以在我们认为合适的时机回收堆外内存。Netty用了这种打法。

累了,晚安。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值