堆外内存的使用

在Jdk1.8之后,jvm内存模型做了一个调整,即方法区的实现从永久代(Perm Space)变成了元空间(MetaSpace)。元空间不在虚拟机中,而是直接使用了系统内存。为了方便,下文把虚拟机内存称为堆内内存,与之相对地,直接内存称之为堆外内存。


为什么要使用堆外内存

在说为什么要使用堆外内存之前,必须来探讨一下堆内内存。堆内内存即Java虚拟机内存,由虚拟机进行管理,开发者不用关心内存空间的分配和回收。但有利必有弊,堆内内存的缺点在于:

  1. 垃圾回收是有成本的,堆中的对象数量越多,GC的开销就越大。
  2. 使用堆内内存进行文件、网络的IO时,JVM会使用堆外内存做一次额外的中转,也就是会多一次内存拷贝。

而堆外内存直接使用机器内存,受操作系统管理。在一定程度上减少了垃圾回收对应用程序造成的影响。

实际案例

博主本人在实际工作中没有碰到过必须使用堆外内存的场景。不过《蚂蚁消息中间件 (MsgBroker) 在 YGC 优化上的探索》这篇文章里有个比较完整的案例(参考资料2)。

文中的业务场景是蚂蚁的消息中间件的使用。为了保证消息的可靠性,将消息持久化到数据库,而为了降低消息投递时对db的读压力,对消息进行了缓存。这样虚拟机就要为缓存的消息分配内存。当缓存中的消息由于各种原因投递不成功时,这些消息就要一直维持着,且越积越多。在堆空间里对象由年轻代变为老年代,占用的空间也越来越大。最终表现出的问题是年轻代的gc时间太长(超过100ms)。

那么问题来了,为什么老年代对象增多会导致ygc时间变长呢?根据文中的回答:
在ygc中占用大部分时间的是older-gen scanning,这个阶段主要用于扫描老年代持有的对年轻代对象的引用。在消息缓存且大量消息无法投递出去的场景中,大量年轻代对象转化为老年代对象,并且持有年轻代对象的引用。在这种情况下,扫描的时间就比较长。

最终的解决方案是使用堆外内存,减少消息对JVM内存的占用,并使用基于Netty的网络层框架,达到了理想的YGC时间。

堆外内存的使用

堆外内存可以通过两种方式来使用。

Unsafe类操作

sun.misc.Unsafe提供了一组方法来进行堆外内存的分配,重新分配和释放。

Unsafe是java留给开发者的后门,用于直接操作系统内存且不受jvm管辖,实现类似c++风格的操作。但一般不提倡使用,正如类名所示,它并不安全,容易造成内存泄露。

Unsafe类的大部分方法均为native方法,直接调用的其他语言的方法(大部分是c++)来进行操作,很多细节无法追溯,只能大致了解。 Unsafe类在jdk9之后移到了jdk.unsupported模块中。

  • public native long allocateMemory(long size): 分配内存空间
  • public native long reallocateMemory(long address, long size): 重新分配一块内存,把数据从address指向的缓存中拷贝到新的内存块。
  • public native void freeMemory(long address): 释放内存

Unsafe类的构造方法是私有的,提供了getUnsafe方法用于获取其实例。不过这个方法是不对普通开发者开放的,使用时会报异常:java.lang.SecurityException: Unsafe。可以通过反射机制来使用该类。如下所示:

public class unsafeDemo {
    public static void main(String[] args) {
        Unsafe unsafe = null;
        long memoryAddress = 0;
        try {
            // 获取Unsafe类型的私有单例对象
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);
            memoryAddress = unsafe.allocateMemory(1024);
            // 将int型整数存入到指定地址中
            unsafe.putInt(memoryAddress, 5);
            // 根据地址获取到整数
            int a = unsafe.getInt(memoryAddress);
            System.out.println(a);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            assert unsafe != null;
            unsafe.freeMemory(memoryAddress);
        }
    }
}

NIO类操作

JDK1.4引入了NIO,可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里的DirectByteBuffer对象作为这块内存的引用进行操作。

分配一块堆外内存如下所示:

ByteBuffer bf = ByteBuffer.allocateDirect(10 * 1024);

查看源码可以看到allocateDirect的函数定义:

/**
 * Allocates a new direct byte buffer.
 *
 * <p> The new buffer's position will be zero, its limit will be its
 * capacity, its mark will be undefined, and each of its elements will be
 * initialized to zero.  Whether or not it has a
 * {@link #hasArray backing array} is unspecified.
 *
 * @param  capacity
 *         The new buffer's capacity, in bytes
 *
 * @return  The new byte buffer
 *
 * @throws  IllegalArgumentException
 *          If the <tt>capacity</tt> is a negative integer
 */
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

可以看到,该方法返回了一个DirectByteBuffer对象。进一步查看DirectByteBuffer的创建过程:

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;
}

在这个过程,真正的内存分配是使用的Bits.reserveMemory方法。Bits.reserveMemory()的主要逻辑如下:

  • Bits类中有一个全局变量totalCapacity,用于标识DirectByteBuffer的总大小。每次申请内存的时候,都先检查一下是否超过大小限制(可通过-XX:MaxDirectMemorySize设置)。如果已经超限,则会调用System.gc(),期待能主动回收一点堆外内存。然后休眠一会儿,看看totalCapacity是否可用。如果还是内存不足,则抛出OOM异常。

在创建DirectByteBuffer对象的最后,通过Cleaner.create(this, new Deallocator(base, size, cap))创建了一个Cleaner对象。该对象的作用是:当DirectByteBuffer对象被回收时,释放其对应的堆外内存。

堆外内存的回收

堆外内存基于GC的回收

上文说过,堆外内存通过堆内的DirectByteBuffer对象来进行引用。DirectByteBuffer对象本身是很小的,但它代表着所分配的一大段内存,是所谓的“冰山”对象。当DirectByteBuffer对象被gc时,它引用的堆外内存也会被回收。

也就是说,堆外内存的回收是DirectByteBuffer被回收时触发的,而DirectByteBuffer的回收则是堆内GC的事。

回忆一下堆内GC的机制:当新生代满了,就会触发YongGC,如果此时对象未失效,则不会被回收;经过几次YongGC之后仍然存活的新生代对象被移到老年代中。当老年代满了则进行Full GC。

如果DirectByteBuffer对象熬过了几次YongGC后被迁移到老年代中,即使失效了也能在老年代中一直待着。相对于YongGC,老年代发生Full GC的频率是比较低的。在老年代未发生Full GC时,失效的DirectByteBuffer对象就一直占着一大片堆外内存不释放。

当然,还有另外一种情况也能触发DirectByteBuffer回收。那就是上文提到的,当申请堆外内存而空间不足时,会主动调用System.gc()来告诉虚拟机该进行GC了。但这种方式并不靠谱,因为只有在堆外内存空间不足时才会触发。而且,如果设置了-DisableExplicitGC禁止了system.gc(),那就无法回收了。

堆外内存的主动回收

堆外内存的主动回收指的是通过DirectByteBuffer的clean对象进行内存回收。如下所示:

public class ByteBufferTest {
    public static void main(String[] args) throws Exception {
        long size = Runtime.getRuntime().maxMemory();
        System.out.println("默认最大堆外内存为:" + size / 1024.0 / 1024.0 + "mb");
        ByteBuffer bf = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
        Thread.sleep(2000);
        System.out.println("cleaner start");
        // clean(bf);
        ByteBuffer bf2 = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
        Thread.sleep(2000);
        clean(bf2);
    }

    private static void clean(final ByteBuffer byteBuffer) throws Exception {
        if (byteBuffer.isDirect()) {
            Field cleanerField = byteBuffer.getClass().getDeclaredField("cleaner");
            cleanerField.setAccessible(true);
            Cleaner cleaner = (Cleaner) cleanerField.get(byteBuffer);
            cleaner.clean();
        }
//        // 第二种方法获取cleaner
//        if (byteBuffer.isDirect()) {
//            Cleaner cleaner = ((DirectBuffer)byteBuffer).cleaner();
//            cleaner.clean();
//        }
    }
}

在不通过-XX:MaxDirectMemorySize参数来指定最大堆外内存的情况下,默认堆外内存与堆内存差不多,通过Runtime.getRuntime().maxMemory()可以获取到。

接着申请了1024m堆外内存,并通过cleaner做了一次堆外内存回收。然后再次申请1024m堆外内存。运行效果如下:

默认最大堆外内存为:1753.0mb
cleaner start

如果注释掉clean(bf)这一行,运行也没有问题。上文说过,在申请堆外内存而空间不足时,系统会调用System.gc()来触发Full GC,以此达到回收堆外内存的目的。

如果注释掉clean(bf)同时,指定-XX:+DisableExplicitGC参数来禁止显式地触发gc,则申请第二次堆外内存时,就会报OOM错误,因为System.gc()的调用是无效的。运行结果如下所示:
在这里插入图片描述

参考资料

[1]. https://www.jianshu.com/p/17e72bb01bf1
[2]. https://juejin.im/post/5a9cb49df265da239706561a?utm_source=gold_browser_extension
[3]. https://www.jianshu.com/p/ae3847326e69

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值