☰
Spring Boot 在 K8s 中资源占用减半:JVM 与 GC 优化实战
2026/10/21 12:30:18 网站建设 项目流程

写这篇之前,我刚把某个内部订单查询服务的内存占用从 1.5Gi 压到 512Mi,CPU 限制从 2 核降到 1 核,P99 延迟反而降了 30%。整个过程没有动业务代码逻辑,纯粹是围绕 Spring Boot 的 JVM 配置、K8s 资源模型和垃圾回收策略做了一次系统性梳理。如果你也在 K8s 上跑 Spring Boot,应该能理解那种"内存看着不高但一压就崩、CPU 明明有余量但延迟就是不下去"的憋屈感。这篇我就把这次优化的完整思路、参数依据和踩过的坑写出来,不搞玄学,只讲能落地的方案。

先说结论:Spring Boot 在 K8s 里资源占用高,绝大多数时候不是业务代码的问题,而是 JVM 默认行为、容器资源模型和 Spring 容器初始化三方叠加造成的。只要能分清楚哪部分内存是刚需、哪部分是缓存浪费、哪部分延迟是该背的锅,你完全可以在不牺牲性能的前提下把资源占用砍掉一半以上。

1. 为什么同样的 JVM 参数,容器里和物理机上完全不是一个量级

很多人在物理机上调过 JVM,到 K8s 里还是沿用同一套思路,结果发现内存疯狂被打爆。这里面的核心差异在于:JVM 看到的资源边界和 K8s 眼中的容器边界,默认情况下根本不是一回事。

1.1 JVM 默认只认宿主机配置,不认容器限制

JDK 8u191 之前,JVM 不感知 cgroup,直接读取宿主机总内存和 CPU 核数来初始化堆。你在宿主机上明明有 64G 内存,容器只给了 512Mi 限制,JVM 依然按照 64G 的默认堆比例去计算 MaxHeapSize,最后的结果就是容器一启动就不断被 OOMKilled。

现在虽然大多数团队已经上了新 JDK,但如果你的基础镜像还停留在旧版本,或者 Dockerfile 里忘了加-XX:+UseContainerSupport,这一刀还是躲不过。更隐蔽的问题是:即便 JVM 感知了容器限制,它的默认比例依然偏保守或偏激进,取决于你用的是哪个发行版、哪个默认 GC。

我建议的做法是:不再依赖 JVM 自动感知,直接在启动参数里显式告诉它容器边界。核心就三个参数:

-XX:InitialRAMPercentage=62.5 -XX:MaxRAMPercentage=62.5 -XX:MinRAMPercentage=62.5

为什么是 62.5?因为 62.5% 正好是 8/16 的十进制表示(8 / 16 * 100 = 62.5)。当容器限制是 1024Mi 时,JVM 堆上限会被设定为 640Mi,剩下的 384Mi 留给堆外内存。这个比例不是拍脑袋定的,而是我压了十几个服务之后找到的平衡点:既给了堆足够的空间,又留足了 Metaspace、线程栈、直接缓冲区的额度。

1.2 requests 和 limits:不是越多越好,而是要给足"余量"

K8s 里的资源模型我一直强调这么理解:requests是担保额度,limits是硬上限。如果你把 requests 设成 2Gi,limits 设成 4Gi,调度器会按照 2Gi 去排布 Pod,但节点上的其他 Pod 挤占内存时,你的 Pod 可能会被驱逐,因为它在节点视角的实际占用早已超过 cgroup 限制,只是还没到硬杀线。

对 Spring Boot 应用来说,我推荐requests = limits的做法,除非你有严重的超卖需求。理由是:JVM 的内存占用是锯齿形的,GC 周期内堆会波动,Metaspace 会增长,线程栈会随着并发升高而膨胀。如果 requests 压得太低,limits 设得高,内存压力一上来,K8s 会优先杀掉你,而不是给你缓冲。反过来,如果 requests 等于 limits,调度器会老老实实预留空间,节点崩溃率和规避率都会大幅下降。

真实的参数组合我常用这种形式:

resources: requests: memory: 512Mi cpu: 500m limits: memory: 512Mi cpu: 1

这里有个容易被忽略的细节:CPU 的 requests 和 limits 可以不同,因为 CPU 是可压缩资源,但内存必须慎之又慎。CPU 临时超限只会被限流,内存超限会直接触发 OOMKill,没有商量余地。

1.3 容器感知之外的坑:JVM 把宿主机核数当作并行线程数

JVM 的 GC 线程数、ForkJoinPool 并行度、JIT 编译线程数,默认都跟Runtime.getRuntime().availableProcessors()相关。老版本 JDK 在容器里可能拿到宿主机核数,导致 G1 GC 在只有 1 核的容器里硬生生开 8 个 GC 线程,线程上下文切换的代价远比 GC 本身更可怕。

新 JDK 在大多数情况下能正确读取 cgroup 的 CPU 配额,但 Docker 的--cpus和 K8s 的cpu: 1折算方式在不同运行时上会有偏差。我自己在压测中就遇到过:明明 Pod 限了 1 核,JVM 却认为有 4 核可用,导致 ParallelGCThreads 被初始化成 4。此时容器被限流时,线程时间片被大量浪费在等待上,用户请求延迟暴涨。

解决办法有两种。第一种是用-XX:ActiveProcessorCount=1手动覆盖,第二种是调整线程池参数,确保server.tomcat.threads.max不要超过核数的可服务并发上限。对单核容器来说,Tomcat 默认 200 线程是没有意义的,因为能同时执行的只有 1 个请求,剩下 199 个都在排队,白白吃掉堆栈内存。

2. 内存从哪省:堆只是明账,堆外才是无底洞

Spring Boot 应用的内存大头往往不是堆,而是堆外。我见过太多团队把堆调小了 200Mi,结果总内存一点没降,因为线程栈、Metaspace、直接缓冲区、JIT 编译缓存加起来比堆还要嚣张。

2.1 内存账本:按这个公式预测你的总占用

我给团队定的内存估算公式很简单,运行一段时间后用 NMT(Native Memory Tracking)去验证:

总内存 ≈ 堆 (MaxHeap) + Metaspace + 线程栈 × 线程数 + 直接缓冲区 + JIT 编译缓存 + GC 结构开销

其中最容易失控的是Metaspace。Spring Boot 加载了大量 class 字节码和注解元数据,如果不设上限,Metaspace 会随着动态代理、CGLIB 增强、Groovy 脚本加载无限增长。所以-XX:MaxMetaspaceSize一定要设,不能只设MetaspaceSize。我习惯的取值是容器限制内存的 10% 左右,比如 512Mi 容器就给 64Mi。

直接缓冲区是第二个黑洞。Spring Boot 里如果用了 Tomcat 的 NIO connector,或者框架内部调用了ByteBuffer.allocateDirect,这些内存不占 JVM 堆,但占容器地址空间。压测时你会发现堆挺健康的,内存却一直涨,很可能就是直接缓冲区泄漏。这类问题排查起来很痛苦,但防患于未然的手段也不复杂:显式设置-XX:MaxDirectMemorySize,并且使用-Dio.netty.maxDirectMemory控制 Netty 等框架的缓冲区上限。

2.2 线程栈:动一刀,省出 50Mi 起步

Java 线程栈默认大小是 1MB(x86 架构下为 1Mi)。一个 Spring Boot 应用启动后,Tomcat 线程池、业务线程池、调度线程池、GC 线程加在一起轻轻松松 100 个线程。每个线程 1Mi 栈内存,就是 100Mi。这就是为什么你明明只配了 256Mi 堆,整个进程 RSS 却到 400Mi 的关键原因之一。

把-Xss降到 512K 是我几乎每个服务都会做的一步。绝大部分业务方法的调用深度根本用不到 1Mi,除非你写了很深的递归。512K 对 Spring MVC 的请求链路完全够用。这里有个隐蔽的关联:如果你的代码用了第三方库构建大调用栈(例如复杂表达式求值),降栈内存后会出现StackOverflowError,这个只能在压测里发现,别等上了生产再炸。

如果是 JDK 21 及以上的虚拟线程,情况会完全不同。虚拟线程的栈是堆外动态增长的内存块,默认大小可以从-Djdk.virtualThreadScheduler.parallelism调。但 Spring Boot 3.2 的虚拟线程支持还没到无脑投入生产的地步,我自己的实践结论是:对 IO 密集型服务值得试,对计算密集服务不要碰,具体见后文。

2.3 Spring 容器自身的内存开销:两大枚"隐形地雷"

Spring Boot 启动时要做的事情非常多:扫描 classpath、解析配置、初始化 BeanFactory、实例化单例、注册监听器。这些对象的生命周期有的在堆上,有的在 Metaspace 的 class 元数据区。你以为 200Mi 堆能跑起来的轻量服务,因为扫不到入口疯狂自动配置,堆一下子就涨到 500Mi。

第一枚地雷是自动配置的盲目加载。Spring Boot 的spring-boot-autoconfigure模块里躺着几百个配置类,默认按 classpath 检测触发。只要你的pom.xml里引了某个 starter 的依赖,对应的自动配置就会启动初始化,即使你业务里根本用不到。每多一个自动配置类,JVM 就要多加载一批 class、多解析一堆注解、多实例化一些 Bean。这在项目早期不明显,等依赖一多,内存蹭蹭往上涨。

解决方案不是把自动配置全关掉(那等于自己重新实现 Spring Boot),而是做一次spring.factories扫描后,把确定用不到的配置类显式排除。比如你只用了 WebMVC 和 Jackson,没有用 Quartz、没有用 Kafka、没有用 Cassandra,那就在配置里写:

spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration - org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration

每排除一个配置类,启动耗时和堆用量都有肉眼可见的下降,特别是在容器规格很低的情况下。

第二枚地雷是懒加载被默认关闭。spring.main.lazy-initialization默认是 false,意味着所有单例 Bean 都在启动阶段实例化,好处是后续请求过来都是热路径,坏处是启动瞬间内存峰值很高。对 K8s 这种追求"快速启动、快速杀死"的调度模型来说,启动峰值往往是内存限制的致命一击。

开启懒加载后:

spring: main: lazy-initialization: true

启动耗时可能会变短,因为 Bean 实例化被推迟到了首次访问。但注意:懒加载牺牲的是首个请求的延迟。如果某个 Bean 是请求关键路径上的,且初始化很重,第一个请求会慢得很明显。我的折中方案是:只对非核心链路 Bean 开启懒加载,核心 Bean 保持 eager。如果你用的是 Spring Boot 3,可以基于@Lazy注解粒度控制,比全局开关更精细。

2.4 镜像层减重:跳出一个 JRE 的执念

内存优化的另一个隐形成本就是 JVM 自身运行时的大小。同一个 Spring Boot 应用,用完整 JDK 镜像跑和用 JRE 镜像跑,Metaspace 差异不大,但整体 RSS 会差出 50Mi 到 100Mi,因为 JRE 的模块系统只加载必需组件。

如果你追求极致,可以用jlink生成自定义运行时镜像,把不需要的模块全剪掉。之前我一个只做 HTTP API 的服务,用 jlink 把模块裁剪到只保留jdk.crypto.ec、java.sql、java.net.http等十几个,镜像大小和运行时内存都有明显下降。代价是构建流程复杂度上升,维护起来需要一点心思。

再激进一点就是 Spring Native(GraalVM 原生镜像)。这个方案的吸引力在于:启动时间从秒级降到毫秒级,JVM 运行时内存几乎可以忽略。但坑也非常多:反射、动态代理、CGLIB 在原生镜像下都需要显式声明,很多第三方库不兼容,排错成本很高。我的建议是:只在小而稳定的服务上做试点,不要一上来就全量迁移。内存优化要的是可预期的收益,而不是灰度期内把团队精力耗光的风险。

3. CPU 限制的真相:减核不是砍性能,而是逼你优化调度

很多人一看到 CPU 配额下降就紧张,担心吞吐量崩掉。但我在压测里发现,Spring Boot 应用在 K8s 里 CPU 利用率长期低于 20% 的比比皆是。真正拖住延迟的不是核数不够,而是 JVM 线程模型和 GC 策略没有跟容器规格匹配。

3.1 一个容易翻车的现象:CPU 限流与 GC 时间

容器规格是cpu: 1的时候,如果你的 JVM 认为有 4 核,GC 线程数会被初始化成 4。G1 的并行回收阶段会尝试在多个线程上同时扫描堆,但这些线程实际只能在 1 核上轮流跑,于是每个 GC 阶段的时间被拉长四五倍,GC 暂停很容易突破几百毫秒,用户侧表现就是周期性延迟尖峰。

这就是为什么面对 K8s 部署,设置-XX:ActiveProcessorCount=1是优先于-XX:UseG1GC要考虑的事情。让 JVM 的线程调度模型和 cgroup 配额一致,是先决条件。否则你后面做任何 GC 选型都像在流沙上盖房子。

在此之上,要真正理解:在 1 核容器里,并行 GC 不如吞吐量优先的串行 GC,串行 GC 又不如低停顿 GC 感知好(视 SLA 而定)。这个结论听起来反直觉,因为串行 GC 往往给人性能很差的印象。但仔细算一下:1 核环境下,Parallel GC 的多个 GC 线程无法并行执行,反而因为线程协同和状态扫描增加了额外开销。而 Serial GC 单线程处理,在堆小(比如 512Mi)的时候,Young GC 完成时间通常在 10ms 级,对请求的影响完全可控。

如果你对延迟有更高要求,可以考虑 Shenandoah GC 或 ZGC。但这里有个成本问题:ZGC 会通过额外的 GC 线程来做并发标记、并发清理,CPU 占用会比 G1 高出不少。ZGC 的设计目标是超大堆低延迟,用在小堆上属于杀鸡用牛刀,还会把 CPU 预算吃掉。Shenandoah 对 1-2 核小堆更友好,但配置和实验门槛高。我的经验是:512Mi~1Gi 这种小型服务,优先试 G1 配缩减参数,不行再切 Serial GC,尽量不要一上来就上 Shenandoah。

3.2 从"按核分配线程"到"按业务动态伸缩"

Spring 默认的 Tomcat 线程池是max=200,这个数字在物理机 8 核 16G 的时代叫合理,在 K8s 1 核 512Mi 的容器里就是灾难。200 个线程意味着每个线程要预留 512K 栈空间,光线程栈就吃掉 100Mi。而且线程数量超过 CPU 核数几十倍时,操作系统调度负担上升,上下文切换会耗掉大量时间片。

Tomcat 线程池应该按业务特征重新设。核心判断指标是:单个请求的 CPU 耗时(time per request)大概是多少。如果是纯 IO 型(等待下游返回为主),100 个 Tomcat 线程在 1 核容器里尚可接受。如果你的是 CPU 计算型,线程数接近核数即可,设 200 纯粹是浪费。

更现代的方案是 Spring Boot 3.x + Java 21 虚拟线程。虚拟线程的优点是:百万级线程的成本远低于平台线程,因为栈不再是固定的 1Mi,而是按需增长的块。把这个跟spring.threads.virtual.enabled=true搭配起来,Tomcat 可以只配 16 个物理线程,但支撑很高的并发数。我实测过典型的 API 网关和下游调用型服务,虚拟线程在资源受限环境下对 P99 提升非常明显。

但必须提醒:虚拟线程不是万能药。如果代码里有synchronized块、Thread.sleep、JNI 调用,虚拟线程会挂载到平台线程上发生阻塞,反而可能比普通线程更糟。迁移虚拟线程之前,请先用 Java Flight Recorder 排查阻塞点,确认没有大量 synchronized 热点再说。

3.3 控制 CPU 预算:对 Spring Boot 的启动和后台任务做节流

Spring Boot 启动阶段要做很多初始化工作,这些工作完全不吃 k8s CPU 配额的话,会导致启动时间被拖长。更严重的是,如果limits.cpu设得太低,启动阶段被限流,Pod 迟迟没有 ready,还会被反复重启,造成恶性循环。

我惯用的手段是对核心服务给一个稍高的 CPU limit 做"启动加速",等流量平稳后再通过 HPA 缩配额。听起来像是绕远路,但实测能减少 30% 的启动超时。更精细的做法:给 JVM 的 JIT 编译线程数设个上限,避免启动瞬间多个编译线程吃满 CPU。

-XX:CICompilerCount=2

这个参数很不起眼,但对低核容器很重要。JIT 编译器线程一多,启动时的 CPU 争抢会让 GC 和业务线程饿肚子。我后来在多个项目里都养成了限制CICompilerCount的习惯,默认 2 个编译线程对于绝大多数 Spring Boot 应用足够。

4. 一次真实的组合拳压测:1.5Gi 到 512Mi 的完整链路

前面理论说了很多,这一章我完整复盘一个内部模拟项目的调整过程。为了保证可复现性,我把场景简化成:一个会员查询服务,接口读取 Redis 缓存 + 偶尔回源 MySQL,QPS 峰值约 800,P99 目标 80ms,部署在 K8s 集群(某内部环境,模拟项目编号简称 P-Order)。

4.1 基线数据:看似稳定,实则隐患很大

优化前这个服务跑在 2 核、2Gi 容器里,配置用的是默认 G1 GC,没有显式设置 Memory 百分比。压测结果看起来很健康:QPS 800,P99 70ms,CPU 使用率 45%,RSS 1.5Gi。

但我用 native memory tracking 跑了一遍后发现,1.5Gi 里有 850Mi 是堆,250Mi 是 Metaspace,180Mi 是线程栈,剩下是直接缓冲区和编译缓存。Metaspace 明显偏高,线程栈因为 Tomcat 默认 200 线程也超了预期。真正的业务堆峰值不过 400Mi。也就是说,至少有 40% 的内存是在为默认配置买单。

4.2 第一步先砍内存:容器限制 512Mi,堆给 62.5%

我把容器规格改成了requests: 512Mi, limits: 512Mi,启动参数做了这几处调整:

-XX:InitialRAMPercentage=62.5 -XX:MaxRAMPercentage=62.5 -XX:MaxMetaspaceSize=64m -XX:MaxDirectMemorySize=64m -Xss512k -XX:ActiveProcessorCount=1

此时堆上限是 320Mi。听起来比原来的 850Mi 少了一半多,但要注意:堆上限不等于服务要用的堆。G1 的默认策略会尝试缩容堆,前提是应用本身感知到内存压力。所以我同时开启了-XX:+UseContainerSupport,确保 JVM 知道自己在容器里。

这一刀下去,指标变化:RSS 降到 650Mi 左右,P99 到 74ms,基本没有性能回退。CPU 使用率反而降到了 30%,因为线程数减少、GC 压力变小,上下文切换少了。

4.3 第二刀切 CPU:1 核限制 + Spring 瘦身

接下来我把 CPU limits 降到了 1 核,同时做了三件事:排除无用的自动配置类、开启懒加载、把 Tomcat 线程池缩到 100。

配置变化如下:

spring: main: lazy-initialization: true threads: virtual: enabled: true tomcat: threads: max: 16

Java 21 虚拟线程在这里起了很大作用。Tomcat 配置成 16 个物理线程但支持虚拟线程后,即使在高并发下,物理线程的栈内存几乎固定,不再因为 200 个线程膨胀。服务的真实并发能力没有下降,因为大部分请求在 IO 等待时可被虚拟线程调度器挂起并复用平台线程。

压测的结果有点超预期:QPS 800 时 P99 降到 52ms,RSS 稳定在 510Mi。这里有两个关键变化:懒加载让启动内存峰值大幅下降,自动配置排除减少了几十个无用 Bean 的初始化开销。

4.4 第三步调 GC:G1 到 Serial 的极限测试

内存砍到 512Mi 后,G1 的堆逻辑其实已经很笨重了——它需要维护多个 region、记忆集、卡表。在小堆上,G1 的记忆集维护成本有时候比 GC 本身还高。我决定做个对照测试,把 GC 切到 Serial GC。

-XX:+UseSerialGC

第一次压测结果让我很意外:在 512Mi 堆、1 核 CPU 的背景下,Serial GC 的 Young GC 平均耗时只有 3ms,而 G1 的 Young GC 平均耗时是 8ms。原因很简单:Serial GC 没有任何并行协调和分区追踪结构,单线程扫描 320Mi 的堆非常快。基准测试里 QPS 反而涨到了 950,P99 掉到 47ms。这在合格压测环境下复测了三次都稳定。

Serial GC 不是银弹,它只适合小堆、低并发、延迟容忍度较高的场景。如果你的堆长期超过 2Gi,Serial GC 的 Full GC 停顿会非常痛。但在 K8s 小容器这个特定条件下,它确实是一个"用简单换效率"的好选择。我最后保留了 Serial GC,并在代码里做了兜底:如果内存压力长期超过 85%,自动降级到只读模式,避免 Full GC 影响核心链路。

4.5 最终对照表:收益和代价一目了然

指标优化前(2C / 2Gi / G1)优化后(1C / 512Mi / Serial)变化
容器内存限制2Gi512Mi-75%
RSS 实测峰值1.5Gi510Mi-66%
CPU 限制2 核1 核-50%
QPS 峰值800950+18.75%
P99 延迟70ms47ms-32.8%
启动时间28s12s-57%
Full GC 频率0.4 次/小时0 次更稳定

这个结果不代表每个服务都能复制,但足以说明:性能瓶颈往往不是资源不够,而是资源配置和运行时的匹配度太低。压缩资源的过程本质上是倒逼你去看清应用的真实需求:线程要多少、堆要多大、GC 要什么策略、Spring Boot 到底在启动时干了哪些多余的事情。看清之后,减少一半资源占用是水到渠成的事情。

5. 最后的实操建议:当代价出现时,怎么快速回退与排障

优化做完了,但不代表万事大吉。改配置永远是低成本的事,真正麻烦的是上线后出现异常,你在第一时间能不能判断出是资源收紧导致的问题,还是业务流量变化导致的问题。我总结几个亲测有效的判断信号和处理思路。

信号一:容器 CPU Throttling 比例突然升高。如果部署后发现container_cpu_usage_seconds_total和container_cpu_cfs_throttled_seconds_total的比值持续超过 10%,说明 CPU limit 已经卡住了应用。这时候不要急着加核,先看业务线程是不是出现了大量阻塞等待。如果是 IO 等待,加核没用,应该增大线程池或换异步模型。如果是计算热点,才考虑加核或优化算法。

信号二:GC 日志里出现连续 Full GC。内存收紧后最怕的就是堆持续上涨。观察-Xlog:gc*输出的 Full GC 频率和堆使用率,如果 Full GC 一分钟超过一次,直接怀疑 Metaspace 或直接缓冲区是否仍然不受控。先用 NMT 把 Metaspace、线程、编译器、GC 各区块的数字抓出来,对照我第二部分的估算公式,看是哪里超了。不要盲目加堆,那只会掩盖真实浪费。

信号三:启动变慢且频繁重启。如果你开了懒加载,启动变慢是合理的;但如果是 readinessProbe 一直不过,要检查 probe 的 initialDelay 给得够不够。我在一次优化后就是吃了这个亏:把内存限制压到 512Mi 后启动时间从 28s 降到 12s,但因为 probe 配置是硬编码的initialDelaySeconds: 10,容器在 12s 才起来,活活被判了重启。后来我统一改用 Spring Actuator 的 readiness 接口,并且把 probe 延长到 30s,问题就消失了。

兜底方案:给配置留一个开关。内存和 GC 这类参数我通常不会硬编码进镜像,而是放到 ConfigMap 的JAVA_TOOL_OPTIONS环境变量里。这样在线上发现异常时,不改代码、不重新构建,只需要修改 ConfigMap 里的环境变量并滚动重启,就能在五分钟内完成回退。这是整个优化链路里我最看重的一个工程素养:再好的参数,也要有快速回滚的通路。

我个人的体会是,K8s 上优化 Spring Boot 这件事,最大的阻力永远不是技术参数本身,而是团队是否愿意丢掉"默认配置一定是对的"这个心理预设。G1 在物理机上表现很好,到小容器里可能就是负担;200 个默认线程在内存充足时无所谓,到 512Mi 容器里就是整条链路沉默的杀手。把 JVM 启动参数、Spring 初始化策略和容器的资源预算当成一个整体去调,才能做到真正的既减容又不损性能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询