欢迎光临
我们一直在努力

JVM 征服手册:从 CRUD 到性能调优的完整指南(二)- 进阶实战

上篇我们聊了 JVM 的基础——类加载、内存结构、垃圾回收算法。如果你已经读完了上篇,恭喜你,你已经掌握了 JVM 知识体系的"骨架"。但就像学开车一样,光知道方向盘、油门、刹车在哪还不够,你还得上路跑一圈才能真正学会。

本篇是 JVM 系列的下篇,我们从"是什么"转向"怎么用"——具体到每一个垃圾收集器怎么选、GC 日志怎么看、线上出了问题用什么工具排查、面试时怎么把零散的知识点串联成一个漂亮的回答。如果说上篇帮你"懂 JVM",那这一篇的目标就是帮你"用 JVM"。


第一章:垃圾收集器——HotSpot 的"环卫工"们

上篇我们讲了垃圾回收的算法(标记-清除、标记-复制、标记-整理),但算法只是理论。真正在 JVM 里干活的,是垃圾收集器——它们是算法的具体实现。

1.1 经典收集器全家福

先认识一下各位"环卫工"以及它们的分工搭配:

连线表示经典搭配。上半部分的三个组合是 JDK 8 时代的主流,下半部分的 G1 和 ZGC 是现在和未来的趋势。

1.2 逐个认识它们

Serial / Serial Old——单线程的"老古董"

工作时要暂停所有用户线程(Stop The World,简称 STW),然后单线程慢慢干活。适合单核 CPU 或内存很小的桌面应用。服务器端基本不用,但理解它是理解其他收集器的起点。

ParNew——Serial 的多线程版

新生代用多条 GC 线程并行干活,默认搭配老年代的 CMS。在 JDK 8 时代是很多 Web 应用的选择。

Parallel Scavenge / Parallel Old——追求吞吐量

这两个收集器的重点是 "吞吐量"(运行用户代码的时间占比)。如果你跑后台批处理任务,对偶尔的停顿不敏感,这对搭档是首选。JDK 8 的默认组合就是它们。

CMS(Concurrent Mark Sweep)——追求低延迟

CMS 的设计目标是 "用户线程的停顿时间越短越好"。GC 的某些阶段可以和用户线程并发执行,大幅减少了 STW 时间。但它有两个硬伤:

  • 基于"标记-清除",会产生内存碎片
  • 并发清理阶段新产生的垃圾(浮动垃圾)只能等下次 GC
  • CMS 已于 JDK 14 被正式废弃。如果你负责的系统还在用 CMS,建议尽快迁移到 G1。

    G1(Garbage First)——平衡之王

    JDK 9 开始成为默认收集器。核心理念不再是"先分代再收集",而是把堆分成大量大小相等的 Region,每次 GC 优先回收垃圾最多的 Region(所以叫 Garbage First):

    G1 最大的优势是停顿时间可预测——通过 -XX:MaxGCPauseMillis=200 设定你期望的最大停顿时间,G1 会尽量把每次 GC 控制在目标范围内。

    ZGC——超大堆低延迟的终极方案

    JDK 11 引入,JDK 15 正式生产可用。目标是无论堆是 8MB 还是 16TB,STW 时间都控制在 10ms 以内。它用了着色指针和读屏障等高级技术,是目前延迟最低的收集器。

    1.3 选型速查表

    场景推荐收集器原因
    JDK 8、堆 2G-4G、后台批处理 Parallel Scavenge 高吞吐量,JDK 8 默认
    JDK 8、堆 4G-8G、Web 应用 G1(手动开启) 低延迟,可预测
    JDK 8、堆 8G+、对延迟敏感 G1(手动开启) 大堆优势明显
    JDK 11+、通用 Web 应用 G1(默认) 不用配置,开箱即用
    JDK 17+、超大堆、极低延迟 ZGC 10ms 以内 STW
    小型桌面应用 Serial 简单够用

    实用建议:如果你在用 JDK 8 且堆在 4G 以上,加一行配置就够了:

    -XX:+UseG1GC -XX:MaxGCPauseMillis=200


    第二章:GC 日志与常用参数——线上排障的瑞士军刀

    知道收集器怎么工作还不够。真正到了线上出问题时,你手里最有力的武器就是 GC 日志和 JVM 参数。

    2.1 堆内存参数速查

    参数说明示例
    -Xms 初始堆大小 -Xms2g
    -Xmx 最大堆大小 -Xmx4g
    -XX:NewRatio 老年代/新生代比例 -XX:NewRatio=2(老年代:新生代 = 2:1)
    -XX:SurvivorRatio Eden 与单个 Survivor 比例 -XX:SurvivorRatio=8(Eden:Survivor = 8:1)
    -XX:MaxMetaspaceSize 元空间最大值 -XX:MaxMetaspaceSize=256m
    -XX:MaxDirectMemorySize 最大堆外内存 -XX:MaxDirectMemorySize=512m

    注意:-Xms 和 -Xmx 建议设为相同值,避免堆在运行时频繁扩展和收缩。生产环境通常设为物理内存的 50%-75%。

    2.2 生产必备参数

    # 这几个参数强烈建议所有生产环境都加上
    -XX:+HeapDumpOnOutOfMemoryError # OOM 时自动 dump 堆快照
    -XX:HeapDumpPath=/data/logs/heap-dump/ # dump 文件存放路径
    -XX:ErrorFile=/data/logs/hs_err_pid%p.log # JVM 崩溃时的错误日志
    -XX:+PrintCommandLineFlags # 启动时打印所有 JVM 参数(方便确认参数是否生效)

    2.3 如何阅读 GC 日志?

    JDK 8 开启 GC 日志:

    -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log

    JDK 11+ 开启 GC 日志:

    -Xlog:gc*:file=/data/logs/gc.log:time,level,tags

    日志解读示例(G1):

    [2024-06-01T14:23:45.123+0800] GC(131) Pause Young (Normal) (G1 Evacuation Pause) 812M->726M(2048M) 12.321ms
    [2024-06-01T14:23:45.123+0800] GC(131) User=0.04s Sys=0.01s Real=0.01s

    逐字段拆解:

    字段含义
    GC(131) 第 131 次 GC
    Pause Young (Normal) 新生代回收,需要 STW
    812M->726M(2048M) GC 前堆占用 812M → GC 后 726M(堆总大小 2048M)
    12.321ms 本次 GC 耗时
    User=0.04s CPU 用户态耗时
    Sys=0.01s 内核态耗时
    Real=0.01s 实际壁钟时间

    看 GC 日志的口诀:先看频率,再看耗时。Full GC 每小时几十次 → 有问题。每次 GC 耗时超过 1 秒 → 也要查。


    第三章:JVM 诊断工具——线上出问题时的应对策略

    理论学了不少,但如果线上真的出了问题——CPU 飙升、内存泄漏、死锁——你怎么办?这一章我们全用命令说话。

    3.1 jps:一切诊断的起点

    jps -l
    # 输出示例:
    # 12345 com.example.MyApplication
    # 12346 jdk.jcmd/sun.tools.jps.Jps

    所有其他工具都需要进程 ID,所以 jps 是第一步。你也可以用 ps -ef | grep java,但 jps 更精准。

    3.2 排查 CPU 飙升的标准三板斧

    # 第一步:找到 CPU 最高的线程
    top -Hp 12345
    # 假设发现线程 12389 的 CPU 使用率最高

    # 第二步:线程 ID 转十六进制
    printf "%x\\n" 12389
    # 输出:3065

    # 第三步:在 jstack 输出中搜索该线程
    jstack 12345 | grep -A 20 3065
    # 定位到具体代码行

    这就是 CPU 排查的标准流程,五分钟内定位到问题代码。

    3.3 jstack:线程分析的瑞士军刀

    jstack 12345 > thread.txt # 导出线程快照

    jstack 能帮你看到:

    • 每个线程在干什么(RUNNABLE / BLOCKED / WAITING / TIMED_WAITING)
    • 是否有死锁——输出末尾会直接标注 Found one Java-level deadlock:
    • 是否有线程长期处于 BLOCKED 状态(可能锁竞争严重)

    建议:出问题时连续抓 3-5 次 jstack(间隔 2 秒),比只抓一次更能看出问题。如果某个线程每次都在同一行代码上卡着,基本就是它了。

    3.4 jmap + MAT:内存泄漏排查

    # 查看堆内存概况
    jmap -heap 12345

    # 快速统计:哪些类的实例最多?(生产慎用,耗时)
    jmap -histo:live 12345 | head -30

    # 导出堆 dump 文件(生产慎用,会触发 STW)
    jmap -dump:live,format=b,file=heap.hprof 12345

    更好的做法:配置 -XX:+HeapDumpOnOutOfMemoryError,让 JVM 在 OOM 时自动 dump,避免手动导出的风险。

    导出 dump 后用 Eclipse MAT(免费)分析:

  • 打开 Leak Suspects Report —— 它会直接告诉你"最大的嫌疑对象"
  • 看 Dominator Tree —— 按占用大小列出所有对象
  • 找到异常大的集合或未关闭的资源,追溯到代码
  • 3.5 jstat:实时监控 GC

    # 每 1000ms 输出一次 GC 统计,共 10 次
    jstat -gc 12345 1000 10

    关键输出字段:
    字段含义怎么判断有问题
    S0C/S1C Survivor 容量 —
    EC Eden 容量 —
    OC 老年代容量 —
    OU 老年代使用量 持续接近 OC,快满了
    YGC 新生代 GC 次数 —
    YGCT 新生代 GC 总耗时 —
    FGC Full GC 次数 频繁增长意味着问题
    FGCT Full GC 总耗时 占比过高影响吞吐量

    看到 FGC 在持续增长,但怎么回收也回收不了多少空间 → 大概率是内存泄漏,dump 出来用 MAT 分析。

    3.6 Arthas:阿里开源的线上排障神器

    当 jstack/jmap 不够用时,Arthas 可以提供更强大的动态诊断能力:

    curl -O https://arthas.aliyun.com/arthas-boot.jar
    java -jar arthas-boot.jar # 选择要 attach 的 Java 进程

    常用命令:

    dashboard # 实时面板:线程、内存、GC 一览无余(类似 top)

    # 追踪方法调用——比打日志快一万倍
    trace com.example.UserService getUser '#cost > 100'
    # 输出 getUser 方法中所有耗时 >100ms 的子调用链

    # 观察方法入参和返回值
    watch com.example.OrderService createOrder '{params, returnObj, throwExp}'

    # 热替换 class 文件(紧急修复线上 bug,无需重启)
    redefine /tmp/UserService.class

    Arthas 的强大之处在于:不需要重启应用,不需要加日志,直接 attach 到运行中的 JVM 进程,实时诊断。


    第四章:JIT 编译与性能优化——JVM 为什么越来越快?

    你可能听过"Java 比 C++ 慢"的说法。这个说法放在 20 年前是对的,但放在今天已经过时了。HotSpot 之所以叫 HotSpot,核心就在于 JIT(Just-In-Time,即时编译)。

    4.1 解释执行 + 编译执行:两手都要抓

    第一阶段(启动时):
    字节码 → 解释器逐条翻译 → 机器码(慢,但零等待)

    第二阶段(运行中,发现热点代码):
    字节码 → JIT 编译器深度优化 → 高质量机器码(快,编译有开销)

    HotSpot 采用混合模式:启动时用解释器快速响应,运行一段时间后,把执行频繁的**"热点代码"**交给 JIT 做深度编译优化。这样兼顾了启动速度和峰值性能。

    4.2 C1 和 C2:两个编译器,各司其职

    编译器特点适用场景
    C1(Client) 编译快,优化浅 桌面应用,追求启动速度
    C2(Server) 编译慢,优化深 服务器应用,追求峰值性能

    JDK 7 起引入了分层编译——C1 先快速编译让程序热起来,C2 在后面对真正的热点做深度优化。两阶段配合,兼得快速启动和高峰性能。

    4.3 JIT 的三个关键优化

    (1)方法内联(Method Inlining)

    // 原本是两次方法调用
    private int add(int a, int b) { return a + b; }
    private int calc() { return add(1, 2); }

    // JIT 优化后等价于:直接展开,零调用开销
    private int calc() { return 1 + 2; }

    减少方法调用的压栈出栈开销,是最基础也最有效的优化之一。

    (2)逃逸分析(Escape Analysis)

    如果一个对象只在一个方法内部使用,不会"逃出"这个方法,JIT 就能做三种激进优化:

    // 无逃逸——对象只在方法内部使用
    public void test() {
    Point p = new Point(1, 2);
    // 1. 栈上分配:p 可能根本不去堆里,在栈上就分配了
    // 2. 标量替换:p.x 和 p.y 被拆成两个独立变量
    // 3. 同步消除:如果 p 被用作锁,这个锁也没必要了
    System.out.println(p.x + p.y);
    }

    // 有逃逸——对象返回出去了,必须在堆上
    public Point test() {
    return new Point(1, 2); // 逃逸了!
    }

    (3)锁消除与锁粗化

    // 锁消除:StringBuffer 的 append 是同步方法,但如果 sb 是局部变量
    // (没有逃逸),JIT 就直接把锁去掉了
    public String concat() {
    StringBuffer sb = new StringBuffer(); // 局部变量,无逃逸
    sb.append("a").append("b"); // 锁被 JIT 消除!
    return sb.toString();
    }

    // 锁粗化:连续对同一个对象加锁,JIT 合并成一次
    synchronized (obj) { doA(); }
    synchronized (obj) { doB(); }
    // JIT 优化后 ≈
    synchronized (obj) { doA(); doB(); }


    第五章:JVM 内存模型(JMM)——并发编程的基石

    先区分清楚:JVM 内存结构(堆、栈、方法区)和 Java 内存模型(JMM)是两个完全不同的概念。前者描述数据存放在 JVM 的哪里,后者描述多线程之间如何通过内存通信。

    5.1 为什么需要 JMM?

    CPU 速度和内存速度差了两个数量级,所以现代 CPU 加上了多级缓存。但这带来了缓存一致性问题——同一个变量在不同 CPU 核心的缓存里可能值不一样。

    JMM 就是一套规范,定义了三件事:

    • 可见性:一个线程改了变量,其他线程什么时候能看到?
    • 有序性:指令重排的边界在哪里?
    • 原子性:哪些操作不可分割?

    5.2 happens-before 原则

    理解 JMM 的最佳入口是 happens-before 原则:如果操作 A happens-before 操作 B,那么 A 的结果对 B 是可见的。

    规则说明
    程序次序 同一线程内,前面的代码 happens-before 后面的代码
    volatile volatile 变量的写 happens-before 后续对该变量的读
    锁 unlock happens-before 后续的 lock
    传递性 A hb B 且 B hb C → A hb C
    线程启动 thread.start() hb 线程内的任何操作
    线程终止 线程的所有操作 hb thread.join() 返回

    5.3 volatile 到底干了什么?

    volatile 提供两个语义:

  • 可见性:一个线程修改 volatile 变量后,其他线程立即可见
  • 禁止指令重排:通过内存屏障(Memory Barrier)实现
  • 最经典的用例是单例模式的双重检查锁定(DCL):

    public class Singleton {
    private static volatile Singleton instance; // ← 这个 volatile 不能省!

    public static Singleton getInstance() {
    if (instance == null) { // 第一次检查
    synchronized (Singleton.class) {
    if (instance == null) { // 第二次检查
    instance = new Singleton();
    // ⚠️ 这行代码其实分三步:
    // (1) 分配内存空间
    // (2) 调用构造器初始化对象
    // (3) 将 instance 指向分配的内存地址
    //
    // 如果 (2) 和 (3) 被重排了:
    // 线程 A 刚执行完 (3),instance != null,但 (2) 还没做
    // 线程 B 此时进入 getInstance(),第一次检查通过
    // 直接返回了一个还没初始化的"半成品"对象!
    //
    // volatile 禁止了这个重排,保住了最后一道防线
    }
    }
    }
    return instance;
    }
    }

    面试中如果被问到 volatile 的应用场景,DCL 单例是最佳的例子——既能体现 volatile 禁止指令重排的语义,又和实际开发紧密相关。


    第六章:JVM 面试高频题精讲

    前面五章的知识已经足够扎实了。这一章我们做一件事:把知识"翻译"成面试回答。

    6.1 "说说你对 JVM 的理解"

    回答框架(总分总结构):

    JVM 是 Java 虚拟机,实现了"一次编写到处运行"。我从三个层面来理解:

    类加载层:通过双亲委派模型加载 .class 文件,经历加载、链接、初始化三个阶段。

    运行时数据区:堆(存放对象,GC 主战场)、虚拟机栈(方法调用的栈帧)、方法区(类信息和常量)、程序计数器。堆和方法区线程共享,栈和 PC 线程私有。

    执行引擎:字节码可由解释器执行或 JIT 编译为机器码。垃圾收集器自动回收不再使用的对象。

    实际工作中我最关注堆的内存管理和 GC 调优,这直接影响系统性能。

    技巧:用"第一、第二、第三"的结构,显得有逻辑层次。不要一上来就甩内存模型图。

    6.2 "Java 怎么判断对象可以回收?"

    Java 使用可达性分析。从 GC Roots 出发,沿引用链可达的对象存活,不可达的回收。

    GC Roots 包括:栈中局部变量指向的对象、方法区静态变量指向的对象、常量引用的对象、被 synchronized 持有的对象等。

    对比引用计数法:Java 不用它,因为解决不了循环引用——两个对象互相引用但外部都不可达时,计数器永远不是 0。

    6.3 "你遇到的 OOM 和排查过程"

    我遇到过两种:

    堆溢出(Java heap space):查询没分页,一次加载几百万条数据。排查:GC 日志中 Full GC 频繁且回收空间很少 → jmap dump 出堆文件 → MAT 分析发现某个 ArrayList 占用大部分堆 → 追溯接口加了分页解决。

    元空间溢出(Metaspace):框架在运行时动态生成大量类。特点:堆空间还有很多剩余但报了 OOM。设置 -XX:MaxMetaspaceSize=256m 并修复框架的动态类生成逻辑解决。

    6.4 "G1 和 CMS 的区别?"

    维度CMSG1
    内存布局 连续新生代+老年代 Region 分区,动态划分
    算法 标记-清除(会碎片化) 标记-整理+复制(无碎片)
    停顿预测 不可预测 可设置 MaxGCPauseMillis
    大对象 直接进老年代 Humongous Region 存储
    Full GC 单线程,耗时很长 多线程,效率高
    当前状态 JDK 14 已废弃 JDK 9+ 默认

    6.5 "为什么 SurvivorRatio 默认是 8?"

    因为经验数据显示新生代约 98% 的对象活不过第一次 Minor GC。Eden 和两个 Survivor 的 8:1:1 比例正好匹配:Eden 占 80% 容纳新对象,每个 Survivor 占 10%,确保约 2% 的存活对象能顺利放进 Survivor 区。

    6.6 "强引用、软引用、弱引用、虚引用分别是什么?"

    • 强引用:最常见,只要引用在就不会被 GC。
    • 软引用:内存不足时回收,适合做缓存。图片加载库常用。
    • 弱引用:下次 GC 必定回收。ThreadLocalMap 的 Key、WeakHashMap 中用到。
    • 虚引用:无法通过它获取对象,唯一用途是对象被回收时收到通知。NIO 的 DirectByteBuffer 用它来管理直接内存的释放。

    补充:ThreadLocal 内存泄漏的根因就是 Key 是弱引用(被 GC 后变 null)但 Value 是强引用(仍被 Map 持有),如果不调 remove() 就无法释放。

    6.7 "你用过哪些 JDK 版本?有什么区别?"

    主要用 JDK 8 和 JDK 11,也关注新的 LTS 版本。

    JDK 8 的关键变化:Lambda 和 Stream API、方法区从永久代变为元空间、接口支持默认方法。

    JDK 11(LTS):ZGC 实验性引入、新的 HTTP Client API、String 新增 isBlank()/lines() 等方法。

    JDK 17(LTS):G1 更成熟,sealed class、pattern matching 预览、ZGC 进一步提升。

    JDK 21(最新 LTS):虚拟线程(Virtual Threads)正式发布——高并发场景下可以大幅减少线程开销,这是 Java 并发编程的一个重要转折点。


    结语

    JVM 不是可以速成的知识点。但好消息是,你不需要掌握全部细节也能做好日常开发。

    上篇帮你建立了基础框架(内存区域、类加载、GC 原理),本篇帮你补上了实战技能(收集器选型、参数调优、诊断工具、面试应对)。两篇合在一起,就是一套完整的 JVM 知识体系。

    真正让你把 JVM 吃透的,不是看书,而是在你的项目里用一次——给线上服务加上 GC 日志,用 jstat 看一次 GC 频率,用 Arthas trace 一次慢方法,或者定位一个真实的内存泄漏。一次实践,胜过十次阅读。

    希望这两篇文章能成为你 JVM 学习路上一个扎实的起点。下次面试被问到 JVM 时,你不再心头一紧,而是微微一笑——因为这些问题你都已经在脑子里转过很多遍了。


    【上一篇:JVM 征服手册:从 CRUD 到性能调优的完整指南(一)- 基础入门】

    赞(0)
    未经允许不得转载:171主机测评 » JVM 征服手册:从 CRUD 到性能调优的完整指南(二)- 进阶实战
    分享到: 更多 (0)

    评论 抢沙发

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