美文网首页JVM
Java堆外内存回收方法

Java堆外内存回收方法

作者: tracy_668 | 来源:发表于2021-01-20 23:35 被阅读0次

    [TOC]

    一、JVM内存的分配及垃圾回收

    JVM垃圾回收

    由于JVM会替我们执行垃圾回收,因此开发者根本不需要关心对象的释放。但是如果不了解其中的原委,很容易内存泄漏,只能两眼望天了!

    垃圾回收,大致可以分为下面几种:

    Minor GC:当新创建对象,内存空间不够的时候,就会执行这个垃圾回收。由于执行最频繁,因此一般采用复制回收机制。

    Major GC:清理年老代的内存,这里一般采用的是标记清除+标记整理机制。

    Full GC:有的说与Major GC差不多,有的说相当于执行minor+major回收,那么我们暂且可以认为Full GC就是全面的垃圾回收吧。

    二、堆外内存溢出
    从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
    
    

    对于heap的OOM我们可以通过执行jmap -heap来获取堆内内存情况,例如以下输出取自我上周定位的一个问题:

    using parallel threads in the new generation.
    using thread-local object allocation.
    Concurrent Mark-Sweep GC
     
    Heap Configuration:
       MinHeapFreeRatio = 40
       MaxHeapFreeRatio = 70
       MaxHeapSize      = 2147483648 (2048.0MB)
       NewSize          = 16777216 (16.0MB)
       MaxNewSize       = 33554432 (32.0MB)
       OldSize          = 50331648 (48.0MB)
       NewRatio         = 7
       SurvivorRatio    = 8
       PermSize         = 16777216 (16.0MB)
       MaxPermSize      = 67108864 (64.0MB)
     
    Heap Usage:
    New Generation (Eden + 1 Survivor Space):
       capacity = 30212096 (28.8125MB)
       used     = 11911048 (11.359260559082031MB)
       free     = 18301048 (17.45323944091797MB)
       39.42476549789859% used
    Eden Space:
       capacity = 26869760 (25.625MB)
       used     = 11576296 (11.040016174316406MB)
       free     = 15293464 (14.584983825683594MB)
       43.08298994855183% used
    From Space:
       capacity = 3342336 (3.1875MB)
       used     = 334752 (0.319244384765625MB)
       free     = 3007584 (2.868255615234375MB)
       10.015510110294118% used
    To Space:
       capacity = 3342336 (3.1875MB)
       used     = 0 (0.0MB)
       free     = 3342336 (3.1875MB)
       0.0% used
    concurrent mark-sweep generation:
       capacity = 2113929216 (2016.0MB)
       used     = 546999648 (521.6595153808594MB)
       free     = 1566929568 (1494.3404846191406MB)
       25.875968024844216% used
    Perm Generation:
       capacity = 45715456 (43.59765625MB)
       used     = 27495544 (26.22179412841797MB)
       free     = 18219912 (17.37586212158203MB)
       60.144962788952604% used
    

    可见堆内存都是正常的,重新回到业务日志里寻找异常,发现出现在堆外内存的分配上:

    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。

    三、堆外内存回收

    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)

    先看一个示例:(堆外内存回收演示)

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

    通过NIO的ByteBuffer使用堆外内存,将堆外内存设置为40M:

    场景一:不禁用FullGC下的system.gc

    运行这段代码会发现:程序可以一直运行下去,不会报OutOfMemoryError。如果使用了-verbose:gc -XX:+PrintGCDetails,会发现程序频繁的进行垃圾回收活动。

    场景二:同时JVM完全忽略系统的GC调用

    image.png

    与之前的JVM启动参数相比,增加了-XX:+DisableExplicitGC,这个参数作用是禁止显示调用GC。代码如何显示调用GC呢,通过System.gc()函数调用。如果加上了这个JVM启动参数,那么代码中调用System.gc()没有任何效果,相当于是没有这行代码一样。结果如下:

    image.png

    显然堆内存(包括新生代和老年代)内存很充足,但是堆外内存溢出了。也就是说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() 方法内释放了内存,源码如下:

    image.png

    那这个 Deallocator 线程是哪里调用了呢?这里就用到了 Java 的虚引用(PhantomReference),Java 虚引用允许对象被回收之前做一些清理工作。在 DirectByteBuffer 的构造方法中创建了一个 Cleaner:

    cleaner = Cleaner.create(this /* 这个是 DirectByteBuffer 对象的引用 */, 
    new Deallocator(address, cap) /* 清理线程 */)
    

    DirectByteBuffer中Deallocator线程如何创建

    image.png

    而 Cleaner 类继承了 PhantomReference 类,并且在自己的 clean() 方法中启动了清理线程,当 DirectByteBuffer 被 GC 之前 cleaner 对象会被放入一个引用队列(ReferenceQueue),JVM 会启动一个低优先级线程扫描这个队列,并且执行 Cleaner 的 clean 方法来做清理工作。

    根据上面的源码分析,我们可以想到堆外内存回收的几张方法:

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

    显然堆内存(包括新生代和老年代)内存很充足,但是堆外内存溢出了。也就是说NIO直接内存的回收,需要依赖于System.gc()。如果我们的应用中使用了java nio中的direct memory,那么使用-XX:+DisableExplicitGC一定要小心,存在潜在的内存泄露风险。

    相关文章

      网友评论

        本文标题:Java堆外内存回收方法

        本文链接:https://www.haomeiwen.com/subject/rdyzwktx.html