JNI——小心,内存怪兽出没

      最近有一个同事碰到一个很诡异的问题,一个JVM使用默认的启动参数(suse linux 64),内存竟然会一直增涨到4G,而通过jmap dump出来的heap空间只有80多M,jmap dump出来的Alive heap空间则竟然只有几M,到底内存是怎么被吃掉的呢?
      惯例,在目标JVM上启动BlackStar的JMX Proxy(维持现场,不需要重启动目标JVM),我们先用JConsole-ext看看,如下图,很奇怪,不管是Heap空间和NonHeap空间,即使是为这些空间分配的内存,都是在100M级别的。
 
     

      Top/free一下,确实,内存是被吃掉的。
 
     我们知道,Java进程的内存包括Java NonHeap空间、Java Heap空间和Native Heap空间和,既然内存不是被Java NonHeap/Heap空间吃掉的,那么只能怀疑是被Native Heap空间吃掉的,问过同事,该服务主要是作为一个MQ Consumer(此MQ非Java MQ,公司自己构建,C++实现),接受MQ消息处理,会不会是被这个这个consumer分配的Native堆吃掉的呢?
     我们试着GC一下,发现很奇怪,不仅Java Heap被GC了,Native Heap也被GC了,这里似乎不合常理,Native Heap怎么可能会被GC掉呢?

    求助于万能的Google大神,万能的Google大神给出了一个提示——MaxDirectMemorySize,这个限制了nio的Native Heap的大小,但搜索出来的关于这个关键字的信息似乎与我们的场景背道相驰,基本上都是关于MaxDirectMemorySize默认设置太小导致的OutOfMemory。虽然实际并不相关,但搜索到的相关信息为我们重现问题创建了良好的环境,我们使用如下代码,可以大概地在window环境下“大概”地重现一下问题(注意,Window环境下默认值只有几百M,所以如下的循环不能太大)。

package ray.test;

import java.nio.ByteBuffer;

public class Test
{
    public static void main(String[] args) throws Exception
    {
        System.out.println("start");
        for (int i = 0; i < 200; i++)
        {
            final ByteBuffer bb = ByteBuffer.allocateDirect(1024 * 1024);//1M
            bb.clear();
            System.out.println("allocat:" + i);
        }
        System.out.println("before sleep");
       
        Thread.sleep(60 * 1000);
    }
}

    运行一下,我们会看到JVM内存占了200多M


    GC一下,内存下降下来了

      为什么JVM会去GC Native Heap呢?说起来其实很简单,只不过是ByteBuffer在对象被销毁前,自己会调用函数释放掉分配的内存而已(^_^真的是说穿了不值一提,有兴趣可以看一下ByteBuffer.allocateDirect这个类具体是怎么实现了,并不是使用finalize函数,而是使用更有安全保障的PhantomReference)。
     我们回到开头的问题,虽然不知道具体MQConsumer是如何处理的,当大概可以知道会在Native Heap上分配了大量内存,而在GC的时候再释放掉,大概可以想象MQConsumer的实现上会在引用Java对象被销毁时处理这个问题。而至于为什么占那么大的Native Heap而JVM GC还不启动呢,这里其实Sun JVM  GC的标准针对的是Java Heap,而在同事出现的那个场景里,Java Heap占用不多一直没有到GC边界,因此GC很久才会运行一次,导致出现了大量的Native Heap被占用而GC却不运行。
      实际上又怎么样的呢?
      跟MQConsumer(c++)实现的同事沟通了关于MQConsumer导致的Native Heap比较大的问题,反馈的结果却是MQ Consumer的so中,并没有动态申请内存的情况,并且在上面的分析中我们知道,其实GC的时候也会将这块Native Heap释放掉,因此我们可以排除C++代码本身导致的内存泄露问题。既然在MQ Consumer的so代码中没有这样的代码,那么内存到底是谁占去的呢?从MQ Consumer的java端代码上看,可以知道,我们目前使用的是JNA框架——Sun基于JNI的扩展,使地JNI的开发更加简单——那么是不是JNA框架本身实现的原因导致的呢?
      我们把目光投向JNA框架,先证实一下
      jmap一下,看看对象大概有多少

jmap –histo 25361
num #instances #bytes class name
----------------------------------------------
1: 6700 52136840 [B
2: 5402 28981680 [I
3: 13173 2232160 [C
4: 16911 2103160
5: 16911 2038712
6: 1506 1610216
7: 27150 1412992
8: 20561 1315904 java.lang.ref.Finalizer
9: 1506 1079128
10: 1391 1038176
11: 20728 663296 java.util.concurrent.LinkedBlockingQueue$Node
12: 20430 653760 com.sun.jna.Memory
13: 10677 427080 java.lang.String
14: 737 358328

      里面很值得我们注意的是com.sun.jna.Memory这个类,我们反汇编一下看看这个类Sun是如何实现的,下面代码代码中,大概的,猜测malloc和free是直接在Native堆上分配对象的,我们会发现,该类在构造函数中申请Native Heap内存,而在GC的时候释放掉

public class Memory extends Pointer
{
    public Memory(long size)
    {
        this.size = size;
        if(size <= 0L)
            throw new IllegalArgumentException("Allocation size must be >= 0");
        peer = malloc(size);
        if(peer == 0L)
            throw new OutOfMemoryError("Cannot allocate " + size + " bytes");
        else
            return;
}
    protected void finalize()
    {
        if(peer != 0L)
        {
            free(peer);
            peer = 0L;
        }
}

    static native long malloc(long l);

    static native void free(long l);
}

      再dump一下堆空间,我们看一下这些Memory对象中的size是多少,我们基本上可以猜测其会占掉多少内存
 
      里面实际存活的对象只有219个,我们可以知道大量的Memory对象实际上处于等待GC回收的状态。从上面Memory的实现上,我们也可以知道,只要对象不被GC,则其向Native Heap分配的内存就不会释放。我们随机看看里面的size值是多少。见下图,大概都是256K。


     大概我们知道这些Memory对象,每个会 向Native Heap申请256K的空间,而只要这些等待GC的对象不被GC,则这些空间则不会被释放(见Memory的finalize方法)。从上面目前处于等待被GC的Memroy对象数量来看(当然,并非每个都会是256K这么大),这个内存数是非常大的。
     非常遗憾的是,Memory对象本身并没有提供接口可以让我们显示地去释放掉这块内存,我们只能坐等着JVM的GC动作,而更让人郁闷的是,由于实际Java Heap占用不大,导致Native Heap一致不断地增长而得不到回收。
     当然,自己看一下JNA的实现,实际上我们还是有办法的,虽然实现方式并不是那么直截了当,但好歹还是可以实现,至于具体怎么处理,有兴趣地可以研究一下JNA的实现^_^

你可能感兴趣的:(JVM)