这里说的堆外内存主要针对java.nio.DirectByteBuffer,这些对象的创建过程会通过Unsafe接口直接通过os::malloc来分配内存,然后将内存的起始地址和大小存到java.nio.DirectByteBuffer对象里,这样就可以直接操作这些内存。这些内存只有在DirectByteBuffer回收掉之后才有机会被回收,因此如果这些对象大部分都移到了old,但是一直没有触发CMS GC或者Full GC,那么悲剧将会发生,因为你的物理内存被他们耗尽了,因此为了避免这种悲剧的发生,通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc来做一次full gc,以此来回收掉没有被使用的堆外内存。

堆外内存

堆外内存的优势在于IO操作,相比堆内存可以减少一次copy和gc的次数。
JVM启动时分配的内存,称为堆内存,与之相对的,在代码中还可以使用堆外内存,比如Netty,广泛使用了堆外内存,但是这部分的内存并不归JVM管理,GC算法并不会对它们进行回收,所以在使用堆外内存时,要格外小心,防止内存一直得不到释放,造成线上故障。

JVM内存的分配及垃圾回收

堆外内存溢出

从nio时代开始,可以使用ByteBuffer等类来操纵堆外内存了,使用ByteBuffer分配本地内存则非常简单,直接ByteBuffer.allocateDirect(10 1024 1024)即可,如下:
ByteBuffer buffer = ByteBuffer.allocateDirect(numBytes);
像Memcached等等很多缓存框架都会使用堆外内存,以提高效率,反复读写,去除它的GC的影响。可以通过指定JVM参数来确定堆外内存大小限制(有的VM默认是无限的,比如JRocket,JVM默认是64M):
-XX:MaxDirectMemorySize=512m
对于这种direct buffer内存不够的时候会抛出错误:
java.lang.OutOfMemoryError: Direct buffer memory

堆外内存的回收机制分析 - 图1
可见堆内存都是正常的,重新回到业务日志里寻找异常,发现出现在堆外内存的分配上:
java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at java.nio.DirectByteBuffer.(DirectByteBuffer.java:101)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)
at com.schooner.MemCached.SchoonerSockIOPool$TCPSockIO.(Unknown Source)
对于这个参数分配过小的情况下造成OOM,不妨执行jmap -histo:live看看(也可以用JConsole之类的外部触发GC),因为它会强制一次full GC,如果堆外内存明显下降,很有可能就是堆外内存过大引起的OOM。
BTW,如果在执行jmap命令时遇到:
Error attaching to process: sun.jvm.hotspot.debugger.DebuggerException: Can’t attach to the process
这个算是JDK的一个bug(链接),只要是依赖于SA(Serviceability Agent)的工具,比如jinfo/jstack/jmap都会存在这个问题,但是Oracle说了“won’t fix”……
Ubuntu 10.10 and newer has a new default security policy that affects Serviceability commands.
This policy prevents a process from attaching to another process owned by the same UID if
the target process is not a descendant of the attaching process.
不过它也是给了解决方案的,需要修改/etc/sysctl.d/10-ptrace.conf:
kernel.yama.ptrace_scope = 0
堆外内存泄露的问题定位通常比较麻烦,可以借助google-perftools这个工具,它可以输出不同方法申请堆外内存的数量。当然,如果你是64位系统,你需要先安装libunwind库
最后,JDK存在一些direct buffer的bug(比如这个这个),可能引发OOM,所以也不妨升级JDK的版本看能否解决问题。

三、堆外内存回收

3.1、ByteBuffer的堆外内存回收

 由前面的文章可知,堆外内存分配很简单,直接ByteBuffer.allocateDirect(10 1024 1024)即可。很像C语言。在C语言的内存分配和释放函数malloc/free,必须要一一对应,否则就会出现内存泄露或者是野指针的非法访问。java中我们需要手动释放获取的堆外内存吗?在谈到堆外内存优点时提到“可以无限使用到1TB”,既然可以无限使用,那么会不会用爆内存呢?这个是很有可能的…所以堆外内存的垃圾回收也很重要。
由于堆外内存并不直接控制于JVM,因此只能等到full GC的时候才能垃圾回收!(direct buffer归属的的JAVA对象是在堆上且能够被GC回收的,一旦它被回收,JVM将释放direct buffer的堆外空间。前提是没有关闭DisableExplicitGC
先看一个示例:(堆外内存回收演示)

  1. /**
  2. * @VM args:-XX:MaxDirectMemorySize=40m -verbose:gc -XX:+PrintGCDetails
  3. * -XX:+DisableExplicitGC //增加此参数一会儿就会内存溢出java.lang.OutOfMemoryError: Direct buffer memory
  4. */
  5. public static void TestDirectByteBuffer() {
  6. List<ByteBuffer> list = new ArrayList<ByteBuffer>();
  7. while(true) {
  8. ByteBuffer buffer = ByteBuffer.allocateDirect(1 * 1024 * 1024);
  9. //list.add(buffer);
  10. }
  11. }

通过NIO的ByteBuffer使用堆外内存,将堆外内存设置为40M:
场景一:不禁用FullGC下的system.gc
运行这段代码会发现:程序可以一直运行下去,不会报OutOfMemoryError。如果使用了-verbose:gc -XX:+PrintGCDetails,会发现程序频繁的进行垃圾回收活动。
结果省略。
场景二:同时JVM完全忽略系统的GC调用
堆外内存的回收机制分析 - 图2
与之前的JVM启动参数相比,增加了-XX:+DisableExplicitGC,这个参数作用是禁止显示调用GC。代码如何显示调用GC呢,通过System.gc()函数调用。如果加上了这个JVM启动参数,那么代码中调用System.gc()没有任何效果,相当于是没有这行代码一样。结果如下:
堆外内存的回收机制分析 - 图3
显然堆内存(包括新生代和老年代)内存很充足,但是堆外内存溢出了。也就是说NIO直接内存的回收,需要依赖于System.gc()。如果我们的应用中使用了java nio中的direct memory,那么使用-XX:+DisableExplicitGC一定要小心,存在潜在的内存泄露风险
  从DirectByteBuffer的源码也可以分析出来,ByteBuffer.allocateDirect()会调用Bits.reservedMemory()方法,在该方法中显示调用了System.gc()用户内存回收,如果-XX:+DisableExplicitGC打开,则让System.gc()无效,内存无法有效回收,导致OOM。
我们知道java代码无法强制JVM何时进行垃圾回收,也就是说垃圾回收这个动作的触发,完全由JVM自己控制,它会挑选合适的时机回收堆内存中的无用java对象。代码中显示调用System.gc(),只是建议JVM进行垃圾回收,但是到底会不会执行垃圾回收是不确定的,可能会进行垃圾回收,也可能不会。什么时候才是合适的时机呢?一般来说是,系统比较空闲的时候(比如JVM中活动的线程很少的时候),还有就是内存不足,不得不进行垃圾回收。我们例子中的根本矛盾在于:堆内存由JVM自己管理,堆外内存必须要由我们自己释放;堆内存的消耗速度远远小于堆外内存的消耗,但要命的是必须先释放堆内存中的对象,才能释放堆外内存,但是我们又不能强制JVM释放堆内存。
Direct Memory的回收机制:Direct Memory是受GC控制的,例如
ByteBuffer bb = ByteBuffer.allocateDirect(1024),
这段代码的执行会在堆外占用1k的内存,Java堆内只会占用一个对象的指针引用的大小,堆外的这1k的空间只有当bb对象被回收时,才会被回收,这里会发现一个明显的不对称现象,就是堆外可能占用了很多,而堆内没占用多少,导致还没触发GC,那就很容易出现Direct Memory造成物理内存耗光。
ByteBuffer与Unsafe使用堆外内存在回收时的不同:
Direct ByteBuffer分配出去的直接内存其实也是由GC负责回收的,而不像Unsafe是完全自行管理的,Hotspot在GC时会扫描Direct ByteBuffer对象是否有引用,如没有则同时也会回收其占用的堆外内存

GC是如何回收ByteBuffer分配的“直接内存”的,看下面的源码

  DirectByteBuffer 类有一个内部的静态类 Deallocator,这个类实现了 Runnable 接口并在 run() 方法内释放了内存,源码如下:
堆外内存的回收机制分析 - 图4
那这个 Deallocator 线程是哪里调用了呢?这里就用到了 Java 的虚引用(PhantomReference),Java 虚引用允许对象被回收之前做一些清理工作。在 DirectByteBuffer 的构造方法中创建了一个 Cleaner:
cleaner = Cleaner.create(this / 这个是 DirectByteBuffer 对象的引用 /,
new Deallocator(address, cap) / 清理线程 /);
DirectByteBuffer中Deallocator线程如何创建
堆外内存的回收机制分析 - 图5
而 Cleaner 类继承了 PhantomReference 类,并且在自己的 clean() 方法中启动了清理线程,当 DirectByteBuffer 被 GC 之前 cleaner 对象会被放入一个引用队列(ReferenceQueue),JVM 会启动一个低优先级线程扫描这个队列,并且执行 Cleaner 的 clean 方法来做清理工作。
堆外内存的回收机制分析 - 图6
根据上面的源码分析,我们可以想到堆外内存回收的几张方法:

  1. Full GC,一般发生在年老代垃圾回收以及调用System.gc的时候,但这样不一顶能满足我们的需求。
  2. 调用ByteBuffer的cleaner的clean(),内部还是调用System.gc(),所以一定不要-XX:+DisableExplicitGC

堆外内存的回收机制分析 - 图7

package xing.test;
import java.nio.ByteBuffer;
import sun.nio.ch.DirectBuffer;
public class NonHeapTest {
    public static void clean(final ByteBuffer byteBuffer) {  
        if (byteBuffer.isDirect()) {  
           ((DirectBuffer)byteBuffer).cleaner().clean();  
        }  
  }  

    public static void sleep(long i) {  
        try {  
              Thread.sleep(i);  
         }catch(Exception e) {  
              /*skip*/  
         }  
    }  
    public static void main(String []args) throws Exception {  
           ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 200);  
           System.out.println("start");  
           sleep(5000);  
           clean(buffer);//执行垃圾回收
//         System.gc();//执行Full gc进行垃圾回收
           System.out.println("end");  
           sleep(5000);  
    }  
}

这样就能手动的控制回收堆外内存了!其中sun.nio其实是java.nio的内部实现。所以你可能不能通过eclipse的自动排错找到这个包,直接复制
import sun.nio.ch.DirectBuffer;
堆外内存的回收机制分析 - 图8
显然堆内存(包括新生代和老年代)内存很充足,但是堆外内存溢出了。也就是说NIO直接内存的回收,需要依赖于System.gc()。如果我们的应用中使用了java nio中的direct memory,那么使用-XX:+DisableExplicitGC一定要小心,存在潜在的内存泄露风险
我们知道java代码无法强制JVM何时进行垃圾回收,也就是说垃圾回收这个动作的触发,完全由JVM自己控制,它会挑选合适的时机回收堆内存中的无用java对象。代码中显示调用System.gc(),只是建议JVM进行垃圾回收,但是到底会不会执行垃圾回收是不确定的,可能会进行垃圾回收,也可能不会。什么时候才是合适的时机呢?一般来说是,系统比较空闲的时候(比如JVM中活动的线程很少的时候),还有就是内存不足,不得不进行垃圾回收。我们例子中的根本矛盾在于:堆内存由JVM自己管理,堆外内存必须要由我们自己释放;堆内存的消耗速度远远小于堆外内存的消耗,但要命的是必须先释放堆内存中的对象,才能释放堆外内存,但是我们又不能强制JVM释放堆内存。
Direct Memory的回收机制:Direct Memory是受GC控制的,例如ByteBuffer bb = ByteBuffer.allocateDirect(1024),这段代码的执行会在堆外占用1k的内存,Java堆内只会占用一个对象的指针引用的大小,堆外的这1k的空间只有当bb对象被回收时,才会被回收,这里会发现一个明显的不对称现象,就是堆外可能占用了很多,而堆内没占用多少,导致还没触发GC,那就很容易出现Direct Memory造成物理内存耗光。
Direct ByteBuffer分配出去的内存其实也是由GC负责回收的,而不像Unsafe是完全自行管理的,Hotspot在GC时会扫描Direct ByteBuffer对象是否有引用,如没有则同时也会回收其占用的堆外内存。

3.2、正确释放Unsafe分配的堆外内存

    虽然第3种情况的ObjectInHeap存在内存泄露,但是这个类的设计是合理的,它很好的封装了直接内存,这个类的调用者感受不到直接内存的存在。那怎么解决ObjectInHeap中的内存泄露问题呢?可以覆写Object.finalize(),当堆中的对象即将被垃圾回收器释放的时候,会调用该对象的finalize。由于JVM只会帮助我们管理内存资源,不会帮助我们管理数据库连接,文件句柄等资源,所以我们需要在finalize自己释放资源。
import sun.misc.Unsafe;
public class RevisedObjectInHeap
{
 private long address = 0;
 private Unsafe unsafe = GetUsafeInstance.getUnsafeInstance();
 // 让对象占用堆内存,触发[Full GC
 private byte[] bytes = null;
 public RevisedObjectInHeap()
 {
     address = unsafe.allocateMemory(2 * 1024 * 1024);
     bytes = new byte[1024 * 1024];
 }
 @Override
 protected void finalize() throws Throwable
 {
     super.finalize();
     System.out.println("finalize." + bytes.length);
     unsafe.freeMemory(address);
 }
 public static void main(String[] args)
 {
     while (true)
     {
   RevisedObjectInHeap heap = new RevisedObjectInHeap();
   System.out.println("memory address=" + heap.address);
     }
 }
}

我们覆盖了finalize方法,手动释放分配的堆外内存。如果堆中的对象被回收,那么相应的也会释放占用的堆外内存。这里有一点需要注意下
// 让对象占用堆内存,触发[Full GC
private byte[] bytes = null;
这行代码主要目的是为了触发堆内存的垃圾回收行为,顺带执行对象的finalize释放堆外内存。如果没有这行代码或者是分配的字节数组比较小,程序运行一段时间后还是会报OutOfMemoryError。这是因为每当创建1个RevisedObjectInHeap对象的时候,占用的堆内存很小(就几十个字节左右),但是却需要占用2M的堆外内存。这样堆内存还很充足(这种情况下不会执行堆内存的垃圾回收),但是堆外内存已经不足,所以就不会报OutOfMemoryError。