Important Update 2022-07-08
一位读者告诉我,报告的内存大小不正确。我在写博文时知道数字是正确的,所以我必须检查一下,看看为什么不同。 事实证明,cgroup v2 在 Docker engine 20.10( Docker Desktop for Mac v4.2.0)中启用。这意味着 docker 不再使用 cgroups v1,而是转而使用 cgroups v2,就像所有现代 Linux 发行版一样。 旧的 Java 版本使用 cgroup v1,Java 15引入了对 cgroup v2 的支持。这意味着当使用旧的 Java 版本时,仍然使用 cgroup v1,JVM 的内存计算不正确,可能会导致 Out Of Memory。我知道有一些关于向后移植此更改的讨论,但我不确定那是否真的发生了。如果您想暂时将 Docker 切换回使用 cgroup v1,您可以使用选项“deprecatedCgroupv1”:true。在 Macos 上,编辑文件 ~/Library/Group\ Containers/group.com.docker/settings.json 并将 deprecatedCgroupv1 设置为 true。完成此更改并重新启动 Docker 后,下面使用“旧”Java 版本的示例将显示正确的值。尽管我建议您使用 Java 15+ 版本而不是更改 Docker 的行为,并且此选项可能会在以后的 Docker 版本中被弃用和删除。
写在最前面
在 Java 8u131 和 Java 9 之前,JVM 无法识别容器设置的内存或 CPU 限制。 Java 8u131 和 Java 9是第一个实现该功能的一个实验性特性,并且有bug;但在 Java 10 中,内存限制是自动识别和强制执行的,然后将此功能反向移植到 Java-8u191。
所以:如果运行的是 Java 8 update 191 或更高版本,或者 Java 10、11、12、13 等,则不需要使用 UseCGroupMemoryLimitForHeap 选项。相反,您应该使用默认激活的 UseContainerSupport。
介绍
昨天我不得不对 Kubernetes 集群中的 Java 应用程序进行故障排除。该应用程序的行为非常奇怪,看起来我们遇到了内存不足的情况,该应用程序使用 Java 8,我确实知道 UnlockExperimentalVMOptions 和 UseCGroupMemoryLimitForHeap 标志,但我偶然看到 Java 8 Update 191 已经向后移植了 Java 10 功能,稍后我会向您展示。 在 Java 8u131 和 Java 9 之前,JVM 无法识别容器设置的内存或 CPU 限制。在 Java 10 中,内存限制是自动识别和强制执行的。然后将此功能反向移植到 Java-8u191。
1、Java 8u131 and Java 9
Java 8u131 首先实现了一个名为 UseCGroupMemoryLimitForHeap 的实验性特性。这是第一次尝试,有其缺陷。
1.1)让我们看看它在 Java 8u131 之前的 Java 8 中是什么样子的
让我们看看如果我们以 100MB 可用内存启动容器,Java 8u121 会是什么样子:
➜ docker run -m 100MB openjdk:8u121 java -XshowSettings:vm -version
VM settings:
Max. Heap Size (Estimated): 1.73G
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM
openjdk version "1.8.0_121"
OpenJDK Runtime Environment (build 1.8.0_121-8u121-b13-1~bpo8+1-b13)
OpenJDK 64-Bit Server VM (build 25.121-b13, mixed mode)
因此容器有 100MB 可用内存,最大堆大小设置为 1.73G。这告诉我们 JVM 不知道它在具有 100MB 可用内存的容器中运行。
1.2)Jdk8u131+的情况
Jdk8u131+ 和 9 支持检测 cpu 和内存限制以设置堆和核心使用情况。因此,让我们首先启动一个 Java 8u131 映像,看看它在 100MB 可用内存下的表现。
➜ docker run -m 100MB openjdk:8u131 java \
-XshowSettings:vm -version
VM settings:
Max. Heap Size (Estimated): 1.73G
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-2-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
这看起来也不太好,因为当我们只有 100MB 可用内存时它仍在使用 Max Heap Size 1.73G。
1)使用 XX:+UnlockExperimentalVMOptions 和-XX:+UseCGroupMemoryLimitForHeap 标志:
这样 JVM 就可以检查控制组内存限制并计算最大堆大小。
➜ docker run -m 100MB openjdk:8u131 java \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseCGroupMemoryLimitForHeap \
-XshowSettings:vm -version
VM settings:
Max. Heap Size (Estimated): 44.50M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-2-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
这样看起来就正确了。JVM只会使用 100MB 中的 44.50MB。
2)还可以使用 -XX:MaxRAMFraction 标志来帮助计算更好的堆大小。
➜ docker run -m 100MB openjdk:8u131 java \
-XX:+UnlockExperimentalVMOptions \
-XX:+PrintFlagsFinal -version \
| grep -E "UnlockExperimentalVMOptions | UseCGroupMemoryLimitForHeap | MaxRAMFraction | InitialRAMPercentage | MaxRAMPercentage | MinRAMPercentage"
uintx MaxRAMFraction = 4 {product}
bool UnlockExperimentalVMOptions := true {experimental}
bool UseCGroupMemoryLimitForHeap = false {experimental}
MaxRAMFraction 的默认值为 4,但不幸的是,它是一个分数而不是百分比,因此很难设置一个可以有效利用可用内存的值。为什么不将 MaxRAMFraction 设置为 1 并使用 100% 的可用内存,这不正是我们想要的吗?可能不是,因为容器中可能有其他进程在运行,或者我们想使用 shell 连接到容器以进行故障排除或只是检查容器。 下面分析一下MaxRAMFraction的使用,启动容器,内存限制设置为1GB,结果如下:
-XX:MaxRAMFraction=1 => maximum heap size = 1GB
-XX:MaxRAMFraction=2 => maximum heap size ~ 500MB
-XX:MaxRAMFraction=3 => maximum heap size ~ 333MB
-XX:MaxRAMFraction=4 => maximum heap size ~ 250MB
具体示例:
# MaxRAMFraction default value (4)
➜ docker run -m 1GB openjdk:8u131 java \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseCGroupMemoryLimitForHeap \
-XshowSettings:vm -version
VM settings:
Max. Heap Size (Estimated): 228.00M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM
# MaxRAMFraction=1
➜ docker run -m 1GB openjdk:8u131 java \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseCGroupMemoryLimitForHeap \
-XX:MaxRAMFraction=1 \
-XshowSettings:vm -version
VM settings:
Max. Heap Size (Estimated): 910.50M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM
# MaxRAMFraction=2
➜ docker run -m 1GB openjdk:8u131 java \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseCGroupMemoryLimitForHeap \
-XX:MaxRAMFraction=2 \
-XshowSettings:vm -version
VM settings:
Max. Heap Size (Estimated): 455.50M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM
# MaxRAMFraction=3
➜ docker run -m 1GB openjdk:8u131 java \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseCGroupMemoryLimitForHeap \
-XX:MaxRAMFraction=3 \
-XshowSettings:vm -version
VM settings:
Max. Heap Size (Estimated): 304.00M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM
结论是: MaxRAMFraction 很难处理,因为它是一个分数,所以你必须明智地选择你的值。
2、Java 10+
java 10 更好地支持容器环境。如果您在 Linux 容器中运行 Java 应用程序,JVM 将使用 UseContainerSupport 选项自动检测控制组内存限制。然后,您可以使用以下选项控制内存:InitialRAMPercentage、MaxRAMPercentage 和 MinRAMPercentage。如您所见,我们使用的是百分比而不是分数,这很好而且更有用。
稍后您会看到此行为被反向移植到 Java 8u191。
Let’s look at the default values for the new java version 👉 see update about croup v1
➜ docker run -m 1GB openjdk:10 java \
-XX:+PrintFlagsFinal -version \
| grep -E "UseContainerSupport | InitialRAMPercentage | MaxRAMPercentage | MinRAMPercentage"
double InitialRAMPercentage = 1.562500 {product} {default}
double MaxRAMPercentage = 25.000000 {product} {default}
double MinRAMPercentage = 50.000000 {product} {default}
bool UseContainerSupport = true {product} {default}
openjdk version "10.0.2" 2018-07-17
OpenJDK Runtime Environment (build 10.0.2+13-Debian-2)
OpenJDK 64-Bit Server VM (build 10.0.2+13-Debian-2, mixed mode)
我们可以看到 UseContainerSupport 默认是激活的。看起来 MaxRAMPercentage 是 25%,MinRAMPercentage 是 50%。让我们看看当我们给容器 1GB 内存时计算的堆大小。
➜ docker run -m 1GB openjdk:10 java \
-XshowSettings:vm \
-version
VM settings:
Max. Heap Size (Estimated): 247.50M
Using VM: OpenJDK 64-Bit Server VM
当我们有 1GB 可用空间时,JVM 计算出 247.50M,这是因为 MaxRAMPercentage 的默认值为 25%。为什么他们决定使用 25% 对我来说是个谜,而 MinRAMPercentage 是 50%。让我们稍微提高 RAMPercentage
➜ docker run -m 1GB openjdk:10 java \
-XX:MinRAMPercentage=50 \
-XX:MaxRAMPercentage=80 \
-XshowSettings:vm \
-version
VM settings:
Max. Heap Size (Estimated): 792.69M
Using VM: OpenJDK 64-Bit Server VM
这样就好多了,通过这种配置,可以控制 JVM 从 500MB 开始,然后增长到最大 792.69MB
3、Backported to Java 8
As we said earlier, option UseContainerSupport
was backported to Java 8u191 and activated by default.
➜ docker run -m 1GB openjdk:8u191-alpine java \
-XX:+PrintFlagsFinal -version \
| grep -E "UseContainerSupport | InitialRAMPercentage | MaxRAMPercentage | MinRAMPercentage"
double InitialRAMPercentage = 1.562500 {product}
double MaxRAMPercentage = 25.000000 {product}
double MinRAMPercentage = 50.000000 {product}
bool UseContainerSupport = true {product}
让我们像使用 Java 10 一样使用这些选项:
➜ docker run -m 1GB openjdk:8u191-alpine java \
-XX:MinRAMPercentage=50 \
-XX:MaxRAMPercentage=80 \
-XshowSettings:vm \
-version
Improperly specified VM option 'MinRAMPercentage=50'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
什么?!这是什么?错误指定的 VM 选项“MinRAMPercentage=50”?我们知道 RAMPercentage 是一个双精度值,所以让我们添加十进制值。
➜ docker run -m 1GB openjdk:8u191-alpine java \
-XX:MinRAMPercentage=50.0 \
-XX:MaxRAMPercentage=80.0 \
-XshowSettings:vm \
-version
VM settings:
Max. Heap Size (Estimated): 792.69M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM
不错,好多了。我们现在可以在基于 Java 8u191+ 的容器中使用 UseContainerSupport 选项。但请注意,实验性功能 UseCGroupMemoryLimitForHeap 仍然可用但已弃用,您应该立即停止使用它。
4、Set the memory cap
我们现在知道 JVM 是容器感知的,我们应该在我们的运行时环境中设置提供的内存量。您可能会在 Kubernetes 中运行您的应用程序,这就是我们在 Kubernetes 清单中设置内存的方式。
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
limits 是最大内存,requests 是最小内存。
5、Bonus
当在 docker 容器中运行 JVM 时,使用 HeapDumpOnOutOfMemoryError 选项可能是明智的,因此如果内存不足,jvm 会将堆转储写入磁盘。
默认情况下,堆转储创建在 VM 工作目录中名为 java_pid.hprof 的文件中。您可以使用 -XX:HeapDumpPath= 选项指定替代文件名或目录。例如 -XX:HeapDumpPath=/disk2/dumps 将导致在 /disk2/dumps 目录中生成堆转储。确保 java 进程对写入堆转储的目录具有写入权限。
您的工作目录可以通过 pwdx <PID> 命令找到。 Java 程序进程的 pid 号最大概率为 1,但您可以使用命令 ps -ef | 查找。 grep Java。然后运行 pwdx <PID> 它会告诉你工作目录。
$ pwdx 1
1: /usr/local/app
这就是它在 Kubernetes 的部署清单中的样子。
...
env:
- name: JAVA_OPTS
value: "-XX:MinRAMPercentage=60.0 -XX:MaxRAMPercentage=90.0 -XX:+HeapDumpOnOutOfMemoryError"
...
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
就是这样。现在您知道如何设置 Java 容器的堆大小了。