找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4988

积分

0

好友

696

主题
发表于 昨天 05:21 | 查看: 20| 回复: 0

很多文章讲得都对,但一上生产就不好使,核心在于缺失了四个关键环节:

  1. 只讲命令,不讲原理,导致看到 Full GC 只会机械搜索参数。
  2. 只讲单机,不讲微服务、容器、Kubernetes、Prometheus、链路追踪的组合场景。
  3. 只讲“如何看”,不讲“如何构建证据链”,最终靠经验猜。
  4. 只讲堆,不讲线程、锁、直接内存、类元空间、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/库/本地分配       |
+----------------------+----------------+-----------------------+

线上最常见的五类内存问题:

  1. Java Heap OOM:缓存泄漏、集合膨胀、对象生命周期过长。
  2. Metaspace OOM:动态代理、热部署、类加载器泄漏。
  3. Direct Memory OOM:Netty、Kafka、Redis、NIO 使用堆外内存过多。
  4. Native Memory 被吃光:JVM 外部库、本地分配、线程栈总量过大。
  5. 容器 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 工具分成四类:

类别 工具 用途 是否适合线上
快速定位 jpsjcmdjstatjstack 先判断问题类型
深入诊断 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 监控指标必须分层

建议至少分为四层:

  1. 资源层:CPU、内存、磁盘、网络、容器重启。
  2. JVM 层:Heap、Old Gen、GC Pause、线程数、类加载、直接内存。
  3. 组件层:Tomcat/Undertow/Netty、线程池、连接池、Kafka、Redis。
  4. 业务层: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 在重启前数小时明显恶化

排查路径:

  1. Grafana 看到 Old Gen 使用率持续上涨且无法回落。
  2. jstat -gcutil 观察 O 持续抬升。
  3. jcmd <pid> GC.class_histogram 发现 OrderContextHashMap$Node 数量异常。
  4. 导出 Heap Dump 用 MAT 看 Dominator Tree。
  5. 发现根因是本地缓存未设置上限。

根因代码:

private final Map<String, OrderContext> orderCache = new ConcurrentHashMap<>();

修复方案:

  • 改为 Caffeine,限制 maximumSize
  • 设置 TTL
  • 只缓存必要字段,不缓存整条大对象

9.2 案例二:CPU 100%,但 GC 很正常

现象:

  • QPS 没明显上涨
  • CPU 从 30% 飙到 95%
  • GC 无异常

排查路径:

  1. top -H -p <pid> 找到高 CPU 线程。
  2. 转换线程 ID 后配合 jstack 看调用栈。
  3. 发现热点在 JSON 序列化与日志拼接。
  4. 再用 JFR 确认 Allocation Hotspots。

根因:

  • 大量 log.info("request={}", JsonUtils.toJson(req))
  • 无采样、无级别判断
  • 每次请求创建大量短命对象

修复方案:

  • 降低高频日志级别
  • 避免无意义全量 JSON 序列化
  • 对热点接口做日志采样

9.3 案例三:线程数暴涨,最终 RT 雪崩

现象:

  • 线程数从 150 涨到 1200
  • 接口 RT 从 80ms 升到 8s
  • 错误率同步升高

排查路径:

  1. 监控发现 jvm_threads_live_threads 持续增长。
  2. jstack 看到大量线程阻塞在下游 HTTP 调用。
  3. 业务线程池队列持续堆积。
  4. 下游服务超时后触发多次重试,放大压力。

根因:

  • 线程池过大
  • 下游超时设置过长
  • 重试无熔断

修复方案:

  • 收紧线程池和队列上限
  • 缩短下游超时
  • 引入熔断、隔离、降级
  • 将同步串行改为可控并行

9.4 案例四:没有 Heap OOM,但 Pod 被 OOMKilled

现象:

  • 应用日志没有 OutOfMemoryError
  • Kubernetes 事件显示 OOMKilled

排查路径:

  1. 查看容器限制和 JVM 参数。
  2. 发现 MaxRAMPercentage=75,同时 Netty 使用大量 Direct Memory。
  3. 线程数偏高,-Xss1m
  4. 总内存预算超过容器限制。

根因:

  • 只算了堆,没算堆外内存和线程栈

修复方案:

  • MaxRAMPercentage 降到 60
  • 限制 MaxDirectMemorySize
  • 调整线程池与线程栈

9.5 案例五:Metaspace 持续上涨

现象:

  • 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
  • 现场:jcmdjstatjstack
  • 深挖:JFR、Heap Dump、MAT、Arthas

没有证据链,改参数极容易误伤。

10.4 第四步:区分“JVM 问题”和“业务设计问题”

下面这些问题,调 JVM 参数往往治标不治本:

  • 无界缓存
  • 线程池无上限
  • 接口超时不合理
  • 重试风暴
  • 大对象频繁序列化
  • 下游依赖慢导致请求堆积

10.5 第五步:小步验证

任何调优都要经过:

  1. 压测基线
  2. 修改方案
  3. 对比指标
  4. 灰度验证
  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 低延迟场景建议

适用场景:

  • 交易撮合
  • 实时报价
  • RT 对 P99 非常敏感

原则:

  • 优先减少分配和锁竞争
  • 再考虑低停顿 GC
  • 先用 JFR 证明确实是 GC 主导延迟,再考虑 ZGC

11.3 批处理/离线任务建议

适用场景:

  • ETL
  • 数据导入
  • 后台计算任务

原则:

  • 更看重吞吐
  • 可适当接受更长停顿
  • 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 -Hjstack、JFR
Old 区上涨 JVM 面板 jstatGC.class_histogram、Heap Dump
线程暴涨 线程池监控 jstack、Arthas thread
Pod 被杀 K8s 事件、RSS 容器预算、直接内存、线程栈分析
Full GC 频繁 GC 日志、监控 jstat、JFR、对象分配分析

结语

JVM 调优真正难的地方,不在于命令多,而在于它横跨了业务模型、并发模型、内存结构、GC 行为、容器资源、监控架构和故障处置流程。

如果只记住一件事,请记住这句:

先用观测系统判断问题类型,再用诊断工具建立证据链,最后才是参数与代码优化。

真正成熟的团队,做的不是“线上靠高手救火”,而是把 JVM 问题变成可监控、可复盘、可标准化治理的工程问题。关于JVM调优的更多深度讨论和实战资料,也欢迎你来云栈社区交流分享。




上一篇:生产级Java并发优化:线程池隔离、超时预算与CompletableFuture编排实战
下一篇:三个月全自动编程:我用Claude Code打造了十几个AI Agent项目
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 18:16 , Processed in 0.986946 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表