简介:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》电子版面向具备一定Java基础、希望系统掌握JVM原理与调优的开发者与面试备考者,围绕内存管理、执行子系统与性能优化三大主线展开。内容从Java技术体系与虚拟机家族讲起,深入运行时数据区域、内存溢出异常、垃圾收集算法与Serial至ZGC各代收集器,并覆盖类文件结构、类加载机制、字节码执行引擎等执行子系统,配合jps、jstat、JConsole、VisualVM等监控工具与调优案例实战,帮助读者建立从原理到排错的完整认知。资源包共1个PDF文件,约28.65MB,为完整电子书,目录清晰、章节完整,便于按模块检索与精读。目前已有750人学习,适合作为JVM系统学习与故障排查的案头参考。
1. 从一张 JVM 知识地图说起:五块拼图与一条主线
很多人学 JVM 的路径是反的:一上来就背垃圾收集器参数,背完发现记不住,记住了又不知道什么时候用。真正系统的学法,是先看清这张地图长什么样。把《深入理解 Java 虚拟机》第 3 版的目录摊开,整个 JVM 知识体系其实是五块拼图:走近 Java(技术体系与虚拟机家族)、自动内存管理(运行时数据区、垃圾收集、监控工具、调优案例)、虚拟机执行子系统(Class 文件结构、类加载机制、字节码执行引擎)、程序编译与代码优化(前端编译、即时编译器与优化技术)、高效并发(Java 内存模型、线程安全与锁优化)。这五块之间不是并列关系,而是一条主线:对象从哪来(内存区域)→ 对象怎么死(垃圾收集)→ 类怎么进虚拟机(类加载)→ 代码怎么跑起来(执行引擎与编译优化)→ 多线程下怎么不出错(内存模型与锁)。抓住这条主线,零散的知识点才有挂靠的地方。这份笔记适合已经写过 Java、准备系统啃 JVM 或正在复习备考的人,用来当一张可反复查漏补缺的地图。
2. 自动内存管理:运行时数据区与对象生死判定
自动内存管理是整本书分量最重、也最容易在面试和排障中被追问的部分。它的逻辑起点是一个问题:Java 把内存控制权交给了虚拟机,那虚拟机到底把内存切成了几块、每块放什么、什么时候会溢出。把这块吃透,后面垃圾收集器和调优工具才有落脚点。
2.1 运行时数据区的划分与各自职责
运行时数据区可以按「线程私有 / 线程共享」先切一刀,这是最不容易记混的分类方式。线程私有的区域随线程生灭,不需要额外同步;线程共享的区域随虚拟机进程生灭,是垃圾收集的主战场。
| 区域 | 归属 | 存放内容 | 可能抛出的异常 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 当前线程执行的字节码行号指示器 | 唯一不会抛 OOM 的区域 |
| Java 虚拟机栈 | 线程私有 | 栈帧(局部变量表、操作数栈、动态连接、返回地址) | StackOverflowError / OOM |
| 本地方法栈 | 线程私有 | 为 native 方法服务 | StackOverflowError / OOM |
| Java 堆 | 线程共享 | 对象实例与数组 | OutOfMemoryError |
| 方法区 | 线程共享 | 类信息、常量、静态变量、即时编译后的代码 | OutOfMemoryError |
| 运行时常量池 | 方法区的一部分 | 字面量与符号引用 | OutOfMemoryError |
| 直接内存 | 堆外 | NIO 的 DirectByteBuffer 等 | OutOfMemoryError |
梳理下来会发现,第 3 版相对第 2 版最实质的变化就在这张表里:JDK 8 之后永久代落幕、元空间登场,方法区的实现从堆内搬到了本地内存。这个变化直接决定了「方法区溢出」的复现方式和排查思路——以前调-XX:PermSize,现在要看-XX:MaxMetaspaceSize。另一个容易含糊的点是虚拟机栈:书里专门修正了第 2 版对栈描述的偏差,栈深度溢出和栈内存溢出是两回事,前者抛 StackOverflowError,后者抛 OutOfMemoryError,复现代码的写法完全不同。
2.2 对象创建、内存布局与访问定位
HotSpot 里一个对象的诞生要经过类加载检查、分配内存、零值初始化、设置对象头、执行构造方法这几步。分配内存的两种方式——指针碰撞与空闲列表——取决于堆是否规整,而堆是否规整又取决于用了哪种垃圾收集器是否带压缩整理。这条因果链是理解后面收集器选型的关键,很多人只记住了「两种分配方式」,却答不出「为什么 CMS 下通常用空闲列表」。
对象在内存中的布局分三块:对象头(Mark Word 与类型指针)、实例数据、对齐填充。Mark Word 是理解锁优化的钥匙,它复用存储哈希码、GC 分代年龄、锁状态标志,偏向锁、轻量级锁的状态位都藏在这里。对象访问定位有句柄和直接指针两种方式,HotSpot 用直接指针,省一次间接寻址,这也是一个常被拿来对比的细节。
2.3 对象已死:引用计数与可达性分析
判断对象是否存活,主流虚拟机用的是可达性分析而不是引用计数,原因很直接:引用计数无法解决循环引用。可达性分析从一系列 GC Roots 出发做图遍历,不可达的对象才进入「死亡候选」。GC Roots 包括虚拟机栈中引用的对象、方法区中类静态属性引用的对象、常量引用的对象、本地方法栈中 JNI 引用的对象等。
这里有个极易被跳过的细节:不可达不等于立刻回收。对象要真正被回收,至少要经历两次标记过程,中间还有一次finalize()的「自救」机会。书里对「生存还是死亡」的讨论,本质上是在说 finalize 的不确定性和不推荐使用。另外,引用的四种强度——强、软、弱、虚——要能和实际场景对上号:软引用适合做内存敏感缓存,弱引用配合 ThreadLocal 或 WeakHashMap 使用,虚引用主要用于对象回收时收到通知。
2.4 垃圾收集算法与分代收集理论
三种基础算法要能对比着记:标记-清除产生碎片、标记-复制浪费空间但适合存活率低的区域、标记-整理没有碎片但移动对象成本高。分代收集理论是把它们组合起来的依据:新生代对象朝生夕死,用复制算法;老年代存活率高,用标记-清除或标记-整理。
HotSpot 在算法落地时还有一批「看不见的工程细节」,这是拉开理解深度的分水岭:根节点枚举需要停顿(Stop The World),靠 OopMap 记录引用位置;安全点让线程在特定位置才能被中断,安全区域解决线程不执行时的枚举问题;跨代引用靠记忆集和卡表记录,写屏障负责维护卡表;并发标记阶段用增量更新或原始快照解决对象引用变化导致的漏标错标。这些点单看很碎,但串起来就是一句话:并发收集器的所有复杂度,都来自「收集线程和应用线程同时跑」这件事。
2.5 收集器谱系与选型逻辑
收集器不要孤立地背,按「分代 + 并发能力 + 停顿目标」三个维度排成谱系就清楚了。
| 收集器 | 作用区域 | 算法 | 特点与适用场景 |
|---|---|---|---|
| Serial / Serial Old | 新生代 / 老年代 | 复制 / 标记-整理 | 单线程,客户端模式或小内存 |
| ParNew | 新生代 | 复制 | Serial 的多线程版,常与 CMS 搭配 |
| Parallel Scavenge / Parallel Old | 新生代 / 老年代 | 复制 / 标记-整理 | 关注吞吐量,适合后台计算型任务 |
| CMS | 老年代 | 标记-清除 | 关注低停顿,有碎片和并发失败风险 |
| G1 | 整堆 | 分区 + 标记-整理 | 可预测停顿模型,大堆首选 |
| Shenandoah / ZGC | 整堆 | 并发标记-整理/染色指针 | 低延迟全并发,停顿与堆大小弱相关 |
选型的判断顺序建议是:先看堆大小和停顿目标,再看吞吐还是延迟优先,最后看 JDK 版本支持情况。Epsilon 是个特殊角色——它只分配不回收,用于性能测试和短生命周期任务,理解它的存在反而能帮你确认「垃圾收集不是免费的」。
2.6 内存分配与回收策略的验证
对象优先在 Eden 分配、大对象直接进老年代、长期存活对象晋升老年代、动态年龄判定、空间分配担保——这五条规则光背没用,要用-XX:+PrintGCDetails之类的日志去验证。书里的实战部分就是让你改参数、看日志、对结果,把「规则」变成「观察到的现象」。这一步做没做,直接决定你在真实排障时能不能从 GC 日志里读出问题。
3. 执行子系统:Class 文件、类加载与字节码执行
如果说内存管理回答的是「对象怎么活怎么死」,执行子系统回答的就是「代码怎么从磁盘上的字节码变成机器上跑起来的指令」。这块内容偏底层、偏枯燥,但它是理解反射、动态代理、热部署、模块化这些上层特性的地基。
3.1 Class 文件结构:一份自描述的二进制契约
Class 文件是一组以 8 字节为基础单位的二进制流,各数据项严格按顺序排列,中间没有任何分隔符。结构上可以按「魔数 → 版本号 → 常量池 → 访问标志 → 类/父类/接口索引 → 字段表 → 方法表 → 属性表」的顺序记。
# 用 javap 反编译查看 Class 文件结构,-v 输出常量池等详细信息 javap -v -p MyClass.classjavap -v的输出里,常量池是最值得花时间看的部分:它存放字面量和符号引用,是类加载「解析」阶段的原料。第 3 版把 Class 文件格式跟进到了 JDK 12,模块化带来了 Module、ModulePackages、ModuleMain-Class 三个新属性,常量池新增 CONSTANT_Module_info 和 CONSTANT_Package_info;嵌套类访问控制带来 NestHost 和 NestMembers;动态语言支持带来 CONSTANT_Dynamic_info。这些新增项说明一件事:Class 文件格式是跟着语言特性演进的活文档,不是一成不变的规范。字节码指令按功能分十类记(加载存储、运算、类型转换、对象创建访问、操作数栈管理、控制转移、方法调用返回、异常处理、同步),比逐条背助记符高效得多。
3.2 类加载的五个阶段与双亲委派
类加载过程分加载、验证、准备、解析、初始化五个阶段。最容易记错的是「准备阶段赋零值」和「初始化阶段赋程序设定值」的区别,以及static final常量在准备阶段就被赋为常量值这个例外。解析阶段可以把符号引用转为直接引用,它可以在初始化之后进行,这是支持动态语言的关键设计。
类加载器的层次是启动类加载器、扩展(平台)类加载器、应用程序类加载器,加上自定义加载器。双亲委派模型的工作过程是:收到加载请求先委派给父加载器,父加载器无法完成才自己加载。它的价值在于保证基础类不被篡改、类在各类加载器间的一致性。书里专门讲了「破坏双亲委派」的三种典型场景:JDK 1.2 之前的兼容、线程上下文类加载器(SPI 场景)、追求程序动态性(OSGi 场景)。模块化系统引入后,类加载器的委派关系又有了新变化,这块要结合 JDK 9 之后的实际行为理解,不能停留在旧模型上。
3.3 栈帧结构与方法调用:分派的四种情形
运行时栈帧包含局部变量表、操作数栈、动态连接、方法返回地址和附加信息。局部变量表的容量以变量槽(Slot)为单位,64 位数据类型占两个槽,这个细节在分析字节码时经常用到。
方法调用的核心是「分派」,按静态/动态、单分派/多分派两个维度可以组合出四种情形。静态分派看编译期类型(重载),动态分派看运行期实际类型(重写),虚方法表(vtable)是动态分派在 HotSpot 中的落地实现。invokedynamic指令是 Java 8 之后支持 Lambda 和动态语言的关键,它把方法分派逻辑交给引导方法决定,这是「把分派规则从虚拟机搬到用户代码」的一次重要让步。
// 静态分派:编译期根据参数静态类型选择重载版本 void call(Object o) { System.out.println("Object"); } void call(String s) { System.out.println("String"); } // 传入 null 时会选更具体的 String 版本,这是易错点上面这段代码的注释点出了一个高频易错点:重载版本的选择在编译期就定了,null会匹配最具体的类型。理解分派规则,才能解释为什么「重载看编译期、重写看运行期」这句话是对的。
3.4 基于栈的解释执行与案例借鉴
HotSpot 的解释器是基于栈的,指令集简单、可移植性好,代价是指令条数多、执行速度不如基于寄存器的设计。书里用一段简单的算术代码逐条走了一遍操作数栈的变化,这种「手动模拟执行」的练习对理解字节码非常有帮助。第 9 章的案例——Tomcat 的类加载器架构、OSGi 的模块化类加载、字节码生成与动态代理、Backport 工具——价值不在记住实现,而在于看到「类加载器可以被设计成什么样子」,以及「字节码生成技术能解决什么实际问题」。
4. 编译与优化:从语法糖到即时编译器
Java 的编译是两段式的:前端把源码编译成字节码,后端在运行时把热点字节码编译成本地机器码。理解这两段,才能解释「为什么 Java 越跑越快」以及「为什么有些写法会拖慢 JIT」。
4.1 前端编译与语法糖的真相
Javac 的流程是解析与填充符号表、注解处理、语义分析与字节码生成。注解处理器这一环是很多人忽略的扩展点——书里的实战就是写一个插入式注解处理器来检查命名规范,这类技术在 Lombok、MapStruct 这类工具里被大量使用。
语法糖部分要能说清三件事:泛型在 Java 中是类型擦除实现的,所以有运行时无法获取泛型参数类型、泛型数组创建受限等限制;自动装箱拆箱在循环里可能带来意外的对象创建;条件编译靠if (常量)实现,编译器会消除不可达分支。第 3 版重写了泛型一节,补充了不同语言泛型实现方式的对比和值类型的展望,这部分对理解 Java 泛型的「历史包袱」很有帮助。
4.2 即时编译器的触发与优化技术
解释器与编译器并存(混合模式)是 HotSpot 的默认策略。热点探测基于计数器:方法调用计数器与回边计数器,超过阈值触发即时编译。编译对象分「被多次调用的方法」和「被多次执行的循环体」两类,后者对应栈上替换(OSR)编译。
优化技术里,方法内联是最重要的一项——它是其他优化的前提,内联后逃逸分析才有更大发挥空间。逃逸分析能带来栈上分配、标量替换、锁消除。公共子表达式消除和数组边界检查消除属于局部优化。这些技术不需要会写,但要能解释「为什么小方法更容易被内联」「为什么临时对象不一定在堆上分配」。
# 查看即时编译情况:-XX:+PrintCompilation 输出编译事件 java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyApp上面两条命令分别用于观察编译事件和内联决策,是分析 JIT 行为的入口。参数含义:PrintCompilation打印方法被编译的时机与层级,PrintInlining需要配合诊断选项开启,输出内联成功或失败的原因。失败原因里最常见的是方法体过大、调用层级过深,这直接指导你如何拆分热点方法。
4.3 提前编译与 Graal 编译器
提前编译(AOT)的优劣要辩证看:省去预热、启动快,但失去运行期profile 带来的激进优化,峰值性能可能不如 JIT。第 3 版新增的 Graal 编译器与 JVMCI 接口,代表的是「用 Java 写编译器」的方向,它让编译器可以独立于虚拟机演进。这部分内容偏新,复习时抓住「JVMCI 是编译器与虚拟机的接口」这一句就够撑起框架,细节按需展开。
5. 高效并发:内存模型、线程安全与锁优化
并发这块的难点不在概念多,而在概念之间的因果关系绕。Java 内存模型是「硬件内存一致性模型在语言层的抽象」,线程安全和锁优化都是在这个抽象之上做文章。
5.1 Java 内存模型:三个特性与先行发生原则
主内存与工作内存的划分是理解可见性的起点。内存间交互定义了 8 种原子操作(lock、unlock、read、load、use、assign、store、write),这套操作模型虽然在新版规范中被简化描述,但用来解释 volatile 的语义依然有效。
volatile 的两条语义要背准:保证可见性、禁止指令重排序,但不保证原子性。原子性、可见性、有序性三个特性分别对应哪些机制,要能一一对上。先行发生原则(happens-before)是判断数据是否存在竞争的利器,八条规则里最常用的是程序次序规则、监视器锁规则、volatile 变量规则和传递性。
// 双重检查锁定的正确写法:instance 必须 volatile class Singleton { private static volatile Singleton instance; static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); // 禁止重排序,防止半初始化对象逸出 } } } return instance; } }这段代码是 volatile 语义的经典应用场景:没有 volatile,对象分配、初始化、赋值引用三步可能重排,其他线程会拿到未初始化完成的对象。注释里点出的「半初始化对象逸出」就是最容易翻车的地方。
5.2 线程实现、调度与协程
线程的实现有三种方式:内核线程实现、用户线程实现、混合实现。Java 线程在主流平台上是一对一映射到内核线程,调度权交给操作系统。线程状态转换图要能默画:新建、就绪、运行、阻塞、等待、超时等待、终止。第 3 版新增的协程内容属于前瞻性知识,核心是「内核线程的局限催生了用户态轻量级线程」,理解动机比记 API 更重要。
5.3 线程安全的分级与锁优化手段
线程安全按「不可变、绝对线程安全、相对线程安全、线程兼容、线程对立」五级分类,这个分类的价值在于让你判断一个类到底能不能在多线程下直接用。实现线程安全的手段有互斥同步(synchronized、ReentrantLock)、非阻塞同步(CAS)、无同步方案(可重入代码、线程本地存储)。
锁优化是 HotSpot 的工程亮点:自旋与自适应自旋减少线程切换,锁消除靠逃逸分析判定,锁粗化合并相邻同步块,轻量级锁和偏向锁针对无竞争场景降低开销。这里有个版本相关的注意点:偏向锁在后续 JDK 版本中被默认关闭乃至废弃,复习时要以你实际使用的 JDK 版本行为为准,不能把书里的默认值当成永远成立。
6. 易漏点与易错点:五处最容易翻车的地方
复习到一定阶段,拉开差距的不是记住了多少,而是知道哪里容易错。下面五条是梳理下来最常被忽略或记反的点,每条按「易漏点 → 为什么漏 → 怎么补」展开。
易漏点一:把方法区等同于永久代。为什么漏:早期资料和旧版教材大量使用永久代指代方法区,习惯成自然。怎么补:记住方法区是规范中的逻辑区域,永久代和元空间是不同 JDK 版本下的实现方式,JDK 8 之后是元空间,位于本地内存,受-XX:MaxMetaspaceSize约束。
易漏点二:认为不可达对象会被立即回收。为什么漏:可达性分析的结论太直观,容易跳过后续标记过程。怎么补:记住至少两次标记,以及 finalize 的不确定性;实际排障时不要指望 finalize 释放资源,用 try-with-resources 或显式关闭。
易漏点三:混淆栈深度溢出与栈内存溢出。为什么漏:两者都表现为 StackOverflowError 或 OOM,报错信息相近。怎么补:递归过深是深度问题,创建大量线程是内存问题,复现代码和调参方向完全不同,-Xss调的是单线程栈大小。
易漏点四:把双亲委派当成不可打破的铁律。为什么漏:教材强调它的好处,弱化了它被破坏的场景。怎么补:记住线程上下文类加载器、OSGi、JDK 1.2 之前兼容这三类破坏场景,理解「破坏」往往是为了解决实际问题,不是设计缺陷。
易漏点五:认为 volatile 能保证原子性。为什么漏:可见性和有序性的印象太强,掩盖了原子性的缺失。怎么补:i++这类复合操作在 volatile 下依然不安全,需要 AtomicInteger 或加锁;判断标准是操作是否可分解为读-改-写三步。
提示:这五条建议单独抄在一页纸上,考前只看这一页,比重新翻一遍书效率高得多。
7. 复习清单:按主线走一遍,再按工具查一遍
复习 JVM 最怕的是「每个点都看过,连起来就断片」。我的做法是准备两条复习线,一条按知识主线走,一条按工具和现象反查,两条线交叉验证,缺哪补哪。
第一条线是主线自测,按顺序问自己五个问题,每个问题能答出三层就算过关:运行时数据区怎么划分、每块放什么、哪块会溢出;对象怎么判定死亡、有哪几种引用、finalize 的坑在哪;类加载五个阶段各做什么、双亲委派怎么工作、哪里会被破坏;即时编译的触发条件是什么、方法内联为什么重要、逃逸分析带来哪些优化;Java 内存模型的三个特性、volatile 的两条语义、锁优化有哪几种手段。答不出来的地方,回到对应章节补,不要跳着补。
第二条线是工具反查,把「现象 → 工具 → 参数」做成一张对照表,排障时直接查。
| 现象 | 首选工具 | 关键参数/命令 | 看什么 |
|---|---|---|---|
| 进程卡死或 CPU 飙高 | jstack | jstack -l <pid> | 线程状态、死锁、热点栈 |
| GC 频繁或停顿长 | jstat | jstat -gcutil <pid> 1000 | 各代使用率、GC 次数与耗时 |
| 堆内存持续上涨 | jmap | jmap -histo:live <pid> | 对象数量与占用排行 |
| 需要堆转储分析 | jmap + 分析工具 | jmap -dump:format=b,file=heap.hprof <pid> | 大对象、泄漏根 |
| 参数与系统属性确认 | jinfo | jinfo -flags <pid> | 实际生效的虚拟机参数 |
| 综合可视化分析 | JConsole / VisualVM / JMC | 图形界面 | 内存、线程、GC、JFR 记录 |
这张表的使用顺序建议是:先用 jps 确认进程,再用 jstat 看 GC 趋势,怀疑内存问题用 jmap 抓快照,怀疑线程问题用 jstack 抓栈,最后用可视化工具做整体复盘。调优案例里那些「堆外内存溢出」「安全点导致长时间停顿」「集群同步导致内存溢出」的问题,本质上都是这张表里某个工具的输出指向了非显而易见的根因。
最后说一个我自己的习惯。早些年复习 JVM,我总想着把收集器参数全背下来,结果一到真实场景就懵——因为参数是死的,现象是活的。后来我改成每复习完一个模块,就强制走一遍「这个模块对应的故障现象是什么、用哪个工具看、日志里长什么样」的三问流程,答不上来就说明这个模块只是「看过」而不是「会了」。从那以后我每次复习 JVM 都强制走一遍这条主线加工具表的流程,比反复翻书有用得多。希望这份梳理能帮到你,把零散的知识点真正连成一张能用的地图。
本文还有配套的精品资源,点击获取