不得不提的容器—JVM
当我们将 JVM 生态中的关键要素,例如,垃圾收集器、堆大小和运行时编译器设置默认值时,许多技术人员(开发、运维人员)或许应该意识到在 Linux 容器生态中(诸如,Docker、Rkt、RunC、Lxcfs 等)内所运行的 Java 进程的实际行为与预期不符。当我们在没有任何调优参数(例如,最为简洁的的启动命令行:“ java -jar myapplication .jar”)的情况下执行 Java 应用程序时,JVM 将自行调整某些特定的参数,以在当前执行环境中获得最佳性能表现。
在实际的业务场景中,我们往往倾向于认为容器环境与虚拟机一样,可以完全自定义不同参数的虚拟 CPU 和虚拟 Memory 资源。其实,从本质上而言,容器更倾向于一种隔离机制环境,其中一个进程的资源( CPU、内存、文件系统、网络等)与另一个进程隔离。这种隔离是可能的,因为 Linux 内核中有一个名为 CGroups 的特性。然而,一些从执行环境收集信息的应用程序在 CGroup 存在之前就已经实现了。像大多数常用的命令行 “top”、“free”、“ps” 等诸如此类的工具,甚至 JVM 都没有针对在容器内执行进行优化,毕竟,容器是一个高度受限的 Linux 进程。
当我们在容器中运行 Java 应用程序时,我们可能希望尽可能对其进行调优,以充分利用可用资源,达到资源使用最优化。Java 应用在容器使用中一个常见 Heap 设置的问题。容器与虚拟机不同,其资源限制通过 CGroup 来实现。而容器内部进程如果不感知 CGroup 的限制,就进行内存、CPU分配可能导致资源冲突和问题。
为此,我们可以非常简单地利用 JVM 的新特性和自定义脚本来正确设置资源限制。基于此,可以解决绝大多数资源限制等各种异常问题。
[administrator@JavaLangOutOfMemory ~] % dmesg -T
[Sun Dec 01 09:26:23 2022] Memory cgroup out of memory: Kill process 11144 (java) score 1838 or sacrifice child
[Sun Dec 01 09:26:23 2022] Killed process 11144 (java) total-vm:12040204kB, anon-rss:3705828kB, file-rss:23472kB
...
正如如上日志所述,Docker 中的 JVM 检测到的是宿主机的内存信息,它无法感知容器的资源上限,这样可能会导致意外的情况。比如我们平时在启动容器是设置了容器资源,但是 Java 应用容器在运行中还是会莫名奇妙地被 OOM Killer 干掉。
在本文中,我们将了解如何在运行 Java 进程的容器环境中设置 JVM 参数。尽管以下内容适用于任何 JVM 设置,但我们将重点关注公共参数 -Xmx 和 -Xms 等。除此之外,我们还将讨论一些常见的问题,如如何对使用特定版本的 Java 运行的程序进行容器化,以及如何在一些流行的容器化 Java 应用程序中设置标志。在进入正题之前,我们先来了解下 PrintFlagsFinal 参数,依据官网的解释,其主要输出所有 JVM 配置参数和/或值的最终值。通常,默认情况下,JVM 自动分配的 Heap 大小取决于机器软硬件配置。以下为查看堆内存参数相关的命令行,如下所示:
[administrator@JavaLangOutOfMemory ~] % java -XX:+PrintFlagsFinal -version | grep -Ei "maxheapsize|maxram"
size_t MaxHeapSize = 4253024256 {product} {ergonomic}
uint64_t MaxRAM = 137438953472 {pd product} {default}
uintx MaxRAMFraction = 4 {product} {default}
double MaxRAMPercentage = 25.000000 {product} {default}
size_t SoftMaxHeapSize = 4253024256 {manageable} {ergonomic}
java version "11" 2022-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)
基于上述结果,我们可以看到,JVM 将堆大小设置为可用 RAM 的大约 25%。在本示例中,在 16 GB 的系统上分配了 4 GB 堆内存大小。除此之外,打印结果中的关键字 “MaxRAMFraction” 默认是 4,即意味着,每个 JVM 最多使用 25% 的机器物理内存。其实,在实际的业务场景中,需要值得注意的是,JVM 实际使用的内存往往会比堆内存(Heap)大,具体可参考如下:
JVM 内存=Heap 内存 + 线程 Stack 内存 (XSS) * 线程数 + 启动开销(constant overhead)
默认的 XSS 通常在 256KB 到 1MB,也就是说每个线程会分配最少 256K 额外的内存,constant overhead 是 JVM 分配的其他内存。
以笔者当前线上环境版本为例,基于 JDK1.8 版本特性,针对上述的 JVM 内存组成模型:JVM 运行时的内存=非 Heap(元空间 + Thread Stack * num of thread + ...) + Heap + JVM进程运行所需内存 + 其他数据,我们所设置的 -Xmx 等参数只是限制了 JVM 堆内存(Heap) 的大小,当 -Xmx 设置的值接近与容器限制的值的时候,堆内存 + 非堆内存的使用总和超出了 CGroup 的限制就会被操作系统 Kill 掉。这也就是为什么我们设置了 -Xmx 参数,在某些特定的业务场景中还是有可能被操作系统干掉。
接下来,我们来看一下栈内存参数相关命令行,具体如下所示:
[administrator@JavaLangOutOfMemory ~] % java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
intx CompilerThreadStackSize = 1024 {pd product} {default}
intx ThreadStackSize = 1024 {pd product} {default}
intx VMThreadStackSize = 1024 {pd product} {default}
java version "11" 2022-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)
当然,除了上述的命令行参数,我们也可以使用如下命令行查看相关参数信息,例如 “-XX:+PrintCommandLineFlags(打印传递给虚拟机的显式和隐式参数)” 以及 “-XX:+PrintFlagsInitial (打印传递给虚拟机的默认参数值)” 等等。
通常在容器环境中,由于我们的 Java 应用服务(容器实例)获取不到容器的内存限制,只能获取到服务器的配置。如果没有设置堆内存的大小,默认情况下,JVM 的 Max Heap Size 是操作系统的 1/4,我们知道 Docker 是通过 CGroups 来实现内存的限制,而 /proc 目录只是以只读的形式挂载到容器中,默认情况下 Java 是看不到 CGroups 限制的内存大小,而是通过 /proc/meminfo 中的信息作为内存信息启动,这种不兼容的情况就会导致,容器分配的内存小于JVM Max Heap Size 的情况。如下所示:
[administrator@JavaLangOutOfMemory ~] % free -m
total used free shared buff/cache available
Mem: 192052 3419 169556 4104 19075 182276
Swap: 0 0 0
如上为查看此机器节点的相关内存信息,可以看出:其物理内存为 192 G,Swap Space 为 0 。
[administrator@JavaLangOutOfMemory ~] % sudo kubectl exec -ti demo-usercenter-7966fc87bc-2fpqc -n business-center -- free -m
total used free shared buffers cached
Mem: 192052 27715 164336 4120 219 10413
-/+ buffers/cache: 17083 174968
Swap: 0 0 0
如上为查看此机器节点所承载的容器(Pod)的相关内存信息,可以看出:此承载的容器所分配的物理内存为 192 G,Swap Space 为 0 。
在实际的业务场景中,为保证资源的合理利用以及服务所提供的效能最大化,我们往往会进行容器资源的约束及调整,例如限制容器使用 100M 内存。然而,这样容易引起不必要的问题:在很多情况下,例如业务量瞬间徒增、逻辑处理发生变更,往往会使得 JVM 需要根据服务器配置来进行初始化内存的分配,导致 Java 进程所需要的资源超过容器所设定的限定阈值,从而被操作系统 Kill 掉。为了解决这个问题,在当前的方案中,我们可以通过在容器服务启动命令行中设置 -Xmx 或者 MaxRAM 等参数来解决,但就正如上述描述的那样,在很多时候此种操作显得不够友好、不够优雅。
那么,出现此问题的根源是什么?
1、对于 JVM 而言,如果没有设置 Heap Size,就会按照宿主机环境的内存大小缺省设置自己的最大堆大小。
2、Docker 容器利用 CGroup 对进程使用的资源进行限制,而在容器中的 JVM 依然会利用宿主机环境的内存大小和 CPU 核数进行缺省设置,这导致了 JVM Heap 的错误计算。
同样,类似,JVM 缺省的 GC、JIT 编译线程数量取决于宿主机 CPU 核数。如果我们在一个节点上运行多个 Java 应用,即使我们设置了 CPU 的限制,应用之间依然有可能因为 GC 线程抢占切换,导致应用性能受到影响。
了解了问题的根源,我们就可以非常简单地解决问题了。
因此,为了能够从根本上解决此类问题,我们需要引入一种“动态感知”机制,即:能够让 JVM 动态感知 CGroup,从而使得所承载的资源能够自适应性调整以支撑现有的业务运行。Java 8u131 及以上版本开始支持了 Docker 的 CPU 和 Memory 限制。在 Java 8u131+ 及 Java 9,其启动脚本中需要加上如下参数:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
基于如上这些标志,其强制 JVM 检查 Linux 的 CGroup 配置,毕竟,Docker 是通过 CGroup 来实现最大内存设置的。现在,如果我们的应用实例达到了 Docker 设置的限制范围(例如,所设定最大值为 500MB),此时,JVM 是可以看到这个资源限制的。基于此,JVM 将会开始尝试进行 GC 操作。如果仍然超过内存限制,JVM 就会做它应该做的事情,例如,抛出 OutOfMemoryException 等内存溢出问题。也就是说,Docker 的这些底层的参数设置对 JVM 而言,此时是可观测的。因此,若我们基于上述场景的参数配置,就可以使得 JVM 能够感知到对容器的内存限制。
不过,真正意义上的资源动态感知得益于 “+UseContainerSupport” 参数的出现。自 Java 10 开始正式引入了 +UseContainerSupport(默认情况下启用)参数,基于这个特性,可以使得 JVM 在容器环境分配合理的堆内存。 并且,在 JDK8U191 版本之后,这个功能引入到了 JDK 8,而 JDK 8 是当前广为使用的 LTS 版本。
关于 -XX:+UseContainerSupport 参数,其允许 JVM 能够从主机读取 CGroup 限制,例如,可用的 CPU 和 RAM,并进行相应的适应性配置。这样当容器超过内存限制时,往往只会抛出 OOM 异常,而不是 Kill 掉容器服务实例。除此之外,此参数特性在 Java 8u191 +,10 及更高版本上同样适用。
上面我们说了基于内存层面的资源限制,其实,在基于 CPU 层面的资源限制,在 -XX:+UseContainerSupport 参数的引入的场景下,JVM 将查看硬件并检测 CPU 的数量。它会优化我们的 runtime 以使用这些 CPUs。但是同样的情况,这里还有另一个不匹配,Docker 可能不允许我们使用所有这些 CPUs。不过,遗憾的是,此功能在 Java 8 以及 Java 9 版本中并没有得到修复,直至于 Java 10 中得到了解决。
从 Java 10 开始,可用的 CPUs 的计算将采用以不同的方式(默认情况下)解决此问题(同样是通过借助此参数 -XX:+UseContainerSupport 来实现)。Java 进程可用CPU 核数由 CPU Sets, CPU Shares 和 CPU Quotas 等参数计算而来。
值得注意的是,在 Java 8u191 版本后,-XX:{Min|Max}RAMFraction 参数已经被弃用,引入了 -XX:MaxRAMPercentage 参数,其值介于 0.0 到 100.0 之间,默认值为 25.0。
诚然 JVM 能够动态感知 CGroups 对容器的内存限制了,但是 JVM 默认的堆大小是限制值的 1/4,这将会导致内存的利用率相对而言较为低下。如此看来,要想充分利用服务器的资源,还是需要借助手动调整 -Xmx 参数,以使得性能表现的最大化、资源利用的最优化。
从生态角度而言,Java 与 Go 天生不是那么友好, 基于 Go 生态的云原生容器,例如,Docker 可以设置内存和 CPU 限制,而 Java 则不能自动检测到。故此,我们需要找出一种合适的道路,以开拓我们的 Java 应用实例能够在容器环境中稳定运行。因此,基于综上所述,在实际的业务场景中,可以通过借助 Java 的 -Xmx 关键参数或新的实验性 JVM 标识参数,我们或多或少可以解决诸如此类的资源冲突问题。