《Java 并发编程·性能诊断与事故》1.2 上下文切换、调度延迟与 CPU 配额

CPU 没跑满、吞吐却上不去,先分清是锁排队还是线程太多。本节对照 vmstat 与 pidstat 的口径,给出 macOS 上真实可用的替代命令与实跑输出,用线程数阶梯测出吞吐与每操作 CPU 成本的变化,并说明容器 CPU 配额如何改变可用核数。

承接的问题

1.1 说扩展性有天花板,天花板来自串行段(σ)和一致性开销(κ)。但那两个参数是把观测点喂进公式拟合出来的:它告诉你「到顶了」,不告诉你「顶在哪一层」。真实的顶通常在三处——锁排队、线程数超过可用核数、CPU 配额被卡死。

这一节处理第二和第三处,顺带把第一处的观测方法说清楚。要回答四个问题:

  • vmstat 的 cs 和 pidstat 的 cswch/s 到底在数什么,哪个能证明「线程太多」?
  • 本机是 macOS,这两个命令都不存在,替代品的口径差在哪?
  • 线程数从 1 加到 64,吞吐和每次操作的 CPU 成本怎么变?
  • 容器里 availableProcessors() 为什么和宿主机不一样?

先看骨架。下面的数字是构造场景的示例值,不是本机输出:

症状:32 核机器上把池从 16 扩到 64,吞吐只涨 12%,us 从 60% 掉到 45%,
      sy 从 8% 涨到 25%,p99 从 30 ms 涨到 120 ms。
证据:vmstat 1 的 cs 列从 8 000/s 涨到 60 000/s;pidstat -w 显示 nvcswch/s
      占九成;线程池 activeCount 长期等于 poolSize。
根因:线程数远超可用核数,时间片变短,线程在锁等待中被换出,
      醒来时锁已被别人拿走,重试又被换出——切换本身不贵,贵的是它打乱了锁的交接。
修复:池大小按核数收敛,队列前置削峰,把阻塞调用从计算线程里挪走。
验证:同一压测,cs 回落到 10 000/s 以下,sy 回到 10% 以内,p99 回到 40 ms。

这条链最关键的一步是「nvcswch/s 占九成」。自愿切换多,说明线程在等锁、等 IO;非自愿切换多,说明可运行线程比核多,操作系统在强行轮转。两者处置相反,必须先分清。

Linux 上量什么:vmstat 与 pidstat 的口径

vmstat 是系统级视图,pidstat 是进程级视图。它们不是一回事,混用会得出错误结论。

# 系统级:每秒一行,看整体是不是在疯狂轮转
vmstat 1 5

# 进程级:只盯住这个 JVM
pidstat -w -p <pid> 1 5
pidstat -u -p <pid> 1 5

关键列的口径:

来源列含义用它能判断什么
vmstatr可运行队列长度长期大于核数,说明线程真的太多
vmstatcs每秒上下文切换总数(系统级)只能看趋势,不能定位到进程
vmstatus / sy用户态 / 内核态 CPU 占比sy 高伴随 cs 高,是切换开销的签名
pidstat -wcswch/s自愿切换:线程主动让出高,说明在等锁或等 IO
pidstat -wnvcswch/s非自愿切换:被剥夺 CPU高,说明可运行线程超过核数

一条经验判据:cswch/s 高而 nvcswch/s 低,去查锁和 IO;nvcswch/s 高,去减线程。 减线程对前者没用,加线程对后者是火上浇油。

这几个命令在本机跑不了。macOS 没有 vmstat 和 pidstat:

$ which vmstat pidstat
vmstat not found
pidstat not found

下面这一节是替代方案,所有输出都是本机实跑。

macOS 上我用什么替代

macOS 的观测工具是另一套:top、vm_stat、ps -M、sysctl。能力不完全对等。

想看LinuxmacOS(本机)差距
运行队列vmstat 的 rtop -l 1 -n 0 的 N running只有瞬时值,没有每秒序列
内存压力vmstat 的 si/sovm_stat 的 Pages free / compressor口径不同,不能直接比
进程切换率pidstat -wtop -l 1 -pid <pid> -stats pid,threads,csw累计值,要两次采样做差
线程列表ps -L / top -Hps -M -p <pid>一条线程一行,无 CPU 列
核数nprocsysctl -n hw.ncpu等价

先钉住机器状态:

$ uptime
2:19  up 9 days, 19:42, 6 users, load averages: 80.04 105.29 132.56

$ top -l 1 -n 0 | head -6
Processes: 717 total, 17 running, 700 sleeping, 6638 threads 
2026/10/29 02:19:20
Load Avg: 80.04, 105.29, 132.56 
CPU usage: 27.79% user, 28.12% sys, 44.7% idle 
SharedLibs: 578M resident, 132M data, 99M linkedit.
MemRegions: 1353020 total, 11G resident, 297M private, 3622M shared.

10 核机器上 load 80,17 running,空闲 CPU 还有 44.7%。这三个数放在一起是矛盾的,原因在 1.1 提过:这台机器同时在跑别的负载,load 把它算进去了,而我的 JVM 只分到一小部分。所以本机任何吞吐数字都只能看相对关系。 另外两个口径:sysctl kern.sched 输出 kern.sched: edge(调度策略名,没有更多信息量);sysctl -n hw.ncpu hw.physicalcpu 都是 10,说明这台 M1 Pro 没开超线程——「线程数 = 2 × 核数」的经验公式在这里没有物理依据。

线程数怎么数,本机给出了三个互相矛盾的答案。同一个 JVM 进程(64 个工作线程全部自旋):

$ ps -M -p <pid> | wc -l                  # 85 行,含表头 → 84 条线程
$ jcmd <pid> Thread.print | grep -c '^"'  # 18
$ # 进程内部:Thread.getAllStackTraces().size() → 70

三个数:84、18、70。ps -M 数的是操作系统线程(含 JVM 自己的 GC、编译线程),70 是 JVM 眼里的 Java 线程,18 是 jcmd 这次转储实际吐出来的数量。第三个明显不完整——进程把 10 个核吃满时,转储拿不到完整快照。这不是 macOS 的锅,是「在高压下做观测」的普遍问题。

结论:线程数用 ps -M 的行数减一,交叉验证用进程内的 Thread.getAllStackTraces();jcmd 的转储要先确认完整性再用。 3.3 讲转储读法时还会回到这一点。

线程数、切换率与吞吐:一组实测

光看线程数没有意义,要看它和吞吐的关系。下面这个程序在两个口径下各跑一遍线程数阶梯:

  • independent:每个线程做 2 000 万次线性同余迭代,线程之间没有共享状态,测「纯计算能扩展到哪」。
  • shared:每个线程做 300 万次 synchronized 自增,全部抢同一把锁,测「串行化之后加线程会发生什么」。
  • 每个档位先跑一轮丢弃(预热),再跑三轮;cpu_ns/op 是所有工作线程自己上报的 CPU 时间之和除以操作数。用 ready/go 两个闸门保证 64 个线程同时起跑,否则前几个线程干完时后几个还没启动,测出来的是启动偏斜。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.LongAdder;

public final class ThreadSweep {
    static final Object LOCK = new Object();
    static final int IND_ITERS = 20_000_000;
    static final int SHARED_ITERS = 3_000_000;
    static long shared;
    static final LongAdder SINK = new LongAdder();
    static final ThreadMXBean MX = ManagementFactory.getThreadMXBean();

    public static void main(String[] args) throws Exception {
        int[] levels = {1, 2, 4, 8, 16, 32, 64};
        System.out.println("mode        threads   run1    run2    run3   cpu_ns/op");
        for (boolean contended : new boolean[]{false, true}) {
            for (int n : levels) {
                double[] thr = new double[3];
                double cpu = 0;
                phase(n, contended);
                for (int r = 0; r < 3; r++) {
                    long t0 = System.nanoTime();
                    long cpuNs = phase(n, contended);
                    int ops = n * (contended ? SHARED_ITERS : IND_ITERS);
                    thr[r] = (double) ops / ((System.nanoTime() - t0) / 1e9) / 1e6;
                    cpu += (double) cpuNs / ops;
                }
                System.out.printf("%-11s %7d %7.1f %7.1f %7.1f %8.1f%n",
                        contended ? "shared" : "independent", n, thr[0], thr[1], thr[2], cpu / 3);
            }
        }
    }

    static long phase(int threads, boolean contended) throws Exception {
        CountDownLatch ready = new CountDownLatch(threads);
        CountDownLatch go = new CountDownLatch(1);
        LongAdder cpu = new LongAdder();
        Thread[] pool = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            final int seed = i + 1;
            pool[i] = Thread.ofPlatform().unstarted(() -> {
                ready.countDown();
                try { go.await(); } catch (InterruptedException e) { return; }
                long b = MX.getCurrentThreadCpuTime();
                if (contended) {
                    for (int k = 0; k < SHARED_ITERS; k++) synchronized (LOCK) { shared++; }
                } else {
                    long x = seed;
                    for (int k = 0; k < IND_ITERS; k++) {
                        x = x * 6364136223846793005L + 1442695040888963407L;
                        x ^= x >>> 33;
                    }
                    SINK.add(x);
                }
                cpu.add(MX.getCurrentThreadCpuTime() - b);
            });
            pool[i].start();
        }
        ready.await();
        go.countDown();
        for (Thread t : pool) t.join();
        return cpu.sum();
    }
}

本机输出(单次运行,随调度波动,不作基准;本机 load average 长期在 70–170 区间,外部负载会压低全部绝对值):

mode        threads   run1    run2    run3   cpu_ns/op
independent       1   607.4   610.1   620.8      1.6
independent       2  1222.5  1234.9  1216.8      1.6
independent       4  2395.8  2357.3  2365.3      1.6
independent       8  4642.1  4526.5  4597.9      1.7
independent      16  4452.3  4967.2  4975.9      1.7
independent      32  5264.8  5143.3  5187.9      1.7
independent      64  5217.2  5231.6  5074.0      1.7
shared            1   441.4   236.3   424.8      2.2
shared            2    99.3   106.6    87.5     20.1
shared            4    20.4    24.8    18.5     97.9
shared            8    15.8    19.0    16.4    120.0
shared           16    17.4    19.7    18.1    111.4
shared           32    18.3    16.0    16.3    120.9
shared           64    16.9    16.7    16.6    121.4

三件事从表里直接读得出来。

第一,纯计算在 8 线程之后基本不涨。 1 线程 621 Mop/s,64 线程 5 232 Mop/s,加速比 8.4 倍,而机器有 10 个核。少掉的 1.6 个核被外部负载和内存带宽吃掉。注意 cpu_ns/op 从头到尾是 1.6–1.7——单次操作的成本没变,损失在「拿不到 CPU」而不是「算得慢」。这一列就是判断依据:涨了是成本问题,不涨是排队问题。

第二,抢锁的那一组在 2 线程就掉一半,4 线程掉到 1/20。 1 线程 424–441 Mop/s,2 线程 87–107,4 线程 18.5–24.8,之后稳定在 16–19。64 线程和 8 线程的吞吐几乎一样——多出来的 56 个线程没有贡献任何吞吐,只是加入了排队。

第三,cpu_ns/op 从 2.2 涨到 121,55 倍。 121 ns 的 CPU 时间对应 16.7 Mop/s 的墙钟吞吐(每操作 60 ns),意味着每个操作平均烧掉了约两个核的 CPU:一个在干活,另一个在自旋重试和锁膨胀上。这就是「切换本身不贵,贵的是它破坏了临界区的交接」的量化版本。

切换率本身也能量,只是本机在 64 线程这一档不可靠。下表是两次采样(间隔 5 秒)的 top 读数,采样时 load 约 130:

线程数#TH(top)CSW 累计切换率
121/15 582 → 8 605605 /s
828/841 874 → 66 5924 944 /s
64top 返回 #TH=20,与 ps -M 的 84 条冲突,第二次采样为空行—未取得可信读数

前两行可信:ps -M 的行数(22、29)与 #TH 对得上。第三行不是程序的问题——同一个进程,ps -M 数出 84 条线程,top 只报 20,第二次采样直接不返回。工具在高压下会撒谎,这就是为什么要交叉验证。 1 线程 605 次/秒、8 线程 4 944 次/秒,每线程约 600 次/秒,切换率随线程数近似线性增长;这一档的结论不需要 64 线程的数据也成立。

调度延迟:比切换次数更接近体验

切换次数是过程指标,真正影响 p99 的是「一个线程想跑,多久才能真的跑上」。本机用一个最直接的办法量:请求睡 1 ms,看实际过了多久。下面这个类同时是 conc-ops 里的采样器,csv() 那行给压测报告归档。

package com.plume.conc.ops;

import java.util.Arrays;
import java.util.concurrent.locks.LockSupport;

/** 调度延迟采样器:请求固定时长的 park,记录实际间隔。 */
public final class SchedulerSampler {
    private final long[] gaps;
    private int size;

    public SchedulerSampler(int capacity) {
        this.gaps = new long[capacity];
    }

    public void sample(long parkNanos) {
        long t0 = System.nanoTime();
        LockSupport.parkNanos(parkNanos);
        long dt = System.nanoTime() - t0;
        if (size < gaps.length) {
            gaps[size++] = dt;
        }
    }

    public long percentile(double p) {
        if (size == 0) {
            return 0L;
        }
        long[] copy = Arrays.copyOf(gaps, size);
        Arrays.sort(copy);
        return copy[Math.min(copy.length - 1, (int) Math.ceil(p * copy.length) - 1)];
    }

    public long max() {
        long m = 0L;
        for (int i = 0; i < size; i++) {
            m = Math.max(m, gaps[i]);
        }
        return m;
    }

    /** 一行 CSV,供压测报告归档。 */
    public String csv(long cpuNanos, long ops) {
        long cpuPerOp = ops == 0 ? 0 : cpuNanos / ops;
        return String.format("sched,n=%d,p50_ms=%.2f,p99_ms=%.2f,max_ms=%.2f,cpu_ns_per_op=%d",
                size, percentile(0.50) / 1e6, percentile(0.99) / 1e6, max() / 1e6, cpuPerOp);
    }

    public static void main(String[] args) throws Exception {
        int threads = args.length > 0 ? Integer.parseInt(args[0]) : 1;
        SchedulerSampler[] each = new SchedulerSampler[threads];
        Thread[] pool = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            SchedulerSampler s = new SchedulerSampler(2000);
            each[i] = s;
            pool[i] = Thread.ofPlatform().name("probe-" + i).start(() -> {
                for (int k = 0; k < 2000; k++) {
                    s.sample(1_000_000L);
                }
            });
        }
        for (Thread t : pool) {
            t.join();
        }
        long samples = 0, p50 = 0, p99 = 0, max = 0;
        for (SchedulerSampler s : each) {
            samples += s.size;
            p50 = Math.max(p50, s.percentile(0.50));
            p99 = Math.max(p99, s.percentile(0.99));
            max = Math.max(max, s.max());
        }
        System.out.printf("threads=%d samples=%d p50=%.2f p99=%.2f max=%.2f (ms)%n",
                threads, samples, p50 / 1e6, p99 / 1e6, max / 1e6);
    }
}

本机输出(load 28):

$ java -cp out com.plume.conc.ops.SchedulerSampler 1
threads=1 samples=2000 p50=1.51 p99=2.76 max=13.61 (ms)
$ java -cp out com.plume.conc.ops.SchedulerSampler 8
threads=8 samples=16000 p50=1.51 p99=4.66 max=23.66 (ms)

请求 1 ms,实际中位数 1.51 ms,p99 2.76 ms,最长 13.61 ms。这台机器上「1 ms 的等待」根本不是 1 ms。8 个线程同时等,p99 抬到 4.66。

但这组数字不可复现。同一个程序前后跑了四次:p50 稳定在 1.51–1.52,p99 却在 1.95 到 4.66 之间摆动,max 在 11 到 23 ms 之间;八线程那一组的 p99 有时比单线程还低。所以正确的用法是当基线区间用,而不是当精确值:本机的调度噪声大致是「p99 2–5 ms、max 十几到二十几 ms」。你测到某个接口 p99 是 8 ms,先扣掉这段噪声,剩下的才是代码。1.3 会把这件事接到分位数上。

容器 CPU 配额:核数是算出来的,不是数出来的

容器里 Runtime.availableProcessors() 读的不是宿主机核数,而是 cgroup 给的配额。cgroup v2 的两个文件:

# quota 与 period,单位微秒;"200000 100000" 表示 2 个核
cat /sys/fs/cgroup/cpu.max

# 被限流的次数和总时长:nr_periods / nr_throttled / throttled_usec
cat /sys/fs/cgroup/cpu.stat

cpu.max 里 quota/period 有余数时按多一个核算(比如 150000 100000 算 2 个核)。JDK 从 10 起默认开 UseContainerSupport,availableProcessors() 跟着这个数走;要覆盖就用 -XX:ActiveProcessorCount=N。

$ java -cp out QuotaProbe
availableProcessors=10

$ java -XX:ActiveProcessorCount=2 -cp out QuotaProbe
availableProcessors=2

$ java -XX:ActiveProcessorCount=4 -cp out QuotaProbe
availableProcessors=4

这三行是本机实跑:没有容器,所以第一种是 10 个物理核;后两种是参数强制覆盖的效果。本机没有 Docker、Podman、Colima,/sys/fs/cgroup 不存在,所以上面那两条 cat 命令没有在本机执行过,只给命令和判据。第 14.1 节会用容器实际验证配额与默认池大小的关系。

为什么这里要提配额:nvcswch/s 高不一定是线程数配错了,也可能是 cpu.max 把配额卡死了。一个 64 线程的池跑在 2 核配额里,和跑在 64 核里,看到的现象一模一样,处置却完全相反。先确认可用核数,再谈线程数。

常见错误与边界

  • 把 cs 高当原因。 切换率高通常是「锁竞争」或「线程过多」的结果。先看 cswch 与 nvcswch 的比例,再决定减线程还是改锁。
  • 用 load average 判断 CPU 饱和度。 macOS 的 load 包含不可中断等待,Linux 的 load 包含 D 状态;卡在磁盘上的机器可以 load 很高而 CPU 空闲。
  • 在容器里按宿主机核数配池。 这是最常见的容量事故:availableProcessors() 已经是配额后的值,而配置文件里写死了 poolSize=64。
  • 相信单一工具的线程数。 本机同一进程上 ps -M、jcmd、JVM 内部计数给出 84/18/70 三个数,用两个独立口径交叉验证。
  • 单次测出的 p99 当基线。 本文的调度延迟四次运行 p99 差 2.4 倍;把它写进告警阈值,会让告警随机器负载漂移。
  • 什么时候不要降切换率。 线程大部分时间在等 IO 时,切换率高是健康的,它意味着 CPU 被让给了别人。为了降 cs 去减线程,会把吞吐一起降掉。

小结

  • vmstat 的 cs 是系统级总数,pidstat 的 cswch/s 与 nvcswch/s 才是进程级、能区分「等锁」和「抢不到 CPU」的口径。
  • macOS 没有这两个命令;替代品是 top -stats ... csw、vm_stat、ps -M、sysctl,口径不能直接对齐,高压下还会给出互相矛盾的读数。
  • 纯计算任务在 8 线程后停止增长,每操作 CPU 成本恒定 1.6–1.7 ns;抢锁任务在 2 线程就掉一半,每操作 CPU 成本从 2.2 ns 涨到 121 ns。
  • 请求 1 ms 的 park,本机 p50 约 1.51 ms、p99 在 2–5 ms、max 十几到二十几 ms;这段延迟是测任何接口 p99 前必须扣掉的基线。
  • 容器里先确认 cpu.max 换算出的可用核数,再谈线程池大小;-XX:ActiveProcessorCount 能覆盖,但不能替代正确的配额。

阅读导航:上一节 1.1 Amdahl 与 USL:扩展性的天花板 | 下一节 1.3 吞吐、时延与并发的三角

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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