欢迎光临
我们一直在努力

JVM 调优,到底是调什么?

很多人说自己做过 JVM 调优,但被追问几句就哑火了。本文从"为什么要调"入手,把 JVM 调优这件事从头捋清楚。

一、先问一个问题:你的程序出了什么问题?

JVM 调优不是玄学,更不是无脑堆参数。在动手之前,先得弄清楚:程序到底哪里不对劲?

常见的症状大概就这几类:

症状可能的根因
接口响应时间偶发性飙高 GC 停顿(Stop-The-World)
内存一直涨,最终 OOM 内存泄漏、对象分配太猛
CPU 跑满但吞吐量低 GC 太频繁,或线程竞争严重
程序越跑越慢 JIT 未充分热身,或代码缓存满了
线程 hang 住,响应全无 死锁、资源耗尽

调优的起点永远是问题,不是参数。 拿到一台正常运行的服务器,没有任何问题,你去"调 JVM",多半是在瞎折腾。


二、JVM 是个什么东西?(快速建立全局观)

在调之前,得知道自己在调什么。JVM 内部主要分三大块:

┌─────────────────────────────────────────────────────────┐
│ JVM 内部 │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 运行时数据区 │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ 堆 Heap │ │ 方法区 │ │ 直接内存(堆外) │ │ │
│ │ │(GC管理)│ │Metaspace │ │ Direct Memory │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 每个线程独立:虚拟机栈 / 本地方法栈 / 程序计数器│ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 执行引擎 │ │ 垃圾回收器 GC │ │
│ │ 解释器 + JIT 编译器 │ │ G1 / ZGC / Shenandoah│ │
│ └──────────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘

JVM 调优,本质上就是在这几个模块上做文章:

  • 内存调优 → 堆、非堆、堆外内存的大小和分配
  • GC 调优 → 选哪个收集器,怎么配参数,减少停顿
  • JIT 调优 → 让热点代码更快编译和运行
  • 线程调优 → 栈大小、线程数、锁竞争
  • 三、调优 Part 1:内存——分多少、怎么分

    3.1 堆内存的基本结构

    Java 对象都活在堆里。GC 的设计利用了一个重要观察:大部分对象生命周期极短(朝生夕死)。所以堆被分成两代:

    Java 堆 (-Xms / -Xmx)
    ┌───────────────────────────────────────────────────────┐
    │ │
    │ ┌─────────────────────────┐ ┌───────────────────┐ │
    │ │ 新生代 Young Gen │ │ 老年代 Old Gen │ │
    │ │ │ │ │ │
    │ │ ┌───────┐ ┌──┐ ┌──┐ │ │ 长寿对象、大对象 │ │
    │ │ │ Eden │ │S0│ │S1│ │ │ 经历多次GC后晋升 │ │
    │ │ └───────┘ └──┘ └──┘ │ │ │ │
    │ │ 对象在这里诞生 │ │ │ │
    │ └─────────────────────────┘ └───────────────────┘ │
    │ Minor GC(频繁) Major/Full GC │
    └───────────────────────────────────────────────────────┘

    3.2 关键内存参数

    # 堆的初始和最大值(生产环境建议设相同,避免动态扩容)
    -Xms4g -Xmx4g

    # 新生代大小(约占堆的 1/3)
    -Xmn1g
    # 或者用比例
    -XX:NewRatio=2 # 老年代:新生代 = 2:1

    # Eden 和 Survivor 的比例
    -XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1

    # Metaspace(方法区,存类信息,JDK8+已移出堆)
    -XX:MetaspaceSize=256m
    -XX:MaxMetaspaceSize=512m

    # 堆外直接内存(NIO、Netty 重度用户需要关注)
    -XX:MaxDirectMemorySize=1g

    3.3 一个常见的坑:Xms 和 Xmx 不相等

    很多人 -Xms512m -Xmx4g,觉得"灵活"。实际上 JVM 启动后内存不够用时会触发堆扩容,扩容本身就有性能代价。生产环境建议 Xms = Xmx,让 JVM 一次性申请好内存,省得反复扩。


    四、调优 Part 2:GC——最核心的战场

    GC 调优占了整个 JVM 调优 80% 的工作量。GC 的核心矛盾只有一个:

    吞吐量 vs 停顿时间(延迟)

    这是一对天然的矛盾。你不能既要极低的停顿,又要极高的吞吐量——收集器设计本身就是在做这个 trade-off。

    4.1 选对收集器,是第一步

    收集器适合场景停顿特点
    Serial GC 单核、客户端、内存小 单线程,停顿长
    Parallel GC 批处理、吞吐量优先 多线程,停顿可接受
    G1 GC 通用,大堆(4G+) 可预测停顿(-XX:MaxGCPauseMillis)
    ZGC 超低延迟(JDK15+ 推荐) 亚毫秒级,几乎无 STW
    Shenandoah 低延迟,类似 ZGC Red Hat 主导,OpenJDK 支持

    JDK 版本和默认收集器对照:

    JDK 8 → Parallel GC(默认)
    JDK 9+ → G1 GC(默认)
    JDK 21+ → ZGC 已生产就绪,可考虑直接上

    4.2 G1 GC 的关键参数(目前最主流)

    G1 的设计思路是把堆切成 N 个等大的 Region,每次优先回收垃圾最多的 Region(Garbage First 的由来)。

    堆(G1 视角)
    ┌────┬────┬────┬────┬────┬────┬────┬────┐
    │ E │ S │ O │ E │ H │ O │ E │ S │
    ├────┼────┼────┼────┼────┼────┼────┼────┤
    │ O │ E │ O │ S │ O │ E │ O │ O │
    └────┴────┴────┴────┴────┴────┴────┴────┘
    E=Eden S=Survivor O=Old H=Humongous(大对象)

    # 使用 G1
    -XX:+UseG1GC

    # 最大停顿时间目标(核心参数,G1 会自动调整回收区域)
    -XX:MaxGCPauseMillis=200

    # Region 大小(1MB~32MB,建议让 JVM 自动计算)
    -XX:G1HeapRegionSize=4m

    # Mixed GC 触发时机(老年代占堆比例)
    -XX:InitiatingHeapOccupancyPercent=45

    # 并发标记线程数
    -XX:ConcGCThreads=4

    4.3 怎么知道 GC 有问题?

    先开 GC 日志,这是一切分析的基础:

    # JDK 9+
    -Xlog:gc*:file=/app/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

    # JDK 8
    -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/app/logs/gc.log

    看日志时重点关注:

    # 一条典型的 G1 日志
    [2024-01-15T10:23:45.123] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
    ↑ 这次是 Minor GC,停顿了多久?
    [2024-01-15T10:23:45.123] GC(42) 512M->384M(2048M) 45.678ms
    ↑ 回收前→回收后(堆总大小)耗时

    健康的 GC 应该是什么样的?

    • Minor GC 频率:不超过每 10 秒一次
    • Minor GC 停顿:不超过 100ms
    • Full GC:尽量为 0,最多每天个位数
    • 老年代占用:长期稳定,不持续增长

    五、调优 Part 3:排查 OOM,找到内存泄漏

    OOM 分好几种,错误信息就是线索:

    java.lang.OutOfMemoryError: Java heap space
    → 堆满了。先看是真泄漏还是内存不够用

    java.lang.OutOfMemoryError: Metaspace
    → 类加载太多(动态代理、反射、OSGi、热部署)

    java.lang.OutOfMemoryError: Direct buffer memory
    → NIO/Netty 的堆外内存没有释放

    java.lang.OutOfMemoryError: unable to create native thread
    → 线程太多,或者每个线程栈太大

    排查 Heap OOM 的标准姿势

    第一步:拿到 Heap Dump

    # 方式一:JVM 参数,OOM 时自动 dump
    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/app/logs/heapdump.hprof

    # 方式二:手动触发(进程还活着时)
    jmap -dump:live,format=b,file=heapdump.hprof <pid>

    第二步:用工具分析

    把 .hprof 文件拖进 Eclipse MAT 或 JProfiler,看 Leak Suspects Report,它会直接告诉你哪个对象占了多少内存、是哪段代码创建的。

    典型的泄漏路径长这样:
    ThreadLocal → Map → ArrayList → 你的业务对象(10万个)
    ↑ 这里没有 remove(),随着线程复用一直积累

    六、调优 Part 4:JIT——不用动,但要懂

    JIT(Just-In-Time Compiler)是 JVM 的性能核武器,它在运行时把热点字节码编译成本地机器码。一般不需要干预,但有几个场景值得注意:

    代码缓存(Code Cache)满了

    # 查看代码缓存使用情况
    jcmd <pid> VM.native_memory summary

    # 调大代码缓存
    -XX:ReservedCodeCacheSize=512m

    服务刚启动时性能差(JIT 预热期)

    这是正常的。JVM 默认要一个方法被调用 10000 次(C2 编译器阈值)才会编译成机器码。对于短生命周期的微服务,可以考虑 AppCDS(Application Class Data Sharing) 来加速启动。

    # 开启分层编译(默认开启,确保没有被关闭)
    -XX:+TieredCompilation

    七、调优 Part 5:线程——别让锁成为瓶颈

    线程问题通常表现为:CPU 高但进展缓慢,或者接口偶发 timeout。

    查线程状态,一招鲜:

    # 导出所有线程堆栈
    jstack <pid> > thread_dump.txt

    # 或者用 jcmd
    jcmd <pid> Thread.print > thread_dump.txt

    然后搜索关键词:

    BLOCKED → 线程在等待锁,找锁竞争
    WAITING → 线程在等待某个条件(可能是死锁)

    常见线程参数:

    # 每个线程的栈大小(默认 512k 或 1m,线程多时适当调小)
    -Xss256k

    # 注意:这个不影响业务线程池,线程池大小要在代码里配

    八、调优工具箱,收好了

    场景工具
    实时监控 JVM 状态 jconsole、jvisualvm、Arthas
    查内存/线程/类 jmap、jstack、jstat
    分析 Heap Dump Eclipse MAT、JProfiler
    分析 GC 日志 GCViewer、GCEasy(在线)
    诊断线上问题(神器) Arthas(阿里开源)

    Arthas 几个常用命令:

    # 查看 JVM 内存分布
    memory

    # 查看类加载情况
    classloader -t

    # 追踪某个方法的耗时(不用重启!)
    trace com.example.UserService getUserById

    # 查看对象在内存中的数量
    ognl "@com.example.cache.LocalCache@instance.size()"

    九、调优的正确姿势——一个完整流程

    发现问题

    收集数据(GC日志、Heap Dump、Thread Dump、指标监控)

    定位瓶颈(GC停顿?内存泄漏?锁竞争?CPU占用?)

    单次只改一个参数或方向

    压测或等待足够时间观察效果

    对比前后数据,确认是否改善

    记录结论 → 循环迭代

    千万别做的事:

    • 一次改一堆参数,然后不知道是哪个起的效果
    • 看别人的博客抄参数,不管自己的业务场景
    • 没有监控,靠感觉说"好像快了一点"

    十、一个实际案例:接口 P99 偶发飙到 5 秒

    现象: 电商系统下单接口,平时 P99 在 200ms,每隔几分钟就会飙到 4~5 秒。

    排查过程:

  • 看监控,发现 CPU 没有异常,接口耗时飙高的时间点和 GC 停顿时间完全吻合
  • 看 GC 日志,发现每次 Full GC 停顿约 4 秒
  • Full GC 之前老年代占用 92%,说明对象晋升太频繁
  • 看新生代大小:只有 512MB,老年代 3.5G(比例失调)
  • 排查代码,发现每次请求创建了大量临时 byte[] 对象(序列化逻辑没有复用缓冲区)
  • 解决方案:

    # 调整前
    -Xmx4g -Xms4g -Xmn512m

    # 调整后:新生代放大,让短命对象死在新生代
    -Xmx4g -Xms4g -Xmn2g -XX:MaxGCPauseMillis=200

    # 同时切换到 G1,利用停顿时间预测
    -XX:+UseG1GC

    代码侧用了对象池复用序列化缓冲区。

    效果: Full GC 降为 0,P99 稳定在 180ms。

    总结

    JVM 调优归根结底就是这几件事:

  • 内存分配合理:堆大小、新老代比例、Metaspace 上限
  • GC 停顿可控:选对收集器,参数配合业务场景
  • 没有内存泄漏:长期运行内存稳定,OOM 原因明确
  • 线程健康:没有死锁,没有严重锁竞争
  • JIT 充分工作:代码缓存够用,热点代码被充分编译
  • 真正好的 JVM 调优,是让你感觉不到 JVM 的存在——接口稳定、内存平稳、GC 安静。


    本文所有参数以 JDK 11/17 为主。不同 JDK 版本部分参数有差异,建议结合 java -XX:+PrintFlagsFinal -version 查看当前 JVM 的实际默认值。


    如果本文对你有帮助,点个赞再走 ✌️

    赞(0)
    未经允许不得转载:171主机测评 » JVM 调优,到底是调什么?
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址