专栏名称: 互联网后端架构
主要介绍Java后端架构。其中也会掺杂一些前端、GO、Python、Linux,目标:全栈工程师!---好像很牛叉的样子 ^-^
目录
相关文章推荐
51好读  ›  专栏  ›  互联网后端架构

Java堆外内存增长问题排查

互联网后端架构  · 公众号  · 架构  · 2019-09-17 09:03

正文

最近排查一个线上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 #12045) (malloc=1755KB #29277) (mmap: reserved=1118208KB, committed=77996KB)总共2064个线程,提交内存是2.1G左右,一个线程1M,和设置Xss1m相符。- Thread (reserved=2130294KB, committed=2130294KB) (thread #2064) (stack: reserved=2120764KB, committed=2120764KB) (malloc=6824KB #10341) (arena=2706KB #4127)JIT的代码缓存,12045个类JIT编译后代码缓存整体使用79M内存。- Code (reserved=263071KB, committed=79903KB) (malloc=13471KB #15191) (mmap: reserved=249600KB, committed=66432KB)GC相关使用到的一些堆外内存,比如GC算法的处理锁会使用一些堆外空间。118M左右。- GC (reserved=118432KB, committed=118432KB) (malloc=93848KB #453) (mmap: reserved=24584KB, committed=24584KB)JAVA编译器自身操作使用到的一些堆外内存,很少。- Compiler (reserved=975KB, committed=975KB) (malloc=844KB #1074) (arena=131KB #3)Internal:memory used by the command line parser, JVMTI, properties等。- Internal (reserved=117158KB, committed=117158KB) (malloc=117126KB #44857) (mmap: reserved=32KB, committed=32KB)Symbol:保留字符串(Interned String)的引用与符号表引用放在这里,17M左右- Symbol (reserved=17133KB, committed=17133KB) (malloc=13354KB #145640) (arena=3780KB #1)NMT本身占用的堆外内存,4M左右- Native Memory Tracking (reserved=4402KB, committed=4402KB) (malloc=396KB #5287) (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 -x 11 | sort -n -k3

同时pmap看一下物理内存的分配,RSS占用了10G。

pmap -x 11 | sort -n -k3

运行一段时间后,做一下summary级别的diff,看下内存变化,同时再次pmap看下RSS增长情况。

jcmd 11 VM.native_memory summary.diffNative 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 #12682 +154) (malloc=2175KB +113KB #37289 +2205) (mmap: reserved=1124352KB +2048KB, committed=83596KB +1920KB)
- Thread (reserved=2861485KB +94989KB, committed=2861485KB +94989KB) (thread #2772 +92) (stack: reserved=2848588KB +94576KB, committed=2848588KB +94576KB) (malloc=9169KB +305KB #13881 +460) (arena=3728KB +108 #5543 +184)
- Code (reserved=265858KB +1146KB, committed=94130KB +6866KB) (malloc=16258KB +1146KB #18187 +1146) (mmap: reserved=249600KB, committed=77872KB +5720KB)
- GC (reserved=118433KB +1KB, committed=118433KB +1KB) (malloc=93849KB +1KB #487 +24) (mmap: reserved=24584KB, committed=24584KB)
- Compiler (reserved=1956KB +253KB, committed=1956KB +253KB) (malloc=1826KB +253KB #2098 +271) (arena=131KB #3)
- Internal (reserved=203932KB +13143KB, committed=203932KB +13143KB) (malloc=203900KB +13143KB #62342 +3942) (mmap: reserved=32KB, committed=32KB)
- Symbol (reserved=17820KB +108KB, committed=17820KB +108KB) (malloc=13977KB +76KB #152204 +257)






请到「今天看啥」查看全文