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

前言

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

我们每天都会与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。

堆外内存毕竟也是内存,而服务器的内存量总是有限的,所以堆外内存也面临着回收的问题,并且不像堆内内存一样有垃圾收集器负责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中的四种引用。

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用了这种打法。

累了,晚安。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,463评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,868评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,213评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,666评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,759评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,725评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,716评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,484评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,928评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,233评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,393评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,073评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,718评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,308评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,538评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,338评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,260评论 2 352

推荐阅读更多精彩内容