承接的问题
构造场景:libraryd-conc 在 K8s 上跑了两个版本。
版本 A:Deployment 只写了 requests.cpu: 500m,没有 limits.cpu。节点 64 核,JVM 认为自己有 64 个核,业务池 64 个线程、commonPool 并行度 63。节点空闲时吞吐很好;节点一忙,这个 Pod 的权重只相当于半核,64 个线程在半核的预算里互相排队,P99 从 40 ms 涨到 900 ms。
版本 B:补上了 limits.cpu: 2,JVM 正确看到 2,池子也按 2 配。但 P99 仍然每个 100 ms 出现一次 60–100 ms 的尖刺,像是被什么东西定时按住了。
两个版本的共同点是:JVM 里的 availableProcessors() 只能告诉你「配额折算成几个核」,不能告诉你「这一毫秒还有没有配额」。 版本 A 输在数字错了,版本 B 输在把静态数字当成了动态预算。
requests 与 limits 在 JVM 眼里是什么
K8s 的两个字段落到 cgroup 上完全不是一回事:
| K8s 字段 | cgroup v2 文件 | 作用 | JVM 是否感知 |
|---|---|---|---|
requests.cpu | cpu.weight | 相对权重,节点争抢时按比例分 | 否 |
limits.cpu | cpu.max(quota/period) | 硬上限,超过就节流到下一周期 | 是,折算成核数 |
limits.cpu(整数)+ Guaranteed + static 策略 | cpuset.cpus | 绑定 CPU 集合 | 是,按亲和性核数 |
requests.memory | 无(只影响调度) | 调度保证 | 否 |
limits.memory | memory.max | 硬上限,超过就 OOMKill | 是,用于堆推导 |
关键在第一行和第三行:requests.cpu 只变成权重,不产生任何上限。本机容器里用 --cpu-shares=512 模拟 requests-only,cpu.max 是 max 100000(无配额),availableProcessors 是 4(VM 的全部核数)。K8s 节点有 64 核时,这个数字就是 64。权重只在节点资源紧张时生效,而 JVM 在启动时就把 64 写进了线程池。
第二行的 limits.cpu 才是硬约束,但它有两个副作用:一是核数被向上取整(500m → 1),二是配额按 100 ms 周期重置。第二点是版本 B 的根因。
CFS 节流:availableProcessors 看不见的部分
CFS 的规则很简单:每个 100 ms 周期给 cgroup 一个配额,配额用完,组内所有线程一起被移出运行队列,直到下一个周期开始。limits.cpu: 2 意味着每 100 ms 最多跑 200 ms 的 CPU 时间——这不是「2 个核一直可用」,而是「2 个核时的额度,用完就停」。
用一个 4 线程自旋程序把这件事测出来。每个线程不断读 System.nanoTime(),记录相邻两次读数的最大间隔;如果线程被冻结,这个间隔会直接跳到几十毫秒:
import java.util.concurrent.atomic.AtomicLongArray;
/** 4 个自旋线程跑 3 秒,统计相邻两次 nanoTime 的最大间隔。 */
public class SpinGap {
public static void main(String[] args) throws Exception {
int threads = 4;
long deadline = System.nanoTime() + 3_000_000_000L;
AtomicLongArray maxGap = new AtomicLongArray(threads);
AtomicLongArray over10ms = new AtomicLongArray(threads);
Thread[] ts = new Thread[threads];
for (int i = 0; i < threads; i++) {
final int id = i;
ts[i] = Thread.ofPlatform().name("spin-" + i).start(() -> {
long last = System.nanoTime();
long max = 0, over = 0;
while (System.nanoTime() < deadline) {
long now = System.nanoTime();
long gap = now - last;
if (gap > max) max = gap;
if (gap > 10_000_000L) over++;
last = now;
}
maxGap.set(id, max);
over10ms.set(id, over);
});
}
for (Thread t : ts) t.join();
for (int i = 0; i < threads; i++) {
System.out.print("thread=" + i);
System.out.print(" maxGapMs=" + (maxGap.get(i) / 1_000_000));
System.out.println(" gapsOver10ms=" + over10ms.get(i));
}
}
}
本机 colima 容器(Docker 29.5.2,Ubuntu 24.04,cgroup v2,VM 4 核)实测:
无限制(4 核) maxGapMs=6 gapsOver10ms=0
--cpus=2(4 个自旋线程) maxGapMs=74 gapsOver10ms=29
--cpus=1 maxGapMs=85 gapsOver10ms=30
--cpus=0.5 maxGapMs=97 gapsOver10ms=30
3 秒里有 30 个 100 ms 周期,gapsOver10ms 也是 30 左右——每个周期都被冻结一次。对应的 cpu.stat:
--cpus=2 nr_periods 31 nr_throttled 30 throttled_usec 5968662
--cpus=0.5 nr_periods 32 nr_throttled 31 throttled_usec 10441363
throttled_usec 是组内所有任务被冻结时间的累加,不是墙钟时间,所以 0.5 核那行的 10.4 秒大于 3 秒。
现在回答版本 B 的问题:availableProcessors=2 是启动时算好的静态值,它不知道这个周期的配额已经被 GC 线程、JIT 线程和业务线程一起用掉了。池子按 2 配只保证「稳态下大致匹配」,保证不了「每个周期都不超额」。GC 一次停顿、一批 JIT 编译、一个突发请求,都可能把配额提前烧完,剩下的时间整组线程一起停到下一个周期。这不是线程池大小能解决的问题,只能靠减少总 CPU 消耗、拉长周期内的平滑度,或者给关键路径留出配额余量。
可直接套用的基线表
以下按 requests = limits(Guaranteed QoS,避免权重和配额两套逻辑打架)、MaxRAMPercentage=70、IO 密集 W/C=4.5(5.2 的实测值)计算。堆是计算值,线程数是公式值,不是压测结果:
| 规格 | CPU / 内存 | 最大堆 | ActiveProcessorCount | 业务池 | commonPool | GC 线程 | 线程总数预算 |
|---|---|---|---|---|---|---|---|
| S | 1 / 2Gi | 1434 MiB | 1 | 6 | 1 | 1 | ≈20 |
| M | 2 / 4Gi | 2867 MiB | 2 | 11 | 1 | 2 | ≈26 |
| L | 4 / 8Gi | 5734 MiB | 4 | 22 | 3 | 4 | ≈42 |
| XL | 8 / 16Gi | 11469 MiB | 8 | 44 | 7 | 8 | ≈73 |
线程总数预算 = 业务池 + commonPool + GC + JIT(分别 2/2/3/4)+ 杂项(约 10),用来核对 pids.max 和栈内存。比如 L 规格 42 个线程、每线程 1 MiB 栈,虚拟地址约 42 MiB,已提交页远小于此;但如果有人把业务池写成 200,这个预算立刻翻几倍。
三档之间的比例不是线性的:CPU 从 1 到 8 翻了 8 倍,业务池从 6 到 44 只翻了 7.3 倍,因为 N×(1+W/C) 里的 N 是乘数、1+W/C 是常数。低规格时 +1 和固定开销占比更大,高规格时才接近线性。
一份完整的 Deployment
下面这份可以直接改镜像和端口后套用。关键点都在注释里:
apiVersion: apps/v1
kind: Deployment
metadata:
name: libraryd-conc
labels:
app: libraryd-conc
spec:
replicas: 3
selector:
matchLabels:
app: libraryd-conc
template:
metadata:
labels:
app: libraryd-conc
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: registry.example.com/libraryd-conc:1.0.0
ports:
- name: http
containerPort: 8080
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:MaxRAMPercentage=70
-XX:ActiveProcessorCount=2
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/dumps
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "2"
memory: "4Gi"
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 2
failureThreshold: 30
timeoutSeconds: 1
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
failureThreshold: 2
timeoutSeconds: 1
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
volumeMounts:
- name: dumps
mountPath: /var/dumps
volumes:
- name: dumps
emptyDir: {}
几个决定的理由:
requests = limits:Guaranteed QoS,节点不会因为别的 Pod 争抢而挤压它,cpu.weight和cpu.max也不会互相矛盾。代价是调度更严格,集群需要留出相应容量。- 显式
ActiveProcessorCount=2:即使以后有人删掉limits.cpu,JVM 也不会突然按 64 核开线程。这是防回归,不是当前必需。 ExitOnOutOfMemoryError:堆 OOM 后进程直接退出,由 Deployment 重启,比「半死不活但探针还通过」好排查。HeapDumpPath指向 emptyDir:容器重启后 emptyDir 仍在(Pod 内),可以把转储捞出来;不要写容器根文件系统,会占满层。preStopsleep 5:给 Service 摘流和连接排空留时间,配合terminationGracePeriodSeconds: 30。
这份 YAML 只做了语法校验(PyYAML 解析通过),没有在真实集群上 apply——本机没有 K8s 集群,kubectl 也不存在。要验证运行时行为,用下面的命令读容器内的 cgroup 文件,格式与 Docker 里完全一致:
kubectl exec deploy/libraryd-conc -- cat /sys/fs/cgroup/cpu.max
kubectl exec deploy/libraryd-conc -- cat /sys/fs/cgroup/cpu.stat
kubectl exec deploy/libraryd-conc -- cat /sys/fs/cgroup/pids.current
这三条本节没有执行(无集群)。cpu.max 应该等于 limits.cpu × 100000;nr_throttled 持续增长说明配额不够或线程太多。
探针与启动时间
JVM 应用的启动时间比大多数服务长,探针配错会直接变成「启动即重启」的循环:
| 探针 | 路径 | periodSeconds | timeoutSeconds | failureThreshold | 容忍时间 |
|---|---|---|---|---|---|
| startup | /healthz | 2 | 1 | 30 | 60 秒 |
| liveness | /healthz | 10 | 2 | 3 | 30 秒 |
| readiness | /readyz | 5 | 1 | 2 | 10 秒 |
三条规则:
- 启动阶段只由 startupProbe 管。startupProbe 通过之前,liveness 和 readiness 都不会执行,所以 liveness 不需要
initialDelaySeconds。60 秒的预算要覆盖 JVM 启动 + 类加载 + 连接池预热;2 核 Pod 上 Spring 系应用经常要 30–45 秒,60 秒是下限,不是舒适值。 - liveness 只查进程自身。不要把数据库、下游 HTTP、消息队列放进 liveness;下游抖动会让所有 Pod 同时重启,把小故障放大成雪崩。下游健康放 readiness。
- readiness 要能快速失败。摘流比杀掉温和,
failureThreshold: 2让它在 10 秒内把流量摘掉。
另外,JAVA_TOOL_OPTIONS 会让 JVM 在标准错误打印一行 Picked up JAVA_TOOL_OPTIONS: ...,这是正常的,不要当成错误日志过滤掉——它恰好是确认参数生效的最快方式。
在 libraryd-conc 里落地
把 cpu.max 的解析和节流判据做成一个小类,启动时读一次、运行时定期读 cpu.stat:
package com.plume.conc.ops;
/** 解析 cgroup v2 的 cpu.max 与 cpu.stat,换算成「几个核」和「节流比例」。 */
public record CpuQuota(long quotaMicros, long periodMicros) {
public static CpuQuota parseV2(String cpuMax) {
String[] fields = cpuMax.trim().split("\\s+");
if (fields.length != 2) {
throw new IllegalArgumentException("cpu.max 需要两个字段: " + cpuMax);
}
long period = Long.parseLong(fields[1]);
long quota = fields[0].equals("max") ? -1 : Long.parseLong(fields[0]);
return new CpuQuota(quota, period);
}
public boolean limited() {
return quotaMicros > 0;
}
/** 与 HotSpot 的口径一致:向上取整,最少 1。 */
public int visibleProcessors() {
return limited() ? (int) Math.max(1, (quotaMicros + periodMicros - 1) / periodMicros) : -1;
}
public double quotaCores() {
return limited() ? (double) quotaMicros / periodMicros : -1;
}
/** nr_throttled / nr_periods;持续大于 0.2 就该看线程数和 GC 了。 */
public static double throttleRatio(long periods, long throttled) {
return periods == 0 ? 0 : (double) throttled / periods;
}
public static void main(String[] args) {
for (String s : new String[]{"200000 100000", "50000 100000", "max 100000"}) {
CpuQuota q = CpuQuota.parseV2(s);
System.out.printf("%-16s limited=%b quotaCores=%.2f visibleProcessors=%d%n",
s, q.limited(), q.quotaCores(), q.visibleProcessors());
}
System.out.printf("throttleRatio(31,30)=%.2f%n", throttleRatio(31, 30));
}
}
200000 100000 limited=true quotaCores=2.00 visibleProcessors=2
50000 100000 limited=true quotaCores=0.50 visibleProcessors=1
max 100000 limited=false quotaCores=-1.00 visibleProcessors=-1
throttleRatio(31,30)=0.97
visibleProcessors 与 HotSpot 的取整一致(0.5 核 → 1),quotaCores 保留真实值(0.5),两个数一起看才能发现「JVM 以为有 1 核、实际只有 0.5 核」。max 返回 -1 表示无配额,此时 JVM 会用节点核数,正是版本 A 的情形。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 只写 requests 不写 limits | 无配额,JVM 按节点核数开线程,节点繁忙时排队 | 写 limits,或显式 ActiveProcessorCount |
limits.cpu 带小数 | JVM 向上取整,池子按整数核配 | 知道取整方向,必要时按 quotaCores 再打折 |
| liveness 检查下游依赖 | 下游抖动触发全量重启 | liveness 只查进程,下游放 readiness |
| startup 预算按「最快启动」算 | 冷启动慢的节点反复被杀 | 按最慢节点算,留 50% 余量 |
认为 availableProcessors 能反映节流 | 池子配对了仍然有 100 ms 尖刺 | 读 cpu.stat 的 nr_throttled |
把 JAVA_TOOL_OPTIONS 的提示当日志错误 | 误删参数排查线索 | 保留该行,作为参数生效证据 |
什么时候不要用这套基线:任务是 CPU 密集型(业务池应该用 N+1,不是 N×(1+W/C));Pod 需要突发(limits 远大于 requests,此时 JVM 按 limits 算,但节点可能给不了那么多);或者服务有严格尾延迟要求(应该用 cpuset 绑定而不是 quota,避免 100 ms 周期的节流)。这三种情况都要重新算,不能直接套表。
小结
K8s 里 requests.cpu 只是权重,limits.cpu 才是配额;JVM 只感知后者,并把它向上取整成核数。availableProcessors 是启动时的静态快照,看不见 CFS 的 100 ms 周期,所以「池子按核数配对了」并不等于「不会被节流」。本机容器实测:无限制时 4 个自旋线程最大间隔 6 ms,--cpus=2 时 74 ms、3 秒内 29 次超过 10 ms,--cpus=0.5 时 97 ms;对应的 nr_throttled/nr_periods 是 30/31 和 31/32。
基线表的用法是「先按规格查堆和池子,再用 cpu.stat 和 pids.current 对账」。下一节离开运行时,回到代码层面:并发编码规范与静态检查,把这一卷讲过的坑变成可以在 CI 里拦住规则。
阅读导航:上一节 14.2 内存限制、线程栈与容器 OOM | 下一节 15.1 编码规范与静态检查
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。