网络与I/OJVM

Java中如何分配以及释放直接内存

2021-06-24  本文已影响0人  肥兔子爱豆畜子

Java中如何使用直接内存?

JVM触发回收直接内存操作的时机是fgc

获取Unsafe实例的方法:

public static Unsafe getUnsafeInstance() throws Exception{
        //return Unsafe.getUnsafe();
        Field unsafeInstance = Unsafe.class.getDeclaredField("theUnsafe");
        unsafeInstance.setAccessible(true);
        return (Unsafe)unsafeInstance.get(Unsafe.class);
    }

使用unsafe分配和释放直接内存:

unsafe.allocateMemory(int size);
unsafe.freeMemory(long address);

很类似C语言中的malloc和free.

如果在一个对象中封装使用unsafe分配了直接内存,那么当这个堆对象成为可回收对象被gc垃圾回收的时候,要确保它搞的这部分直接内存也能够释放,比较粗糙的做法是在该对象内Override Object类的finalize()方法,在finalize方法中执行unsafe.freeMemory :

public class MyDirectByteBuf {
    private long address;
    
    public MyDirectByteBuf(int cap) {
        try {
            address = UnsafeUtil.getUnsafeInstance().allocateMemory(cap);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    @Override
    protected void finalize() throws Throwable{
        UnsafeUtil.getUnsafeInstance().freeMemory(address);
    }
}

上面我们自己造了个轮子MyDirectByteBuf,为了回收它分配的直接内存,首先得回收其对应的堆内的对象MyDirectByteBuf实例,靠堆内对象被垃圾回收之前触发finalize()来手工的free对应的直接内存。问题是如果堆内存的垃圾回收一直不自动触发,直接内存先不够用了呢?是不是需要显式的去触发gc才行?为此,我们要先放下造的轮子,去研究一下DirectByteBuffer是如何释放直接内存的。

DirectByteBuffer如何释放直接内存

先看一个实验程序:

public class DirectByteBufferTest {
    private static int M = 1024*1024;
    
    public static void main(String[] args) {
        while(true) {
            ByteBuffer.allocateDirect(10*M);
        }
    }
}

设置vm argument,打印详细gc日志,设定最大堆外直接内存为40M:

-XX:+PrintGCDetails -XX:MaxDirectMemorySize=40M

运行程序,可以看到控制台一直在输出gc日志,程序可以一直运行而不会发生oom问题。

我们再加上一个vm参数, -XX:+DisableExplicitGC,禁止System.gc()显式调用gc,再次运行程序,结果:

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory

看下ByteBuffer.allocateDirect(int capacity)的源码:

public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }
    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;



    }
    static void reserveMemory(long size, int cap) {
        synchronized (Bits.class) {
            if (!memoryLimitSet && VM.isBooted()) {
                maxMemory = VM.maxDirectMemory();
                memoryLimitSet = true;
            }
            // -XX:MaxDirectMemorySize limits the total capacity rather than the
            // actual memory usage, which will differ when buffers are page
            // aligned.
            if (cap <= maxMemory - totalCapacity) {
                reservedMemory += size;
                totalCapacity += cap;
                count++;
                return;
            }
        }

        System.gc();
        try {
            Thread.sleep(100);
        } catch (InterruptedException x) {
            // Restore interrupt status
            Thread.currentThread().interrupt();
        }
        synchronized (Bits.class) {
            if (totalCapacity + cap > maxMemory)
                throw new OutOfMemoryError("Direct buffer memory");
            reservedMemory += size;
            totalCapacity += cap;
            count++;
        }

    }

在每次通过DirectByteBuffer预分配直接内存之前,会先System.gc()来显式执行fgc,然后通过触发对可回收的DirectByteBuffer对象的垃圾回收、来间接的触发其分配的直接内存的回收。

那么按照之前我们的思路,似乎应该是如下的剧本:

每次new DirectByteBuffer -> 调用System.gc() -> 回收堆中失去引用的DirectByteBuffer对象 -> 触发这些DirectByteBuffer对象的finalize -> 在finalize中free对应的直接内存。

但,真的是这样吗?

查看上面的源码,new DirectByteBuffer时会新建一个Cleaner:

cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
public class sun.misc.Cleaner extends java.lang.ref.PhantomReference 

sun.misc.Cleaner是JDK内部提供的用来释放非堆内存资源的API,JVM只负责自动回收堆内存,但是通过Cleaner提供了回调机制来给了释放非堆内存的机会。

create方法的两个参数第一个是需要监控的堆内存对象,第二个是参数是当发现监控的这个对象失去了强引用gc root可以被回收时需要运行的task,在task中做其他资源的释放。Cleaner继承了PhantomReference是个虚引用,所以create第一个参数对象一旦失去强引用,那么它可以被回收,其对应的虚引用对象会进入到引用队列,这样第二个参数task就可以从引用队列中得到相应的虚引用对象进而来获取对应的直接内存地址,来通过Unsafe进行freeMemory了。理解Java四种引用类型——强软弱虚这篇文章中关于虚引用的应用的描述终于搞清楚了!

下面我们还是用代码来模拟一下。

使用PhantomReference来回收直接内存

首先是我们的直接内存对象MyDirectByteBuf:

public class MyDirectByteBuf {
    private long address;
    
    public MyDirectByteBuf(int cap) {
        try {
            address = UnsafeUtil.getUnsafeInstance().allocateMemory(cap);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    public long getAddress() {
        return this.address;
    }

}

获取Unsafe实例的工具:

import java.lang.reflect.Field;
import sun.misc.Unsafe;

public class UnsafeUtil {
    public static Unsafe getUnsafe() throws Exception{
        //return Unsafe.getUnsafe();
        Field unsafeInstance = Unsafe.class.getDeclaredField("theUnsafe");
        unsafeInstance.setAccessible(true);
        return (Unsafe)unsafeInstance.get(Unsafe.class);
    }
}

使用虚引用PhantomReference配合引用队列ReferenceQueue,释放MyDirectByteBuf分配的直接内存:

/**
 * vm arguments: -XX:MaxDirectMemorySize=40M
 * */
public class MyCleaner {
    private static ReferenceQueue<MyDirectByteBuf> refQueue = new ReferenceQueue<>(); //引用队列
    private static Map<PhantomReference, Long> addressMap = new HashMap<>();

    static {
        // 清理线程,负责从引用队列中取出失去强引用的待回收对象,调用unsafe.freeMemory去释放其对应的直接内存
        new Thread(new Runnable() {

            @Override
            public void run() {
                System.out.println("直接内存清理线程已启动...");
                while (true) {
                    try {
                        Reference<? extends MyDirectByteBuf> ref = refQueue.remove();//如果ref被回收了,那这里也会取不出来
                        //System.out.println(ref);
                        /*
                        MyDirectByteBuf buf = (MyDirectByteBuf) ref.get();
                        UnsafeUtil.getUnsafe().freeMemory(buf.getAddress());
                        System.out.println("已释放直接内存,地址" + buf.getAddress());
                        */
                        Long address = addressMap.remove(ref);
                        UnsafeUtil.getUnsafe().freeMemory(address.longValue());
                        System.out.println("已释放直接内存,地址" + address.longValue());
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }

            }
        }).start();
    }

    public static void create(MyDirectByteBuf buf) {
        PhantomReference<MyDirectByteBuf> ref = new PhantomReference<>(buf, refQueue); // 当buf被回收的时候,ref会被放入refQueue
        addressMap.put(ref, new Long(buf.getAddress())); //相当于强引用指向ref,不然ref马上没回收了,要等我们释放好直接内存再回收
    }

    public static void main(String[] args) {
        while (true) {
            MyDirectByteBuf buf = new MyDirectByteBuf(2 * 1024 * 1024);
            MyCleaner.create(buf);
            System.gc();
        }
    }
}

有几个似是而非的地方要注意理解:

总结

有些对象,比如DirectByteBuffer这种、它有jvm无法控制的堆外内存,在gc回收了堆中的对象本身之后,需要我们对它对应的直接内存资源做二次回收。这时候就要用到Cleaner机制。

  1. Cleaner里边定义了虚引用ref,指定它关联的我们的对象obj和引用队列queue,并且建立ref与obj对应的资源之间的映射关系,比如ref-obj对应的直接内存地址,保存在Map里。
  2. 一旦对象obj被gc回收,那么jvm会将ref放入引用队列queue。
    Cleaner里边的回收线程负责从引用队列里拿出ref,然后根据ref和资源之间的映射关系找到对应的资源、进行二次回收。

虚引用PhantomReference是用来确保“彻底”回收某些对象用到,这些对象申请了某些jvm无法直接回收的资源、比如直接内存、底层io资源比如文件句柄、网络连接等等,一般来说我们会将虚引用与这些资源建立映射关系,这样当gc回收对象的时候能够根据从引用队列中拿到的虚引用找到这些资源并进行回收。

参考:https://blog.csdn.net/aitangyong/article/details/39455229

笔者文章的一些内容和程序并非绝对原创,是来源于网络文章以及文档的学习理解,属于个人笔记性质。笔者向这些老师们表示感谢!

上一篇下一篇

猜你喜欢

热点阅读