最近有一个同事碰到一个很诡异的问题,一个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一下,看看对象大概有多少
num #instances #bytes class name
----------------------------------------------
1: 6700 52136840 [B
2: 5402 28981680 [I
3: 13173 2232160 [C
4: 16911 2103160 <constMethodKlass>
5: 16911 2038712 <methodKlass>
6: 1506 1610216 <constantPoolKlass>
7: 27150 1412992 <symbolKlass>
8: 20561 1315904 java.lang.ref.Finalizer
9: 1506 1079128 <instanceKlassKlass>
10: 1391 1038176 <constantPoolCacheKlass>
11: 20728 663296 java.util.concurrent.LinkedBlockingQueue$Node
12: 20430 653760 com.sun.jna.Memory
13: 10677 427080 java.lang.String
14: 737 358328 <methodDataKlass>
里面很值得我们注意的是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的实现^_^