Spring Boot 在 K8s 中资源占用减半:JVM 与 GC 优化实战 写这篇之前我刚把某个内部订单查询服务的内存占用从 1.5Gi 压到 512MiCPU 限制从 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:InitialRAMPercentage62.5 -XX:MaxRAMPercentage62.5 -XX:MinRAMPercentage62.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 设成 2Gilimits 设成 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:ActiveProcessorCount1手动覆盖第二种是调整线程池参数确保server.tomcat.threads.max不要超过核数的可服务并发上限。对单核容器来说Tomcat 默认 200 线程是没有意义的因为能同时执行的只有 1 个请求剩下 199 个都在排队白白吃掉堆栈内存。2. 内存从哪省堆只是明账堆外才是无底洞Spring Boot 应用的内存大头往往不是堆而是堆外。我见过太多团队把堆调小了 200Mi结果总内存一点没降因为线程栈、Metaspace、直接缓冲区、JIT 编译缓存加起来比堆还要嚣张。2.1 内存账本按这个公式预测你的总占用我给团队定的内存估算公式很简单运行一段时间后用 NMTNative 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 线程栈默认大小是 1MBx86 架构下为 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 NativeGraalVM 原生镜像。这个方案的吸引力在于启动时间从秒级降到毫秒级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:ActiveProcessorCount1是优先于-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 核小堆更友好但配置和实验门槛高。我的经验是512Mi1Gi 这种小型服务优先试 G1 配缩减参数不行再切 Serial GC尽量不要一上来就上 Shenandoah。3.2 从按核分配线程到按业务动态伸缩Spring 默认的 Tomcat 线程池是max200这个数字在物理机 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.enabledtrue搭配起来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:CICompilerCount2这个参数很不起眼但对低核容器很重要。JIT 编译器线程一多启动时的 CPU 争抢会让 GC 和业务线程饿肚子。我后来在多个项目里都养成了限制CICompilerCount的习惯默认 2 个编译线程对于绝大多数 Spring Boot 应用足够。4. 一次真实的组合拳压测1.5Gi 到 512Mi 的完整链路前面理论说了很多这一章我完整复盘一个内部模拟项目的调整过程。为了保证可复现性我把场景简化成一个会员查询服务接口读取 Redis 缓存 偶尔回源 MySQLQPS 峰值约 800P99 目标 80ms部署在 K8s 集群某内部环境模拟项目编号简称 P-Order。4.1 基线数据看似稳定实则隐患很大优化前这个服务跑在 2 核、2Gi 容器里配置用的是默认 G1 GC没有显式设置 Memory 百分比。压测结果看起来很健康QPS 800P99 70msCPU 使用率 45%RSS 1.5Gi。但我用 native memory tracking 跑了一遍后发现1.5Gi 里有 850Mi 是堆250Mi 是 Metaspace180Mi 是线程栈剩下是直接缓冲区和编译缓存。Metaspace 明显偏高线程栈因为 Tomcat 默认 200 线程也超了预期。真正的业务堆峰值不过 400Mi。也就是说至少有 40% 的内存是在为默认配置买单。4.2 第一步先砍内存容器限制 512Mi堆给 62.5%我把容器规格改成了requests: 512Mi, limits: 512Mi启动参数做了这几处调整-XX:InitialRAMPercentage62.5 -XX:MaxRAMPercentage62.5 -XX:MaxMetaspaceSize64m -XX:MaxDirectMemorySize64m -Xss512k -XX:ActiveProcessorCount1此时堆上限是 320Mi。听起来比原来的 850Mi 少了一半多但要注意堆上限不等于服务要用的堆。G1 的默认策略会尝试缩容堆前提是应用本身感知到内存压力。所以我同时开启了-XX:UseContainerSupport确保 JVM 知道自己在容器里。这一刀下去指标变化RSS 降到 650Mi 左右P99 到 74ms基本没有性能回退。CPU 使用率反而降到了 30%因为线程数减少、GC 压力变小上下文切换少了。4.3 第二刀切 CPU1 核限制 Spring 瘦身接下来我把 CPU limits 降到了 1 核同时做了三件事排除无用的自动配置类、开启懒加载、把 Tomcat 线程池缩到 100。配置变化如下spring: main: lazy-initialization: true threads: virtual: enabled: true tomcat: threads: max: 16Java 21 虚拟线程在这里起了很大作用。Tomcat 配置成 16 个物理线程但支持虚拟线程后即使在高并发下物理线程的栈内存几乎固定不再因为 200 个线程膨胀。服务的真实并发能力没有下降因为大部分请求在 IO 等待时可被虚拟线程调度器挂起并复用平台线程。压测的结果有点超预期QPS 800 时 P99 降到 52msRSS 稳定在 510Mi。这里有两个关键变化懒加载让启动内存峰值大幅下降自动配置排除减少了几十个无用 Bean 的初始化开销。4.4 第三步调 GCG1 到 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 反而涨到了 950P99 掉到 47ms。这在合格压测环境下复测了三次都稳定。Serial GC 不是银弹它只适合小堆、低并发、延迟容忍度较高的场景。如果你的堆长期超过 2GiSerial 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 峰值80095018.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 初始化策略和容器的资源预算当成一个整体去调才能做到真正的既减容又不损性能。