《Java 虚拟机原理与性能调优》1.1 从现象到机制:JVM 研究的四条路径

libraryd 的延迟抬升不能靠猜。本节把「现象→假设→实验→证据→结论」拆成可执行步骤,用 jcmd 的 VM.info、VM.flags、GC.heap_info 真实输出演示字段怎么读,并给出症状到第一工具的对照表与四条研究路径的分工。

本节目标:遇到「服务变慢」时,能先提出可检验的假设,再用一条命令取得证据,而不是先改参数。
适用版本:JDK 25(Temurin 25.0.5);只用 JDK 自带工具。

承接的问题

卷三结束时,libraryd 已经有了 RuntimeProbe,它每次被调用都返回一行运行时快照:

heap=2/256/256mB nonheap=1mB metaspace=0mB gc=0/0ms threads=6 peak=6 uptime=84ms
heap=11/256/256mB nonheap=2mB metaspace=0mB gc=0/0ms threads=6 peak=6 uptime=296ms
heap=20/256/256mB nonheap=2mB metaspace=0mB gc=0/0ms threads=6 peak=6 uptime=528ms

这是本机运行 RuntimeProbe.main 的真实输出(-Xms256m -Xmx256m)。它能回答「堆用了多少、GC 跑了几次、线程多少」,但回答不了「为什么今天下午的借阅接口比上午慢」。

真实排障通常从一句模糊的现象开始:监控上 P99 抬升、吞吐下降、CPU 没打满、堆曲线看起来也正常。此时最容易犯的错是直接改参数——把堆调大、换 ZGC、加线程池。这些动作可能让曲线暂时好看,却没有解释任何机制,下一次换个负载又会复发。

这一节不编造一个线上事故,而是用 LabApp 这个与 libraryd 同构的最小进程,把一条完整的证据链跑一遍。LabApp 持有 2000 个 Book 和 2000 个 Loan,循环调用 Loan.state 与 FinePolicy.fineCents,每轮 sleep(2),足以让 jcmd 抓到有意义的堆和线程状态。它比真实服务简单,但运行时机制是同一个 JVM。

核心循环就是 libraryd 借阅批处理去掉数据库后的形状:

List<Loan> loans = new ArrayList<>(2_000);
for (int i = 0; i < 2_000; i++) {
    LocalDate dueOn = TODAY.minusDays(i % 40).plusDays(7);
    loans.add(new Loan(i, "97801346859" + (i % 10), "m-" + i,
            dueOn.minusDays(Loan.LOAN_DAYS), dueOn, null, 0));
}
while (System.nanoTime() < deadline) {
    for (Loan loan : loans) {
        LoanState state = loan.state(TODAY);          // 每轮分配 Active 或 Overdue
        if (state instanceof LoanState.Overdue overdue) {
            checksum += FinePolicy.DEFAULT.fineCents(overdue.days());
        }
    }
    Thread.sleep(2);
}

Loan.state 是 record 方法,内部会 new LoanState.Overdue(...);2000 个 loan 里大部分逾期,所以每轮都会产生约 2000 个小对象。这个细节在 1.3 节的分配采样里会重新出现,也是「堆看起来正常、但分配速率不低」的典型来源。

从现象到假设:把「慢」拆成可检验的句子

「慢」不是假设,只是现象。要变成假设,必须指名一个机制和一条可观测的证据。对 libraryd 的借阅路径,常见的假设只有四类:

现象可能的机制第一证据
P99 抬升,CPU 不高GC 停顿、锁等待、下游阻塞GC 日志、线程栈
P99 抬升,CPU 打满热点方法、编译未完成、分配过快JFR 采样、编译日志
堆持续上涨不回落引用未释放、缓存无上限、类加载泄漏GC.heap_info、类直方图
启动后前几十秒慢解释执行、类加载、JIT 预热-Xlog:class+load、JFR

假设必须写成「如果 A,那么命令 B 的输出会出现 C」。例如:

  • 如果停顿来自 Young GC,那么 GC 日志里会出现与 P99 同量级的 Pause Young。
  • 如果堆不是瓶颈,那么 GC.heap_info 的 used 应远小于 committed,且不随时间单调上涨。
  • 如果线程卡在连接池,那么 Thread.print 里会有多个线程停在获取连接的方法上。

先写假设,再选工具;顺序反过来,就会被工具的输出牵着走。

第一手证据:三个 jcmd 命令

VM.info:先确认「跑的是哪个 JVM」

jcmd 71819 VM.info

本机输出(节选,进程是 java -Xms1g -Xmx1g -cp out com.plume.library.app.LabApp 600):

71819:
# JRE version: OpenJDK Runtime Environment Temurin-25.0.5+8 (25.0.5+8) (build 25.0.5+8-LTS)
# Java VM: OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (25.0.5+8-LTS, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, bsd-aarch64)

---------------  S U M M A R Y ------------

Command Line: -Xms1g -Xmx1g com.plume.library.app.LabApp 600

Host: "MacBookPro18,1" arm64, 10 cores, 32G, Darwin 25.3.0, macOS 26.3 (25D125)
Time: Wed Oct 28 11:22:55 2026 CST elapsed time: 8.679675 seconds (0d 0h 0m 8s)

读四个地方:

  • Command Line 是 JVM 实际收到的参数,不是启动脚本里写的参数。怀疑「配置没生效」时先看这一行。
  • Java VM 那行列出 tiered、compressed oops、g1 gc,确认执行引擎、指针压缩和收集器。
  • Host 的 10 cores 是 JVM 看到的 CPU 数;容器里如果没设 ActiveProcessorCount,这里可能显示宿主机核数。
  • elapsed time 是从进程启动到现在,不是记录时长。

继续往下,GC Precious Log 段落给出堆的初始形态:

GC Precious Log:
 CPUs: 10 total, 10 available
 Memory: 32768M
 Compressed Oops: Enabled (Zero based)
 Heap Region Size: 1M
 Heap Min Capacity: 1G
 Heap Initial Capacity: 1G
 Heap Max Capacity: 1G
 Parallel Workers: 9
 Concurrent Workers: 2
 Concurrent Refinement Workers: 9

Heap Region Size: 1M 是 G1 根据堆大小选出来的,不是固定值:本机默认 8 GB 堆时是 4 MB,-Xmx1g 时是 1 MB。Parallel Workers 是 Young/Old 并行阶段的工作线程数,Concurrent Workers 是并发标记线程数。后面第 1.2 节会看到,改 ActiveProcessorCount 会同时改这几个数。

VM.flags:确认参数与默认值

jcmd 71819 VM.flags

本机输出(一行,此处按参数换行):

-XX:CICompilerCount=4
-XX:ConcGCThreads=2
-XX:G1ConcRefinementThreads=9
-XX:G1HeapRegionSize=1048576
-XX:InitialHeapSize=1073741824
-XX:MaxHeapSize=1073741824
-XX:MaxNewSize=643825664
-XX:MinHeapSize=1073741824
-XX:SoftMaxHeapSize=1073741824
-XX:+UseCompressedOops
-XX:+UseG1GC

VM.flags 只列「非默认值或由人体工程学决定的值」,不是全部 flag。要看全部用 jcmd <pid> VM.flags -all,它会按 {product}、{ergonomic}、{command line} 标注来源。这个区别很重要:MaxHeapSize=1073741824 标成 {command line},说明是 -Xmx1g 给的;如果标成 {ergonomic},就是 JVM 自己算的。

本机不加任何堆参数时,InitialHeapSize 是 512 MB(物理内存的 1/64),MaxHeapSize 是 8 GB(物理内存的 1/4),G1HeapRegionSize 是 4 MB。这三个默认值都可以用同一条命令验证。

GC.heap_info:堆现在是什么样

jcmd 71819 GC.heap_info

启动约 9 秒时:

garbage-first heap   total reserved 1048576K, committed 1048576K, used 92198K [0x0000000440000000, 0x0000000480000000)
 region size 1024K, 89 young (91136K), 1 survivors (1024K)

同一进程运行约 6 分钟后:

garbage-first heap   total reserved 1048576K, committed 1048576K, used 466002K [0x0000000440000000, 0x0000000480000000)
 region size 1024K, 455 young (465920K), 4 survivors (4096K)

total reserved 是地址空间预留,committed 是真正向系统要到的内存,used 是其中被对象占用的大小。-Xms1g -Xmx1g 下 committed 恒等于 1 GB;used 从 90 MB 涨到 455 MB,说明这段时间分配速度不低,但离上限还有空间。455 young 表示 455 个 1 MB 的 Region 当前属于年轻代。

这三个命令合起来回答了「环境对不对、参数是不是我以为的、堆有没有接近上限」。它们不回答「时间花在哪」,那是 JFR 和 GC 日志的事(1.3 节)。

四条研究路径

JVM 的问题最终都要落到四类材料上。它们的成本、粒度和适用问题不同,不能互相替代。

路径一:规范与源码

《Java 虚拟机规范》定义 class 文件格式、指令语义、类加载与初始化时机;JDK 源码定义具体实现,比如 G1 如何选 Region、C2 在什么条件下内联。这条路成本最高,但能回答「为什么必须这样」,也是判断博客结论是否过时的依据。JDK 25 移除了 -XX:+ZGenerational、UseBiasedLocking 这类旧参数,只看旧文章就会写出错误结论。

路径二:JVM flag

VM.flags、VM.flags -all、-XX:+PrintFlagsFinal 回答「当前生效的值是什么」。它是所有实验的基线:不知道 MaxHeapSize、G1HeapRegionSize、ConcGCThreads 的值,就无法解释同一份代码在两台机器上的差异。flag 也会告诉你参数的来源,是命令行、ergonomic 还是默认值。

路径三:日志与事件

GC 日志、-Xlog:class+load、JFR 事件回答「发生了什么、什么时候发生」。日志是时间线,适合看停顿与分配;JFR 是结构化事件,适合看采样栈、分配采样、锁与线程状态。它们的共同点是低侵入、可长期开启,适合先收集再分析。

路径四:转储

堆转储、线程转储、类直方图回答「现在内存里到底有什么」。成本最高:GC.heap_dump 会写几个 GB 的文件,GC.class_histogram 默认只统计存活对象,会触发一次 Full GC。它们适合在假设已经收窄到「某类对象泄漏」或「某把锁卡住」之后使用,不适合作为第一步。

四条路径的顺序不是固定的,但有一条纪律:一次只改一个变量。把堆从 1 GB 改到 2 GB、同时把 G1 换成 ZGC、又加了一台机器,最后数字变好了,你也不知道是哪一项起作用。对照实验的价值在于排除,而不在于证明。

症状 → 第一工具

症状第一工具先看什么
进程是否在、参数是什么jps -lv、jcmd VM.command_line主类、-Xmx、-D
堆用了多少、离上限多远jcmd GC.heap_infoused / committed / young
GC 停顿多长GC 日志 -Xlog:gc*Pause Young 的耗时与回收量
CPU 花在哪jcmd JFR.start + jfr view hot-methodsExecutionSample 栈顶
谁在分配jfr view allocation-by-class分配压力占比
线程卡在哪jcmd Thread.printBLOCKED、WAITING、锁地址
类加载了什么-Xlog:class+load=info类名与 source
内存里有什么对象jcmd GC.class_histogram -all实例数、字节数
参数来源jcmd VM.flags -all{command line} / {ergonomic}

表里没有「一键诊断」。工具越多,越需要先写清楚要验证哪句话。

在 libraryd 里落地

libraryd 的排障入口不应该是某个人的记忆,而是一段可以重复执行的代码与脚本。第一步是在 RuntimeProbe 之外增加一个「环境快照」方法,把 VM.flags 里最关键的几项和 GC.heap_info 的语义固化下来:

package com.plume.library.app;

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryUsage;

public final class HeapGuard {

    private final MemoryUsage heap =
            ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();

    public double usedRatio() {
        long max = heap.getMax();
        return max <= 0 ? -1 : (double) heap.getUsed() / max;
    }

    public String verdict() {
        double ratio = usedRatio();
        if (ratio < 0) {
            return "heap-max-undefined";
        }
        if (ratio > 0.85) {
            return "heap-pressure";
        }
        return "heap-ok";
    }
}

HeapGuard 不替代 GC.heap_info,它只是把「used / max」这个比值变成服务自己也能记录的指标,方便和请求延迟放在同一张图上看。注意 getMax() 在未设上限时可能返回 -1,代码必须处理这种情况,不能直接做除法。

第二步是把证据采集写成脚本,而不是让人临时敲命令。1.2 节会给出 scripts/jvm/collect.sh 的完整版本,它把 VM.version、VM.command_line、VM.flags、GC.heap_info 和一段 JFR 记录落到同一个目录,文件名带时间戳。一次采集对应一个目录,复盘时不会把两次实验的输出混在一起。

第三步是给结论留痕。每次调参前记录三样东西:当前 flag、当时的负载、基线数字。调参后只改一个变量,再采一次。没有这三样,所谓「优化有效」只是感觉。

常见错误与边界

错误一:把 jcmd 的 PID 当成容器内的 PID。 在容器里,宿主机看到的 PID 和容器内 jps 看到的不是同一个命名空间。jcmd 报 AttachNotSupportedException 或找不到进程时,先确认是在宿主机还是容器内执行,不要把权限问题当成 JVM 问题。

错误二:把 GC.heap_info 的 used 当成「活跃对象大小」。 used 包含尚未回收的垃圾;一次 Young GC 之后它会下降,但不等于存活集。要看存活对象,用 GC.class_histogram(默认会先做一次 Full GC)或堆转储。

错误三:把 VM.flags 当成完整参数表。 它不列默认值。看到 MaxHeapSize 不在输出里,不代表没有上限,可能只是等于默认值。需要完整视图时用 VM.flags -all。

边界一:jcmd 需要 attach 权限。 本机同用户通常没问题;容器、--read-only 根文件系统、缺少 ptrace 权限时会失败。失败时先看错误原文,不要反复换命令。

边界二:工具开销不同。 VM.info、VM.flags、GC.heap_info 很轻;GC.class_histogram 默认触发 Full GC;GC.heap_dump 会写大文件并可能 STW。生产环境先选轻的,重的留到假设收窄之后。

小结

  • 「慢」不是假设;假设要写成「如果 A,命令 B 会出现 C」。
  • VM.info 确认 JVM 版本、实际命令行、CPU 与堆的初始形态。
  • VM.flags 只列非默认值;VM.flags -all 才给出完整参数与来源。
  • GC.heap_info 的 reserved / committed / used 回答「堆离上限多远」,不回答「谁占的」。
  • 四条路径分工:规范与源码解释为什么,flag 确认是什么,日志与事件记录发生了什么,转储回答现在有什么。
  • 对照实验一次只改一个变量;没有基线、负载和 flag 记录的「优化有效」不可信。

下一节把「一次只改一个变量」变成可操作的实验环境:固定 JDK、CPU、堆、GC 和启动参数,用重复运行与分位数代替单次结果。

阅读导航:上一节:目录与学习路径 · 下一节:1.2 建立可复现的实验环境:CPU、堆与固定参数 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发