这篇文章主要介绍DirectMemory溢出的系统表现和排查方法,另外提醒读者Memcached java客户端3.0.1版本存在一个bug,如果大家使用了这个版本请及时升级,免得带来类似问题。
在运维timeline系统的过程中,发现Java主进程占用7.9G内存(宿主机totalMem为8G),从而导致了OutOfMemeory异常。追踪发现程序中用3.0.1版本的Memcached-Java-Clinet程序出现内存泄露导致OOM问题。泄露内存是Memcached分配的navicate内存(也就是Direct Buffer)。
2. 排查问题的方法
首先 jmap -histo pid (找到这个进程占用最多的对象)
num #instances #bytes class name
----------------------------------------------
1: 390214 351732912 [C
2: 67767 80538064 [I
3: 1601037 51233184 java.util.AbstractList$ListItr
4: 85367 25642952 [B
5: 302804 17066016 [Ljava.lang.Object;
6: 255924 16379136 java.nio.DirectByteBuffer
7: 256046 10241840 sun.misc.Cleaner
8: 62060 9343048
9: 277220 8871040 java.lang.String
10: 62060 8452560
11: 5530 6320272
12: 255924 6142176 java.nio.DirectByteBuffer$Deallocator
13: 97432 5552104
14: 54061 5094352 [Ljava.util.HashMap$Entry;
。。。。。。。。。(下面省略很多)
Total 5580112 675542792
3. 问题分析
这里我们要追问三个问题:
(a)多余的内存分配到哪里去了?(b)内存到底泄露了没有?(c)如果没有泄露,jvm为什么不垃圾回收呢;如果泄露,那么原因是什么?
3.1 多余的内存分配到哪里去了?
分析可知javaheap上共用内存为644M左右,远远小于pid进程所用的内存(7.9G)。也就是说有很多内存并没有分配到javaheap上。仔细分析发现占用内存较多的对象里有DirectByteBuffer和java.nio.DirectByteBuffer$Deallocator,这说明有很多内存是DirectBuffer占用,而这些内存是调用native方法生成的,并不是在堆上分配的,这样就解释了javaheap内存远小于进程占用的内存。(注意:java中直接内存并不算在javaheap中,这也解释了为何javaheap内存很小,但整个进程占用的内存却很大)
3.2 内存到底泄露了没有?
内存泄露的两种情况:
(a) 内存分配出去,程序中依然引用这块内存,但逻辑上不在使用这块内存。
(b) 内存中分配出去,程序中不在引用这块内存,理论上这部分内存是可以被垃圾回收掉的。但是如果这部分内存增长很快,以至于在垃圾回收之前就导致OOM异常,那么我们也应该认为这是一种内存泄露。
其实java中自带有垃圾回收器,可以定期的回收我们程序中不在引用的内存。但是垃圾收集器并不保证可以及时的释放垃圾内存。比如我们遇到的问题,由于javaheap占用内存很少,即使进程把机器内存吃完了却依然没有触发垃圾收集器回收垃圾内存。对于这种内存泄露问题java程序员就要特别小心了。
3.3 如果没有泄露,jvm为什么不垃圾回收呢;如果泄露,那么原因是什么?
从3.2分析可知我们的程序确实存在内存泄露问题。根据3.1的分析我们可以知道大部分内存是DirectByteBuffer和java.nio.DirectByteBuffer$Deallocator对象所引用的本地内存。这部分内存用了足足有7G之多。那么这些对象又是谁生成的呢?为什么会产生如此之多的类似对象呢?
首先使用jstack pid 获取线程堆栈(用来追踪这些对象的生成者):
"http-8181-24" daemon prio=10 tid=0x0000000042208800 nid=0x4f98 runnable [0x00007fe373f3c000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.FileDispatcher.read0(Native Method)
at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:21)
at sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:202)
at sun.nio.ch.IOUtil.read(IOUtil.java:169)
at sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:243)
- locked <0x00000007821888c8> (a java.lang.Object)
at com.schooner.MemCached.SockInputStream.(SockInputStream.java:84)
at com.schooner.MemCached.AscIIClient.get(AscIIClient.java:691)
at com.schooner.MemCached.AscIIClient.get(AscIIClient.java:609)
at com.schooner.MemCached.AscIIClient.get(AscIIClient.java:605)
at com.danga.MemCached.MemCachedClient.get(MemCachedClient.java:911)
at com.netease.timeline.common.util.MemcachedUtilSchoonerImpl.get(MemcachedUtilSchoonerImpl.java:35)
at com.netease.timeline.api.cache.EventCacheManager.getEvent(EventCacheManager.java:57)
通过分析发现堆栈中出现大量类似的信息。我们队这里的readIntoNativeBuffer单词比较敏感,因为这个buffer和我们分析的DirectBuffer有关。顺着这条线外加Mamached-Java-Client源码梳理下去,很快找到了问题的最终原因。
根据堆栈中的get方法梳理下去 MemCachedClient -> get -> AscIIClinet.get -> SchoonerSockIOPool.getSock -> SchoonerSockIOPool.getConnection -> (SchoonerSockIO)sockets.borrowObject() -> SchoonerSockIO extends SockIOPool.SockIO -> SockIO构造函数 -> SockIO.getSocket
此函数源码如下:
protected static Socket getSocket(String host, int port, int timeout) throws IOException{
SocketChannel sock = SocketChannel.open();
sock.socket().connect(new InetSocketAddress(host, port), timeout);
return sock.socket();
}
这个函数第一步open()函数就已经分配了内存(而且是directbuffer),如果第二部connect出现异常,程序并没有主动调用close函数关闭sock句柄,这样第一步open函数中分配的内存没有被释放。由于服务器连接不上,会多次调用这个函数,这样就会分配大量的directbuffer,最终在垃圾会收前导致内存溢出。
4. 总结
a. 这个版本Memcached-Java-Clinet在新的版本中已经得到修复,请大家及时更新使用版本,避免带来类似的麻烦。
b. 在排查java系统内存溢出问题时,如果发现javaheap中内存使用很少但进程所占用内存非常多,此时应该判断是否有direct内存的使用。
c. 直接内存分配的空间不是在javaheap上,由于直接内存不在jvm内存监控范围之内,所以垃圾收集器并没有感受到内存已经很紧张的情况。如果heap上使用空间很少而直接内存分配很多,可能会导致垃圾回收前内存溢出问题。因为heap上剩余空间很多的话不会触发垃圾回收,及时此时整个进程已经内存溢出。