本文以《深入理解 Java 虚拟机》学习笔记为阅读入口,结合当前 JDK 25 诊断与 GC 文档重写。这里不再按章节列知识点,而是讨论怎样把 JVM 原理变成生产排查能力。

学习 JVM 很容易掉进两个极端:一种只背“堆、栈、方法区”,另一种一遇到问题就复制一串 -XX 参数。前者解释不了线上现象,后者可能暂时改变现象,却没有证明原因。

这本书真正值得反复阅读的地方,是它建立了从语言代码到虚拟机实现、再到操作系统资源的映射。把这套映射用于工作时,核心不是记住多少名词,而是形成下面这条链路:

用户症状 → 系统指标 → JVM 运行数据 → 可复查证据 → 代码原因 → 对照验证

内存区域图只是起点,进程预算才是现场

-Xmx4g 不代表 Java 进程最多只用 4 GiB。容器或宿主机看到的是整个进程,它还包含:

区域常见内容典型风险
Java Heap普通对象和数组泄漏、缓存无界、分配过快
Metaspace类元数据动态生成类、ClassLoader 无法回收
Thread Stack平台线程栈帧线程数过多、单栈配置过大
Direct/Native MemoryDirectByteBuffer、JNI、GC 结构等堆很健康但 RSS 持续增长
Code CacheJIT 编译后的机器码编译受限、性能退化

因此容器内存规划不能只把 limit 设置成 Xmx。至少要给 metaspace、直接内存、线程栈、JIT、GC 和本地库留下余量,并用实际工作负载观察 RSS 峰值。

排查内存问题时先判断是哪一种:

  • 堆使用率在 GC 后仍持续上升;
  • 分配速率过高,GC 能回收但 CPU 和暂停不可接受;
  • metaspace 或 class 数量持续增加;
  • 堆稳定,但进程 RSS 增长;
  • 线程数增长带来栈和本地资源消耗;
  • 容器被 OOMKilled,却没有 Java OutOfMemoryError

不同类型需要不同证据。看到“内存高”就立刻增大 Xmx,只会推迟某些泄漏,同时压缩系统其余部分的生存空间。

讨论 GC 前,先写性能目标

垃圾收集器没有脱离场景的排行榜。选择和调优前先明确:

  • 可接受的暂停时间和尾延迟;
  • 需要的吞吐量;
  • 堆大小和容器内存上限;
  • CPU 预算;
  • 对启动速度和内存占用的要求。

Oracle 的 GC 调优文档也强调,不同收集器服务于不同需求;很多应用使用 JVM 的默认选择已经足够。只有现有表现无法满足明确目标时,才值得更换收集器或调整参数。

比“用了哪个 GC”更先要看的指标是:

  • 分配速率;
  • GC 后的 live set;
  • 暂停次数、最大值和分位数;
  • GC CPU 占比;
  • 晋升失败、疏散失败或大对象分配;
  • 应用吞吐和用户延迟是否与 GC 事件相关。

一段 GC 日志如果不能与请求延迟、流量和部署时间线对齐,很容易被过度解读。

JVM 知识也有版本保质期

旧文章常把锁实现概括成“无锁 → 偏向锁 → 轻量级锁 → 重量级锁”。这可以帮助理解历史实现,却不应被当作所有现代 JDK 的固定状态机。

OpenJDK 的 JEP 374 已在 JDK 15 默认禁用并弃用偏向锁相关选项,因为现代应用的收益已不如过去明显,而维护成本较高。收集器、默认参数、JIT 和线程实现也会继续演进。

所以记录 JVM 结论时,至少带上:

JDK 发行版与完整版本:
JVM 实现:
垃圾收集器:
关键启动参数:
容器 CPU/内存限制:
复现实验负载:

没有版本边界的 JVM 调优经验,很快会从知识变成传说。

类加载问题不只发生在启动阶段

类加载器决定的不只是“去哪里找 class”。在 JVM 中,一个类的身份同时由类名和定义它的 ClassLoader 决定。两个名字相同、由不同加载器定义的类,仍然不是同一个类型。

这个事实能解释不少线上问题:

  • 插件或热部署环境中的 ClassCastException
  • 驱动、线程、ThreadLocal 或静态集合持有旧 ClassLoader;
  • 应用已经重新部署,旧类及其静态字段却无法回收;
  • 动态代理或字节码生成造成类数量持续增长。

怀疑 classloader 泄漏时,不要只看 metaspace 使用量。还要观察已加载类数量、ClassLoader 实例数量,以及从 GC Root 到旧加载器的引用链。线程上下文类加载器、全局注册表和未停止的后台线程都是常见入口。

Java 内存模型与“内存占用”没有直接关系

Java Memory Model 讨论的是多线程之间的可见性、有序性和 happens-before,不是堆里有几个区域。

volatile 可以帮助建立可见性和顺序保证,但不能让复合操作自动变成原子操作:

volatile int count = 0;

count++; // 读取、加一、写回,仍可能丢失更新

需要原子更新时,应根据语义选择 synchronized、锁、原子类或把状态限制在单线程内。性能优化也不能只凭“锁很重”的印象;先用线程转储或 JFR 证明阻塞、竞争和持有时间,再决定是否改变同步设计。

一条可复用的 JVM 排查顺序

1. 固定现场

记录故障时间、版本、流量、实例、容器限制、JDK 和启动参数。没有这些信息,后面的 dump 很难与真实事件对应。

2. 先看系统,再看 JVM

确认 CPU、RSS、page fault、磁盘、网络、线程数和容器 throttling。Java 服务慢不一定是 JVM:磁盘延迟、DNS、下游超时和 CPU 配额同样常见。

3. 选择与假设匹配的证据

假设更合适的证据
死锁或线程卡住多次 thread dump、锁等待
CPU 热点JFR 或 async-profiler 的 CPU 采样
对象增长class histogram、heap dump 对比
GC 影响延迟GC 日志、JFR、请求延迟时间线
类加载泄漏classloader 统计、heap dump 引用链
本地内存增长RSS、Native Memory Tracking、直接内存指标

单份线程转储只是一个瞬间。判断“持续卡住”通常需要间隔采集多份,看同一批线程是否一直停在相同调用栈。

4. 用低开销手段开始

常用命令可以从只读信息开始:

jcmd <pid> VM.version
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
jcmd <pid> Thread.print -l
jcmd <pid> GC.class_histogram

需要时间维度时,可以启动一段受控 JFR:

jcmd <pid> JFR.start \
  name=diagnostic \
  settings=profile \
  duration=2m \
  filename=app.jfr

GC.class_histogram、heap dump 和某些 attach 操作可能造成停顿或额外资源消耗。生产执行前要确认版本行为、磁盘空间和影响范围,并保留审批与回滚方案。

5. 修改一个变量,用同样负载验证

一次同时修改堆大小、收集器、线程池和缓存,最终即使变快也不知道为什么。更可靠的闭环是:

  1. 写出可证伪的假设;
  2. 保存修改前证据;
  3. 只改变一个主要变量;
  4. 用相同流量模型和观察窗口验证;
  5. 同时检查延迟、吞吐、错误、CPU 和内存;
  6. 没有改善就回滚,而不是继续叠加参数。

这与《Java 性能问题定位:从系统指标到应用排查》的主线一致:工具不是答案,能够闭环验证的证据才是。

怎样把 JVM 读书笔记写得更有用

每读完一个主题,可以追加三类输出:

一个可运行实验

例如限制堆大小制造稳定 OOM、用单线程池复现 ThreadLocal 串数据,或比较有无界队列时的延迟变化。实验要记录 JDK、参数、预期和结果。

一个生产症状映射

把知识点转换成“线上会看到什么”:metaspace 泄漏会出现哪些指标,死锁与 CPU 忙循环的线程转储有什么差异。

一个反例

说明概念什么时候不适用。例如“内存高”不一定是堆泄漏,“Full GC”不一定意味着必须换收集器,“线程 BLOCKED 多”也不一定是性能瓶颈。

当笔记能帮助下一次事故少走一步,它才从知识摘要变成了工程资产。

参考资料