前阵子我们上了一个订单查询服务,压测环境一切正常,结果一上正式环境,晚高峰还没到,监控面板上就开始刷 CPU 警报,P99 延迟从几十毫秒直接飙到 300 多毫秒,Kubernetes 里还频繁出现 OOMKilled 重启。查到最后才发现,问题根本不在业务代码,而在 Java 应用与 Kubernetes 的一次常规配置冲突。把 Spring Boot 应用塞进 K8s 时,很多团队都会遇到这个“成本与体验平衡”的难题:CPU 和内存给多了,资源浪费,月底对账心疼;给少了,性能下降,凌晨被报警电话叫醒。这篇文章就用我这几轮上线踩坑的经验,把容器里的 CPU、内存配置逻辑重新讲一遍,也把 Spring Boot 层怎么跟着配合讲透,适合正在做容器化或打算优化资源成本的团队参考。
1. 部署前先算清三笔“隐藏账单”:JVM、CPU限流和默认配置
很多人把 Java 应用迁移到 Kubernetes 时的第一反应是“JVM 应该能自动识别容器限制吧”。这个认知在早年的 JDK 8 里并不成立,就算放在现在的 JDK 17 上也不够,因为它只是“识别”,不代表“合理”。如果你不清楚容器给 JVM 带来的内存边界和 CPU 配额机制,后面的所谓优化方案基本都是在猜。
1.1 为什么直觉配置在容器里总是翻车
自带容器支持的 JDK 8u191 之前,JVM 只认宿主机,它看的是/proc/meminfo和 CPU 核心数,对 cgroup 的限制视而不见。宿主机 64GB 内存、32 核,Pod 限制 2GB、1 核,JVM 却仍按 64GB 来算默认堆大小,结果就是一个体面的 Java 进程在容器里经常被内核无差别杀死。JDK 8u191 以后默认启用-XX:+UseContainerSupport,JDK 10 及以后也默认启用,但这只是让 JVM 知道“我能用多少内存”,并没有告诉它“你该用多少”。
更常见的坑是团队手动指定-Xmx。一个内存 limit 为 2GB 的 Pod,你把-Xmx设成 4GB,在虚拟机时代也许只是“内存没利用好”,在容器里就是必现的 OOMKilled。反过来,如果不设-Xmx又完全相信默认值,官方默认的MaxRAMPercentage是 25%,一个 4GB 内存 limit 的 Pod 里,JVM 最大堆只给到 1GB,业务稍微上量,GC 频率立刻爆炸。
这里真正的问题在于:我们把 Java 的内存管理从“一个进程独占一台机”的模型,强行塞进了“容器限制 + 内核 OOM killer”的模型。JVM 是一个擅长自我管理的进程,但它管理的是进程内资源,不了解也不关心外面那层 cgroup 红线。上线前如果不把这条红线量化,之后所有性能排查都会像在迷宫里打转。
1.2 JVM 在容器里的真实内存构成
很多开发者看 JVM 内存只看堆,这是一个特别深的坑。一个 Spring Boot 应用实际占用的容器内存,是以下几部分叠加的结果:
| 内存区域 | 默认情况 | 不收敛时的后果 | 建议控制项 |
|---|---|---|---|
| Heap 堆 | 容器内存的 25%(默认MaxRAMPercentage) | 堆太小 GC 频繁,或堆设置过大被内核杀死 | -XX:MaxRAMPercentage=75.0 |
| Metaspace | 无固定上限 | 类加载器泄漏、动态代理多时持续增长 | -XX:MaxMetaspaceSize=192m |
| 线程栈 | 默认 1MB(64 位 JVM) | 200 个 Tomcat 线程可能吃掉过百 MB | -Xss256k或-Xss512k |
| 直接内存 | 默认约等于堆大小 | Netty/HTTP 客户端大量 ByteBuf 时爆掉 | -XX:MaxDirectMemorySize=128m |
| JIT Code Cache | 保留 240MB 左右 | 启动阶段快速提交,吃掉 native 内存 | -XX:ReservedCodeCacheSize=128m |
我实际见过一个典型案例,应用限制 2GB,堆设置 1.5GB,按说还剩 500MB 足够运行了。但业务一上来,Netty 的直接内存冲到了 700MB,线程栈又占掉 90MB,瞬间把 2GB 打穿。你盯着 Grafana 里的jvm.memory.used,怎么看都一切正常,因为那只看的是堆和已提交的 JVM 内存在,而容器 RSS 早就在悄悄爬升。
所以部署后第一件事,不是看业务指标,而是确认容器真实内存。在 cgroup v2 的节点上执行cat /sys/fs/cgroup/memory.current,在 cgroup v1 的节点上执行cat /sys/fs/cgroup/memory/memory.usage_in_bytes,再配合ps -o rss,cmd -p <pid>看进程实际内存。这个数值才是 Kubernetes 调度和 OOM 判断的依据,早看早安心。
1.3 CPU 限额如何变成延迟杀手
CPU 请求和限制在 Kubernetes 里是两层完全不同的含义。requests 决定调度,节点上所有 Pod 的 requests 之和不能超过可分配容量;limits 决定强制约束,Linux CFS 会按周期控制配额。默认 period 是 100ms,如果你给 Pod 设置了limits.cpu: 1,这个 Pod 在每个 100ms 周期内最多只能使用 100ms 的 CPU 时间。
注意,这是“周期内上限”,不是“平均上限”。假设某个 100ms 窗口内应用正好需要 120ms 的 CPU,用超配额之后,容器会被节流(throttle),线程被迫停下来等待下一个周期的配额。对 Java 应用来说,GC 尤其是 Full GC 是典型的突发 CPU 消耗,在 limit 很紧的情况下,JVM 刚想好好做一次 GC,就被 cgroup 按了暂停键,GC 时间从几十毫秒变成几百毫秒,业务线程全在后面排队。你在监控里看 CPU 使用率可能只有 60%,但cpu.stat里的nr_throttled一直在涨,这就是“CPU 没用满但延迟很烂”的真相。
我过去也天真地认为 CPU limit 设得越小越能省成本,后来发现完全不是这么回事。节点成本取决于 Pod 的 requests,而不是 limits。真正值得担心的不是一个 Pod 瞬时抢 CPU,而是某个异常应用把整台机器跑满。与其用很紧的 CPU limit 保护节点,不如把核心链路放到独立节点池,用 taint 和 toleration 做隔离。对大部分微服务,比较稳妥的做法是 request 给稳态值,limit 留足 2-4 倍余量,或者干脆只设 memory limit、不设 CPU limit,配合 HPA 做水平伸缩。当然,对时延极度敏感的 Guaranteed QoS 场景,你可以把 request 和 limit 设成相等,但前提是压测证明这个值合理,而不是拍脑袋。
2. 可落地的资源配置基线:请求、限制、JVM参数和压测数据
聊完原理,直接给一套我目前用的基线。配置不是为了好看,是为了在“性能”和“成本”之间找一个能复现、能调优的起点,起点错了后面再优化都是白干。
2.1 先定容器规格:requests 和 limits 的正确填法
面对一个新的 Spring Boot 服务,我先不确定线程池和 JVM 参数,先把内存和 CPU 的“信封”定下来。以普通接口型服务为例,我建议这样起步:
resources: requests: cpu: 500m memory: 512Mi limits: memory: 1Gi cpu: 2requests 为什么不是“够用就行”?因为它直接参与节点调度。请求 512Mi 就是为这个 Pod 预留 512Mi 的节点容量,即使应用实际只用了 300Mi,剩下的也不能被其他 Pod 使用。如果每个服务都把 request 设得虚高,集群就会提前买下一堆你根本用不上的节点。所以我更愿意把 request 设成“稳态水位 + 20% 安全边际”,而不是“峰值水位”,峰值交给 limit 和 HPA 去处理。
limits 里的 memory 是必须设的,否则容器内存只受节点资源限制,JVM 一旦出问题会把整台节点打挂。CPU limit 则要谨慎,太紧会触发前面说的 CFS 节流。你可以按 QoS 来选型:
| Kubernetes 资源组合 | QoS | 适用场景 |
|---|---|---|
| CPU/内存都不设限制 | BestEffort | 仅测试,不建议生产 |
| 有 request 且有 limit | Burstable | 生产最常见的形态,适合有波动的微服务 |
| request 等于 limit | Guaranteed | 交易核心链路、时延敏感服务 |
对需要严格保障的链路,Guaranteed 有它的价值,因为 Kubelet 驱逐 Pod 时会优先驱逐 usage 超出 request 比例更高的 Pod,Guaranteed 的 Pod 天然更安全。但代价是资源预留变高,成本也变高。大部分业务场景其实用不到这种保护,Burstable 配合合理的 request 就已经够了。
2.2 JVM 参数模板:我的标配与每个参数的取舍
我的 Dockerfile 或 Deployment 里的JAVA_TOOL_OPTIONS通常长这样:
-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:MaxMetaspaceSize=192m -XX:MaxDirectMemorySize=128m -XX:ReservedCodeCacheSize=128m -Xss256k -XX:+ExitOnOutOfMemoryError -XX:+UseG1GC逐个说下为什么。
MaxRAMPercentage=75.0:给堆留容器内存的 75%,剩下 25% 给线程栈、Metaspace、直接内存、Code Cache 和 JVM 自身结构。如果内存 limit 是 1GB,最大堆就是 768MB,对大多数 Spring Boot 接口型服务是够用的。InitialRAMPercentage=50.0:让 JVM 启动时先按 50% 初始化堆,而不是一开始就尝试提交全部堆内存。Spring Boot 启动阶段类加载和 Bean 初始化本身就吃内存,初始化堆太大会加剧启动期资源竞争,太小又会导致早期频繁扩容 GC。MaxMetaspaceSize=192m:Metaspace 默认没有硬上限,一个长期运行的服务如果热部署频繁或者用了很多动态代理,Metaspace 会缓慢上涨。不设上限,它就可能变成压死容器的最后一根稻草。MaxDirectMemorySize=128m:Netty、OkHttp、各种异步客户端都会用直接内存,默认值约等于堆大小,太危险。限制后至少能让溢出点变得可预期。ReservedCodeCacheSize=128m:JIT 编译产物存在这里,虽然实际提交的不一定到上限,但保留一个明确水位能避免启动阶段突然吃掉过多 native 内存。Xss256k:线程栈从默认 1MB 降到 256KB。200 个线程就是 200MB 和 50MB 的区别。如果业务里有很深的递归,谨慎一点用 512KB。ExitOnOutOfMemoryError:与其让 JVM 半死不活躺在那里,不如直接退出,让 Kubernetes 按restartPolicy快速拉起一个健康实例。UseG1GC:JDK 11/17 默认就是 G1,写出来是为了显式表达意图。ZGC 和 Shenandoah 的停顿更短,但 CPU 开销更高,并不适合追求省资源的场景,除非你的业务对 P99 有极其苛刻的要求。
这套参数不是银弹,但它是经过多次压测后能稳定跑住绝大多数服务的基线。它最大的价值在于把“不可控的默认值”变成“显式的、可解释的配置”,后面每个参数都可以根据实际监控继续调。
2.3 用一次压测把“猜测”变成“决策”
没有压测数据的资源参数都是玄学。我有一次自以为调得很完美,结果压测一开始,P99 直接被打到 500ms,才发现 CPU limit 设成了 1,而服务在高峰期需要 1.8 个核。
压测最好直接在 Kubernetes 集群里跑,因为本地环境没有 cgroup 限制,验证不了容器层的问题。用wrk或hey都行,一个简单的 10 分钟压力测试就能暴露出大问题:
wrk -t8 -c200 -d10m http://<service-host>/api/order/list压测过程中同时盯这几组数据:
kubectl top pod看实时 CPU 和内存/actuator/metrics/jvm.memory.used看堆内外分布/sys/fs/cgroup/cpu.stat看 CPU 节流次数- Grafana 或压测工具里的 P99、QPS、错误率
我自己某次对照压测的数据大致是这样,只说明相对量级,不是标准答案:
| 版本 | 容器规格 | QPS | P99 | Full GC 次数 | 10 分钟节流次数 |
|---|---|---|---|---|---|
| 默认 Tomcat 200 线程、纯默认 JVM | requests 1/1Gi,limits 1/1Gi | 760 | 280ms | 7 | 430+ |
| 调线程池 + JVM 参数 | requests 500m/512Mi,limits 2/1Gi | 1380 | 78ms | 0 | 10 左右 |
第一版看起来资源“给得更多”,实际性能更差。原因就是 CPU limit 太紧导致节流,线程栈太大导致内存紧张,触发 GC 增多,最后性能、成本两头输。第二版把 limit 放宽、线程数降下来、JVM 参数显式控制,资源占用反而更低。压测的意义就在这里:它逼着你把配置从“想象中合理”变成“数据上合理”。
3. Spring Boot 层还能再榨出 30% 资源:线程池、连接池和应用参数
Kubernetes 和 JVM 层面调完之后,Spring Boot 应用本身的默认参数还有不少可以扣的空间。这些参数改起来成本很低,收益却立竿见影。
3.1 默认的 Tomcat 线程池:200 个线程背后的成本
Spring Boot 内嵌 Tomcat 的默认最大线程数是 200。在 8 核 16GB 的老单体时代,200 个线程不算异常。但在 Kubernetes 里,一个 Pod 通常只有 1-4 个 CPU,200 个线程带来的收益很低,成本却非常明确:线程栈内存按 1MB 算就是 200MB,线程切换本身还要消耗 CPU。
更关键的是,Tomcat 的 worker 线程绝大多数时间都在等下游响应。一个请求如果平均响应时间 20ms,只要 QPS 不是特别夸张,50-100 个线程完全够用。我在配置里一般这样改:
server: tomcat: threads: max: 100 min-spare: 10 accept-count: 200压测时专门盯着 Tomcat busy threads 这个指标看。如果长时间贴近 max,说明线程确实不够,再加不迟;如果一直只有二三十,那说明 max 设 200 纯属给 JVM 增加无谓的内存和调度开销。线程池不是越大越好,而是“刚好覆盖住下游等待时间下的 QPS 峰值”才是最好。
3.2 连接池与异步任务:别让数据库成为隐形瓶颈
Spring Boot 默认的 HikariCP 连接池,maximum-pool-size是 10。单个 Pod 10 个连接看着不多,但如果你有 20 个副本,就是 200 个数据库连接,很多数据库实例会被多余连接拖垮。更麻烦的是,连接池和线程池如果都设得很大,数据库一抖动,就可能出现“业务线程在连接池排队 + 连接池同时建立新连接”的双重冲击,最终把数据库打挂。
建议按“数据库最大连接数 / 期望副本数”来倒推。举个例子,数据库上限 100,目标 10 个副本,那每个 Pod 设 5-6 个就够,还要留一些余量给 DBA 的运维连接。别迷信连接池越大吞吐越高的说法,对大多数场景,小连接池反而能减少锁竞争,提升偶发高峰时的稳定性。
@Async的默认线程池也要单独处理。Spring Boot 自动装配的TaskExecutor,如果没做任何配置,队列可能是无界的。无界队列意味着流量一旦上来,任务会在内存里无限排队,先把 Pod 撑爆,而不是触发限流或背压。我建议凡是用到@Async的地方,都显式定义一个带队列上限或CallerRunsPolicy的线程池,宁可丢弃任务让调用方感知超时,也不能让任务堆积变成内存事故。
3.3 启动阶段优化:懒加载、健康检查与原生镜像的现实选择
资源优化不只发生在运行期,启动阶段也有文章可做。Spring Boot 默认会在启动时实例化很多 Bean,哪怕这些 Bean 要等很久之后才会被第一次调用。开启spring.main.lazy-initialization=true可以把一部分 Bean 的实例化延迟到首次访问,起点内存占用确实能降下来,启动时间也会缩短。
但懒加载不是免费的午餐。它改变了 Bean 初始化的时机,第一次真实请求可能要付出额外的初始化延迟,而且一些坑只有在流量到达时才会暴露,比如初始化抛出的异常从启动期转移到了运行期。如果你要用,必须配合 Kubernetes 的startupProbe,给应用足够长的“热身窗口”,不要用默认的 readiness 判定。对愿意投入更多成本的项目,GraalVM Native Image 是另一个方向,内存能从 1GB 级别降到一两百 MB,但构建时间、反射开销、动态代理兼容性都是门槛,适合团队有足够预算去踩坑时再考虑。
4. 从单机省钱到整体省钱:弹性伸缩与容量规划的先后顺序
单 Pod 优化完之后,接下来是整个集群层面的资源治理。很多人只盯着单机参数,却忽视了一个事实:Kubernetes 的成本主要取决于你预留了多少资源,而不是应用实时用了多少。所以伸缩策略和容量规划比 JVM 参数更能决定月底账单。
4.1 为什么只按 CPU 做的 HPA 不太适用于 Java 服务
HPA 的 CPU target 配置起来很简单,但对 Java 服务并不可靠。JVM 的 GC 和 JIT 编译会带来周期性 CPU 突刺,如果 target 设成 60%,一次 Full GC 就可能把 CPU 瞬时顶到 80%,HPA 看了一眼就扩容,等 GC 结束马上又不需要了,五分钟之后再缩容。Pod 频繁上下线,本身也会引起连接池重建、缓存冷启动和负载不均。
内存维度做 HPA 更麻烦。JVM 即使做了 GC,也未必把内存归还给操作系统,RSS 可能一直维持高位。你按内存缩容,它迟迟不缩;你按内存扩容,它又容易过度扩容。我个人不太建议 Java 服务单纯用内存利用率做 HPA,除非你能保证应用内存模型非常规整。
更好的做法是给 HPA 加上冷却时间,比如scaleDown的 stabilization 窗口设到 5-10 分钟。Java 服务的自动扩容应该是“慢缩快伸”,扩容可以激进一点,缩容一定要稳住。
4.2 用业务指标驱动 HPA:Micrometer + Prometheus + KEDA
比 CPU 更贴近真实负载的是业务指标。Spring Boot 集成micrometer-registry-prometheus后,/actuator/prometheus会暴露一组现成的 Web 请求指标,比如http_server_requests_seconds_count。用这个指标驱动 HPA,会比 CPU 扩得准得多。
如果集群里已经有 Prometheus,可以接入 KEDA,配置一个基于请求速率的伸缩规则:
apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: order-service-scaledobject spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: order-service pollingInterval: 30 cooldownPeriod: 180 minReplicaCount: 3 maxReplicaCount: 10 triggers: - type: prometheus metadata: serverAddress: http://prometheus.observability.svc.cluster.local:9090 query: | sum(rate(http_server_requests_seconds_count{ application="order-service" }[2m])) threshold: "1200"KEDA 会把查询结果除以当前副本数,再和 threshold 比较,决定扩缩方向。threshold 写成 1200,表示希望每个副本平均每秒处理 1200 个请求。cooldownPeriod: 180是给缩容踩刹车,避免流量一抖就疯狂缩容。
使用业务指标的好处是伸缩依据和用户体验直接相关:请求多了就扩,请求少了就缩。CPU 指标则更适合作为兜底,和业务指标组合使用,而不是单一依赖。成本优化的核心之一就是这个“不漏扩、不错缩”的伸缩节奏。
4.3 加实例还是加规格,核心看单实例吞吐曲线
资源优化到一定程度,会回到一个经典问题:服务性能不够,到底是加副本还是加单实例规格?我的判断标准很简单,看“单实例吞吐曲线”会不会随着 CPU/内存提升而继续上升。
如果服务是 CPU 密集或内存缓存型,比如本地有 Caffeine 缓存、计算逻辑重,加大规格往往有直接收益,因为数据局部性和计算资源都变好了。如果服务是典型的 I/O 密集型,比如大量时间在等数据库、等外部 API,那么加内存只是把堆搞大,堆大 GC 停顿反而变长,加到一定规格后性能提升会严重钝化。这种情况下,横向加副本更划算,还能顺带提高可用性。
另外一个容易忽略的点:资源请求是乘法。3 个副本 × 1Gi 请求就是 3Gi,6 个副本 × 512Mi 请求也是 3Gi。横向扩展时要注意总请求量,不要让“加副本”变成“悄悄加成本”。我个人在做容量规划时,会先按业务预估 QPS 算单实例能扛多少,再反推副本数,最后看总 requests 是否超过集群余量。链路是反着的:先有业务目标,再定资源规格,而不是先拍一个副本数再说。
4.4 节点级成本:Cluster Autoscaler 与混部
集群节点的数量最终由所有 Pod 的 requests 总和决定,而不是实际使用量。如果每个团队都把 request 写大,Cluster Autoscaler 就会以为资源不够,不停买新节点,月底账单直接失控。所以前面的单 Pod 参数优化,到集群层会被放大成一个很可观的成本数字。
我给团队定了几条规矩。第一,request 按稳态值填,不准按“理论最大并发”填,峰值由 limit 和 HPA 管。第二,在 Namespace 上用 LimitRange 约束 CPU 和内存的默认值及最大值,防止有人不写 resources 就上线。第三,开启 Vertical Pod Autoscaler 的 Recommender 模式,让 VPA 基于历史运行时长出建议的 requests,人审完再改。第四,对可容忍驱逐的离线任务或无状态服务,可以部署到抢占式/竞价节点池,进一步压低成本,但前提是应用能优雅应对节点回收。
这些动作是在“单 Pod 不浪费”之后,进一步避免“集群级浪费”。很多人花大量时间调 JVM,却放任集群里一大堆 Pod 的 requests 虚高,这其实是最不划算的优化顺序。
5. 两个最常见的“配置坑”完整排查链路
不管前面的基线多完善,生产环境总会给你“惊喜”。这里记录两个我实际排查过的经典问题,它们的共性非常强:表象都在监控面板上,但根因都在配置和内存边界上。
5.1 场景一:内存limit够,Pod还是被OOMKill
现象很典型:Pod 内存 limit 2GB,JVM 堆 1GB,怎么看都够,但运行数小时后容器被 OOMKilled,重启后过一阵又来一次。
排查时,我会按这个链路走下去。
第一步,看 JVM 自身的各项内存:
curl http://localhost:8080/actuator/metrics/jvm.memory.used?tag=area:heap curl http://localhost:8080/actuator/metrics/jvm.memory.used?tag=area:nonheap curl http://localhost:8080/actuator/metrics/jvm.buffer.memory.used如果area=nonheap和buffer.memory.used都在正常范围,但容器内存持续走高,那就得把视角从 JVM 内部挪到 JVM 外部。
第二步,看容器实际使用量:
cat /sys/fs/cgroup/memory.current # cgroup v2 cat /sys/fs/cgroup/memory/memory.usage_in_bytes # cgroup v1第三步,开启 Native Memory Tracking 定位堆外内存来源:
-XX:NativeMemoryTracking=summary运行一段时间后:
jcmd <pid> VM.native_memory summary输出里会把内存分为 Java Heap、Class、Thread、Code、GC、Compiler、Internal、Other 等区域。只要看到某一块异常增长,方向就有了。我处理过最多的情况是 Internal 和 Other 持续上涨,背后基本都是 Netty 的 PooledByteBuf 和直接内存没有正确释放。修复动作分两步:先配置层把MaxDirectMemorySize和io.netty.maxDirectMemory压到一个合理水位,再代码层排查 ByteBuf 是否在 finally 里 release。配置层只能让“失控时间变慢”,代码层才是根治。
Metaspace 泄漏也是同样的排查链路,只是增长区域会集中在 Class。遇到这种问题,先看是不是动态生成类的地方没有清理,比如 CGLIB 代理、反射框架、频繁的热部署环境。
5.2 场景二:CPU没跑满,延迟却出现规律性毛刺
另一个诡异的现象是:监控面板上 CPU 使用率只有 50%-60%,但每隔几分钟 P99 就会规律性地冒出一个尖刺,接口偶尔超时。
第一步先排除业务慢调用,如果数据库、外部 API 都没问题,那就怀疑容器 CPU 节流。直接进入 Pod 看一眼:
kubectl exec -it <pod> -- cat /sys/fs/cgroup/cpu.statcgroup v2 节点上重点关注这几个字段:
usage_usec user_usec system_usec nr_periods nr_throttled throttled_usec如果nr_throttled在压测几分钟内涨了几百次,throttled_usec达到秒级,基本可以确定是 CPU limit 太紧导致的节流。即使 CPU 使用率没到 100%,只要某一个瞬时窗口用超了配额,CFS 就会强制暂停容器。
解决方向有两个:一是把 CPU limit 调高,或者只保留 request、去掉 limit;二是限制 JVM 的并行度,因为 G1 和 Parallel GC 会按可用核心数创建 GC 线程,如果 JVM 识别到的核心数远超实际可用的 CPU 配额,GC 线程会把配额瞬间吃光。必要时用-XX:ActiveProcessorCount=2或-XX:ParallelGCThreads=4手动压一下,让 JVM 的线程池数量和容器配额匹配。
这两个问题说明一个共同的道理:Kubernetes 里的 Java 性能问题,很多时候不是“代码不够快”,而是“资源边界没有和 JVM 对齐”。代码写得再干净,也架不住 NMT 里某块内存无限上涨,或者 CFS 每隔几分钟就把线程掐一次。
我自己现在养成了一个习惯:每次上线前,先把容器的cpu.stat和memory.current打出来,跑一轮压测把节流次数和内存峰值记录下来,再决定要不要调 request、limit 和 JVM 参数。这套流程不保证能一次找到最优解,但至少能让你在产品出现问题时,少一层“配置没对齐”的猜测。