Java业务容器后云原生监控中内存使用率高问题基本排查思路

举报
可以交个朋友 发表于 2024/03/25 20:29:27 2024/03/25
【摘要】 Java业务容器后云原生监控中内存使用率高问题基本排查思路

一 背景

在观察Java进程时我们经常会存在疑问,启动了一个 JVM:到底会占据多大的内存?内存都消耗在哪里?为什么 JVM 使用的内存比我设置的 -Xmx 大这么多?为什么我的 JVM 内存一直缓慢增长?为什么堆内存未超过Xmx却发生了OOM?为什么程序占用的内存比Xmx大不少,内存都用在哪里了?为什么设置了-Xmx为4G, top中看到的rss却大于4G?为什么我的 JVM 会被 OOMKiller 等等,这都涉及到 JAVA 虚拟机对内存的一个使用情况,针对以上疑问,我们结合jvm和容器cgroup内存控制做一下简单分析。


二 Java进程内存相关说明

Java进程的内存占用情况可以简略的总结为下图:
image.png


  • Heap(堆区): 堆是OOM故障最主要的发生区域,它是内存区域中最大的一块区域,被所有线程共享,存储着几乎所有的实例对象、数组。所有的对象实例以及数组都要在堆上分配。Java 堆也是垃圾收集器管理的主要区域,因此很多时候也被称为GC堆。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java 堆中还可以细分为: 新生代和老年代

  • Metaspace元空间:在Java 8中,PermGen被元空间代替,元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间和永久代最大的差别在于: 元空间 并不在虚拟机中,二十使用的本地内存,元空间的大小只受本地内存限制

  • Java 虚拟机线程栈: 对于每一个线程,JVM都会在线程被创建的时候,创建一个单独的栈。线程是私有的,除了native 方法以外,Java方法都是通过Java虚拟机栈来实现调用和执行过程的

  • 本地方法栈: 与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务,

  • CodeCache: JVM代码缓存是JVM将其字节码存储为本机代码的区域。一般情况下不关心这部分内存区域,如果这块区域OOM了,在日志里面就会看到 java.lang.OutOfMemoryErrorcode cache

  • Native Memory: 是指在JVM堆内存(heap memory)以外的内存, 也会被叫做堆外内存. 但它仍然属于这个Java程序的进程内存. 通俗的说就是JVM管不到的原生内存. 常见的是Java调用汇编/C/C++的时候, 汇编/C/C++那部分所占用的内存.


Java容器过程中,尤其要注意-Xmx或者--XX:MaxRAMPercentage限制的是Heap空间,而不是整个java process的空间。比如配置--XX:MaxRAMPercentage=70%,只能限制最大Heap内存为70% * 总内存,但是由于非Heap内存也会占用内存空间,最后就是导致整个java进程内存可能超过预期。


三 cgroup内存相关说明

很多客户在虚拟机上部署Java业务时,因为整个虚拟机都给整个java业务使用,所以很少关注java业务整体内存使用问题;而在通过容器化提高调度密度(最终提高整体资源使用率)的过程中,绝大部分用户都会配置Mem Limit来控制单个容器(每个容器都是通过内核cgroup限制资源上限)的内存使用上限,防止某个容器使用过多内存从而影响本节点其他容器。由于内存是不可压缩资源,一旦超过limit限制则会触发操作系统OOM导致容器重启,所以业务容器化后大家都很关注容器内存使用率指标。

  • 操作系统以memory_limit_in_bytes值作为某cgroup可使用内存上限,当某个cgroup内存使用总量memory_usage_in_bytes即将超过memory_limit_in_bytes时,操作系统将尝试回收内存,如果可以回收且回收后低于memory_limit_in_bytes,则不会触发OOM,否则则触发OOM;memory_usage_in_bytes = 匿名内存(inactive_anno + active_anno) + file内存(inactive_file+active_file)
    active_anon:活跃的 LRU 列表中匿名和 swap 缓存的字节数,包括 tmpfs
    inactive_anon:不活跃的 LRU 列表中匿名和 swap 缓存的字节数,包括 tmpfs
    active_file:活跃 LRU 列表中文件支持的(file-backed)的内存字节数
    inactive_file:不活跃列表中文件支持的(file-backed)的内存字节数
    由于k8s暂时不支持容器中开启swap内存,所以可以简单理解容器业务中匿名内存不可回收,file内存(主要包括应用自己管理的内存映射文件mmap、文件读写缓存、二进制、动态链接库等)可以被回收。当触发回收时优先将inactive_file对应的文件内容写回磁盘后回收对应内存(由于active_file列表中为最近访问过文件的内存,所以尽量不回收active_file对应文件内存)。

  • 云原生监控均以容器workingset指标作为容器内存指标:workingset内存 = 匿名内存(inactive_anno + active_anno) + active_file


四 java业务容器化后云原生监控中内存高现象基本分析套路

我们以一个具体的用户问题为例分析Java进程在容器中的使用场景,可以看到关键参数:-XX:MaxRAMPercentage=70.0表示最大堆内存为容器内存限制的70%; -XX:MaxMetaspaceSize=512m 表示元空间内存占用触发FGC的阈值为512M; -Xss256k表示为每个线程分配256k的内存; -XX:+UseG1GC 表示使用G1 GC作为垃圾收集器
image.png

查看容器监控整体内存: 容器内存Limits为6G,容器内存使用率85%左右
image.png


4.1 确认是workingset内存高还是真实内存高

由于标准云原生监控都是采集workingset内存,当看到容器内存高时,先确认容器workingset内存和匿名内存使用率,若如下图所示则为明显的active_file内存过高,结合章节3说明,需要分析代码关注是否频繁读写文件、读取大文件或者有内存映射文件mmap。
image.png
如果workingset和匿名内存基本上重合,则重点需要分析jvm内存构成、GC等判断影响,继续按照后续章节分析。


4.2 分析java业务内存分配和GC

4.2.1 第一步:进入业务容器查看java最大实际堆内存大小

进入容器业务容器使用 -XshowSettings:vm -version 查看 JVM的设置,确认java进程heap最大堆内存配置是否符合预期。
image.png
本例中容器内存limit为6G,java启动参数-XX:MaxRAMPecentage=70%,预期最大heap内存为4.2G。从上图中可以看到最大堆内存大约为4.2G,即符合业务Pod设置的内存限制值6G的70%,堆内存分配正常。


4.2.2 第二步:分析GC日志确认jvm是否正常GC

分析查看gc日志,根据上述启动参数-Xloggc:…/logs/gc.log 查看GC日志。由于java业务内存主要由jvm回收,通过分析GC日志主要是jvm是否回收是否符合预期(比如是否有fullGC等问题)。
image.png
①表示Java进程启动554s之后发生了 Young GC事件;
②表示Young GC之后堆内存的变化,Eden空间大小为1953M,并且全被占用,在发生GC之后,年轻代空间不再被占用;GC之后Survivors空间由6144k增长到7168k,说明了对象从年轻代(Young Generation)提升到幸存者空间(Survivor space);堆Heap空间总大小3266M在GC前后未发生变化,GC之后堆的占用空间由2991.2M降低到829.2M,差不多有2G的对象被垃圾回收了;
③real=0.03s,表示YoungGC总共花费0.03s;
并未发现Full GC行为,Young GC停顿时间基本一致,耗时很短,同时结合上下文日志初步判断当前Java进程GC回收正常


4.2.3 第三步:查看NMT分析内存JVM内存占用情况

  1. 开启Native Memory Tracking, 查看NMT中的内存分配信息。NMT是Java提供的原生内存监控工具,首先需要在java进程的启动参数中添加-XX:NativeMemoryTracking=detail;然后执行命令 jcmd pid VM.native_memory summary,pid为指定进程号, 查看内存分配信息,如下图所示:
    image.png
    可以看到Java进程所占的内存项有:Java Heap、Class、Thread、Code等对应内存空间。其中Total committed内存大约为:5.2G,但是Java Heap commited 内存就占了大约4.2G,pod使用率高主要还是因为Java堆内内存使用较多。

  2. NMT baseline对比数据分析java内存变化。针对服务运行后内存只增不减,可以通过NMT数据的baseline进行内存占用上涨分析。先执行jcmd pid VM.native_memory baseline创建内存使用状况基线,等待运行一段时间后(压测一段时间)
    再执行jcmd pid VM.native_memory summary.diff 与baseline基线进行对比分析不同区域内存上涨情况。
    image.png
    比如上图,在压测过程中,在容器内使用jcmd 1 VM.native_memory summary.diff命令比较20s左右 jvm内存增长发现在20s总内存增加6602KB,其中Arena Chunk占了5271KB。可能由JDK版本引入,建议方案:增加环境变量MALLOC_ARENA_MAX=4,限制Arena内存池的个数,具体内容可参考:https://developer.aliyun.com/article/894765;或则升级jdk版本1.17.0.9(可能涉及代码改动,需要架构师评估),或者bishengjdk 1.8,该JDK版本从高版本openjdk回合malloc_trim特性,具体内容可参考:https://gitee.com/openeuler/bishengjdk-8/wikis/pages/export?doc_id=961117&type=pdf “修剪Native(Glibc)Heap ”章节

  3. 测试环境可以再手动触发FullGC后,观察NMT输出。手动触发Full GC后(命令:jcmd pid GC.run),观察内存是否下降,比对JVM Heap堆内和堆外内存占用情况:
    image.png
    触发full GC后,查看NMT信息发现,Java Heap堆内内存commited值已经由4.2G变成1.2G,堆外内存占用几乎没有太大改变。所以pod的内存使用率会降低到35%左右。再次说明了pod使用率高主要还是因为Java堆内内存使用较多,接下来需要对Java 堆内存占用进行分析
    image.png


4.2.4 第四步:heap dump分析

通过jmap获取Java进程的dump内存数据,分析dump后的bin,是否符合预期。执行命令jmap -dump:format=b,file=/xxx/dump.hprof pid(注意加了live参数会触发GC,jmap -dump:live,format=b,file=dump.hprof pid)
image.png
image.png
通过分析dump bin文件,可以看到在java进程中未被GC回收的内存数据有数据库查询的大对象、大数组等信息。该部分为堆内内存数据,如果不触发Full GC不会进行内存回收。建议部分SQL语句可以进行优化,非必要字段不进行查询。

综上以上分析和大规模测试过程结果可以得出以下结论:

  1. 客户java业务在压测过程中 -XX:MaxRAMPercentage配置为70%生效,对heap内存上限设置在4G左右时业务压测均正常无问题,heap内存主要存储缓存数据库查询的结果,可以被JVM主动回收;
  2. 客户java业务在压测过程中no-heap内存诉求大约在1G左右;
  3. 客户JDK存在Arena Chunk内存泄漏,导致jvm释放内存给glibc,但是glibc没有归还操作系统,导致一部分内存泄漏;
  4. 客户云原生监控内存高的主要原因为 -XX:MaxRAMPercentage配置为70%,容器limit配置为6G,上述配置导致java总内存约为heap内存4.2G+非heap内存1G共5.2G,云原生监控内存使用率5.2/6为87%;

问题处理:

  1. 按照“第三步:查看NMT分析内存JVM内存占用情况”中指导修改Arena内存泄漏;
  2. 调整java进程启动参数将-XX:MaxRAMPercentage参数由70.0%设置为50.0%,同时调整memeory.Limits参数由6G扩大至8G,如此保证heap内存达到最大诉求的同时,给noheap内存预留足够空间;

五 附录 JVM 参数配置说明

在Java进程中经常看到一堆启动参数,正是通过这些参数我们可以优化堆栈内存的使用。分别都有哪些具体的参数呢,这些启动参数代表什么意思呢 ?下面将以JDK8以后的版本对常用参数进行相关介绍


5.1 Java堆栈内存相关参数

Java 堆内存是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。Java堆内存区域唯一目的就是存放对象实例,几乎所有的对象实例和数组都在这里分配内存。

  1. -Xms : 设置最小堆内存,并初始化堆内存大小。单位可以是k/K, m/M, g/G, 最小值为1M;例如 -Xms6m
  2. -Xmx: 设置最大堆内存。一般建议于-Xms保持一致。防止动态扩展
  3. -Xmn:设置年轻代内存最大允许大小,并初始化年轻代内存大小。官方建议该区域大小一般为堆内存的1/2-1/4之间。设置太小会导致频繁发生Minor GC,设置过大只有full GC才会生效,fullGC完成时间一般较久。例如-Xmn2G
  4. -XX:NewSize : 设置年轻代内存初始大小
  5. -XX:MaxNewSize: 设置年轻代最大内存大小
  6. -XX:MetaspaceSize: 设置元数据空间的大小(使用的是本地内存),超出时将触发FGC。MetaspaceSize表示使用过程中触发GC的阈值
  7. -XX:MaxMetaspaceSize: 设置元空间的最大大小(使用的是本地内存)。例如-XX:MaxMetaspaceSize=256m
  8. -Xss: 设置线程栈的大小。Linux/x64平台下默认值是1024kb。例如-Xss1024k

5.2 GC相关参数

这些参数控制JVM如何执行垃圾回收策略:

  1. -XX:+UseG1GC : 启用G1(garbage first)算法的垃圾回收策略。对于堆内存分配大小大于6G,且GC延迟要求低的应用程序建议使用G1收集器
  2. -XX:+PrintGCDetailes: 在每次发生GC时打印GC详细信息,默认关闭该参数
  3. -XX:+PrintGCDateStamps: 在每次发生GC时打印日期戳
  4. -Xloggc: gc.log : GC日志文件的输出路径

5.3 OOM相关参数

  1. -XX:+HeapDumpOnOutOfMemoryError :当JVM抛出java.lang.OutOfMemoryError异常时,将heap转储到物理文件中
  2. -XX:HeapDumpPath=/var/log/xxx/xx.hprof : 写入dump文件的路径,使用.hprof格式

5.4 容器场景下相关参数

因为容器场景下,容器的资源分配是动态可调的,如果每次调整完容器的资源配额,然后再手动设置最大堆内存,比较繁琐。可以通过以下参数动态调整最大堆内存大小。

  1. -XX:+UseContainerSupport 默认启用容器支持。JVM能够自动进行容器平台检测,能够确定容器中运行的Java进程可用的内存量和cpu处理器
  2. -XX:MaxRAMPercentage=xxx 设置Java堆内存最大内存大小。适用于容器场景下资源限制量动态变化,动态调节最大堆内存大小。等同于 -Xmx
  3. -XX:MinRAMPercentage=xxx设置Java堆内存的最大内存大小(当容器memory资源limits大小为250MB及以下时,使用该参数计算最大堆内存。)
【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。