Wynn5a 的技术博客
技术分享约 6 分钟读完

正确地调整容器中的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 应用

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 启动的参数,详情参看下面的文章

  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 的技能。

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

参考文章

docs.oracle.com https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks002.html

读完了,谢谢你读到这里

有新文章时想第一时间看到?订阅 RSS