《Java 并发编程·性能诊断与事故》14.1 availableProcessors 与 CPU 配额

limits.cpu=2 的 Pod 里 JVM 到底看到几个核?本节用 cgroup v2 的 cpu.max 讲清 availableProcessors 的取值来源,在宿主与 colima 容器中实测 0.5/1/2/4/10/16 核下的 commonPool 并行度,并证明 -XX:ActiveProcessorCount 只能模拟核数、模拟不了 CFS 节流。

承接的问题

构造两个来自 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,之后缓存,运行期间不会变。它的取值按优先级来自三处:

  1. 命令行 -XX:ActiveProcessorCount=N,最高优先级,直接覆盖;
  2. 容器的 cgroup 限制(quota 或 cpuset),-XX:-UseContainerSupport 可以关掉;
  3. 操作系统报告的可用 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 没有半个处理器的概念,只能向上取整。这一点后面会变成一个真实的坑:核数被向上取整,配额不会。

版本分界要记住:

JDKcgroup v1cgroup 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 模拟不同的「报告核数」:

ActiveProcessorCountavailableProcessorscommonPool 并行度ParallelGCThreadsConcGCThreadsCICompilerCount
1110(auto)02
221212
443413
887824
10109924
16161513312

三个默认值都跟着动: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.maxavailableProcessorscommonPool 并行度
--cpus=0.550000 10000011
--cpus=1100000 10000011
--cpus=2200000 10000021
--cpus=4400000 10000043
--cpu-shares=512(无 quota)max 10000043
--cpus=2 -XX:ActiveProcessorCount=4200000 10000043
--cpus=2 -XX:-UseContainerSupport200000 10000043

表里最后两行是重点:命令行覆盖和关闭容器感知,都能让 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 时,这个数字连「预算」都算不上,只是「节点核数」。这三种情况下,正确的做法是先确定真正的稀缺资源,再倒推线程数。

改完配置之后,用三步验证,不要只看启动日志:

  1. java -XshowSettings:system -version 确认 Provider 和 Effective CPU Count 与 limits.cpu 一致;
  2. 启动日志里 CpuBudget.describe 的 cpus 与上一步相同,且 businessPool 不超过下游配额;
  3. 压测时读 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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收