Docker和JVM应用OOM那些事

1. 前言

Java 应用运行过程中你是否遇到以下类似问题

  1. 为什么 Java 应用所在的 Docker 容器内存使用量不会减少?
  2. 发生 OOM 后程序还能运行吗?
  3. Java 应用所在的容器为什么宕机或者自动重启了?

在回答以上问题前,我们先了解下“OOM”和“JVM 内存管理”。本文涉及的 JVM 相关描述特指 HotSpot JDK8。

2. OOM 机制

2.1. Linux 的 OOM 机制

当系统内存不足时,Linux 内核会触发 OOM Killer(OOM 杀手)机制。OOM Killer 会尝试找出最适合终止的进程,并向其发送 SIGKILL 信号,使其被强制终止。选择目标进程的策略通常基于进程的 OOM 分数(OOM Score),该分数反映了进程使用内存的情况和重要性。 具体选择哪个进程杀掉,有一套算分策略,分两部分:

  1. 参考进程占用的内存情况打分,进程内存开销是变化的,因此该值也会动态变化;
  2. 用户可以设置 oom_score_adj 参数,取值范围是[-1000,1000],oom_score_adj 的值越小,进程得分越少,也就越难被杀掉。

2.2. Docker 限制内存的原理

  1. Docker 基于 Linux 内核提供的 cgroups 功能,可以限制容器在运行时使用到的资源,比如内存、CPU 等。容器的内存随容器内进程内存使用量的增加而超过了设置的上限,在启用 OOM killer 时(默认启用),就会导致容器触发 linux 的 OOM 机制而被终止进程。
  2. Docker 可在启动容器时使用–oom-kill-disable=true 来禁止被 OOM 杀掉,默认启用(一般不建议禁用)。docker 的这个参数对应 cgroups 的 memory.oom_control 参数。如果开启,进程如果尝试申请内存超过允许,就会被系统 OOM killer 终止。OOM killer 在每个使用 cgroup 内存子系统中都是默认开启的。如果 OOM killer 关闭,那么进程尝试申请的内存超过允许,那么它就会被暂停,直到额外的内存被释放。
  3. 运行在 Docker 中的 Java 应用是一个进程,自然而然会受 Linux 内核的 OOM 机制影响。

小知识:k8s 对资源的限制也是通过 cgroups 来实现的,POD 本身并没有限制资源的能力。

2.3. JVM 的 OOM

  1. 官方说明,当 JVM 没有足够的内存来为对象分配空间并且垃圾回收器也已经没有空间可回收时,就会抛出这个 Error。这个错误不是普通的异常,已经严重到无法被应用处理。
  2. 发生 OutOfMemoryError(OOM)错误可能会导致 JVM 退出。当 JVM 的内存不足以分配新的对象时,会抛出 OOM 错误。这通常是由于程序使用了过多的内存或存在内存泄漏导致的。在发生 OOM 错误后,JVM 可能会尝试进行一些垃圾回收操作来释放内存,但如果没有足够的可用内存,JVM 可能无法继续正常执行,并最终退出。

2.3.1. JVM 内存管理机制简述

JVM 就像一个需要一些内存才能运行的虚拟操作系统,而从操作系统请求分配内存是一项耗时的操作。当 JVM 中的程序任务执行完成时,虽然 GC 回收器可能回收了这部分内存(逻辑上释放),但大多数 JVM 不会将内存释放回操作系统,仅仅是释放回 JVM 中对应的内存区域;等到下次执行任务时,无需再从底层操作系统请求内存资源,JVM 的这种架构有助于提高性能。

JVM 中 MemoryUsage 各指标的含义

  1. init,JVM 启动时向操作系统申请的初始内存量。
  2. used,当前使用的内存量。
  3. committed,保证供虚拟机使用的内存量,这部分内存量可能随时间而变化(增加或减少),committed 的部分一直是大于或等于 used 的内存量。
  4. max,JVM 内存管理中可以被使用的内存上限。

2.3.2. JVM 运行时数据区域

  1. 常见的图
    Docker和JVM应用OOM那些事_第1张图片

  2. 整理的图

    Docker和JVM应用OOM那些事_第2张图片

2.3.3. 内存溢出区域

2.3.3.1. 堆

最容易遇到内存溢出的区域。

  1. 异常

      java.lang.OutOfMemoryError: Java heap space
    
  2. 处理方法

    • 一般在事前配置好 JVM 堆溢出的自动导堆转储快照的参数。
        -XX:+HeapDumpOnOutOfMemoryError
        -XX:HeapDumpPath=/opt/xx/logs/heapdump.hprof
      
    • 使用 JProfiler 或者 MAT 分析堆转储快照;分清楚是内存泄露还是内存溢出。
      • 内存泄露,通过工具进一步查看泄露对象到 GC Roots 的引用链,一般可以比较准确定位到具体的代码。
      • 非内存泄露,检查 JVM 的堆参数(-Xmx 和-Xms)配置是否合理。
2.3.3.2. 虚拟机栈和本地方法栈

HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈,栈容量由-Xss 参数设定,JDK8 中默认值为 1M。

  1. 异常

      // 异常1:栈溢出
      java.lang.StackOverflowError
    
      // 异常2:服务器剩余内存不足
      java.lang.OutOfMemoryError: unable to create native thread
    
  2. 处理方法

    • 线程请求的栈深度大于 JVM 所允许的最大深度,检查是否-Xss 设置过小导致或者程序问题。
    • 栈帧内存无法分配(线程大小*N>=总内存-堆-元空间-其它内存占用),检查 JVM 参数配置是否合理或者程序问题导致线程过多。
2.3.3.3. 方法区
  1. 异常

      java.lang.OutOfMemoryError: Metaspace
    
  2. 处理方法

    • JVM 参数配置不合理,-XX:MaxMetaspaceSize 设置过小。
    • 程序问题,运行时生成大量动态类,比如使用了 CGLib 字节码增强、用到了动态语言(如 Groovy 等)、大量 JSP 或动态产生 JSP 文件应用(JSP 第一次运行时需要编译为 Java 类)、基于 OSGi 的应用(即使同一个类文件,被不同的加载器加载也会视为不同的类)等。
2.3.3.4. Compressed class space

JVM 有个功能是 CompressedOops ,目的是为了在 64bit 机器上使用 32bit 的原始对象指针(oop,ordinary object pointer,这里直接就当成指针概念理解就可以)来节约成本(减少内存/带宽使用),提高性能(提高 Cache 命中率)。

使用了这个压缩功能,每个对象中的 Klass* 字段就会被压缩成 32bit(不是所有的 oop 都会被压缩的), Klass* 指向的 Klass 在永久代(Java7 及之前)。但是在 Java8 及之后,永久代没了,有了一个 Metaspace,于是之前压缩指针 Klass* 指向的这块 Klass 区域有了一个名字 —— Compressed Class Space。Compressed Class Space 是 Metaspace 的一部分,默认大小为 1G。所以其实 Compressed Class Space 这个名字取得很误导,压缩的并不是 Klass,而是 Klass*。

  1. JDK8 中,启用对象和类指针压缩(默认启用)且堆内存-Xmx<32G,会额外分配的非堆空间;可通过参数-XX:CompressedClassSpaceSize 控制大小,在启动的时候就限制 Class Space 的大小,默认值是 1G,启动后不可以修改。它是 reserved 不是 committed 的内存。
  2. 禁用指针压缩(-XX:-UseCompressedOops)或者堆内存-Xmx>=32G 时,此区域在 Metaspace 中,不独立存在。
  3. 以下异常描述特指第一种独立分配空间时的情况。
  1. 异常

      java.lang.OutOfMemoryError: Compressed class space
    
  2. 处理方法

    • 一般是 JVM 参数设置不合理,可通过-XX:CompressedClassSpaceSize 控制。
    • 如果是程序问题做进一步排查优化。
2.3.3.5. Code Cache
  1. JIT 编译成本地机器码的缓存区域大小,不同 jdk 版本和机器默认值不同(一般是 240m),可由-XX:ReservedCodeCacheSize=240m 控制大小。
  2. 关联参数-XX:+UseCodeCacheFlushing,代码缓存区即将耗尽,尝试回收一些早期编译、很久未被调用的方法,默认打开。
  1. 异常

     Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full. Compiler has been disabled.
     Java HotSpot(TM) 64-Bit Server VM warning: Try increasing the code cache size using -XX:ReservedCodeCacheSize=
    
  2. 处理方法

    • 通过参数-XX:ReservedCodeCacheSize 设置合理的值。
2.3.3.6. 直接内存

直接内存(Direct Memory)的容量大小可以通过-XX:MaxDirectMemorySize 参数来指定,如果不指定,默认与 Java 堆最大值(由-Xmx 指定)一致。

  1. 异常

       // 异常1
       java.lang.OutOfMemoryError: Direct buffer memory
       // 异常2
       java.lang.OutOfMemoryError
         at sum.misc.Unsafe.allocateMemory(Native Method)
    
  2. 处理方法

    • 直接内存导致的内存溢出,一个明显特征是在 Heap Dump 文件中不会看见明显异常,如果程序中直接或者间接使用了 DirectMemory(典型间接使用就是 NIO),可以考虑重点检查直接内存方面的原因;
    • 可能的原因是 JVM 参数配置不合理(比如-XX:MaxDirectMemorySize 设置不合理),或者程序问题。
  3. 注意事项

    • 如果使用 Java 自带的 ByteBuffer.allocateDirect(size) 或者直接 new DirectByteBuffer(capacity) , 这样受-XX:MaxDirectMemorySize 这个 JVM 参数的限制。其实底层都是用的 Unsafe#allocateMemory,区别是对大小做了限制. 如果超出限制直接 OOM。
    • 如果通过反射的方式拿到 Unsafe 的实例,然后用 Unsafe 的 allocateMemory 方法分配堆外内存。确实不受-XX:MaxDirectMemorySize 这个 JVM 参数的限制 。所限制的内存大小为操作系统的内存。
    • 如果不设置-XX:MaxDirectMemorySize 默认的话,是跟堆内存大小保持一致。 [堆内存大小如果不设置的话,默认为操作系统的 1/4, 所以 DirectMemory 的大小限制 JVM 的 Runtime.getRuntime().maxMemory()内存大小 ]

3. 问题分析

在了解了 Linux 的 OOM 机制和 JVM 内存管理的基本知识后,前面的 3 个问题的分析就变简单了。

3.1. 为什么 Java 应用所在的 Docker 容器内存使用量不会减少?

由上文中“JVM 内存管理机制简述”我们可以直接得到答案,减少的是 JVM 中管理的内存,占用的操作系统内存(docker 内存)一般情况下不会减少。

  1. 早期,运维/工程人员老问 XX 应用的 Docker 内存占用超过 80%了并且没有回落,赶紧检查下程序是不是有问题。
  2. 多数情况下点开 JVM 的内存监控面板,发现只是某段业务繁忙时刻 JVM used 的内存升高,一定时间后又回落到正常水平,只是这个时候 committed 的内存大部分情况下并不会释放回给操作系统导致 Docker 内存长期处于高位。

因此,Docker 的内存占用并不能很好反应 JVM 真实的内存使用情况,推荐大家看应用的内存占用时一定要结合 JVM 的内存监控来看。那么有没有办法归还空余内存给操作系统呢?

JVM 提供了-XX:MinHeapFreeRatio 和-XX:MaxHeapFreeRatio 两个参数,用于配置这个归还策略。

  • MinHeapFreeRatio 代表当空闲区域大小下降到该值时,会进行扩容,扩容的上限为 Xmx。
  • MaxHeapFreeRatio 代表当空闲区域超过该值时,会进行“缩容”,缩容的下限为 Xms。
    JVM 在归还的时候,是线性递增归还的,并不是一次全部归还。这个归还内存的机制,在不同的垃圾回收器,甚至不同的 JDK 版本不一。以下表格摘自网友实测:
JAVA 版本 GC 回收器 VM Options 是否可以“归还”
JAVA 8 UseParallelGC(ParallerGC + ParallerOld) -Xms100M -Xmx2G -XX:MaxHeapFreeRatio=40
JAVA 8 CMS+ParNew -Xms100M -Xmx2G -XX:MaxHeapFreeRatio=40 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC 是,需要 4 次 FGC 之后触发
JAVA 8 UseG1GC(G1) -Xms100M -Xmx2G -XX:MaxHeapFreeRatio=40 -XX:+UseG1GC 是,首次 FGC 之后触发
JAVA 11 UseG1GC(G1) -Xms100M -Xmx2G -XX:MaxHeapFreeRatio=40 是,首次 FGC 之后触发
JAVA 16 UseZGC(ZGC) -Xms100M -Xmx2G -XX:MaxHeapFreeRatio=40 -XX:+UseZGC

其它:

  • JAVA 9 后-XX:-ShrinkHeapInSteps 参数(默认启用),在第一次 FGC 后,可以让 JVM 以非线性递增的方式归还内存。如果禁用,会立即将堆减小到目标大小(受 MaxHeapFreeRatio 限制),禁用此选项可能会遇到性能下降问题。
  • JAVA 12 后的 ShenandoahGC(openJDK 特有),不需要 FGC 就能异步回收不再使用的内存并归还给操作系统。
  • JAVA 12 后新增两个 G1 参数 G1PeriodicGCInterval( milliseconds ) 及 G1PeriodicGCSystemLoadThreshold,设置为 0 的话,表示禁用,可以设置定期自动触发 GC 操作,从而达到归还内存的目的,启用定期 GC 可能会遇到性能下降问题。

3.2. 发生 OOM 后程序还能运行吗?

可能处于运行中,也可能退出。 以堆内存溢出作说明:

  1. 线程栈空间是线程独享,OOM 后线程被 kill,线程栈上的空间被释放。
  2. 堆空间是共享的,存在两种情况:
    1. 被 Kill 掉的线程中的对象可能被该线程之外的其他线程引用,这部分被引用的对象就没有办法被 GC 掉,其他线程如果此时需要申请资源但是又资源又不足,那么此时其他线程就不能运行,现象就是系统会卡住,然后极端情况引起连锁反应,外部请求持续进入,积压的线程过多,此时是有可能触发 Linux 的 OOM。
    2. 被 Kill 掉的线程中的对象未被其它线程应用,这部分空间也能被释放掉,此时程序可以正常运行。
  3. JVM 的其它内存区域道理类似;当然,发生 OOM 之后可能导致应用状态不一致,建议最好重启。以下是几种自动退出运行状态的方式:
    • -XX:OnOutOfMemoryError(推荐),发生 OOM 时,JVM 就会调用此参数设置的脚本,此种方式可以对 JVM 进行优雅的重启应用。示例:
         -XX:OnOutOfMemoryError=/scripts/restart-myapp.sh
      
    • -XX:+CrashOnOutOfMemoryError,发生 OOM 时,配置此参数会导致 JVM 立即退出(非优雅退出),并生成包含崩溃信息的文本,这些崩溃信息大多很基本,不足以对 OOM 进行故障排除(经测验,此参数不会影响自动导堆操作,会在导完堆转储之后才退出)。输出消息示例如下:
      Aborting due to java.lang.OutOfMemoryError: GC overhead limit exceeded
      #
      # A fatal error has been detected by the Java Runtime Environment:
      #
      #  Internal Error (debug.cpp:308), pid=26064, tid=0x0000000000004f4c
      #  fatal error: OutOfMemory encountered: GC overhead limit exceeded
      #
      # JRE version: Java(TM) SE Runtime Environment (8.0_181-b13) (build 1.8.0_181-b13)
      # Java VM: Java HotSpot(TM) 64-Bit Server VM (25.181-b13 mixed mode windows-amd64 compressed oops)
      # Failed to write core dump. Minidumps are not enabled by default on client versions of Windows
      #
      # An error report file with more information is saved as:
      # C:workspacetier1app-svntrunkbuggyapphs_err_pid26064.log
      #
      # If you would like to submit a bug report, please visit:
      #   http:
      #
      
    • -XX:+ExitOnOutOfMemoryError,大体同上一个参数,只是少了崩溃消息的输出。

3.3. Java 应用所在的容器为什么宕机或者自动重启了?

被 Linux OOM-killer 杀掉了进程。

3.3.1. JVM max 内存量 < 容器内存上限,并且 JVM max 内存量 < 操作系统可用内存

此种情况一般不会被 Linux 的 OOM-killer 杀掉进程。

  1. 对应 JVM 会溢出的区域报错,此处不赘述。
  2. 由上个问题可以得出 JVM 一般情况还处于运行状态,往往不会导致 Docker 停止或重启,最可能发生的情况是 Java 应用程序卡顿。

3.3.2. JVM committed 内存量 < 容器内存上限,并且 JVM committed 内存量 > 操作系统可用内存

被 Linux OOM-killer 杀掉了进程。

  • 应用容器被终止,docker inspect <容器>

      "State": {
           "Status": "exited",
           "Running": false,
           "Paused": false,
           "Restarting": false,
           "OOMKilled": false, //此处为false
           "Dead": false,
           "Pid": 0,
           "ExitCode": 137,
           "Error": "",
           "StartedAt": "2023-06-17T07:55:15.1271987Z",
           "FinishedAt": "2023-06-17T07:55:34.9597495Z"
       }
    
  • 系统日志输出 OOM 信息:

     > 执行dmesg -T 或者 egrep -i -r 'killed process' /var/log
    
     [Mon Jun 26 13:44:10 2023] Out of memory: Kill process 26727 (java) score 275 or sacrifice child
     [Mon Jun 26 13:44:10 2023] Killed process 26727 (java), UID 0, total-vm:14137304kB, anon-rss:8854784kB, file-rss:0kB, shmem-rss:0kB
    
    

3.3.3. JVM committed 内存量 > 容器内存上限,并且 JVM committed 内存量 < 操作系统可用内存

被 Linux OOM-killer 杀掉了进程。

  • 系统日志报错如下:

     > dmesg -T|grep "out of memory"
    
     [Sat Jun 17 15:55:34 2023] Memory cgroup out of memory: Killed process 4906 (java) total-vm:4563472kB, anon-rss:119968kB, file-rss:16776kB, shmem-rss:0kB, UID:0 pgtables:1036kB oom_score_adj:0
    
  • 查看 docker inspect <容器>

      "State": {
           "Status": "exited",
           "Running": false,
           "Paused": false,
           "Restarting": false,
           "OOMKilled": true, //此处为true,代表内存超过容器上限被主动kill。
           "Dead": false,
           "Pid": 0,
           "ExitCode": 137,
           "Error": "",
           "StartedAt": "2023-06-17T07:55:15.1271987Z",
           "FinishedAt": "2023-06-17T07:55:34.9597495Z"
       }
    

3.3.4. 那么如何合理规划内存?

JVM 参数调优是一个循序渐进的过程,很难做到一蹴而就。以下以常见的 SpringBoot 应用内存分配为例,假设需要配置 2C4G 堆内存:

  1. 堆内存 4G,-Xms4G -Xmx4G,一般为了性能,防止 JVM 频繁申请内存,最大和最小堆内存会设置成一样。
  2. Metaspace 256m, -XX:MetaspaceSize=128M -XX:MaxMetaspaceSize=256M ,此区域和引用的 Jar、加载的类数量等有关。
  3. CompressedClassSpace 64m,-XX:CompressedClassSpaceSize=一般建议设置为 MaxMetaspaceSize 的 20% 左右 256*0.2=52m 左右。
  4. 栈空间,512m,2C4G 经验值并发数在 400,考虑还有一些后台线程,按 512 个预估,jdk8 之后的栈空间默认值为 1m,则栈空间总计占用至少 512m。
  5. Code Cache 128m,-XX:ReservedCodeCacheSize=128m,默认值为 240m,在自己程序稳定运行一段时间后,观察下这块区域的大小,进一步设置合理值。
  6. GC 400m,具体占用大小和实际堆内存大小以及 GC 回收器有关,从几十兆到几百兆不等。Parallel GC 不会占什么内存,G1 最多会占的内存大小为堆内存 10% 左右,ZGC 会最多会占内存大小为堆内存的 15~20% 左右额外内存,这块内存比较不好估算,结合监控持续调优。
  7. Direct Memory 64m,-XX:MaxDirectMemorySize=64m,看是否用到 NIO 相关特性,结合监控进行调优。
    总计需要设置的 Docker 内存上限为 5.5G 左右=4096+256+64+512+128+400+64=5520m

4. 总结

  1. OOM 并不是 JVM 独有,Linux 下也有 OOM 机制;需要区分好运维反馈的 OOM 是哪一种。
  2. 合理设置操作系统中各 Docker 实例的内存上限是前提,在此前提下才能合理设置好 JVM 相关内存分配参数。
  3. JVM 运行时涉及的内存区域,不仅仅是堆、元空间,还有文中截图中涉及的区域,都需要做好内存的合理分配。

5. 其它你可能需要知道的事

  1. -XX:MaxMetaspaceSize,必须配置,默认基本是无穷大,但仍然受本地内存大小的限制。
  2. -XX:MaxDirectMemorySize,程序用到直接内存,比如 NIO 特性时,务必设置合理数值,默认 64m,达到上限会触发 Full GC。
  3. JDK8 默认的 GC 收集器是 Parallel Scavenge +Serial Old(PS MarkSweep),针对 Web 应用场景,建议改用 CMS 或者 G1。
    • Parallel Scanvenge,新生代多线程收集器,适合在后台运算而不需要太多交互的分析任务,会导致 STW。
    • Serial Old,老年代单线程收集器,适合客户端模式下使用,会导致 STW。
  4. 常用的 GC 收集器,ParNew+CMS、G1 也是会导致 STW,只是 STW 的阶段不同、时长不同。(ZGC、ShenandoahGC 也一样)
  5. 使用 Docker 运行 JVM 时,最好禁用 swap,当用到 swap 内存时容易导致应用性能下降。可在运行容器实例时通过制定–memory-swap 等于-m 设置的内存大小来规避 或者 Linux 禁用 swap。

6. 参考资料

  1. OOP-Klass
  2. Compressed Class Space
  3. TLAB
  4. HotSopt 虚拟机的内存管理
  5. docker 内存 limit 与 swap 限制
  6. 理解 OutOfMemoryError
  7. 深入理解堆外内存
  8. JVM 内存不释放
  9. 5 大 GC 的内存伸缩能力

你可能感兴趣的:(Java,生产实践,docker,jvm,java)