最近排查一个线上java服务常驻内存异常高的问题,大概现象是:java堆Xmx配置了8G,但运行一段时间后常驻内存RES从5G逐渐增长到13G #补图#,导致机器开始swap从而服务整体变慢。
由于Xmx只配置了8G但RES常驻内存达到了13G,多出了5G堆外内存,经验上判断这里超出太多不太正常。
前情提要–JVM内存模型
开始逐步对堆外内存进行排查,首先了解一下JVM内存模型。根据JVM规范,JVM运行时数据区共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
-
虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。当栈调用深度大于JVM所允许的范围,会抛出StackOverflowError的错误,不过这个深度范围不是一个恒定的值。虚拟机栈除了上述错误外,还有另一种错误,那就是当申请不到空间时,会抛出 OutOfMemoryError。
-
本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行java方法,本地方法站执行native方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。本地方法栈也可以抛出StackOverflowError和OutOfMemoryError。
-
PC 寄存器,也叫程序计数器。可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有”内存。倘若当前线程执行的是 JAVA 的方法,则该寄存器中保存当前执行指令的地址;倘若执行的是native 方法,则PC寄存器中为空。
-
堆内存。堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。
-
方法区也是所有线程共享。主要用于存储类的信息、常量池、静态变量、及时编译器编译后的代码等数据。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。
前情提要–PermGen(永久代)和 Metaspace(元空间)
PermGen space 和 Metaspace是HotSpot对于方法区的不同实现。在Java虚拟机(以下简称JVM)中,类包含其对应的元数据,比如类名,父类名,类的类型,访问修饰符,字段信息,方法信息,静态变量,常量,类加载器的引用,类的引用。在HotSpot JDK 1.8之前这些类元数据信息存放在一个叫永久代的区域(PermGen space),永久代一段连续的内存空间。在JDK 1.8开始,方法区实现采用Metaspace代替,这些元数据信息直接使用本地内存来分配。元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
堆外内存
java 8下是指除了Xmx设置的java堆(java 8以下版本还包括MaxPermSize设定的持久代大小)外,java进程使用的其他内存。主要包括:DirectByteBuffer分配的内存,JNI里分配的内存,线程栈分配占用的系统内存,jvm本身运行过程分配的内存,codeCache,java 8里还包括metaspace元数据空间。
分析java堆
由于现象是RES比较高,先看一下java堆是否有异常。把java堆dump下来仔细排查一下,jmap -histo:live pid,发现整个堆回收完也才几百兆,远不到8G的Xmx的上限值,GC日志看着也没啥异常。基本排查java堆内存泄露的可能性。

分析DirectByteBuffer的占用
DirectByteBuffer简单了解
由于服务使用的RPC框架底层采用了Netty等NIO框架,会使用到DirectByteBuffer这种“冰山对象”,先简单排查一下。关于DirectByteBuffer先介绍一下:JDK 1.5之后ByteBuffer类提供allocateDirect(int capacity)进行堆外内存的申请,底层通过unsafe.allocateMemory(size)实现,会调用malloc方法进行内存分配。实际上,在java堆里是维护了一个记录堆外地址和大小的DirectByteBuffer的对象,所以GC是能通过操作DirectByteBuffer对象来间接操作对应的堆外内存,从而达到释放堆外内存的目的。但如果一旦这个DirectByteBuffer对象熬过了young GC到达了Old区,同时Old区一直又没做CMS GC或者Full GC的话,这些“冰山对象”会将系统物理内存慢慢消耗掉。对于这种情况JVM留了后手,Bits给DirectByteBuffer前首先需要向Bits类申请额度,Bits类维护了一个全局的totalCapacity变量,记录着全部DirectByteBuffer的总大小,每次申请,都先看看是否超限(堆外内存的限额默认与堆内内存Xmx设定相仿),如果已经超限,会主动执行Sytem.gc(),System.gc()会对新生代的老生代都会进行内存回收,这样会比较彻底地回收DirectByteBuffer对象以及他们关联的堆外内存。但如果启动时通过-DisableExplicitGC禁止了System.gc(),那么这里就会出现比较严重的问题,导致回收不了DirectByteBuffer底下的堆外内存了。所以在类似Netty的框架里对DirectByteBuffer是框架自己主动回收来避免这个问题。

DirectByteBuffer为什么要用堆外内存
DirectByteBuffer是直接通过native方法使用malloc分配内存,这块内存位于java堆之外,对GC没有影响;其次,在通信场景下,堆外内存能减少IO时的内存复制,不需要堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。所以DirectByteBuffer一般用于通信过程中作为缓冲池来减少内存拷贝。当然,由于直接用malloc在OS里申请一段内存,比在已申请好的JVM堆内内存里划一块出来要慢,所以在Netty中一般用池化的 PooledDirectByteBuf 对DirectByteBuffer进行重用进一步提升性能。
如何排查DirectByteBuffer的使用情况
JMX提供了监控direct buffer的MXBean,启动服务时开启-Dcom.sun.management.jmxremote.port=9527 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=10.79.40.147,JMC挂上后运行一段时间,此时Xmx是8G的情况下整体RES逐渐增长到13G,MBean里找到java.nio.BufferPool下的direct节点,查看direct buffer的情况,发现总共才213M。为了进一步排除,在启动时通过-XX:MaxDirectMemorySize来限制DirectByteBuffer的最大限额,调整为1G后,进程整体常驻内存的增长并没有限制住,因此这里基本排除了DirectByteBuffer的嫌疑。

使用NMT排查JVM原生内存使用
Native Memory Tracking(NMT)使用
NMT是Java7U40引入的HotSpot新特性,可用于监控JVM原生内存的使用,但比较可惜的是,目前的NMT不能监控到JVM之外或原生库分配的内存。java进程启动时指定开启NMT(有一定的性能损耗),输出级别可以设置为“summary”或“detail”级别。如:
-XX:NativeMemoryTracking=summary 或者 -XX:NativeMemoryTracking=detail
开启后,通过jcmd可以访问收集到的数据。
jcmd VM.native_memory [summary | detail | baseline | summary.diff | detail.diff
如:jcmd 11 VM.native_memory,输出如下:
Native Memory Tracking:
Total: reserved=12259645KB(保留内存), committed=11036265KB (提交内存)
堆内存使用情况,保留内存和提交内存和Xms、Xmx一致,都是8G。
- Java Heap (reserved=8388608KB, committed=8388608KB)
(mmap: reserved=8388608KB, committed=8388608KB)
用于存储类元数据信息使用到的原生内存,总共12045个类,整体实际使用了79M内存。
- Class (reserved=1119963KB, committed=79751KB)
(classes
(malloc=1755KB
(mmap: reserved=1118208KB, committed=77996KB)
总共2064个线程,提交内存是2.1G左右,一个线程1M,和设置Xss1m相符。
- Thread (reserved=2130294KB, committed=2130294KB)
(thread
(stack: reserved=2120764KB, committed=2120764KB)
(malloc=6824KB
(arena=2706KB
JIT的代码缓存,12045个类JIT编译后代码缓存整体使用79M内存。
- Code (reserved=263071KB, committed=79903KB)
(malloc=13471KB
(mmap: reserved=249600KB, committed=66432KB)
GC相关使用到的一些堆外内存,比如GC算法的处理锁会使用一些堆外空间。118M左右。
- GC (reserved=118432KB, committed=118432KB)
(malloc=93848KB
(mmap: reserved=24584KB, committed=24584KB)
JAVA编译器自身操作使用到的一些堆外内存,很少。
- Compiler (reserved=975KB, committed=975KB)
(malloc=844KB
(arena=131KB
Internal:memory used by the command line parser, JVMTI, properties等。
- Internal (reserved=117158KB, committed=117158KB)
(malloc=117126KB
(mmap: reserved=32KB, committed=32KB)
Symbol:保留字符串(Interned String)的引用与符号表引用放在这里,17M左右
- Symbol (reserved=17133KB, committed=17133KB)
(malloc=13354KB
(arena=3780KB
NMT本身占用的堆外内存,4M左右
- Native Memory Tracking (reserved=4402KB, committed=4402KB)
(malloc=396KB
(tracking overhead=4006KB)
不知道啥,用的很少。
- Arena Chunk (reserved=272KB, committed=272KB)
(malloc=272KB)
其他未分类的堆外内存占用,100M左右。
- Unknown (reserved=99336KB, committed=99336KB)
(mmap: reserved=99336KB, committed=99336KB)
-
保留内存(reserved):reserved memory 是指JVM 通过mmaped PROT_NONE 申请的虚拟地址空间,在页表中已经存在了记录(entries),保证了其他进程不会被占用,且保证了逻辑地址的连续性,能简化指针运算。
-
提交内存(commited):committed memory 是JVM向操做系统实际分配的内存(malloc/mmap),mmaped PROT_READ | PROT_WRITE,仍然会page faults,但是跟 reserved 不同,完全内核处理像什么也没发生一样。
这里需要注意的是:由于malloc/mmap的lazy allocation and paging机制,即使是commited的内存,也不一定会真正分配物理内存。
malloc/mmap is lazy unless told otherwise. Pages are only backed by physical memory once they're accessed
Tips:由于内存是一直在缓慢增长,因此在使用NMT跟踪堆外内存时,一个比较好的办法是,先建立一个内存使用基线,一段时间后再用当时数据和基线进行差别比较,这样比较容易定位问题。
同时pmap看一下物理内存的分配,RSS占用了10G。

运行一段时间后,做一下summary级别的diff,看下内存变化,同时再次pmap看下RSS增长情况。
jcmd 11 VM.native_memory summary.diff
Native Memory Tracking:
Total: reserved=13089769KB +112323KB, committed=11877285KB +117915KB
- Java Heap (reserved=8388608KB, committed=8388608KB)
(mmap: reserved=8388608KB, committed=8388608KB)
- Class (reserved=1126527KB +2161KB, committed=85771KB +2033KB)
(classes
(malloc=2175KB +113KB
(mmap: reserved=1124352KB +2048KB, committed=83596KB +1920KB)
- Thread (reserved=2861485KB +94989KB, committed=2861485KB +94989KB)
(thread
(stack: reserved=2848588KB +94576KB, committed=2848588KB +94576KB)
(malloc=9169KB +305KB
(arena=3728KB +108
- Code (reserved=265858KB +1146KB, committed=94130KB +6866KB)
(malloc=16258KB +1146KB
(mmap: reserved=249600KB, committed=77872KB +5720KB)
- GC (reserved=118433KB +1KB, committed=118433KB +1KB)
(malloc=93849KB +1KB
(mmap: reserved=24584KB, committed=24584KB)
- Compiler (reserved=1956KB +253KB, committed=1956KB +253KB)
(malloc=1826KB +253KB
(arena=131KB
- Internal (reserved=203932KB +13143KB, committed=203932KB +13143KB)
(malloc=203900KB +13143KB
(mmap: reserved=32KB, committed=32KB)
- Symbol (reserved=17820KB +108KB, committed=17820KB +108KB)
(malloc=13977KB +76KB