正确地调整容器中的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 v1 和 cgroups v2 的配置,所以如果要跟上时代的脚步,还是选择升级到最新的 JDK 版本吧(当然好处绝不止对容器的支持)。
容器中 Java 应用的 OOM 问题#
⚠️ 此处所有的解决方案或者对 JVM 的行为描述全部基于 HotSpot 实现
如果你的 Java 程序已经在支持容器感知的版本了,那么解决容器中 Java 应用的 OOM 问题,就跟解决其他运行环境的思路一致,只是在容器中运行的时候,我们需要采用更加灵活的方式去配置 JVM 的参数。
具体的 OOM 根据发生的位置和行为的不同,会有不同的解决方案,请参看这篇文章。这里主要针对 java.lang.OutOfMemoryError: Java heap space 这个问题展开说明如何在容器环境下,正确设置 JVM 参数。
一般碰到这个问题,只需要设置正确的 -Xmx 参数即可解决,让我们通过一段简单的代码来复现一下这个异常,程序为申请 10M 左右内存的 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 的异常
$ 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,即可将上面的程序打包为镜像,并复现此错误
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"]执行命令打包并运行
$ 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,即可修复次错误
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 默认支持的环境变量,常见的有下面两个
-
**
JAVA_TOOL_OPTIONS解决无法直接设置命令行参数的情况下调整 JVM 启动的参数,详情参看下面的文章 -
_JAVA_OPTIONS** 同样的目的,只是该参数是未标准化在 JVM 规范文档里,不同的 JVM 实现者支持程度不同,不推荐使用
JAVA_OPTS是网上到处可见的一个环境变量,是在各个应用的脚本中使用的,比如大家常见的 Tomcat 就是用此变量来修改 JVM 的一些参数,所以它并不能直接被 JVM 识别从而改变运行时的行为,不要在此场景下使用。
使用环境变量来改变 JVM 参数的执行步骤如下:
-
编写不带 JVM 参数的 Dockerfile
dockerFROM 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
dockerFROM 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 的异常,比如我们通过下面的命令运行不带任何参数的镜像
# 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 参数(见下文)来解决。
docker run -m 30m -e
JAVA_TOOL_OPTIONS="
-XX:MaxRAMPercentage=80.0" demo:11综上#
容器化运行应用已经是整个行业的大势所趋,而在所有的软件应用中,Java 应用的占比不容小觑,希望能通过这一篇文章让更多人能够正确设置在容器中运行的 JVM 的运行参数,掌握解决容器中 Java 应用 OOM 的技能。
同时,以上操作对所有容器运行时和容器编排平台有效。
参考文章