正确地调整容器中的JVM参数
技术分享
Jul 27, 2022
type
Post
status
Published
date
Jul 27, 2022
slug
container-java-oom
summary
在容器中运行 Java 应用时,JVM的表现跟物理机或者虚拟机运行会有什么不同,如何避免容器中 JVM 发生 OOM,如何正确设置容器中的 JVM 参数…
tags
Java
OOM
Docker
K8S
category
技术分享
icon
fab fa-java
password
Property
Jun 7, 2023 02:29 AM

容器感知的 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 应用
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 默认支持的环境变量,常见的有下面两个
  1. JAVA_TOOL_OPTIONS 解决无法直接设置命令行参数的情况下调整 JVM 启动的参数,详情参看下面的文章
  1. _JAVA_OPTIONS 同样的目的,只是该参数是未标准化在 JVM 规范文档里,不同的 JVM 实现者支持程度不同,不推荐使用
JAVA_OPTS 是网上到处可见的一个环境变量,是在各个应用的脚本中使用的,比如大家常见的 Tomcat 就是用此变量来修改 JVM 的一些参数,所以它并不能直接被 JVM 识别从而改变运行时的行为,不要在此场景下使用。
使用环境变量来改变 JVM 参数的执行步骤如下:
  • 编写不带 JVM 参数的 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"]
  • 使用 JAVA_TOOL_OPTIONS 环境变量
    • $ 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
    • 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"]
  • 使用自定义的环境变量
    • $ 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 的技能。
同时,以上操作对所有容器运行时和容器编排平台有效。
 
参考文章
💡
有关容器里面运行 Java 应用的问题,欢迎您在底部评论区留言,一起交流~
 
  • Java
  • OOM
  • Docker
  • K8S
  • 论文阅读-Reactor 设计模式
    限流器算法简介