很多文章讲得都对,但一上生产就不好使,核心在于缺失了四个关键环节:
- 只讲命令,不讲原理,导致看到
Full GC 只会机械搜索参数。
- 只讲单机,不讲微服务、容器、Kubernetes、Prometheus、链路追踪的组合场景。
- 只讲“如何看”,不讲“如何构建证据链”,最终靠经验猜。
- 只讲堆,不讲线程、锁、直接内存、类元空间、JIT、系统资源与流量模型。
真正的JVM调优,不是“把 GC 参数背下来”,而是把下面这条链路打通:
业务模型 -> 请求特征 -> 对象生命周期 -> 内存/线程行为 -> GC/CPU/RT 表现 -> 监控告警 -> 诊断工具 -> 配置与代码优化
本文不做“工具罗列大全”,而是把 JVM 工具放到真实生产流程里讲清楚:
- 为什么这个问题会发生
- 应该先看什么指标
- 用什么工具拿证据
- 哪些参数可以调
- 哪些问题根本不是 JVM 参数能解决的
二、先建立正确认知:JVM 调优的本质是什么
JVM 调优本质上是在平衡四个目标:
- 吞吐量:单位时间处理更多请求
- 延迟:尤其是 P99、P999 的尾延迟
- 稳定性:避免 OOM、长时间 Stop-The-World、线程雪崩
- 成本:同样流量下占用更少机器资源
这四个目标天然冲突。
例如:
- 堆开得很大,Young GC 频率下降,但 Full GC 一旦发生,停顿更重。
- 线程池开得很大,看起来吞吐量上去了,但上下文切换、锁竞争、内存占用会恶化。
- 缓存命中率提升了,但对象长生命周期进入 Old Gen,GC 压力可能更大。
所以 JVM 调优不是单点优化,而是一个系统工程:
容量规划 + 代码设计 + 中间件使用方式 + JVM 参数 + 观测体系 + 压测验证
如果没有压测和监控,所谓“调优”大概率只是改了几个看起来很专业的参数。
三、JVM 核心原理升级版:真正影响线上表现的几个机制
3.1 运行时内存不止是堆
很多线上问题表面看是“内存不够”,实际可能根本不是 Java Heap。
+---------------------------------------------------------------+
| JVM Runtime |
+----------------------+----------------+-----------------------+
| Java Heap | Metaspace | Code Cache |
| Young / Old | 类元数据 | JIT 编译后代码缓存 |
+----------------------+----------------+-----------------------+
| Thread Stack | Direct Memory | Native Memory |
| 每线程栈空间 | NIO/Netty | JVM/库/本地分配 |
+----------------------+----------------+-----------------------+
线上最常见的五类内存问题:
- Java Heap OOM:缓存泄漏、集合膨胀、对象生命周期过长。
- Metaspace OOM:动态代理、热部署、类加载器泄漏。
- Direct Memory OOM:Netty、Kafka、Redis、NIO 使用堆外内存过多。
- Native Memory 被吃光:JVM 外部库、本地分配、线程栈总量过大。
- 容器 OOMKilled:Java 进程看起来没报
OutOfMemoryError,但被 cgroup 直接杀死。
3.2 GC 关注的不是“有没有回收”,而是“回收成本”
GC 的核心不是免费清理,而是以停顿、CPU、带宽为代价回收对象。
需要重点理解三个概念:
- 对象分配速率:每秒产生多少新对象
- 晋升速率:多少对象从 Young 进入 Old
- 回收收益:一次 GC 能回收多少空间
如果系统对象分配非常快,即使没有内存泄漏,也可能出现:
- Young GC 频繁,CPU 抖动
- Survivor 放不下,提前晋升
- Old 区膨胀,Mixed GC 或 Full GC 压力上升
3.3 G1、ZGC 的本质差异
| 收集器 |
核心目标 |
适合场景 |
关键特征 |
| G1 |
平衡吞吐与停顿 |
大多数在线业务 |
分 Region 回收,可预测停顿 |
| ZGC |
极低停顿 |
超大堆、低延迟服务 |
并发标记/转移,停顿极短 |
| Parallel GC |
高吞吐 |
批处理、离线任务 |
吞吐优先,停顿不敏感 |
工程上不要问“哪个 GC 最强”,而要问:
- 业务更怕吞吐下降,还是更怕 RT 抖动?
- 堆有多大?
- 峰值流量下对象分配速率有多高?
- 你的 P99 SLA 是多少?
3.4 JIT 与代码形态会直接影响 CPU
线上 CPU 高,不一定是业务逻辑复杂,也可能是:
- 热点方法没有被充分编译优化
- 装箱拆箱、反射、动态代理过多
- 日志字符串拼接、JSON 序列化产生大量短命对象
- 锁竞争导致线程空转、自旋或阻塞
所以 JVM 问题从来不是“只有 GC”。
3.5 线程模型决定了你能不能撑住高并发
JVM 调优里最容易被低估的是线程:
- 线程太少,请求排队,RT 飙升。
- 线程太多,CPU 切换、栈内存、锁竞争急剧上升。
- 无界线程池或无界队列,会把流量高峰转成内存灾难。
一句话总结:
高并发系统出问题时,JVM 只是承压层,根因常常在对象模型、线程模型和资源边界设计。
四、工具全景图:按场景而不是按命令学习
建议把 JVM 工具分成四类:
| 类别 |
工具 |
用途 |
是否适合线上 |
| 快速定位 |
jps、jcmd、jstat、jstack |
先判断问题类型 |
是 |
| 深入诊断 |
jmap、Heap Dump、MAT、JFR |
追根溯源 |
条件性可用 |
| 在线观测 |
Micrometer、Prometheus、Grafana、Arthas |
持续监控与临时诊断 |
是 |
| CPU/火焰图 |
JFR、async-profiler、Arthas profiler |
分析热点与锁竞争 |
是,需控制范围 |
一个推荐的诊断路径是:
告警出现
-> 看监控面板判定是 CPU / 内存 / 线程 / RT / 错误率
-> 用 jcmd / jstat / jstack 快速拿现场
-> 必要时 JFR 或 Arthas 精准采样
-> 只有进入内存根因分析时,再导出 Heap Dump
这比上来就 jmap -dump 要安全得多。
五、JDK 自带工具链:线上诊断的第一现场
5.1 jps:先确认进程身份
jps -lvm
适用场景:
- 一台机器上有多个 Java 进程
- 需要确认启动参数、主类、Jar
- 做脚本化巡检
5.2 jstat:第一时间判断 GC 是否异常
jstat -gcutil <pid> 1000 20
重点看四类信号:
E 持续接近 100%:Young 区分配压力大
O 持续上涨且回不来:Old 区可能泄漏或晋升过快
FGC 快速增长:严重风险
GCT 占比过高:GC 正在吞噬 CPU 时间
经验判断:
- Young GC 次数多不一定有问题,关键看单次停顿和总占比。
- Old 区使用率高也不一定有问题,关键看 GC 后是否能回落。
5.3 jcmd:现代 JDK 的综合诊断入口
比起零散地记 jmap/jinfo/jstack,更推荐优先使用 jcmd。
jcmd <pid> help
jcmd <pid> VM.flags
jcmd <pid> VM.system_properties
jcmd <pid> GC.class_histogram
jcmd <pid> Thread.print -l
jcmd <pid> GC.heap_info
jcmd <pid> JFR.start name=prod duration=60s filename=/tmp/prod.jfr
它适合做什么:
- 看 JVM 启动参数是否真的生效
- 看线程堆栈和锁信息
- 看类直方图判断对象膨胀
- 触发 JFR 进行低开销采样
5.4 jstack:线程问题几乎都要靠它定性
jstack -l <pid> > /tmp/thread.dump
重点分析:
- 是否有大量
BLOCKED
- 是否线程池线程堆积在同一方法
- 是否出现数据库、Redis、HTTP 客户端阻塞
- 是否有死锁
典型现象与结论:
| 线程表现 |
可能原因 |
大量 RUNNABLE 且卡在 JSON/加密/序列化 |
CPU 热点 |
大量 BLOCKED |
锁竞争 |
大量 WAITING 在队列获取 |
下游变慢或没有任务 |
大量 TIMED_WAITING 在连接池/网络调用 |
外部依赖延迟高 |
5.5 jmap 与 Heap Dump:只有在需要证据时再出手
jcmd <pid> GC.class_histogram
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof
注意事项:
- Heap Dump 文件很大,可能打满磁盘。
- 导出时可能带来额外停顿。
- 在线导出前先确认磁盘、时机、限流策略。
Heap Dump 适合回答的问题:
- 谁占用了最多内存
- 哪条引用链让对象活到了现在
- 是缓存、集合、线程本地变量还是类加载器导致对象无法释放
5.6 JFR:线上最值得掌握的 Profile 工具
JFR 的最大价值是“低成本拿到高价值现场”。
jcmd <pid> JFR.start \
name=order-profile \
settings=profile \
duration=120s \
filename=/tmp/order-profile.jfr
JFR 能看见:
- CPU Hot Methods
- Allocation Hotspots
- GC Pause 分布
- Monitor Enter/锁竞争
- Socket Read/Write
- 文件 IO
- 线程状态切换
它尤其适合定位:
- RT 偶发毛刺
- CPU 升高但不确定热点
- 请求量不高却出现 GC 压力
- 怀疑锁竞争但线程栈不够直观
5.7 Arthas:线上诊断“手术刀”
Arthas 的强项不是替代监控,而是快速验证假设。
常用命令:
dashboard
thread -n 10
jad com.example.OrderService
trace com.example.OrderService submitOrder
watch com.example.OrderService submitOrder '{params,returnObj,throwExp}' -x 2
profiler start
profiler stop --format html
推荐使用原则:
- 先用监控和 JDK 工具缩小范围
- 再用 Arthas 针对单方法、单类、单线程验证
- 谨慎使用热更新,严格控制权限与变更记录
六、生产级观测架构:指标、日志、链路、Profile 一体化
只靠单一工具,很难在高并发微服务里定位 JVM 问题。更合理的运维与监控体系是:
+----------------------+
| Grafana Dashboard |
+----------+-----------+
|
+--------------------------+--------------------------+
| | |
v v v
+-------------+ +----------------+ +------------------+
| Prometheus | | Log Platform | | Tracing System |
| JVM/业务指标 | | GC/应用日志 | | Trace/Span |
+------+------+ +--------+-------+ +---------+--------+
| | |
+-------------------+-----+---------------------------+
|
v
+-----------------------+
| Java Service |
| Micrometer + JFR + |
| Business Metrics |
+-----------------------+
6.1 监控指标必须分层
建议至少分为四层:
- 资源层:CPU、内存、磁盘、网络、容器重启。
- JVM 层:Heap、Old Gen、GC Pause、线程数、类加载、直接内存。
- 组件层:Tomcat/Undertow/Netty、线程池、连接池、Kafka、Redis。
- 业务层:QPS、成功率、错误率、RT、订单量、重试次数、降级次数。
如果没有业务层,只看 JVM 指标很容易误判。
例如:
- GC 频率升高,可能不是参数问题,而是活动流量翻倍。
- 线程暴涨,可能不是线程池配置不合理,而是下游超时引起请求堆积。
6.2 Spring Boot + Micrometer 生产配置
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
application.yml
server:
shutdown:
graceful
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,threaddump,heapdump
endpoint:
health:
probes:
enabled: true
show-details: always
metrics:
tags:
application: ${spring.application.name}
env: ${spring.profiles.active:default}
distribution:
percentiles-histogram:
http.server.requests: true
order.submit.latency: true
slo:
http.server.requests: 50ms,100ms,200ms,500ms,1s,3s
order.submit.latency: 20ms,50ms,100ms,300ms,1s
6.3 把线程池、队列、缓存做成可观测对象
很多系统的问题不在 JVM,而在“不可观测的资源池”。
下面是一个生产级线程池指标绑定器:
package com.example.order.metrics;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import java.util.concurrent.ThreadPoolExecutor;
public final class ExecutorMetricsBinder {
private ExecutorMetricsBinder() {
}
public static void bind(String name, ThreadPoolExecutor executor, MeterRegistry registry) {
Tags tags = Tags.of("executor", name);
Gauge.builder("app_executor_pool_size", executor, ThreadPoolExecutor::getPoolSize)
.tags(tags)
.register(registry);
Gauge.builder("app_executor_active_count", executor, ThreadPoolExecutor::getActiveCount)
.tags(tags)
.register(registry);
Gauge.builder("app_executor_queue_size", executor, e -> e.getQueue().size())
.tags(tags)
.register(registry);
Gauge.builder("app_executor_completed_task_count", executor, ThreadPoolExecutor::getCompletedTaskCount)
.strongReference(true)
.tags(tags)
.register(registry);
}
}
6.4 Prometheus 告警不要只盯 Heap
示例规则:
groups:
- name: jvm-alerts
rules:
- alert: HighOldGenUsage
expr: sum by (application, instance) (
jvm_memory_used_bytes{area="heap", id=~".*Old.*"}
) / sum by (application, instance) (
jvm_memory_max_bytes{area="heap", id=~".*Old.*"}
) > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "Old Gen usage is higher than 85%"
- alert: FrequentMajorGC
expr: increase(jvm_gc_pause_seconds_count[10m]) > 20
for: 5m
labels:
severity: warning
annotations:
summary: "GC pause events are too frequent"
- alert: ExecutorQueueBacklog
expr: max_over_time(app_executor_queue_size[5m]) > 1000
for: 3m
labels:
severity: critical
annotations:
summary: "Business executor queue backlog detected"
- alert: TooManyJvmThreads
expr: jvm_threads_live_threads > 500
for: 10m
labels:
severity: warning
annotations:
summary: "Live JVM thread count is too high"
这里的关键点是:
- 告警对象是“风险模式”,不是单个数值。
- JVM 告警必须与线程池、连接池、接口 RT、错误率联合分析。
七、代码生产级升级:高并发订单服务的 JVM 工程实践
这一节不只讲 JVM 参数,而是给出更完整的代码设计,因为很多 JVM 问题本质是工程设计问题。
7.1 一个更接近线上真实情况的订单提交链路
场景:
- 峰值每秒 3000 单
- 订单提交需要查库存、扣减额度、调用支付网关、发送 MQ
- 必须控制尾延迟,不能让流量高峰把线程和内存打穿
7.2 线程池不能无界
package com.example.order.config;
import com.example.order.metrics.ExecutorMetricsBinder;
import io.micrometer.core.instrument.MeterRegistry;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OrderExecutorConfig {
@Bean(destroyMethod = "shutdown")
public ThreadPoolExecutor orderExecutor(MeterRegistry registry) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
16,
64,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2000),
runnable -> {
Thread thread = new Thread(runnable);
thread.setName("order-exec-" + thread.threadId());
return thread;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
ExecutorMetricsBinder.bind("order", executor, registry);
return executor;
}
}
这段代码背后的 JVM 价值:
- 有界队列避免请求无限堆积成内存风险。
- 最大线程数受控,避免线程过多导致切换和栈内存膨胀。
CallerRunsPolicy 在高峰期形成自然背压。
7.3 业务代码要主动缩短对象生命周期
package com.example.order.service;
import com.example.order.client.InventoryClient;
import com.example.order.client.PaymentClient;
import com.example.order.model.OrderRequest;
import com.example.order.model.OrderResult;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final ThreadPoolExecutor orderExecutor;
private final InventoryClient inventoryClient;
private final PaymentClient paymentClient;
private final Counter rejectedCounter;
private final Timer submitTimer;
public OrderService(
ThreadPoolExecutor orderExecutor,
InventoryClient inventoryClient,
PaymentClient paymentClient,
MeterRegistry registry) {
this.orderExecutor = orderExecutor;
this.inventoryClient = inventoryClient;
this.paymentClient = paymentClient;
this.rejectedCounter = registry.counter("order_submit_rejected_total");
this.submitTimer = Timer.builder("order.submit.latency")
.publishPercentileHistogram()
.serviceLevelObjectives(
Duration.ofMillis(50),
Duration.ofMillis(100),
Duration.ofMillis(300),
Duration.ofSeconds(1))
.register(registry);
}
public OrderResult submit(OrderRequest request) {
return submitTimer.record(() -> doSubmit(request));
}
private OrderResult doSubmit(OrderRequest request) {
try {
CompletableFuture<Boolean> inventoryFuture =
CompletableFuture.supplyAsync(
() -> inventoryClient.reserve(request.productId(), request.count()),
orderExecutor)
.orTimeout(200, TimeUnit.MILLISECONDS);
CompletableFuture<String> paymentFuture =
CompletableFuture.supplyAsync(
() -> paymentClient.preAuth(request.userId(), request.amount()),
orderExecutor)
.orTimeout(300, TimeUnit.MILLISECONDS);
boolean reserved = inventoryFuture.join();
String paymentToken = paymentFuture.join();
if (!reserved) {
return OrderResult.failed("INSUFFICIENT_STOCK");
}
return OrderResult.success(paymentToken);
} catch (Exception ex) {
rejectedCounter.increment();
return OrderResult.failed("SYSTEM_BUSY");
}
}
}
这段实现比“简单线程池 + Future.get()”更接近生产实践:
- 每个异步阶段都有超时,不让线程永远等下游。
- 使用受控线程池承载并发。
- 失败快速返回,保护资源。
- 指标与业务动作直接绑定。
7.4 缓存不是越大越好,必须有上限、TTL、淘汰策略
package com.example.order.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig {
@Bean
public Cache<String, String> hotSkuCache() {
return Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
}
}
缓存引发 JVM 问题的典型原因:
- 没有限制大小
- 永不过期
- key/value 过大
- 把临时对象错误提升为长生命周期对象
7.5 避免典型“隐形内存泄漏”
以下代码在线上非常常见:
private static final Map<String, Object> CACHE = new ConcurrentHashMap<>();
如果没有淘汰和容量控制,它几乎等于一个内存泄漏入口。
另一个高频问题是 ThreadLocal:
private static final ThreadLocal<byte[]> BUFFER =
ThreadLocal.withInitial(() -> new byte[1024 * 1024]);
如果线程是线程池线程,ThreadLocal 生命周期可能跟线程池一样长,导致对象长期存活。
正确做法:
- 明确释放
ThreadLocal.remove()
- 限制缓存大小
- 尽量让对象随请求结束而死亡
八、容器与 Kubernetes 场景:JVM 参数必须服从资源边界
容器环境下,最怕的不是 JVM 自己抛 OOM,而是 Pod 被直接 OOMKilled。
8.1 一定先做内存预算
假设 Pod limits.memory=4Gi,这 4Gi 不是都给堆。
更合理的预算模型:
| 项目 |
建议预算 |
| Java Heap |
50% - 65% |
| Metaspace + Code Cache |
5% - 10% |
| Direct Memory |
10% - 15% |
| Thread Stack |
5% - 10% |
| 安全冗余 |
10% - 15% |
如果服务依赖 Netty、Kafka、Redis、gRPC,堆外内存更不能忽略。
8.2 推荐的 Kubernetes Deployment 模板
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: production
spec:
replicas: 4
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/actuator/prometheus"
spec:
terminationGracePeriodSeconds: 60
containers:
- name: order-service
image: registry.example.com/order-service:1.0.0
ports:
- containerPort: 8080
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:+UseContainerSupport
-XX:InitialRAMPercentage=40
-XX:MaxRAMPercentage=60
-XX:MaxDirectMemorySize=512m
-XX:MaxMetaspaceSize=384m
-Xss512k
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=30
-XX:+ParallelRefProcEnabled
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps
-XX:ErrorFile=/data/logs/hs_err_pid%p.log
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
resources:
requests:
cpu: "1000m"
memory: "2Gi"
limits:
cpu: "2000m"
memory: "4Gi"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 20
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 20
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
emptyDir: {}
8.3 参数解释不要只背定义,要看工程影响
-XX:MaxRAMPercentage=60
- 让堆受容器上限约束
- 给 Metaspace、Direct Memory、线程栈留空间
-XX:MaxDirectMemorySize=512m
- 避免 Netty/NIO 类库无限吃堆外内存
- 对高并发网关、Redis、Kafka 客户端尤其重要
-Xss512k
- 降低线程栈总内存占用
- 适合线程数相对较多的服务
- 不能盲目调太小,深递归或复杂调用链要评估
-XX:InitiatingHeapOccupancyPercent=30
- 让 G1 更早开始并发标记
- 适合对象晋升较快、Old 区增长快的服务
8.4 高并发场景下,资源限制要和线程模型联动
一个常见误区是:
- 给 Pod 2 核 CPU
- 线程池开 200
- 连接池开 300
- 再把
MaxRAMPercentage 调得很激进
结果通常是:
- CPU 抢占严重
- 请求堆积
- RT 抖动
- 内存被线程与排队对象消耗
JVM 参数不能脱离下面这些配置单独看:
- Web 容器工作线程数
- 业务线程池大小与队列长度
- DB/Redis/HTTP 连接池大小
- MQ 消费并发度
- 熔断、超时、重试策略
九、典型线上故障案例:从现象到证据链
9.1 案例一:Old Gen 持续上涨,最终 OOM
现象:
- 运行 2 天后 Pod 重启
- GC 次数越来越多
- RT 在重启前数小时明显恶化
排查路径:
- Grafana 看到 Old Gen 使用率持续上涨且无法回落。
jstat -gcutil 观察 O 持续抬升。
jcmd <pid> GC.class_histogram 发现 OrderContext、HashMap$Node 数量异常。
- 导出 Heap Dump 用 MAT 看 Dominator Tree。
- 发现根因是本地缓存未设置上限。
根因代码:
private final Map<String, OrderContext> orderCache = new ConcurrentHashMap<>();
修复方案:
- 改为 Caffeine,限制
maximumSize
- 设置 TTL
- 只缓存必要字段,不缓存整条大对象
9.2 案例二:CPU 100%,但 GC 很正常
现象:
- QPS 没明显上涨
- CPU 从 30% 飙到 95%
- GC 无异常
排查路径:
top -H -p <pid> 找到高 CPU 线程。
- 转换线程 ID 后配合
jstack 看调用栈。
- 发现热点在 JSON 序列化与日志拼接。
- 再用 JFR 确认 Allocation Hotspots。
根因:
- 大量
log.info("request={}", JsonUtils.toJson(req))
- 无采样、无级别判断
- 每次请求创建大量短命对象
修复方案:
- 降低高频日志级别
- 避免无意义全量 JSON 序列化
- 对热点接口做日志采样
9.3 案例三:线程数暴涨,最终 RT 雪崩
现象:
- 线程数从 150 涨到 1200
- 接口 RT 从 80ms 升到 8s
- 错误率同步升高
排查路径:
- 监控发现
jvm_threads_live_threads 持续增长。
jstack 看到大量线程阻塞在下游 HTTP 调用。
- 业务线程池队列持续堆积。
- 下游服务超时后触发多次重试,放大压力。
根因:
修复方案:
- 收紧线程池和队列上限
- 缩短下游超时
- 引入熔断、隔离、降级
- 将同步串行改为可控并行
9.4 案例四:没有 Heap OOM,但 Pod 被 OOMKilled
现象:
- 应用日志没有
OutOfMemoryError
- Kubernetes 事件显示
OOMKilled
排查路径:
- 查看容器限制和 JVM 参数。
- 发现
MaxRAMPercentage=75,同时 Netty 使用大量 Direct Memory。
- 线程数偏高,
-Xss1m。
- 总内存预算超过容器限制。
根因:
修复方案:
- 将
MaxRAMPercentage 降到 60
- 限制
MaxDirectMemorySize
- 调整线程池与线程栈
现象:
- Full GC 后堆能回收
- 但进程 RSS 持续增长
- 最后抛
OutOfMemoryError: Metaspace
高频根因:
- 动态代理或字节码增强产生大量类
- 类加载器泄漏
- 热部署、脚本引擎、插件化加载未释放
排查工具:
jcmd <pid> VM.classloader_stats
jcmd <pid> GC.class_histogram
处理思路:
- 排查重复生成类
- 排查 ClassLoader 生命周期
- 给 Metaspace 设合理上限并做告警
十、调优方法论:不要上来就改参数
生产环境最稳妥的方法是六步走:
10.1 第一步:先定性
先回答它属于哪一类问题:
- CPU 高
- 内存高
- GC 频繁
- 线程阻塞
- RT 抖动
- OOMKilled
10.2 第二步:看趋势,不看瞬时
至少看:
- 最近 30 分钟
- 最近 6 小时
- 最近 24 小时
因为很多问题是缓慢累积的,不是某一秒突然发生。
10.3 第三步:建立证据链
推荐组合:
- 指标:Prometheus/Grafana
- 现场:
jcmd、jstat、jstack
- 深挖:JFR、Heap Dump、MAT、Arthas
没有证据链,改参数极容易误伤。
10.4 第四步:区分“JVM 问题”和“业务设计问题”
下面这些问题,调 JVM 参数往往治标不治本:
- 无界缓存
- 线程池无上限
- 接口超时不合理
- 重试风暴
- 大对象频繁序列化
- 下游依赖慢导致请求堆积
10.5 第五步:小步验证
任何调优都要经过:
- 压测基线
- 修改方案
- 对比指标
- 灰度验证
- 长稳测试
10.6 第六步:形成团队标准
真正能提升团队生产力的不是某次神操作,而是沉淀:
- 标准 JVM 启动模板
- 标准告警项
- 标准排障手册
- 标准压测流程
十一、生产环境推荐配置模板
11.1 通用在线业务模板
适用场景:
- Spring Boot 微服务
- 2C/2B 在线接口
- Kubernetes 运行
- 目标是稳定、均衡、易维护
-XX:+UseContainerSupport
-XX:InitialRAMPercentage=40
-XX:MaxRAMPercentage=60
-XX:MaxDirectMemorySize=512m
-XX:MaxMetaspaceSize=384m
-Xss512k
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=30
-XX:+ParallelRefProcEnabled
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps
-XX:ErrorFile=/data/logs/hs_err_pid%p.log
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
说明:
- 这不是银弹模板,但比“盲目把堆顶到 75%-80%”更稳。
- 如果服务强依赖 Netty/Kafka/Redis,
MaxDirectMemorySize 需要单独评估。
11.2 低延迟场景建议
适用场景:
原则:
- 优先减少分配和锁竞争
- 再考虑低停顿 GC
- 先用 JFR 证明确实是 GC 主导延迟,再考虑 ZGC
11.3 批处理/离线任务建议
适用场景:
原则:
- 更看重吞吐
- 可适当接受更长停顿
- Parallel GC 在一些离线场景仍然有价值
十二、发布版 Checklist 与命令速查
12.1 上线前 Checklist
- 是否给堆、堆外、线程栈做过总内存预算
- 是否给线程池、连接池、缓存设置了上限
- 是否暴露了 Prometheus 指标
- 是否有 GC、线程池、RT、错误率联合告警
- 是否保留 Heap Dump 与 GC Log 落盘路径
- 是否完成过峰值压测与长稳测试
- 是否验证过下游超时、重试、熔断配置
- 是否有故障现场保全手册
12.2 线上排障速查命令
# 1. 找进程
jps -lvm
# 2. 看 JVM 参数
jcmd <pid> VM.flags
# 3. 看 GC 趋势
jstat -gcutil <pid> 1000 20
# 4. 看线程现场
jstack -l <pid> > /tmp/thread.dump
# 5. 看类直方图
jcmd <pid> GC.class_histogram
# 6. 开一段 JFR
jcmd <pid> JFR.start name=prod settings=profile duration=120s filename=/tmp/prod.jfr
# 7. 必要时导出 Heap Dump
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof
12.3 一张表记住工具怎么选
| 问题 |
先看什么 |
再用什么 |
| RT 突然飙高 |
Grafana、Trace |
jstack、JFR、Arthas trace |
| CPU 飙高 |
容器 CPU、线程数 |
top -H、jstack、JFR |
| Old 区上涨 |
JVM 面板 |
jstat、GC.class_histogram、Heap Dump |
| 线程暴涨 |
线程池监控 |
jstack、Arthas thread |
| Pod 被杀 |
K8s 事件、RSS |
容器预算、直接内存、线程栈分析 |
| Full GC 频繁 |
GC 日志、监控 |
jstat、JFR、对象分配分析 |
结语
JVM 调优真正难的地方,不在于命令多,而在于它横跨了业务模型、并发模型、内存结构、GC 行为、容器资源、监控架构和故障处置流程。
如果只记住一件事,请记住这句:
先用观测系统判断问题类型,再用诊断工具建立证据链,最后才是参数与代码优化。
真正成熟的团队,做的不是“线上靠高手救火”,而是把 JVM 问题变成可监控、可复盘、可标准化治理的工程问题。关于JVM调优的更多深度讨论和实战资料,也欢迎你来云栈社区交流分享。