由来
时间回到 2017 年,老东家要上 Kubernetes 了,有幸参与和学习(主要是学习)。当时遇到的一了所有 Java 容器化者都遇到的坑:JDK8 不为容器化设计综合症。最简单的例子是Runtime.getRuntime().availableProcessors()
返回了主机的 CPU 数,而非期望的容器自身的cpu share/quota
,或说 k8s 的 cpu request/limit
。
时间到了 2021 年,一切本该云淡风轻(虽然工资依然追不上CPI和房价)。虽然我在的项目还是使用 JDK8,但好歹也是 jdk 1.8.0_261
了,已经 backport 了很多容器化的特性到这个版本了。最近在做项目的性能优化,在 Istio 的泥潭苦苦挣扎中。
突然前方同学传来喜讯: 把 POD 的 cpu request
由 2 变 4 后,性能有明显的优化。我在羡慕嫉妒😋的同时,好奇地研究了一下原理。
原理
直线思维逻辑
Kubernetes 使用 cgroup 进行资源限制:
- cpu request 对应于 cgroup 的 share 指标。在主机CPU不足,各容器需要争抢CPU情况下,指定各容器的优先级(数字大优先,比例化)
- cpu limit 对应于 cgroup 的 limit 指标。这是硬限制,不能超。超了就卡慢线程。
那么问题来了,测试环境主机CPU 资源充足,不存在各容器需要争抢CPU
的情况。那么,为何调大 cpu request
后,会明显优化性能?
可能性:
- 直线思维:Linux CFS Scheduler(任务调度器)实现不太好,在非
各容器需要争抢CPU
情况下,cpu request 仍然影响了调度 - 怀疑论者:新版本的 jdk8 只是依据 cpu request 来自动计算各默认配置,如各线程池。
作为一个只懂 java 的程序员,我关注后者。
求证
作为只懂写代码的程序员,没什么比运行的程序更能帮你说话了。起码,机器不会因为你和他关系好,或等着你给他通点气,或填个KPI,就跑你的程序快一点(不要和我说linux taskset
),更不会生成一个和关系有关系的小报告。
回来吧,先看看 POD 的配置:
resources:
limits:
cpu: "16"
requests:
cpu: "2"
进入 container:
$ cd /tmp
$ cat <<EOF > /tmp/Main.java
public class Main {
public static void main(String[] args) {
System.out.println("Runtime.getRuntime().availableProcessors() = " +
Runtime.getRuntime().availableProcessors());
}
}
EOF
$ javac Main.java
$ java -cp . Main
Runtime.getRuntime().availableProcessors() = 2
加点CPU request :
resources:
limits:
cpu: "16"
requests:
cpu: "4"
进入 container:
$ cd /tmp
$ java -cp . Main
Runtime.getRuntime().availableProcessors() = 4
可见,java 得到 cpu 数,来源于 容器配置的 cpu request 。
availableProcessors() 的影响
再看看 availableProcessors() 的影响。-XX:+PrintFlagsFinal
的作用是在 jvm 启动时打印计算后的默认配置。
# Request cpu=1 时
$ java -XX:+PrintFlagsFinal -cp . Main > req1.txt
# Request cpu=4 时
$ java -XX:+PrintFlagsFinal -cp . Main > req4.txt
$ diff req1.txt req4.txt
2c2
< intx ActiveProcessorCount = -1 {product}
---
> intx ActiveProcessorCount := 4 {product}
59c59
< intx CICompilerCount := 2 {product}
---
> intx CICompilerCount := 3 {product}
305c305
< uintx MarkSweepDeadRatio = 5 {product}
---
> uintx MarkSweepDeadRatio = 1 {product}
312c312
< uintx MaxHeapFreeRatio = 70 {manageable}
---
> uintx MaxHeapFreeRatio = 100 {manageable}
325c325
< uintx MaxNewSize := 178913280 {product}
---
> uintx MaxNewSize := 178782208 {product}
336,337c336,337
< uintx MinHeapDeltaBytes := 196608 {product}
< uintx MinHeapFreeRatio = 40 {manageable}
---
> uintx MinHeapDeltaBytes := 524288 {product}
> uintx MinHeapFreeRatio = 0 {manageable}
360c360
< uintx NewSize := 11141120 {product}
---
> uintx NewSize := 11010048 {product}
371c371
< uintx OldSize := 22413312 {product}
---
> uintx OldSize := 22544384 {product}
389c389
< uintx ParallelGCThreads = 0 {product}
---
> uintx ParallelGCThreads = 4 {product}
690,691c690,691
< bool UseParallelGC = false {product}
< bool UseParallelOldGC = false {product}
---
> bool UseParallelGC := true {product}
> bool UseParallelOldGC = true {product}
738c738
< Runtime.getRuntime().availableProcessors() = 1
---
> Runtime.getRuntime().availableProcessors() = 4
可见,availableProcessors()
不但影响了 jvm 的 GC 线程数,JIT 线程数,甚至是 GC算法。更大问题是一些 servlet container(如 Jetty)和 Netty 默认也会使用这个数字去配置他们的线程池。
反证
如果还是觉得Linux CFS Scheduler(任务调度器)在主机CPU过剩时,调度还是受到了 cgroup share(cpu request)影响
这个可能性需要排除。那么在POD拉起后,直接使用 linux 终端,去修改 cgroup 的 share 文件,增加一倍,再测试,就可以知道。对,反模式是排除问题的常用方法。但我没做这个测试,因我不想太科学🙃凡事留一线。
填坑
填坑是程序员的天职,无论你喜不喜欢,无论这个坑是你挖的,还是前度留下的。这个坑有几个填法:
- 修改 POD CPU request 为忙时使用量,即加大request,limit 不变
- 升级到 JDK11,使用期默认打开的
PreferContainerQuotaForCPUCount
参数,即availableProcessors()
返回 CPU limit 数。 - 所有默认使用
availableProcessors()
的地方,修改为显式指定,如GC线程数,Netty 线程数…… - CPU request/limit 不变,即 request 大大 小于 limit。但显式告诉 JVM 可以使用的 CPU 数。
国际习惯,我选用了 4。原因:
- POD 如果配置了大的 request,相当于锁定独占了主机的资源。主机实际资源利用率一定降低。而这个 request 其实只是个忙时峰值需求,如启动时的编译,或电商的抢购。
- 为所有默认使用
availableProcessors()
的地方,修改为显式指定。这个工作量大,对未来未知的使用到availableProcessors()
的地方不可控。 - 升级 JDK11,不是我等程序员能定的
明白了我能做什么后,就 Just do it 了。
话说,从 JDK 8u191
后,支持了-XX:ActiveProcessorCount=count
参数,告诉JVM真正可用的CPU数。所以,只要:
java -XX:+PrintFlagsFinal -XX:ActiveProcessorCount=$POD_CPU_LIMIT -cp . Main
# 当然,如果觉得 $POD_CPU_LIMIT 太大,就自行调整吧
-XX:ActiveProcessorCount
的说明见:https://www.oracle.com/java/technologies/javase/8u191-relnotes.html#JDK-8146115
总结
很明显,这是个应该早几年就写的 Blog。现在估计你家已经不使用JDK8了。而一般直接到 JDK11 LTS 了。或者,本文想说的是一种求证问题的方法和态度。它或者不能直接给你带来什么好处,有时候,甚至很让一些人讨厌,影响你进升的大好前程。不过,一个行业如果要进步,还得依赖这种情怀。英文有个词:Nerd
。专门形容这种态度。
扩展阅读
史前的修正 availableProcessors() 大法
在 JDK8 还没为容器化设计前,大神们只能先自行解决了。方法两种(层):
- mount bind 修改内核层 cpu 数的 system file
- 重载 gun libc 的 sysconf 函数
- 在 Linux 的动态 link .so 时重载 JVM_ActiveProcessorCount 函数,定制后返回
方法3相对简单。这里只说方法2:
#include <stdlib.h>
#include <unistd.h>
int JVM_ActiveProcessorCount(void) {
char* val = getenv("_NUM_CPUS");
return val != NULL ? atoi(val) : sysconf(_SC_NPROCESSORS_ONLN);
}
First, make a shared library of this:
gcc -O3 -fPIC -shared -Wl,-soname,libnumcpus.so -o libnumcpus.so numcpus.c
Then run Java as follows:
$ LD_PRELOAD=/path/to/libnumcpus.so _NUM_CPUS=2 java AvailableProcessors
方法1、2比较通用,对 JNI 等非 java 生态的同样有效,但实现需要了解一些 Linux。可以参考: https://geek-tips.imtqy.com/articles/493531/index.html、https://github.com/jvm-profiling-tools/async-profiler/issues/176
参考
https://www.batey.info/docker-jvm-k8s.html
https://mucahit.io/2020/01/27/finding-ideal-jvm-thread-pool-size-with-kubernetes-and-docker/
https://blog.gilliard.lol/2018/01/10/Java-in-containers-jdk10.html
https://cloud.google.com/run/docs/tips/java
https://www.oracle.com/java/technologies/javase/8u191-relnotes.html#JDK-8146115
https://stackoverflow.com/questions/64489101/optimal-number-of-gc-threads-for-a-single-cpu-machine