Java直接内存的分配和释放

2019-07-21 19:47:40

阅读次数 - 1316

Java IO 直接内存 虚引用 JVM

在前面介绍零拷贝的文章《Linux和Java的零拷贝》中,大概介绍了什么是Java的直接内存。

直接内存是分配在JVM堆外的,那JVM是怎么对它进行管理的呢?本文主要介绍一下在Java中,直接内存的空间分配和释放的机制。

直接内存和堆内存的比较

在比较两者的性能时,我们分两方面来说。

  • 申请空间的耗时:堆内存比较快
  • 读写的耗时:直接内存比较快

直接内存申请空间其实是比较消耗性能的,所以并不适合频繁申请。但直接内存在IO读写上的性能要优于堆内存,所以直接内存特别适合申请以后进行多次读写。

为什么在申请空间时,堆内存会更快?堆内存的申请是直接从已分配的堆空间中取一块出来使用,不经过内存申请系统调用,而直接内存的申请则需要本地方法通过系统调用完成。

而为什么在IO读写时,直接内存比较快?因为直接内存使用的是零拷贝技术。

所以直接内存一般有两个使用场景:

  • 复制很大的文件
  • 频繁的IO操作,例如网络并发场景

直接内存由于是直接分配在堆外的,所以不受JVM堆的大小限制。但还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制,所以还是有可能会抛出OutOfMemoryError异常。

直接内存的最大大小可以通过-XX:MaxDirectMemorySize来设置,默认是64M

直接内存的分配和释放

在Java中,分配直接内存有三种方式:

  • Unsafe.allocateMemory()
  • ByteBuffer.allocateDirect()
  • native方法

Unsafe

Java提供了Unsafe类用来进行直接内存的分配与释放:

public long allocateMemory(long bytes); public void freeMemory(long address);

DirectByteBuffer类

虽然Java提供了Unsafe类用来操作直接内存的分配和释放,但Unsafe无法直接使用,需要通过反射来获取。Unsafe更像是一个底层设施。

DirectByteBuffer类里面使用了Unsafe,它对Unsafe进行了封装,所以更适合开发者使用。它分配内存和释放内存是通过一下方法来实现的。

构造方法:

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

这里有两个概念。一个是Bits类里面的reserveMemory,另一个是Cleaner创建的Deallocator。

reserveMemory方法源码:

static void reserveMemory(long size, int cap) { // 初始化maxMemory,如果没有指定-XX:MaxDirectMemorySize, // 就使用VM.maxDirectMemory()的值:64M if (!MEMORY_LIMIT_SET && VM.initLevel() >= 1) { MAX_MEMORY = VM.maxDirectMemory(); MEMORY_LIMIT_SET = true; } // 第一次先采取乐观的方式尝试告诉Bits要分配内存 if (tryReserveMemory(size, cap)) { return; } final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess(); boolean interrupted = false; try { // 尝试释放内存,直到内存空间足够 boolean refprocActive; do { try { refprocActive = jlra.waitForReferenceProcessing(); } catch (InterruptedException e) { interrupted = true; refprocActive = true; } if (tryReserveMemory(size, cap)) { return; } } while (refprocActive); // trigger VM's Reference processing System.gc(); // 按照1ms,2ms,4ms,...,256ms的等待间隔尝试9次分配内存 long sleepTime = 1; int sleeps = 0; while (true) { if (tryReserveMemory(size, cap)) { return; } if (sleeps >= MAX_SLEEPS) { break; } try { if (!jlra.waitForReferenceProcessing()) { Thread.sleep(sleepTime); sleepTime <<= 1; sleeps++; } } catch (InterruptedException e) { interrupted = true; } } // no luck 还是没有足够的空间可以分配 throw new OutOfMemoryError("Direct buffer memory"); } finally { if (interrupted) { // don't swallow interrupts Thread.currentThread().interrupt(); } } }

内存释放是通过Deallocator来实现的。

private static class Deallocator implements Runnable { 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释放内存 UNSAFE.freeMemory(address); address = 0; // 利用Bits管理内存的释放,就是标记一下该内存已释放 Bits.unreserveMemory(size, capacity); } }

很简单的一个Runnable,主要通过Cleaner来进行调度。Cleaner的数据结构为一个双向链表,采用“头插法”,每次插入新的结点是插入到“头结点”的。Cleaner继承了PhantomReference,其referent为DirectByteBuffer:

public class Cleaner extends PhantomReference<Object> { private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue<>(); private static Cleaner first = null; private Cleaner next = null, prev = null; 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)); } // other methods... }

这里用到了JVM的虚引用。JVM有四种引用类型,分别是:强引用,弱引用,软引用,虚引用。

PhantomReference的get方法总是返回null,因此无法访问对应的引用对象;其意义在于说明一个对象已经进入finalization阶段,可以被GC回收。

GC过程中如果发现某个对象除了只有PhantomReference引用它之外,并没有其他的地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里,在GC完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理,这里是调用Cleaner的clean方法:

// Cleaner类 public void clean() { if (!remove(this)) return; try { // 调用Deallocator的run方法, // 进而通过Unsafe类释放内存。 thunk.run(); } catch (final Throwable x) { AccessController.doPrivileged(new PrivilegedAction<>() { public Void run() { if (System.err != null) new Error("Cleaner terminated abnormally", x) .printStackTrace(); System.exit(1); return null; }}); } }

总结成一张图:

直接内存管理

native方法

我们知道,Java可以通过native方法来直接调用C/C++的接口。那native方法中分配的内存是否是属于DirectByteBuffer对象呢?掘金上有一篇文章《Java直接内存分配与释放原理》写了一个Demo进行了实验,发现native方法分配的内存并不会产生DirectByteBuffer对象,同样的也不受-XX:MaxDirectMemorySize影响。

所以如果你使用native方法来操作直接内存的话,也需要使用native方法来自己进行直接内存的管理。

总结

通常来说,我们是使用DirectByteBuffer类来操作直接内存的比较多,所以可以了解一下DirectByteBuffer对直接内存的分配和回收的流程,这样如果以后遇到因为直接内存引起的性能瓶颈或者OOM异常,可以进行快速排查。

感谢阅读


觉得文章还不错?点击下方按钮分享吧!

评论

快来占个沙发吧~


TO: