承接的问题
构造两个来自 libraryd-conc 的场景。
场景 A:只写 requests,没写 limits。 Deployment 里是 requests.cpu: 500m,没有 limits.cpu。生产节点 64 核,JDK 25 在容器里调用 Runtime.availableProcessors() 返回 64。业务代码拿这个数配池子:64 个核心线程、ForkJoinPool.commonPool 并行度 63、G1 的 GC 线程也按 64 核算。节点空闲时一切正常;节点一忙,requests.cpu 只折算成一个相对权重,这个 Pod 实际能分到的可能只有半核,而 64 个线程在一个核的预算里排队。症状是「CPU 使用率不高,但 P99 一直在涨」。
场景 B:limits.cpu=2。 JVM 正确地看到 2,池子也按 2 配了。但 P99 仍然每隔约 100 ms 出现一次 60–100 ms 的尖刺。原因是 availableProcessors() 报的是配额折算出的核数,不是这一刻还剩多少配额。GC、JIT 编译线程和应用线程共享同一个 CFS 配额,配额用尽时整组线程一起被冻结到下一个周期。
两个场景的共同点:availableProcessors() 是一个静态数字,而 CPU 预算是动态消耗的。 这一节先把第一个数字的来源讲清楚,再用实测说明它为什么不足以支撑容量决策。
availableProcessors 数的是谁
Runtime.availableProcessors() 返回的是 HotSpot 在 VM 初始化时算出的 active_processor_count,之后缓存,运行期间不会变。它的取值按优先级来自三处:
- 命令行
-XX:ActiveProcessorCount=N,最高优先级,直接覆盖; - 容器的 cgroup 限制(quota 或 cpuset),
-XX:-UseContainerSupport可以关掉; - 操作系统报告的可用 CPU,兜底。
容器这一层有两种写法,对应 cgroup 两个版本:
# cgroup v1
/sys/fs/cgroup/cpu/cpu.cfs_quota_us # 例如 200000,-1 表示不限
/sys/fs/cgroup/cpu/cpu.cfs_period_us # 例如 100000
# cgroup v2(K8s 1.25 之后的默认形态)
/sys/fs/cgroup/cpu.max # "200000 100000",或 "max 100000"
折算公式是向上取整:
processors = ceil(quota / period)
quota=50000, period=100000 得到 1;200000/100000 得到 2。所以 limits.cpu: 500m 在 JVM 眼里是 1 个核,不是 0.5 个——JVM 没有半个处理器的概念,只能向上取整。这一点后面会变成一个真实的坑:核数被向上取整,配额不会。
版本分界要记住:
| JDK | cgroup v1 | cgroup v2 | -XX:ActiveProcessorCount |
|---|---|---|---|
| 8u191 之前 | 不支持 | 不支持 | 不支持 |
| 8u191+ | 支持(回移) | 不支持 | 支持 |
| 10–14 | 支持 | 不支持 | 支持 |
| 15+ | 支持 | 支持 | 支持 |
更早的 8u 镜像在 limits.cpu=2 的 Pod 里照样返回节点核数,而且没有任何报错——它只是「不知道」容器的存在。判断方法不是看版本号,而是进容器跑一次 java -XshowSettings:system -version,看有没有 Provider: cgroupv2 那几行,或者直接打印 availableProcessors 和 cpu.max 对账。
--cpuset-cpus 走的是另一条路:它限制的是 CPU 亲和性,不是配额,JVM 通过 sched_getaffinity 看到的就是被限制后的核数。本机实测(colima 的 Docker,cgroup v2,VM 4 核):
--cpus=0.5 nproc=4 cpu.max="50000 100000" availableProcessors=1
--cpuset-cpus=0-1 nproc=2 cpu.max="max 100000" availableProcessors=2
注意 nproc 和 /proc/cpuinfo 在 --cpus=0.5 时仍然报 4:它们只看亲和性,不看配额。判断 JVM 会看到几个核,不能信 nproc,要么直接问 JVM,要么读 cpu.max 自己算。
实测:同一台机器上的不同核数
宿主是 Apple M1 Pro 10 核 / 32 GB,Temurin 25.0.5+8。宿主上没有配额,用 -XX:ActiveProcessorCount=N 模拟不同的「报告核数」:
| ActiveProcessorCount | availableProcessors | commonPool 并行度 | ParallelGCThreads | ConcGCThreads | CICompilerCount |
|---|---|---|---|---|---|
| 1 | 1 | 1 | 0(auto) | 0 | 2 |
| 2 | 2 | 1 | 2 | 1 | 2 |
| 4 | 4 | 3 | 4 | 1 | 3 |
| 8 | 8 | 7 | 8 | 2 | 4 |
| 10 | 10 | 9 | 9 | 2 | 4 |
| 16 | 16 | 15 | 13 | 3 | 12 |
三个默认值都跟着动:ForkJoinPool.commonPool 的并行度是 max(1, n-1);G1 的 ParallelGCThreads 在超过 8 核后按 8 + (n-8)×5/8 增长,10 核是 9、16 核是 13;ConcGCThreads 约为前者的四分之一,最少 1。CICompilerCount 的取值不线性,16 核时跳到 12,这里只作为观察值列出,不推导公式。
容器里的实测更直接(镜像 eclipse-temurin:25-jre-alpine,Temurin 25.0.4.1+1,与宿主的 25.0.5+8 不是同一构建,但本节涉及的取值逻辑一致):
| 容器参数 | cpu.max | availableProcessors | commonPool 并行度 |
|---|---|---|---|
--cpus=0.5 | 50000 100000 | 1 | 1 |
--cpus=1 | 100000 100000 | 1 | 1 |
--cpus=2 | 200000 100000 | 2 | 1 |
--cpus=4 | 400000 100000 | 4 | 3 |
--cpu-shares=512(无 quota) | max 100000 | 4 | 3 |
--cpus=2 -XX:ActiveProcessorCount=4 | 200000 100000 | 4 | 3 |
--cpus=2 -XX:-UseContainerSupport | 200000 100000 | 4 | 3 |
表里最后两行是重点:命令行覆盖和关闭容器感知,都能让 JVM 报出与配额不符的核数。 一个 -XX:ActiveProcessorCount=4 就能把 2 核的 Pod 骗成 4 核,池子随之翻倍;-XX:-UseContainerSupport 更彻底,直接回到「VM 有几个核就报几个核」。这两个开关在排查「为什么这个 Pod 开了这么多线程」时必须先看一眼启动参数。
运行时怎么确认
最省事的确认方式是 -XshowSettings:system,它会把 JVM 实际采信的 cgroup 数据打出来。在 --cpus=2 的容器里:
Operating System Metrics:
Provider: cgroupv2
Effective CPU Count: 2
CPU Period: 100000us
CPU Quota: 200000us
CPU Shares: -1
List of Processors: N/A
List of Effective Processors, 4 total:
0 1 2 3
这里同时出现了两个数:Effective CPU Count: 2 是配额折算后的结果,List of Effective Processors, 4 total 是亲和性允许的 CPU 集合。JVM 用前者,nproc 用后者,所以两者会打架。同一段设置在 macOS 宿主上只会打印 No metrics available for this platform——宿主没有 cgroup,这行输出本身就是「容器感知没有生效」的证据。
Provider 那一行还告诉你 cgroup 版本:cgroupv2 / cgroupv1 / none。JDK 8 的早期版本没有这一行,因为它根本没有这套机制。
线程转储里怎么认
核数变了,线程名也会跟着变,这是从转储反推配置的捷径。-XX:ActiveProcessorCount=4 下提交 8 个 CompletableFuture 任务并起一个虚拟线程,jcmd <pid> Thread.print 里能看到:
"ForkJoinPool.commonPool-worker-1" #23 daemon prio=5
"ForkJoinPool.commonPool-worker-2" #24 daemon prio=5
"ForkJoinPool.commonPool-worker-3" #25 daemon prio=5
"ForkJoinPool-1-worker-1" #28 daemon prio=5
"ForkJoinPool-1-delayScheduler" #29 daemon prio=5
"GC Thread#0" os_prio=31
"C2 CompilerThread0" #18 daemon prio=9
三组名字对应三件事:ForkJoinPool.commonPool-worker-* 最多 n-1 个(这里 3 个),是 CompletableFuture 的默认执行器;ForkJoinPool-1-worker-* 是虚拟线程调度器的载体线程,上限是 n;GC Thread#* 与 C* CompilerThread* 是 JVM 自己按核数开的。转储里如果看到 63 个 commonPool-worker,不用问,这个 JVM 认为自己有 64 个核,而配额大概率不是 64。
线程池默认值跟着变
受 availableProcessors 影响的默认值不止一个,排查时要一起看:
ForkJoinPool.commonPool:并行度max(1, n-1),CompletableFuture的默认执行器就是它。Executors.newWorkStealingPool():无参版本直接用availableProcessors,实测 1/2/4 核分别得到 1/2/4。- 虚拟线程调度器:载体线程池的并行度默认也是
availableProcessors。本机起 1000 个虚拟线程后看线程转储,-XX:ActiveProcessorCount=2有 2 个ForkJoinPool-1-worker-*,4 核有 4 个。 - GC 线程:
ParallelGCThreads/ConcGCThreads,见上表。 - JIT 编译线程:
CICompilerCount,见上表。 - 业务代码里手写的
new ThreadPoolExecutor(availableProcessors(), ...):没有默认值,但这是最常见的误用来源。
前五个是 JVM 自己按核数推导的,最后一个由业务代码决定。诊断时把这几组数一起打印出来,比逐个猜快得多:
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
public class Probe {
public static void main(String[] args) throws Exception {
int ap = Runtime.getRuntime().availableProcessors();
ForkJoinPool common = ForkJoinPool.commonPool();
var ws = Executors.newWorkStealingPool();
System.out.printf("availableProcessors=%d commonParallelism=%d commonPoolSize=%d workStealingParallelism=%d%n",
ap, common.getParallelism(), common.getPoolSize(),
((ForkJoinPool) ws).getParallelism());
ws.shutdown();
}
}
commonPoolSize 是 0 不代表池子没配好——ForkJoinPool 的工作线程是懒启动的,没有任务时一个都不建。要看真实并行度,用 getParallelism(),不要用 getPoolSize()。
模拟核数不等于模拟 CFS 节流
这是本节最需要说清的一件事,也是很多「本地复现不了线上」的根源。
-XX:ActiveProcessorCount=N 只改变 JVM 认为有几个核,它不会在操作系统层面装任何配额。本机 4 个自旋线程、-XX:ActiveProcessorCount=1 时,线程照样能同时用上 4 个核,相邻两次 nanoTime 的最大间隔是 2 ms。同样的 4 个线程放进 --cpus=1 的容器,最大间隔变成 85 ms,3 秒内有 30 次超过 10 ms 的停顿——这才是配额用尽后被 CFS 冻结的效果。
宿主 -XX:ActiveProcessorCount=1 worstMaxGapMs=2 gapsOver10ms=0
容器 --cpus=1 worstMaxGapMs=85 gapsOver10ms=30
容器 --cpus=0.5 worstMaxGapMs=97 gapsOver10ms=30
容器 --cpus=2(4 个自旋线程) worstMaxGapMs=74 gapsOver10ms=29
容器 无限制(VM 4 核) worstMaxGapMs=6 gapsOver10ms=0
所以本机用 ActiveProcessorCount 得到的表,只能回答「JVM 会推导出多少线程」,回答不了「这些线程会不会被节流」。要验证节流,必须在有 quota 的环境里跑,或者直接读 cpu.stat 的 nr_throttled / throttled_usec。上面那三轮实验对应的计数器是:
--cpus=2 nr_periods 31 nr_throttled 30 throttled_usec 5968662
--cpus=0.5 nr_periods 32 nr_throttled 31 throttled_usec 10441363
3 秒、100 ms 一个周期,nr_periods 是 31 左右,nr_throttled 几乎每个周期都命中;throttled_usec 是组内所有任务被冻结时间的累加,不是墙钟时间,所以 0.5 核那行的 10.4 秒大于 3 秒的墙钟。这两组数在 14.3 会展开成告警口径。
还有一个容易忽略的细节:--cpus=0.5 时 availableProcessors=1,核数被向上取了整。如果按「1 核」配一个线程,这个线程仍然只能拿到半核的预算,会有一半时间被冻结;而 JVM 里没有任何一个 API 会告诉你「其实只有 0.5 核」。同理,--cpus=1.5 会得到 2,--cpus=2.4 会得到 3。取整只会往大,不会往小,所以按 availableProcessors 配出来的池子天然偏大。
在 libraryd-conc 里落地
把「核数 → 池大小」的推导封进一个类,避免每处业务代码各写一遍 availableProcessors():
package com.plume.conc.ops;
/** 按 JVM 报告的核数与实测 W/C 推导池大小;启动时打印一次,便于和配额对账。 */
public record CpuBudget(int processors, double waitComputeRatio, int dependencyLimit) {
public CpuBudget {
if (processors < 1) {
throw new IllegalArgumentException("processors 必须 >= 1");
}
if (waitComputeRatio < 0) {
throw new IllegalArgumentException("W/C 不能为负");
}
if (dependencyLimit < 1) {
throw new IllegalArgumentException("dependencyLimit 必须 >= 1");
}
}
public static CpuBudget detect(double waitComputeRatio, int dependencyLimit) {
return new CpuBudget(Runtime.getRuntime().availableProcessors(), waitComputeRatio, dependencyLimit);
}
/** CPU 密集:N+1,只作为上限。 */
public int cpuBound() {
return processors + 1;
}
/** IO 密集:N×(1+W/C),向上取整。 */
public int ioBound() {
return (int) Math.ceil(processors * (1 + waitComputeRatio));
}
/** 业务池最终值:公式值与下游并发上限取小。 */
public int businessPool(boolean ioHeavy) {
int formula = ioHeavy ? ioBound() : cpuBound();
return Math.min(formula, dependencyLimit);
}
/** JVM 自己会开的并行度,用于日志对账。 */
public int commonParallelism() {
return Math.max(1, processors - 1);
}
public String describe(boolean ioHeavy) {
return String.format(
"cpus=%d commonParallelism=%d businessPool=%d (formula=%d, dependencyLimit=%d, W/C=%.2f)",
processors, commonParallelism(), businessPool(ioHeavy),
ioHeavy ? ioBound() : cpuBound(), dependencyLimit, waitComputeRatio);
}
public static void main(String[] args) {
CpuBudget budget = CpuBudget.detect(4.5, 64);
System.out.println(budget.describe(true));
System.out.println("cpuBound=" + budget.cpuBound());
}
}
describe 每次启动打一行,和 kubectl describe pod 里的 limits.cpu 对一下就能发现不一致:日志写 cpus=4、配额是 2,说明有人加了 -XX:ActiveProcessorCount 或者 -XX:-UseContainerSupport;日志写 cpus=64、配额是 2,说明容器感知没生效(老 JDK 或开关被关)。这行日志比任何监控都先到达现场。
用同一个 main 在宿主上换核数跑,可以直接看到池子怎么随配额变化(W/C 取 5.2 实测的 4.5,下游上限 64):
-XX:ActiveProcessorCount=1 cpus=1 commonParallelism=1 businessPool=6 cpuBound=2
-XX:ActiveProcessorCount=2 cpus=2 commonParallelism=1 businessPool=11 cpuBound=3
-XX:ActiveProcessorCount=4 cpus=4 commonParallelism=3 businessPool=22 cpuBound=5
-XX:ActiveProcessorCount=10 cpus=10 commonParallelism=9 businessPool=55 cpuBound=11
注意 businessPool 是 IO 密集口径(N×(1+W/C) 向上取整),cpuBound 是 N+1。两个数差得很远,说明池大小首先取决于任务类型,其次才取决于核数;把两个公式混用,比把核数算错后果更严重。这段输出也提醒一件事:核数从 1 变到 10,业务池从 6 变到 55,是 9 倍,不是 10 倍——+1 在低核数时占比很大。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
用 nproc 判断 JVM 看到几个核 | quota 场景下 nproc 报的是亲和性核数,和 JVM 不一致 | 直接打印 availableProcessors,或解析 cpu.max |
认为 limits.cpu: 500m 是 0.5 核 | JVM 向上取整成 1 核,池子按 1 配 | 知道取整规则,池子按 1 核再留余量 |
用 availableProcessors 配数据库连接池 | 连接池上限由数据库决定,与核数无关 | 连接池用下游配额,线程池才用核数 |
| 只写 requests 不写 limits | 无 quota,JVM 看到节点核数,节点繁忙时被权重挤压 | 要么写 limits,要么显式 ActiveProcessorCount |
用 ActiveProcessorCount 复现线上卡顿 | 只改数字不装配额,永远复现不出节流 | 在有 quota 的容器里复现,或看 cpu.stat |
以为 commonPoolSize=0 是没配置 | 工作线程懒启动 | 看 getParallelism() |
什么时候不要用 availableProcessors 做决策:当下游有独立并发上限时(数据库连接池、HTTP 客户端连接数、消息队列分区数),线程数不该由核数决定;当任务是混合型时,一个数代表不了分布;当 Pod 的 CPU 是 requests-only 时,这个数字连「预算」都算不上,只是「节点核数」。这三种情况下,正确的做法是先确定真正的稀缺资源,再倒推线程数。
改完配置之后,用三步验证,不要只看启动日志:
java -XshowSettings:system -version确认Provider和Effective CPU Count与limits.cpu一致;- 启动日志里
CpuBudget.describe的cpus与上一步相同,且businessPool不超过下游配额; - 压测时读
cpu.stat的nr_throttled:如果每秒都在涨,说明线程数和配额仍然不匹配,或者 GC/JIT 吃掉了太多预算。
三步里最容易漏的是第三步。前两步只能证明「JVM 看到的核数对了」,证明不了「这些线程跑得动」。
小结
availableProcessors() 是三个来源的合成:命令行覆盖、cgroup 限制、OS 可见核数。cgroup v2 用 cpu.max 的 quota/period 向上取整,limits.cpu: 500m 在 JVM 眼里是 1 核;cpuset 则直接改亲和性。这个数字会连带改变 ForkJoinPool.commonPool 并行度(max(1,n-1))、newWorkStealingPool、虚拟线程载体数、GC 线程与 JIT 线程,本机实测 1/2/4/8/10/16 核的取值全部列在上表。
更要紧的是它的边界:本机用 -XX:ActiveProcessorCount=1 时 4 个自旋线程的最大间隔只有 2 ms,而同样 4 个线程在 --cpus=1 的容器里最大间隔 85 ms、3 秒内 30 次超过 10 ms。模拟核数只改变 JVM 的推导,不会装配额;CFS 节流必须到有 quota 的环境里才看得见。 下一节换一个维度:内存限制、线程栈,以及容器里几种长得完全不同的 OOM。
阅读导航:上一节 13.3 低延迟 GC 下的并发设计 | 下一节 14.2 内存限制、线程栈与容器 OOM
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。