承接的问题
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
关键列的口径:
| 来源 | 列 | 含义 | 用它能判断什么 |
|---|---|---|---|
vmstat | r | 可运行队列长度 | 长期大于核数,说明线程真的太多 |
vmstat | cs | 每秒上下文切换总数(系统级) | 只能看趋势,不能定位到进程 |
vmstat | us / sy | 用户态 / 内核态 CPU 占比 | sy 高伴随 cs 高,是切换开销的签名 |
pidstat -w | cswch/s | 自愿切换:线程主动让出 | 高,说明在等锁或等 IO |
pidstat -w | nvcswch/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。能力不完全对等。
| 想看 | Linux | macOS(本机) | 差距 |
|---|---|---|---|
| 运行队列 | vmstat 的 r | top -l 1 -n 0 的 N running | 只有瞬时值,没有每秒序列 |
| 内存压力 | vmstat 的 si/so | vm_stat 的 Pages free / compressor | 口径不同,不能直接比 |
| 进程切换率 | pidstat -w | top -l 1 -pid <pid> -stats pid,threads,csw | 累计值,要两次采样做差 |
| 线程列表 | ps -L / top -H | ps -M -p <pid> | 一条线程一行,无 CPU 列 |
| 核数 | nproc | sysctl -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 累计 | 切换率 |
|---|---|---|---|
| 1 | 21/1 | 5 582 → 8 605 | 605 /s |
| 8 | 28/8 | 41 874 → 66 592 | 4 944 /s |
| 64 | top 返回 #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 吞吐、时延与并发的三角
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。