前言
上次网易一面面试官提到了“是否了解堆外内存?”、“堆外内存是否需要手动释放?”等问题,那时候我误以为所提到的“堆外内存”是指元空间这个jvm管理的堆外内存,对于元空间是否手动释放这样的问题就令我十分疑惑,按理说当元空间的类信息会在类被定义成“无用的类”时会被回收,因此不需要我们手动释放,然后面试小哥又重复了一遍我的回答“不需要手动释放吗?”,我只能回答对此可能不是很了解。
面试结束后上网搜索了一下,他想要问的应该是java中的DirectByteBuffer,而今天早上又看到一篇博客“堆外内存泄漏的排查过程”。就打算今天对堆外内存做一个总结。
使用
DirectByteBuffer的创建非常简单,使用ByteBuffer的静态方法
1 ByteBuffer dirBuf = ByteBuffer.allocateDirect(capacity);
就可以创建一个DirectByteBuffer,与普通的ByteBuffer不一样的地方在于一个是在jvm堆内存中,另一个不在jvm堆内存中。
创建、清理
为了了解堆外内存是如何被回收的,我们先来看allocateDirect这个方法是如何创建一个实例的。
1 DirectByteBuffer(int cap) { // package-private 2 3 super(-1, 0, cap, cap); 4 boolean pa = VM.isDirectMemoryPageAligned(); 5 int ps = Bits.pageSize(); 6 long size = Math.max(1L, (long)cap + (pa ? ps : 0)); 7 Bits.reserveMemory(size, cap);//1.1预定一块空间 8 9 long base = 0; 10 try { 11 base = unsafe.allocateMemory(size);//1.2创建 12 } catch (OutOfMemoryError x) { 13 Bits.unreserveMemory(size, cap); 14 throw x; 15 } 16 unsafe.setMemory(base, size, (byte) 0); 17 if (pa && (base % ps != 0)) { 18 // Round up to page boundary 19 address = base + ps - (base & (ps - 1)); 20 } else { 21 address = base; 22 } 23 cleaner = Cleaner.create(this, new Deallocator(base, size, cap));//2.构造一个Cleaner对象 24 att = null; 25 26 }
先进入(注释1.1)Bits.reserveMemory(size, cap)这个方法,它主要是预申请一块空间,size是系统的页大小。
1 static void reserveMemory(long size, int cap) { 2 3 if (!memoryLimitSet && VM.isBooted()) { 4 maxMemory = VM.maxDirectMemory(); 5 memoryLimitSet = true; 6 } 7 8 // optimist! 9 if (tryReserveMemory(size, cap)) { 10 return; 11 } 12 13 final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess(); 14 15 // retry while helping enqueue pending Reference objects 16 // which includes executing pending Cleaner(s) which includes 17 // Cleaner(s) that free direct buffer memory 18 while (jlra.tryHandlePendingReference()) { 19 if (tryReserveMemory(size, cap)) { 20 return; 21 } 22 } 23 24 // trigger VM's Reference processing 25 System.gc(); 26 27 // a retry loop with exponential back-off delays 28 // (this gives VM some time to do it's job) 29 boolean interrupted = false; 30 try { 31 long sleepTime = 1; 32 int sleeps = 0; 33 while (true) { 34 if (tryReserveMemory(size, cap)) { 35 return; 36 } 37 if (sleeps >= MAX_SLEEPS) { 38 break; 39 } 40 if (!jlra.tryHandlePendingReference()) { 41 try { 42 Thread.sleep(sleepTime); 43 sleepTime <<= 1; 44 sleeps++; 45 } catch (InterruptedException e) { 46 interrupted = true; 47 } 48 } 49 } 50 51 // no luck 52 throw new OutOfMemoryError("Direct buffer memory"); 53 54 } finally { 55 if (interrupted) { 56 // don't swallow interrupts 57 Thread.currentThread().interrupt(); 58 } 59 } 60 }
reserveMemory方法流程:
1、首先是第八行的tryReserveMemory方法,尝试申请空间
2、申请失败就到第18-22行尝试清理堆外内存,再tryReserveMemory
3、如果清理完了还是申请失败,就调用System.gc(),触发full gc,触发后,可以清理老年代(新生代也可能有但是大多是在老年代的)的堆外内存的引用(如果存在应该被清理的引用),清理完后就是剩下的部分,33-53行自旋MAX_SLEEPS次,sleep等待full gc的触发(System.gc()可能有延时),如果次数大于MAX_SLEEPS还没申请成功,就抛出异常。
接下来是(注释1.2)的unsafe.allocateMemory真正创建堆外内存空间。
再之后是(注释2)创建Cleaner对象,用于之后清理这块堆外内存空间。
Cleaner继承PhantomReference类,并通过自身的next和prev字段维护的一个双向链表,当DirectByteBuffer对象从“pending” 变为 “enqueue”时(即gc过程中对象只有被虚引用,这个引用会被放到java.lang.ref.Reference.pending队列里,调用ReferenceHandler的run中不断自旋的tryHandlePending(true)方法处理,清理pending链,使用clean方法将堆外内存清理掉)。
参数设置
我们可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc()来做一次full gc,以此来回收掉没有被使用的堆外内存,这个是jvm底层帮我们做的,我们只需要设定其参数即可。
使用情景
1、直接的文件拷贝操作,或者I/O操作。当操作系统对堆内内存进行文件拷贝、io处理时先会拷贝一份到堆外,再进行发送处理,而堆外内存就少了一个拷贝的耗时。
2、堆外内存适用于生命周期较长的对象,不会占用堆内的内存,这点与元空间的出现原因类似,Class信息一般生命周期也都较长。