本文以《深入理解 Java 虚拟机》学习笔记为阅读入口,结合当前 JDK 25 诊断与 GC 文档重写。这里不再按章节列知识点,而是讨论怎样把 JVM 原理变成生产排查能力。
学习 JVM 很容易掉进两个极端:一种只背“堆、栈、方法区”,另一种一遇到问题就复制一串 -XX 参数。前者解释不了线上现象,后者可能暂时改变现象,却没有证明原因。
这本书真正值得反复阅读的地方,是它建立了从语言代码到虚拟机实现、再到操作系统资源的映射。把这套映射用于工作时,核心不是记住多少名词,而是形成下面这条链路:
用户症状 → 系统指标 → JVM 运行数据 → 可复查证据 → 代码原因 → 对照验证
内存区域图只是起点,进程预算才是现场
-Xmx4g 不代表 Java 进程最多只用 4 GiB。容器或宿主机看到的是整个进程,它还包含:
| 区域 | 常见内容 | 典型风险 |
|---|---|---|
| Java Heap | 普通对象和数组 | 泄漏、缓存无界、分配过快 |
| Metaspace | 类元数据 | 动态生成类、ClassLoader 无法回收 |
| Thread Stack | 平台线程栈帧 | 线程数过多、单栈配置过大 |
| Direct/Native Memory | DirectByteBuffer、JNI、GC 结构等 | 堆很健康但 RSS 持续增长 |
| Code Cache | JIT 编译后的机器码 | 编译受限、性能退化 |
因此容器内存规划不能只把 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. 修改一个变量,用同样负载验证
一次同时修改堆大小、收集器、线程池和缓存,最终即使变快也不知道为什么。更可靠的闭环是:
- 写出可证伪的假设;
- 保存修改前证据;
- 只改变一个主要变量;
- 用相同流量模型和观察窗口验证;
- 同时检查延迟、吞吐、错误、CPU 和内存;
- 没有改善就回滚,而不是继续叠加参数。
这与《Java 性能问题定位:从系统指标到应用排查》的主线一致:工具不是答案,能够闭环验证的证据才是。
怎样把 JVM 读书笔记写得更有用
每读完一个主题,可以追加三类输出:
一个可运行实验
例如限制堆大小制造稳定 OOM、用单线程池复现 ThreadLocal 串数据,或比较有无界队列时的延迟变化。实验要记录 JDK、参数、预期和结果。
一个生产症状映射
把知识点转换成“线上会看到什么”:metaspace 泄漏会出现哪些指标,死锁与 CPU 忙循环的线程转储有什么差异。
一个反例
说明概念什么时候不适用。例如“内存高”不一定是堆泄漏,“Full GC”不一定意味着必须换收集器,“线程 BLOCKED 多”也不一定是性能瓶颈。
当笔记能帮助下一次事故少走一步,它才从知识摘要变成了工程资产。