Akamas 发布的 The State of Java on Kubernetes 2026 提出了一个让人不安的论断:大多数跑在 Kubernetes 上的 Java 应用,默认配置正在悄无声息地浪费资源、降低性能,甚至让服务在压力下直接不可用。

联系到最近我碰到的生产环境上的 oom 和 CPU 限制问题。于是我用 Java 25 + Spring Boot 3.5.1 + kind 本地集群搭了一套实验环境,用真实数据来验证这些说法。

结论先说:基本都是真的,修复成本极低,收益巨大。


实验设计#

用一个 Spring Boot 应用暴露两个压测端点:

  • GET /stress/memory?mb=N:分配 N MB 短生命周期对象,触发 GC
  • GET /stress/cpu?seconds=N:持续计算质数 N 秒,消耗 CPU

k6 混合打压(60% 内存请求 + 40% CPU 请求,10 VUs,60 秒),通过 Spring Actuator 采集 JVM 指标。

基准容器规格:1c / 1Gi,4 个场景:

场景 JVM 参数 资源 验证目标
01-default 1c / 1Gi 默认配置基准
02-heap-fixed MaxRAMPercentage=75 1c / 1Gi 修复堆大小
03-cpu-throttle MaxRAMPercentage=75 250m / 1Gi CPU 节流影响
04-pod-small MaxRAMPercentage=75 0.5c / 1Gi × 3 小副本集群
04-pod-large MaxRAMPercentage=75 1.5c / 1Gi × 1 大副本集群

GC 类型全部由 JVM 自动选择(Java 25 在 1c 以上默认为 G1GC)。


问题一:默认堆只用了 25%#

这是文章最核心的论断,也是最容易验证的。

运行场景 01(无任何 JVM 参数,1Gi 容器)后,Actuator 给出:

jvm.memory.max (heap) = 247.5 MB

而 1Gi ≈ 1,024 MB。JVM 只用了 24.2% 的容器内存。

加上 -XX:MaxRAMPercentage=75 之后(场景 02):

jvm.memory.max (heap) = 742.4 MB

变成了 72.5%,差距整整 3 倍

为什么默认只有 25%?#

在 2026 年,随着 Ubuntu 24.04+RHEL 10+ 等主流发行版彻底废弃 Cgroup v1,Java 25 + Cgroup v2 成为了唯一的标准。JVM 不再需要兼容破碎的 v1 接口,而是直接通过 /sys/fs/cgroup 实现原生感知。

但即便感知准确,JVM 默认的三个百分比参数依然非常保守:

  1. MaxRAMPercentage=25.0:在容器内存 > 250MB 时,最大堆内存仅为容器内存的 25%。
  2. InitialRAMPercentage=1.5625:初始堆内存小到可以忽略不计(1Gi 容器只有 ~16MB),这会导致启动时频繁触发 GC 和堆扩容。
  3. MinRAMPercentage=50.0:这是一个极具误导性的名字——它实际上是用于内存较小(通常 < 250MB)环境下的最大堆内存百分比

在 Kubernetes 这种「单容器单进程」的场景下,这些默认值会导致 75% 的资源闲置,且启动性能极差。

2026 年的平衡点:MaxRAMPercentage=75.0#

在 Java 8/11 时代,我们通常建议设为 70-75% 以预留非堆内存(元空间、线程栈等)。

虽然在 Java 25 时代,由于 虚拟线程 (Virtual Threads) 的大规模应用,传统平台线程创建的 Xss 开销大幅减少(理论上可以上调至 80%),但考虑到大多数生产环境仍有 Netty 直接内存、元空间波动等不确定性,75% 依然是最稳健、最通用的金科玉律。

进阶建议:如果你的服务是非 I/O 密集型且经过严格压测,确认非堆占用极低,可以尝试将其上调至 80% 以压榨极致性能。

更进一步:实现 Guaranteed QoS (服务质量保证) 为了避免堆动态扩容带来的系统调用开销以及 Kubernetes 中的 CPU 抖动,应将 InitialRAMPercentage 设为与 MaxRAMPercentage 相同的值(即 75.0),实现“启动即满额”。

这 3 倍差距在什么时候会出问题?#

这里有一个微妙之处:如果你的应用只产生短生命周期对象(比如本实验这种纯 GC 压测),G1GC 在 247.5 MB 和 742.4 MB 下的吞吐和延迟可以相差无几——因为年轻代垃圾收集得足够快。

但以下场景会让默认配置直接翻车:

  • 内存峰值:请求处理需要临时持有 > 247 MB 数据 → OOM Kill
  • 应用缓存:Spring Cache、Caffeine、本地缓存 → 老年代累积 → Full GC 或 OOM
  • 连接池 + Session:数据库连接池、HTTP 客户端、会话对象 → 长生命周期对象堆积
  • 大对象处理:图片处理、JSON 反序列化大 Payload → 直接分配进老年代

结论:这个问题是真实的。 修复成本是零——加一个 JVM 参数,一行配置。


问题二:CPU 节流比你想象的更危险#

这是本实验最震撼的发现,也是文章所说的「CPU limits function as time quotas enforced by Linux CFS」。

实验结果#

将 CPU limit 从 1000m 降到 250m(场景 03),观察到:

指标 1000m CPU 250m CPU 变化
GC 最大停顿 28 ms 318 ms 11 倍
k6 成功请求 5,532 0 100% 失败
故障表现 正常服务 全部 EOF 服务实质不可用

关键细节kubectl rollout status 报告 Pod 正常(进程存活),健康检查也能通过(轻量级请求),但所有业务请求返回 EOF——服务端接受了 TCP 连接,但 Tomcat 工作线程被 CFS 反复中断,无法在合理时间内完成 HTTP 响应。

这是 Kubernetes 中最难排查的故障模式:Pod 是"活着"的,但业务 100% 失败。健康检查告诉你一切正常,用户却在报障。

为什么 GC 停顿从 28ms 变成 318ms?#

G1GC 的并发 GC 线程和 JVM 工作线程共享 CPU 时间配额。在 250m CPU 限制下,Linux CFS 每 100ms 只给容器 25ms CPU 时间。GC 线程在标记和回收过程中被反复挂起,单次 Stop-The-World 停顿从 28ms 拉长到 318ms——即使堆配置正确也救不了。

Java 25 的改进:ActiveProcessorCount 自动修剪#

在旧版本 Java 中,如果你给容器限流 250m 但宿主机是 128 核,JVM 可能会错误地识别出核心数过多,从而开启上百个 GC 线程或庞大的 ForkJoinPool,导致剧烈的 上下文切换 (Context Switching),进一步拖垮性能。

Java 25 的黑科技: 它引入了更智能的调度感知。在场景 03 (250m) 的验证中,即使不手动设置 -XX:ActiveProcessorCount=1,Java 25 也能在启动瞬间通过 cpu.max 的时间切片分布,自动修剪其内部的并行线程数(这一机制在 JDK-8281181 引入 Cgroup v2 原生支持后得到了彻底优化)。这确保了即便资源受限,JVM 也不会为了“并行”而过度竞争 CPU。


问题三:Pod 规格——大副本 vs 小副本#

文章说相同总算力下,大副本优于多个小副本。实验结果(相同总算力 1.5 vCPU):

配置 单 Pod GC 最大停顿 p95 延迟 最大延迟
3 × 0.5c 小副本 104 ms 32 ms 778 ms
1 × 1.5c 大副本 31 ms 20 ms 256 ms
改善 -70% -37% -67%

大副本的 GC 最大停顿降低 70%,p95 延迟降低 37%。原因和 CPU 节流一样:G1GC 的并发 GC 线程在 0.5c 下被限流,停顿拉长;在 1.5c 下有充足 CPU,停顿缩短。

实际部署建议:

场景 推荐
需要高可用(不可单点故障) 多副本,但每副本 CPU ≥ 1c
服务发现 / 边车模式 每 Pod CPU request ≥ 500m,limit ≥ 1000m
弹性扩缩容 设合理的 HPA,基于 CPU 利用率而非固定副本数

最佳实践:部署 Java 25 on Kubernetes 的完整配置#

Deployment YAML 模板#

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-java-app
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: app
          image: my-java-app:latest
          env:
            # 2026 推荐配置:Guaranteed QoS + AlwaysPreTouch
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:InitialRAMPercentage=75.0 -XX:MaxRAMPercentage=75.0 -XX:MinRAMPercentage=75.0 -XX:+AlwaysPreTouch -XX:+ExitOnOutOfMemoryError"
          resources:
            requests:
              memory: "1Gi"
              cpu: "1000m"    # 2026 时代,1.0 CPU 应该是微服务起点
            limits:
              memory: "1Gi"
              cpu: "2000m"    # 允许突发峰值,但不建议设过低
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30   # 给 JVM 足够启动时间
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 5

Spring Boot application.properties#

# 暴露 liveness 和 readiness 探针
management.endpoints.web.exposure.include=health,metrics,info
management.endpoint.health.show-details=always
management.health.livenessstate.enabled=true
management.health.readinessstate.enabled=true

# 启用虚拟线程(Java 21+)
spring.threads.virtual.enabled=true

JVM 参数说明#

# 核心三剑客(解决内存浪费和扩容开销问题)
-XX:MaxRAMPercentage=75.0      # 容器内存 > 250MB 时,最大堆占容器比例(业界稳健标准)
-XX:InitialRAMPercentage=75.0  # 初始堆占容器比例(与 Max 相等实现 Guaranteed QoS)
-XX:MinRAMPercentage=75.0      # 容器内存 < 250MB 时,最大堆占容器比例

# 启动加速与性能抖动防御
-XX:+AlwaysPreTouch           # 启动时预先触碰物理页,减少运行时首次访问 Page Fault 导致的延迟

# 退出机制(OOM 时主动退出,触发 K8s 重启而不是假活)
-XX:+ExitOnOutOfMemoryError

注意:在 2026 年,请彻底移除脚本中的 -Xmx-Xms。如果在 JAVA_TOOL_OPTIONS 中混用了 -Xmx,百分比参数将被静默忽略,这可能导致你的服务在发布后因资源限制误判而 OOM。


如何验证你的配置是否正确#

加上 Spring Actuator 后,查询 jvm.memory.max(最大堆)和 jvm.memory.used(初始堆在启动后应立即达到最大值的接近水平):

# 查看堆上限(应该是容器内存的 75%)
curl http://localhost:8080/actuator/metrics/jvm.memory.max?tag=area:heap

# 查看 GC 停顿统计
curl http://localhost:8080/actuator/metrics/jvm.gc.pause

# 或者直接在容器启动日志里找
# 加了参数后,启动时会打印:
# Heap space: ...(约 75% 的容器内存)

总结#

问题 是否属实 修复方案 难度
默认堆只有容器内存 25% 完全属实 -XX:MaxRAMPercentage=75.0 极低
初始堆过小导致启动慢 属实 -XX:InitialRAMPercentage=75.0 极低
CPU 不足导致性能崩溃 属实且更严重 CPU limit ≥ 500m,推荐 ≥ 1000m
大 Pod 优于多小 Pod 属实 每副本 CPU ≥ 1c,保持单副本资源充足

最重要的一行配置:

JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=75.0 -XX:MaxRAMPercentage=75.0 -XX:MinRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

成本:零。收益:内存利用率从 25% 提升到 75%,省去启动时的堆扩容开销,并增强了对小规格容器的自适应性。任何 Java on Kubernetes 的应用都应该加。


实验代码和完整数据:java25-config-in-k8s