死磕 Netty 之内存篇:探索那个不为人知的堆外内存

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。

本文已收录到我的技术网站:www.skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经


Netty 为什么使用堆外内存

为什么说堆外内存也是 Netty 实现零拷贝的一种方式?首先我们需要清楚明白,任何减少 CPU 拷贝的技术我们都可以叫做零拷贝,那使用堆外内存在哪里减少了 CPU 拷贝呢?

我们再次回顾这张图:

这里大明哥在简述 write() 过程:

  1. 应用程序调用 write() 向网卡写入数据
  2. CPU 将数据从用户缓冲区拷贝到内核缓冲区
  3. DMA 将数据从内核缓冲区拷贝到网卡

到这里不知各位小伙伴有这样一个疑问没有:堆外内存也是用户缓冲区,也是需要 CPU 拷贝到内核缓冲区,为什么 Netty 使用堆外内存就能够减少一次 CPU 拷贝呢?我们来看 Java NIO SocketChannel。

在利用 SocketChannel 进行通信时我们一般会写这样一段代码:

ini 复制代码
SocketChannel socketChannel = SocketChannel.open();
ByteBuffer buf = ...;
socketChannel.write(buf);

调用 write() 方法:

java 复制代码
    public int write(ByteBuffer buf) throws IOException {
        if (buf == null)
            throw new NullPointerException();
        synchronized (writeLock) {
            //..
            try {
                //...
                for (;;) {
                    // 调用IOUtil.write写数据
                    n = IOUtil.write(fd, buf, -1, nd);
                    if ((n == IOStatus.INTERRUPTED) && isOpen())
                        continue;
                    return IOStatus.normalize(n);
                }
            } finally {
                // ..
            }
        }
    }
    

调用 IOUtil.write() 来写数据:

scss 复制代码
    static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd)
        throws IOException {
        if (src instanceof DirectBuffer)
            // 堆外内存直接走这里
            return writeFromNativeBuffer(fd, src, position, nd);

        // Substitute a native buffer
        int pos = src.position();
        int lim = src.limit();
        assert (pos <= lim);
        int rem = (pos <= lim ? lim - pos : 0);
        // 申请堆外内存
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
        try {
            // 将数据写入到堆外内存
            bb.put(src);
            bb.flip();
            src.position(pos);
            // 走堆外内存写数据
            int n = writeFromNativeBuffer(fd, bb, position, nd);
            if (n > 0) {
                // now update src
                src.position(pos + n);
            }
            return n;
        } finally {
            Util.offerFirstTemporaryDirectBuffer(bb);
        }
    }
  1. 如果是堆外内存就直接调用 writeFromNativeBuffer()
  2. 如果不是堆外内存就先申请一个堆外内存,然后将数据拷贝过去,最后调用 writeFromNativeBuffer()

所以如果我们不直接使用堆外内存,那么 SocketChannel 会先申请一个堆外内存,然后将数据拷贝到堆外内存,最后写入到内核缓冲区。那为什么不直接将堆内内存拷贝到内核缓冲区呢?偏偏要过一个堆外内存?我们继续看 writeFromNativeBuffer()

scss 复制代码
    private static int writeFromNativeBuffer(FileDescriptor fd, ByteBuffer bb,
                                             long position, NativeDispatcher nd)
        throws IOException {
        // ..
        if (position != -1) {
            //
            written = nd.pwrite(fd, ((DirectBuffer)bb).address() + pos, rem, position);
        } else {
            //
            written = nd.write(fd, ((DirectBuffer)bb).address() + pos, rem);
        }
        if (written > 0)
            bb.position(pos + written);
        return written;
    }

最终是调用 NativeDispatcher.write(),其定义如下:

java 复制代码
abstract int write(FileDescriptor fd, long address, int len) throws IOException;

第二个参数是 long address,它是我们要发送数据的内存地址。我们知道 Java GC 会对内存进行整理,整理后的 Java 对象的内存地址可能已经发生了变化,而如果我们使用堆内,那么我们需要保证在操作系统发送数据的时候我们需要将 GC 暂停来保证该对象内存地址不会因为 GC 而发生变化,但是可能吗?所以,我们必须弄一块地址不会因为 GC 而变化的内存,然后将这个地址给操作系统。所以上面图我们可以细化成这样:

Netty 使用了堆外内存就少了一次从堆内内存拷贝数据到堆外内存的操作了。

深入理解堆外内存

上面部分我们知道了,Netty 为什么要使用堆外内存,其目的就是减少一个内存拷贝,提升性能,那什么是堆外内存呢?堆外内存又是如何分配和释放的呢?下面我们将来探索这三个问题。

什么是堆外内存

在 Java 开发中我们知道 new 一个 Java 对象,我们就要给这个对象分配内存,这个对象是在堆内内存分配的,堆内内存完全由 JVM 虚拟机管所管理。对于整个机器而言,堆内内存以外的都是堆外内存。

堆内内存由 JVM 虚拟机所管理,它有自己的垃圾回收算法,对于开发者而言我们不需要关心对象是如何分配和回收的,但是堆外内存则不受 JVM 管理,它对开发者来说是未知的。

通常来说,未知的都是有风险的,那为什么还使用堆外内存呢?它有如下几个好处:

  1. 使用堆外内存可以减少一次内存拷贝。在上节我们知道,当进行网络 IO、文件读写时,堆内内存都需要转换为堆外内存,然后再和底层硬件设备进行交互,直接使用堆外内存就可以减少一次内存拷贝的操作。
  2. 降低 GC 的开销。堆外内存不受 JVM 管理,所以在一定程度上可以降低 GC 对应用程序带来的影响。
  3. 堆外内存可以实现进程之间 、JVM 多实例之间的数据共享

整体上来说,如果想要实现高效的 I/O 操作,降低 JVM GC 压力,堆外内存是一个非常不错的选择。但是使用堆外内存也会带来一些弊端:

  1. 容易内存泄露。因为堆外内存不受 JVM 管理,所以需要手动释放,如果我们不熟悉对应的框架,可能稍有不慎就会造成内存泄露,而且排查起来也比较困难。
  2. 对开发者要求较高

所以,堆外内存就像是一个法外之地,不受管理,不受约束,稍有不慎就会导致生产事故。

Java 程序一般使用 -XX:MaxDirectMemorySize 来限制最大堆外内存

分配堆外内存

在 Java 中分配堆外内存的方式有两种:

  1. Unsafe#allocateMemory()
  2. Java NIO 中的 ByteBuffer#allocateDirect()

一般不推荐使用第一种方式,所以我们直接看第二种方式,使用方式如下:

ini 复制代码
// 分配 1M 堆外内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024);

查看源码,它是直接构造一个 DirectByteBuffer 对象:

arduino 复制代码
    public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }

DirectByteBuffer 构造函数:

ini 复制代码
    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 = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;
    }

整个过程分为三步:

  1. 预分配内存 。主要是判断当前堆外内存是否还够分配,这个方法会调用 System.gc()
  2. 分配内存 。调用 unsafe.allocateMemory(size) 方法来分配 size 大小的堆外内存,返回堆外内存地址。
  3. 构造 Cleaner 对象。Cleaner 对象用于堆外内存的回收

DirectByteBuffer 对象它仅仅只是一个"中介者",它存储在堆内内存中,对于堆外内存它只有堆外内存的地址,大小等属性,还有一个用于堆外内存回收的 Cleaner 对象。

堆外内存不归 JVM 管,所以它的回收也不归 JVM 负责,那堆外内存是如何回收的呢?

回收堆外内存

堆外内存的回收有两种方式:

  1. System.gc() 触发。
  2. Cleaner 对象。

System.gc() 触发

什么时候触发 System.gc() ,在构造 DirectByteBuffer 对象的时候,会调用 Bits.reserveMemory(size, cap) 方法进行内存预分配:

go 复制代码
    DirectByteBuffer(int cap) {
        //...
        Bits.reserveMemory(size, cap);
        //..
    }

该方法会判断是否有足够的内存空间内来分配内存,如果没有则会触发 System.gc() ,源码如下:

ini 复制代码
   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();
            }
        }
    }

过程如下:

  1. maxMemory 最大堆外内存。获取 JVM 允许申请的最大 DirectByteBuffer,该参数可通过 XX:MaxDirectMemorySize 来设置。这里需要注意的是 -XX:MaxDirectMemorySize限制的是总 cap,而不是真实的内存使用量,因为在页对齐的情况下,真实内存使用量和总 cap 是不同的。
  2. 调用 tryReserveMemory() 预分配堆外内存,如果预分配成功,则返回,否则进行第 3 步。
  3. tryReserveMemory() 预分配内存失败,则会调用 jlra.tryHandlePendingReference() 来进行会触发一次非堵塞的 Reference#tryHandlePending(false),通过注释我们了解到该方法主要还是协助 ReferenceHandler 内部线程进行下一次 pending 的处理,内部主要是希望遇到 Cleaner,然后调用 Cleaner#clean() 进行堆外内存的释放。
  4. 如果还不行,则主动触发 System.gc() 了。但是我们需要注意的是,调用 System.gc() 并不能马上就可以执行 Full GC。
  5. 由于 Full GC 并不是马上就能执行,所以我们需要等待,看是否有足够的内存空间来分配。在这个过程中会尝试循环调用 tryReserveMemory() 方法来进行预分配,一共尝试 MAX_SLEEPS(9)次,每次按照指数退避规则从 1ms 开始进行延迟,一共会延迟 511 ms(1 + 2 + 4 + 8 + 16 + 32 + 64 + 128 + 256),9 次都没有申请成功则会抛出 OOM(throw new OutOfMemoryError("Direct buffer memory"))。

**总结:**我们可以通过 JVM 参数 -XX:MaxDirectMemorySize 来指定最大堆外内存,当堆外内存大小超过阈值时,则会调用 System.gc() 来触发一次 Full GC 来进行内存回收,如果回收后还无法满足申请的堆外内存,则就抛出 OOM。这种方式虽然可行,但是不靠谱,因为我们可以通过 -XX:+DisableExplicitGC 来禁用 System.gc()

Cleaner 对象

在构造 DirectByteBuffer 时,除了调用 Bits.reserveMemory(size, cap) 来触发 System.gc() 外,还创建了 Cleaner 对象,该对象负责堆外内存的回收工作。

arduino 复制代码
Cleaner.create(this, new Deallocator(base, size, cap));

首先调用 Deallocator 的构造函数创建一个 Deallocator 对象,该构造函数有三个参数:

  1. base:堆外内存地址
  2. size:堆外内存大小
  3. cap:申请堆外内存大小

因为存在页对齐的情况,所以堆外内存真实占用大小 size 和 申请大小 cap 不一定相同。

Deallocator 是 DirectByteBuffer 真正回收堆外内存的类,它实现 Runnable 接口,在 run() 方法中释放堆外内存。

arduino 复制代码
  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) {
                // Paranoia
                return;
            }
            // 释放堆外内存
            unsafe.freeMemory(address);
            address = 0;
            Bits.unreserveMemory(size, capacity);
        }
    }

既然 Deallocator#run() 是 DirectByteBuffer 真正回收堆外内存的地方,那它是在哪里调用的呢?我们继续 Cleaner.create()

当创建完 Deallocator 对象后,就调用 Cleaner.create() 创建 Cleaner 对象。

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

我们知道,Java 对象有四种引用:

  • **强引用(StrongReference):**一个对象为强引用则该对象永远都不会被回收。
  • **软引用(SoftReference):**在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。
  • 弱引用(WeakReference):无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。
  • **虚引用(PhantomReference):**若某个对象与虚引用关联,那么在任何时候都可能被 JVM 回收掉。虚引用不能单独使用,必须配合引用队列一起使用。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

Cleaner 对象继承 PhantomReference,属于虚引用:

scala 复制代码
public class Cleaner extends PhantomReference<Object> {
  // ...
}

创建完 Cleaner 对象后,调用 add() 将其加入到 Cleaner 链表中:

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

Cleaner 链表它是一个双向链表。这里涉及到几个对象,我们先画一张图来总结下:

当 DirectByteBuffer 对象被回收后,Cleaner 对象就不会再有任何引用关系了,在下次 GC 的时候,该 Cleaner 对象就会被添加到 PendingReference 链表中,同时通知 ReferenceHandler 处理:

scala 复制代码
    private static class ReferenceHandler extends Thread {
        // ...
        public void run() {
            while (true) {
                tryHandlePending(true);
            }
        }
    }

ReferenceHandler 线程的 run() 就是不停地执行 tryHandlePending()

typescript 复制代码
    static boolean tryHandlePending(boolean waitForNotify) {
        Reference<Object> r;
        Cleaner c;
        try {
            synchronized (lock) {
                if (pending != null) {
                    r = pending;
                    // 是否为 Cleaner
                    c = r instanceof Cleaner ? (Cleaner) r : null;
                    pending = r.discovered;
                    r.discovered = null;
                } else {
                  //...
                }
            }
        } catch (OutOfMemoryError x) {
            //...
        } catch (InterruptedException x) {
            // retry
            return true;
        }
        
        // 调用 Cleaner.clean()
        if (c != null) {
            c.clean();
            return true;
        }

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

tryHandlePending() 的主要工作从 PendingReference 链表中获取 Reference 对象,然后根据类型的不同执行不同的处理。

  • 如果是 Cleaner 类型则执行 Cleaner#clean()
  • 如果不是 Cleaner 类型则执行 ReferenceQueue#enqueue()

这里我们关注 Cleaner 对象。clean() 如下:

csharp 复制代码
    public void clean() {
        if (!remove(this))
            return;
        try {
            // 调用 Deallocator.run()
            thunk.run();
        } catch (final Throwable x) {
            // ...
        }
    }

该方法核心部分就是执行 thunk.run(),thunk 对象就是我们在创建 DirectByteBuffer 构造的 Deallocator 对象:

csharp 复制代码
        public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            // 回收堆外内存
            unsafe.freeMemory(address);
            address = 0;
            Bits.unreserveMemory(size, capacity);
        }

最终还是调用 Unsafe.freeMemory() 来释放堆外内存。

总结:

  1. 在创建 DirectByteBuffer 对象时,会创建 Cleaner 和 Deallocator 对象来回收堆外内存。 其中 Cleaner 为 PhantomReference 类型,Deallocator 保存类堆外内存的地址和大小。
  2. 当 GC 回收 DirectByteBuffer 对象后,Cleaner 就无引用了,这个时候 JVM 在 GC 时会将该 Cleaner 加入到 PendingReference 链表中,同时通知 ReferenceHandler 线程处理。
  3. ReferenceHandler 线程则从 PendingReference 链表中获取 Reference 对象,判断 Reference 是否为 Cleaner 类型,如果是则执行 Cleaner 的 clean() 方法。
  4. Cleaner#clean() 中,执行 Deallocator#run() 来释放堆外内存。

最后,大明哥附上 Reference 核心处理流程来结束这篇文章吧(参考:www.cnblogs.com/yungyu16/p/...)。

相关推荐
得不到的更加爱24 分钟前
Java多线程不会?一文解决——
java·开发语言
五敷有你27 分钟前
Go:hello world
开发语言·后端·golang
拔剑纵狂歌1 小时前
Golang异常处理机制
开发语言·后端·golang·go
缘友一世1 小时前
Armbian 1panel面板工具箱中FTP服务无法正常启动的解决方法
linux·运维·后端·1panel
ffyyhh9955111 小时前
java进行音视频的拆分和拼接
java·音视频
weixin_419349791 小时前
flask使用定时任务flask_apscheduler(APScheduler)
后端·python·flask
乐之者v1 小时前
Spring之 IoC、BeanFactory、ApplicationContext
java·后端·spring
DS_Watson1 小时前
字符串和正则表达式踩坑
java·开发语言
Wayfreem1 小时前
Java锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
java·开发语言
我焦虑的编程日记1 小时前
【Java EE】验证码案例
java·java-ee