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 | 启用 G1 | JDK 9+ 默认 |
-XX:MaxGCPauseMillis | G1 目标停顿 | 设 200ms,G1 会自适应调整 |
-XX:MetaspaceSize | 元空间初始 | 大量 AOT/动态类时调大 |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时转储 | 生产必开 |
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.GCPhasePause | GC 停顿时长 |
jdk.ObjectAllocationSample | 谁在分配内存 |
jdk.ExecutionSample | CPU 热点方法 |
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 | ~180MB | musl,注意 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 停顿 P99 | JFR / Micrometer | < 200ms |
| 老年代使用率 | JMX / Prometheus | 稳态 < 70% |
| 线程数 | JMX | 无持续增长 |
| 直接内存 | BufferPool MXBean | < MaxDirectMemorySize |
这些指标通过 Micrometer 暴露为 Prometheus 格式,与日志、链路一起构成完整观测——具体接入方式见 可观测性:日志、指标与链路 。
9. 小结
Clojure 的 JVM 调优与部署可以浓缩成一张清单:
- 先测量:
jcmd看堆与直方图,JFR 看热点与停顿,别猜; - 堆设对:容器里用
MaxRAMPercentage而非固定-Xmx,给堆外留空间; - 选 GC:通用用 G1,尾延迟敏感用 ZGC,极小容器用 Serial;
- 容器感知:
limits与requests别差太远,直接内存要显式限制; - 优雅停机:
exec+ shutdown hook +preStop睡眠 + 就绪探针; - 持续观测:GC 停顿、老年代、线程数纳入告警。
JVM 的默认参数在裸机上大多合理,但在容器里必须显式对齐资源限制——这一步做对了,Clojure 服务的稳定性就拿到了大半。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。