$Wynn5a 技术博客 - AI编程与软件工程实践
~/blog/container-java-oom

正确地调整容器中的JVM参数

容器感知的 JVM#

容器是什么就毋庸赘述了,那么跟物理机或者虚拟机比较起来,跑在容器里面的 JVM 有什么不同?

在应用容器化异军突起的头几年,JVM 最开始并没有对运行在容器中有任何特别的反应。当时的 JVM 还不能察觉到自己处于容器之中,它认为自己还是在一个独立的操作系统中,所以依旧从系统配置中(比如 Linux 中的 /proc/meminfo)获取自己能够使用的内存、CPU 核心等资源的信息,然后据此来初始化默认运行参数,比如设置最初启动时的堆大小为系统内存的 1/64,设置堆大小的最大值为系统内存的 1/4,GC 算法的默认并发线程数等。

换句话说,它直接无视了 CGroup 的存在,导致容器层面的资源限制失去作用,此时运行中的 JVM 会尝试使用错误数量的资源而导致进程被杀或者 OOM 等问题。

JDK 8u191 这个版本(前面的某些版本也JDK的开发者们尝试过部分解决方案,但是并不完美)开始,JVM 开始默认启用参数 XX:+UseContainerSupport,支持识别容器的资源限制参数,能够正确的获取到系统分配的可用资源信息。截止目前,JDK 17 已经可以支持识别 cgroups v1cgroups v2 的配置,所以如果要跟上时代的脚步,还是选择升级到最新的 JDK 版本吧(当然好处绝不止对容器的支持)。

容器中 Java 应用的 OOM 问题#

⚠️ 此处所有的解决方案或者对 JVM 的行为描述全部基于 HotSpot 实现

如果你的 Java 程序已经在支持容器感知的版本了,那么解决容器中 Java 应用的 OOM 问题,就跟解决其他运行环境的思路一致,只是在容器中运行的时候,我们需要采用更加灵活的方式去配置 JVM 的参数。

具体的 OOM 根据发生的位置和行为的不同,会有不同的解决方案,请参看这篇文章。这里主要针对 java.lang.OutOfMemoryError: Java heap space 这个问题展开说明如何在容器环境下,正确设置 JVM 参数。

一般碰到这个问题,只需要设置正确的 -Xmx 参数即可解决,让我们通过一段简单的代码来复现一下这个异常,程序为申请 10M 左右内存的 JAVA 应用

java
import java.lang.management.ManagementFactory; import java.lang.management.RuntimeMXBean; import java.util.List; import java.util.stream.Collectors; /** * @author wynn5a */ public class Main { static final int M_10 = 10 * 1024 * 1024; public static void main(String[] args) { RuntimeMXBean runtimeMxBean = ManagementFactory.getRuntimeMXBean(); List<String> arguments = runtimeMxBean.getInputArguments(); String argument = arguments.stream().filter(s -> s.contains("-X")).collect(Collectors.joining(",")); System.out.println("All jvm arguments with -X:" + argument); long beforeUsedMem = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); byte[] some = new byte[M_10]; long afterUsedMem = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); System.out.println("Used: " + ((afterUsedMem - beforeUsedMem) / 1024 / 1024) + "M"); } }

上面的代码如果通过下面的命令执行,设置最大堆内存为 5M,即会发生 OOM for heap 的异常

shell
$ java --version #openjdk 17.0.3 2022-04-19 LTS #OpenJDK Runtime Environment Corretto-17.0.3.6.1 (build 17.0.3+6-LTS) #OpenJDK 64-Bit Server VM Corretto-17.0.3.6.1 (build 17.0.3+6-LTS, mixed mode, sharing) $ javac Main.java #compile $ java -Xmx5m Main #set max heap size to 5m #All jvm arguments with -X:-Xmx5m #Exception in thread "main" java.lang.OutOfMemoryError: Java heap space # at Main.main(Main.java:20) $ java -Xmx10m Main # fix it #All jvm arguments with -X:-Xmx10m #Used: 10M

配合简单的 Dockerfile,即可将上面的程序打包为镜像,并复现此错误

docker
FROM openjdk:11.0.16-jdk-slim-buster COPY Main.java /usr/myapp/src WORKDIR /usr/myapp/src RUN javac Main.java ENTRYPOINT ["java", "-Xmx5m", "Main"]

执行命令打包并运行

shell
$ docker build -t demo:11 . $ docker run demo:11 #All jvm arguments with -X:-Xmx5m #Exception in thread "main" java.lang.OutOfMemoryError: Java heap space # at Main.main(Main.java:20)

从上面的运行结果可以看到,在容器里面运行同样抛出 OOM 异常,那么如果修正这个错误呢?

在 Dockerfile 里面设置#

直接修改 Dockerfile 的 JVM 参数设置,即将 -Xmx 设置为 10M,即可修复次错误

docker
FROM openjdk:11.0.16-jdk-slim-buster COPY Main.java /usr/myapp/src WORKDIR /usr/myapp/src RUN javac Main.java ENTRYPOINT ["java", "-Xmx10m" , "Main"]

该方法的优点是简单直接,缺点很多,比如需要重新打包镜像才能生效、只能设置固定参数等,不建议采用此方法。

那么有没有更加灵活的方式来修改 JVM 参数呢?有的,往下面看

通过 JVM 支持的环境变量设置#

我们可以在容器运行的时候读取到预先设置好的环境变量,动态的调整 JVM 参数,但是这种方案需要 Java 进程识别并解析环境变量,最简单直接的就是使用 JVM 默认支持的环境变量,常见的有下面两个

  1. **JAVA_TOOL_OPTIONS 解决无法直接设置命令行参数的情况下调整 JVM 启动的参数,详情参看下面的文章

    Troubleshooting Guide This appendix describes environment variables and system properties that can be useful for troubleshooting problems with the Java HotSpot VM. https://docs.oracle.com/en/java/javase/11/troubleshoot/environment-variables-and-system-properties.html#GUID-BE6E7B7F-A4BE-45C0-9078-AA8A66754B97
  2. _JAVA_OPTIONS** 同样的目的,只是该参数是未标准化在 JVM 规范文档里,不同的 JVM 实现者支持程度不同,不推荐使用

JAVA_OPTS 是网上到处可见的一个环境变量,是在各个应用的脚本中使用的,比如大家常见的 Tomcat 就是用此变量来修改 JVM 的一些参数,所以它并不能直接被 JVM 识别从而改变运行时的行为,不要在此场景下使用。

使用环境变量来改变 JVM 参数的执行步骤如下:

  • 编写不带 JVM 参数的 Dockerfile

    docker
    FROM openjdk:11.0.16-jdk-slim-buster COPY Main.java /usr/myapp/src WORKDIR /usr/myapp/src RUN javac Main.java ENTRYPOINT ["java", "Main"]
  • 使用 JAVA_TOOL_OPTIONS 环境变量

    shell
    $ docker run -e JAVA_TOOL_OPTIONS="-Xmx20m" demo:11 #Picked up JAVA_TOOL_OPTIONS: -Xmx20m #All jvm arguments with -X:-Xmx20m #Used: 9M

通过自定义的环境变量设置#

除了可以使用 JVM 内置的环境变量,还可以在 Dockerfile 中使用 Shell 脚本读取环境变量,然后在容器运行的时候使用该变量达到控制 JVM 参数的目的。这个方法本质上跟 JVM 内置支持的环境变量类似,当 JAVA_TOOL_OPTIONS 不可用时(原因参看上面的文章),可以考虑这种方式。

执行步骤如下:

  • 编写可以读取环境变量的 Dockerfile

    docker
    FROM openjdk:11.0.16-jdk-slim-buster COPY Main.java /usr/myapp/src WORKDIR /usr/myapp/src RUN javac Main.java ENTRYPOINT ["sh", "-c", "java ${OPTS} Main"]
  • 使用自定义的环境变量

    shell
    $ docker run -e OPTS="-Xmx20m" demo:11 #All jvm arguments with -X:-Xmx20m #Used: 9M

调整容器资源限制和 JVM 参数解决 OOM 问题#

还有一种情况会导致容器中的应用发生 OOM for heap 的异常,比如我们通过下面的命令运行不带任何参数的镜像

shell
# Dockerfile FROM openjdk:11.0.16-jdk-slim-buster COPY Main.java /usr/myapp/src WORKDIR /usr/myapp/src RUN javac Main.java ENTRYPOINT ["java", "Main"] $ docker run -m 30m demo:11 # 限制容器可用内存 30M #All jvm arguments with -X: #Exception in thread "main" java.lang.OutOfMemoryError: Java heap space # at Main.main(Main.java:20)

因为上文说过,JDK 11 版本已经支持了识别容器的资源限制,上面的命令将容器可使用的内存限制在 30MB,此时 JVM 将会在初始化的时候设置最大堆内存为可用内存的 25%,所以导致了程序发生 OOM for heap(在 Kubernetes 里面也是同理)。

跟随着 JDK 对容器的支持而添加的参数中,跟内存相关的如下:

  • -XX:InitialRAMPercentage 初始堆内存占所有可用内存的百分比,要使用 double 类型
  • -XX:MaxRAMPercentage 最大堆内存占所有可用内存的百分比,设置为 100 的时候可能会发生异常情况,默认为 25,要使用 double 类型
  • -XX:MinRAMPercentage 最小堆内存占所有可用内存的百分比,要使用 double 类型

所以,上面的 OOM 异常可以通过调整内存资源限制大小超过 40,或者,添加 -XX:MaxRAMPercentage=80.0 参数(见下文)来解决。

shell
docker run -m 30m -e JAVA_TOOL_OPTIONS=" -XX:MaxRAMPercentage=80.0" demo:11

综上#

容器化运行应用已经是整个行业的大势所趋,而在所有的软件应用中,Java 应用的占比不容小觑,希望能通过这一篇文章让更多人能够正确设置在容器中运行的 JVM 的运行参数,掌握解决容器中 Java 应用 OOM 的技能。

同时,以上操作对所有容器运行时和容器编排平台有效。

参考文章

Understand the OutOfMemoryError Exception This guide helps you to troubleshoot issues that might occur with Java Client applications created on the Java Platform, Standard Edition (Java SE) and Java HotSpot VM. https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks002.html