JVM 调优与容器化部署:GC、JFR 与 Docker

Clojure 服务的 JVM 调优与容器化部署实践:堆与 GC 选型(G1、ZGC、Shenandoah)、关键 JVM 参数、JFR 与 JMC 诊断、容器内存与 CPU 感知、Dockerfile 分层构建与 Kubernetes 资源配置、优雅停机与健康探针。

Clojure 跑在 JVM 上,因此它的性能与部署特征很大程度上由 JVM 决定:延迟抖动来自 GC 停顿,内存占用来自堆与元空间(Metaspace),启动慢来自类加载。很多「Clojure 服务很吃内存」的抱怨,其实是容器内存限制与 JVM 默认堆策略没对齐。

本文从堆与 GC 选型讲到 JFR 诊断、容器资源感知、Docker 分层构建与 Kubernetes 探针配置,给出一套可直接落地的部署参数。

1. 先测量,再调优

调优的第一原则是不要凭感觉改参数。JVM 提供的观测手段有三层:

层次工具能回答的问题
概览jcmd <pid> GC.heap_info堆用了多少、GC 次数
采样jcmd <pid> JFR.start方法热点、分配、锁竞争
详细JFR 记录 + JDK Mission Control完整时间线、事件关联
# 查看运行中 JVM 的堆与 GC 概况
jcmd 12345 GC.heap_info
jcmd 12345 GC.class_histogram | head -20   # 哪些类占内存最多

class_histogram 会立刻告诉你内存大户是 byte[]、clojure.lang.PersistentVector 还是某个第三方库的缓存对象。

2. 堆结构与关键参数

2.1 堆分区

现代 JVM(G1 为默认)把堆分为年轻代(Young:Eden + Survivor)与老年代(Old)。新对象先在 Eden 分配,存活对象晋升到 Survivor 再到 Old。Clojure 的不可变数据会产生大量短命对象(每次 assoc、map 都新建结构),所以 Eden 的分配速率(Allocation Rate)通常很高。

2.2 核心参数

参数作用建议
-Xms / -Xmx初始/最大堆生产设为相等,避免扩容停顿
-XX:MaxRAMPercentage按容器内存占比设堆容器里优先用它而非固定 -Xmx
-XX:+UseG1GC启用 G1JDK 9+ 默认
-XX:MaxGCPauseMillisG1 目标停顿设 200ms,G1 会自适应调整
-XX:MetaspaceSize元空间初始大量 AOT/动态类时调大
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时转储生产必开
java -XX:MaxRAMPercentage=70 \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/dumps \
     -jar app.jar

MaxRAMPercentage=70 的含义:容器限制 1GB 时,堆约占 700MB,剩下 300MB 留给元空间、线程栈、直接内存(Direct Memory)与 JVM 自身。不要把堆设成容器上限,否则容易触发 OOMKilled。

3. GC 选型

3.1 三种主流收集器

收集器停顿吞吐适用
G1中(可设目标)高通用默认,堆 4~32GB
ZGC极低(<10ms)中大堆、低延迟(JDK 15+ 生产可用)
Shenandoah极低中低延迟,OpenJDK 系
Serial高高小堆、单核、CLI

3.2 ZGC 与 Clojure

Clojure 服务如果对尾延迟敏感(如 API 网关、实时计算),ZGC 是很好的选择:

java -XX:+UseZGC -XX:+ZGenerational -Xmx8g -jar app.jar

-XX:+ZGenerational(JDK 21+)把 ZGC 分为分代版本,吞吐损失更小。ZGC 的代价是需要更大的堆余量(染色指针与并发回收需要空间),以及略高的 CPU 占用。

3.3 分代与停顿权衡

场景选择理由
通用 Web 服务G1平衡,默认无需改
P99 敏感ZGC/Shenandoah亚毫秒停顿
批处理、离线G1 + 高吞吐停顿不敏感
极小容器(<256MB)SerialGC内存开销最小

4. JFR:飞行记录仪

4.1 开启记录

JFR(Java Flight Recorder)是内建的低开销诊断工具(生产开销通常 <1%):

# 启动即记录,滚动保存
java -XX:StartFlightRecording=filename=/logs/app.jfr,dumponexit=true,settings=profile \
     -jar app.jar

# 对运行中进程动态开启
jcmd 12345 JFR.start name=prof settings=profile duration=60s filename=/tmp/60s.jfr

4.2 关键事件

事件揭示
jdk.GCPhasePauseGC 停顿时长
jdk.ObjectAllocationSample谁在分配内存
jdk.ExecutionSampleCPU 热点方法
jdk.JavaMonitorEnter锁竞争
jdk.ThreadPark线程阻塞在哪

4.3 分析

用 JDK Mission Control(JMC)打开 .jfr 文件,或在容器里用 jfr 命令:

jfr summary /logs/app.jfr
jfr print --events jdk.GCPhasePause /logs/app.jfr | head -30

一个常见发现:Clojure 的 persistent! / transient 用得好能显著降低分配率——把 (reduce conj [] xs) 改成 (persistent! (reduce conj! (transient []) xs)),分配与 GC 压力都会下降。这与 性能优化与 GraalVM 里讲的内存布局优化是同一类问题。

4.4 一次内存增长排查实例

现象:服务运行 6 小时后老年代使用率从 40% 缓慢爬升到 90%,Full GC 后回落但不归零。排查步骤:

# 1. 看对象直方图,找内存大户
jcmd 12345 GC.class_histogram | head -15
# => 1: 3145728  clojure.lang.PersistentVector$TransientVector
#    2: 2097152  byte[]

# 2. 抓分配采样,定位分配热点
jcmd 12345 JFR.start name=alloc settings=profile duration=120s filename=/tmp/alloc.jfr
# 在 JMC 里看 jdk.ObjectAllocationSample,按「分配类 + 调用栈」排序

结论通常是某个 atom 里的集合只增不减(缓存无淘汰、日志缓冲未清理)。对策:给该集合设上限,或改用带 TTL 的缓存。这类「只增不减的数据结构」在 Clojure 里尤其隐蔽,因为 assoc / conj 每次返回新结构,旧结构若被某个长生命周期的 atom 引用就无法回收。

5. 容器化:让 JVM 感知容器

5.1 内存与 CPU 感知

JDK 10+ 默认开启 UseContainerSupport,会读取 cgroup 限制。但要注意:

  • MaxRAMPercentage 只对堆生效,直接内存(NIO、Netty、Kafka 客户端)不受它约束;
  • CPU 限制会影响 GC 线程数与 ForkJoinPool.commonPool 的并行度。
resources:
  requests: { memory: "512Mi", cpu: "250m" }
  limits:   { memory: "1Gi",  cpu: "1" }

limits 与 requests 差距过大时,JVM 按 limits 算堆,但调度按 requests 分配 CPU——高负载下 GC 线程抢不到 CPU,停顿飙升。

5.2 显式设置

# 显式指定直接内存上限,防止堆外泄漏触发 OOMKilled
-XX:MaxDirectMemorySize=256m
# 关闭 JVM 自己挑 CPU 数量(容器里有时不准)
-XX:ActiveProcessorCount=2

6. Docker 构建

6.1 多阶段构建

# ---- 构建阶段 ----
FROM clojure:temurin-21-tools-deps AS builder
WORKDIR /app
COPY deps.edn ./
RUN clojure -P                      # 预下载依赖,利用层缓存
COPY src ./src
COPY resources ./resources
COPY build.clj ./
RUN clojure -T:build uber

# ---- 运行阶段 ----
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar app.jar"]

要点:

  • clojure -P 单独一层,依赖不变时命中缓存,改代码不重下依赖(依赖声明方式见 工具链与 deps.edn );
  • exec 让 java 成为 PID 1,正确接收 SIGTERM;
  • 非 root 用户运行;
  • 用 alpine 或 distroless 减小镜像。

6.2 镜像大小对比

基础镜像大小说明
temurin-21-jre~280MB完整 JRE
temurin-21-jre-alpine~180MBmusl,注意 DNS 差异
distroless/java21~200MB无 shell,最安全
GraalVM native~50MB需 AOT 编译

6.3 启动优化

JVM 启动慢主要来自类加载与 JIT 预热。可选项:

# 应用类数据共享(AppCDS),加速启动
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar   # 首次生成
java -XX:SharedArchiveFile=app.jsa -jar app.jar      # 后续使用

# 分层编译调优:让热点方法更快达到 C2
-XX:TieredStopAtLevel=1    # 只到 C1,启动快、峰值低(短生命周期进程)

对常驻服务,别用 TieredStopAtLevel=1,那会牺牲峰值性能。它只适合 CLI 或函数计算。

7. Kubernetes 部署

7.1 健康探针

区分存活(liveness)与就绪(readiness):

livenessProbe:
  httpGet: { path: /health/live, port: 8080 }
  initialDelaySeconds: 30      # 给 JVM 启动留时间
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /health/ready, port: 8080 }
  initialDelaySeconds: 10
  periodSeconds: 5

/health/live 只表示进程活着(JVM 没死锁),/health/ready 要检查数据库/缓存依赖是否就绪。不要把依赖检查放进 liveness,否则依赖抖动会导致 Pod 被反复重启。

7.2 优雅停机

;; 注册 shutdown hook,等正在处理的请求结束
(.addShutdownHook (Runtime/getRuntime)
  (Thread. (fn []
             (log/info "收到 SIGTERM,开始优雅停机")
             (stop-server!)          ;; 停止接收新请求
             (wait-inflight 10)      ;; 最多等 10 秒
             (log/info "停机完成"))))

配合:

terminationGracePeriodSeconds: 30
lifecycle:
  preStop:
    exec: { command: ["sleep", "5"] }   # 等 Service 摘除本 Pod 的 Endpoint

preStop 里 sleep 5 是常见技巧:给 kube-proxy 时间更新转发规则,避免停机瞬间仍有请求打进来。

7.3 资源与 GC 的联动

env:
  - name: JAVA_OPTS
    value: >-
      -XX:MaxRAMPercentage=70
      -XX:+UseG1GC
      -XX:MaxGCPauseMillis=200
      -XX:+ExitOnOutOfMemoryError
      -XX:+HeapDumpOnOutOfMemoryError
      -XX:HeapDumpPath=/dumps

-XX:+ExitOnOutOfMemoryError 让 JVM 在 OOM 时直接退出,交给编排层重启——比一个「半死不活、疯狂 GC」的进程更健康。堆转储目录要挂载到持久卷,否则 Pod 重启后证据就没了。

8. 观测与告警

部署完只是开始,运行期要持续观测。关键指标:

指标来源阈值参考
GC 停顿 P99JFR / Micrometer< 200ms
老年代使用率JMX / Prometheus稳态 < 70%
线程数JMX无持续增长
直接内存BufferPool MXBean< MaxDirectMemorySize

这些指标通过 Micrometer 暴露为 Prometheus 格式,与日志、链路一起构成完整观测——具体接入方式见 可观测性:日志、指标与链路 。

9. 小结

Clojure 的 JVM 调优与部署可以浓缩成一张清单:

  1. 先测量:jcmd 看堆与直方图,JFR 看热点与停顿,别猜;
  2. 堆设对:容器里用 MaxRAMPercentage 而非固定 -Xmx,给堆外留空间;
  3. 选 GC:通用用 G1,尾延迟敏感用 ZGC,极小容器用 Serial;
  4. 容器感知:limits 与 requests 别差太远,直接内存要显式限制;
  5. 优雅停机:exec + shutdown hook + preStop 睡眠 + 就绪探针;
  6. 持续观测:GC 停顿、老年代、线程数纳入告警。

JVM 的默认参数在裸机上大多合理,但在容器里必须显式对齐资源限制——这一步做对了,Clojure 服务的稳定性就拿到了大半。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「clojure」更多文章

  1. Clojure 桌面 UI:cljfx 与 JavaFX 实战
  2. JSON/EDN 序列化与数据格式互操作
  3. 解析与 DSL:Instaparse 与解析器组合子