《Java 并发编程·性能诊断与事故》14.3 K8s 下的并发参数基线

同样的 2 vCPU,为什么 requests-only 的 Pod 开 63 个线程、limits=2 的 Pod 每 100 ms 被冻结一次?本节用 cpu.max、cpu.stat 与实测间隔证明 availableProcessors 看不见 CFS 节流,给出 1/2/4/8 核四档 requests、limits、堆、线程池与探针的基线表和一份完整 Deployment。

承接的问题

构造场景: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.cpucpu.weight相对权重,节点争抢时按比例分否
limits.cpucpu.max(quota/period)硬上限,超过就节流到下一周期是,折算成核数
limits.cpu(整数)+ Guaranteed + static 策略cpuset.cpus绑定 CPU 集合是,按亲和性核数
requests.memory无(只影响调度)调度保证否
limits.memorymemory.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业务池commonPoolGC 线程线程总数预算
S1 / 2Gi1434 MiB1611≈20
M2 / 4Gi2867 MiB21112≈26
L4 / 8Gi5734 MiB42234≈42
XL8 / 16Gi11469 MiB84478≈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 内),可以把转储捞出来;不要写容器根文件系统,会占满层。
  • preStop sleep 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 应用的启动时间比大多数服务长,探针配错会直接变成「启动即重启」的循环:

探针路径periodSecondstimeoutSecondsfailureThreshold容忍时间
startup/healthz213060 秒
liveness/healthz102330 秒
readiness/readyz51210 秒

三条规则:

  1. 启动阶段只由 startupProbe 管。startupProbe 通过之前,liveness 和 readiness 都不会执行,所以 liveness 不需要 initialDelaySeconds。60 秒的预算要覆盖 JVM 启动 + 类加载 + 连接池预热;2 核 Pod 上 Spring 系应用经常要 30–45 秒,60 秒是下限,不是舒适值。
  2. liveness 只查进程自身。不要把数据库、下游 HTTP、消息队列放进 liveness;下游抖动会让所有 Pod 同时重启,把小故障放大成雪崩。下游健康放 readiness。
  3. 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 编码规范与静态检查

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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