更新时间:2025-06-13 GMT+08:00
分享

Java容器内存虚高以及OOM问题定位

Java进程内存相关背景

Java堆内存(Heap Memory)是Java虚拟机(JVM)管理的主要内存区域,但将Java堆内存限制参数Xmx并不能当作进程内存限制参数使用,将容器内存限制设置为和Xmx一样大,可能会导致OOM。除了堆内存,JVM还管理其他类型的内存,Java进程的内存占用情况可以简略总结为下图:

Java应用程序的内存使用情况包括JVM内存和非JVM内存。

  • JVM内存(JVM Memory):这是JVM管理的内存部分,可以进一步分为堆(Heap)和非堆(Non-Heap)内存。
    • 堆内存(Heap Memory):堆是OOM故障最主要的发生区域,它是内存区域中最大的一块区域,被所有线程共享,存储着几乎所有的实例对象、数组。所有的对象实例以及数组都要在堆上分配。Java堆也是垃圾收集器管理的主要区域,因此很多时候也被称为GC堆。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为新生代(Young Gen)和老年代(Old Gen)。
    • 非堆内存(Non-Heap Memory):这部分内存不用于存储对象实例,而是用于存储类的元数据、线程信息等。
      • 元空间(MetaSpace):在Java 8中,永久代(PermGen)被元空间代替,元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间和永久代最大的差别在于元空间并不在虚拟机中,而是使用的本地内存,元空间的大小只受本地内存限制。
      • Java虚拟机线程栈(VM Thread Stacks): 对于每一个线程,JVM都会在线程被创建的时候,创建一个单独的栈。线程是私有的,除了本地方法以外,Java方法都是通过Java虚拟机栈来实现调用和执行过程的。
      • 本地方法栈(Native Thread Stacks): 与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的本地方法服务。
      • 代码缓存(CodeCache):JVM代码缓存是JVM将其字节码存储为本机代码的区域。一般情况下不关心这部分内存区域,如果这块区域OOM了,在日志里面就会看到java.lang.OutOfMemoryErrorcode cache
      • Native Memory:是指在JVM堆内存(heap memory)以外的内存,也会被叫做堆外内存。但它仍然属于这个Java程序的进程内存。通俗的说就是JVM管不到的原生内存。常见的是Java调用汇编/C/C++的时候,汇编/C/C++那部分所占用的内存。
  • 非JVM内存(Non-JVM Memory):这是JVM之外的内存,包括本地库和JNI本地代码。

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

容器内存指标说明

很多用户在虚拟机上部署Java业务时,因为整个虚拟机都给整个Java业务使用,所以很少关注Java业务整体内存使用问题;而在通过容器化提高调度密度(最终提高整体资源使用率)的过程中,绝大部分用户都会配置Memory 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计算方式如下:

    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)的内存字节数。

    由于Kubernetes暂时不支持容器中开启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作为垃圾收集器

在该示例中,容器内存Limit为6G,假设容器内存实际使用率偏高,可通过以下步骤进行排查:

  1. 确认是WorkingSet内存高还是真实内存高
    登录目标容器,运行以下命令查看内存统计信息:
    cat /sys/fs/cgroup/memory/memory.stat

    查看total_cache(缓存内存量)、total_rss(当前应用进程实际使用内存量)、total_inactive_file(不活跃文件内存使用量)。

    WorkingSet内存 = total_cache + total_rss - total_inactive_file

    假设memory.stat文件内容如下:

    rss 1048576
    cache 524288
    inactive_file 262144

    则WorkingSet内存为:

    WorkingSet = 1048576 + (524288 - 262144) = 1310720 bytes

    由于标准云原生监控都是采集workingset内存,当看到容器内存高时,先确认容器workingset内存和匿名内存使用率,如果active_file内存过高,结合容器内存指标说明,需要分析代码是否频繁读写文件、读取大文件或者有内存映射文件mmap。

    如果workingset和匿名内存基本上重合,则重点需要分析JVM内存构成、GC等判断影响。

  2. 分析Java业务内存分配和GC
    1. 进入业务容器查看Java最大实际堆内存大小

      进入容器业务容器使用-XshowSettings:vm -version选项查看JVM的设置,确认Java进程heap最大堆内存配置是否符合预期。

      本例中容器内存Limit为6G,Java启动参数-XX:MaxRAMPecentage=70%,则最大heap内存为4.2G时符合预期。

    2. 分析查看gc日志,根据上述启动参数-Xloggc:…/logs/gc.log查看GC日志。由于Java业务内存主要由JVM回收,通过分析GC日志主要是jvm是否回收是否符合预期(比如是否有fullGC等问题)。
    3. 通过Java自带的Native Memory Tracking(NMT)分析内存JVM内存占用情况
      1. 首先需要在Java进程的启动参数中添加-XX:NativeMemoryTracking=detail
        # Java 启动时先打开 NativeMemoryTracking,默认是关闭的。注意不要在生产环境长期开启,有性能损失 
        java -XX:NativeMemoryTracking=detail -jar
      2. 然后执行命令 jcmd pid VM.native_memory summary,其中pid为指定进程号, 查看内存分配信息。
        # 查看详情 
        jcmd pid VM.native_memory summary

      Java进程所占的内存项有:Java Heap、Class、Thread、Code等对应内存空间。其中Total committed内存大约为:5.2G,但是Java Heap commited内存就占了大约4.2G,因此Pod使用率高主要还是因为Java堆内内存使用较多。

    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

      通过分析dump bin文件,可以看到在Java进程中未被GC回收的内存数据有数据库查询的大对象、大数组等信息。该部分为堆内内存数据,如果不触发Full GC不会进行内存回收。建议部分SQL语句可以进行优化,非必要字段不进行查询。

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

  1. Java业务在压测过程中 -XX:MaxRAMPercentage配置为70%生效,对heap内存上限设置在4G左右时业务压测均正常无问题,heap内存主要存储缓存数据库查询的结果,可以被JVM主动回收。
  2. Java业务在压测过程中no-heap内存诉求大约在1G左右。
  3. 因此云原生监控内存高的主要原因为: -XX:MaxRAMPercentage配置为70%,容器Limit配置为6G,上述配置导致Java总内存约为heap内存4.2G+non-heap内存1G共5.2G,因此云原生监控内存使用率约为5.2/6=87%。

问题处理:

建议调整Java进程启动参数将-XX:MaxRAMPercentage参数由70.0%设置为50.0%,同时调整memory.Limits参数由6G扩大至8G,如此保证heap内存达到最大诉求的同时,给non-heap内存预留足够空间。

附录:JVM参数配置说明

在Java进程可以通过一些启动参数可以堆栈内存的使用。下面将对JDK8以后版本中的常用参数进行介绍。

Java堆栈内存相关参数

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

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

GC相关参数

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

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

OOM相关参数

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

容器场景下相关参数

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

  • -XX:+UseContainerSupport:默认启用容器支持。JVM能够自动进行容器平台检测,能够确定容器中运行的Java进程可用的内存量和CPU处理器。
  • -XX:MaxRAMPercentage=xxx:设置Java堆内存的最大百分比。例如,设置为-XX:MaxRAMPercentage=50,意味着Java堆内存最大不超过容器可用内存的50%。适用于容器场景下资源限制量动态变化,动态调节最大堆内存大小,等同于-Xmx参数。
  • -XX:MinRAMPercentage=xxx:设置Java堆内存的最小百分比,当容器的内存资源限制较低时,确保堆内存的最低分配。当容器Memory资源Limits大小为250MB及以下时,使用该参数计算最大堆内存。

相关文档