
简介《深入理解Java虚拟机JVM高级特性与最佳实践第3版》电子版由周志明撰写面向具备一定 Java 基础、希望系统理解虚拟机底层机制的中高级开发者也可用于面试备战与线上性能排障参考。全书按走近 Java、自动内存管理、虚拟机执行子系统等部分组织逐层展开运行时数据区域与堆、栈、方法区、直接内存溢出的排查实战垃圾收集算法及 Serial、CMS、G1 直至 Shenandoah、ZGC 的取舍jps、jstat、jmap、jstack 与 JConsole、VisualVM、JMC 等监控工具的用法并延伸到调优案例复盘、类文件结构、类加载与双亲委派、字节码执行引擎等内容。其中内存分配与回收策略、Eclipse 运行速度调优等小节配有完整过程与前后对比数据便于对照复现。压缩包为 1 个 PDF 文件约 28.65MB版式完整、目录层级清晰可按模块跳读。目前已有 747 人学习下载适合作为案头工具书随查随用。1. 一个查不出根因的抖动为什么调优方案要从虚拟机内部找答案线上服务跑了三天堆内存曲线一路向上Full GC 之后也没降回原位接口 P99 从 80ms 涨到 900ms。值班的人把-Xmx从 2G 调到 4G扛了两天又回到老样子再想问「到底是哪块内存、哪个收集器、哪段代码」的时候谁也答不上来。这类问题之所以难查是因为 Java 虚拟机在替我们做内存管理和即时优化的同时也把那套决策过程塞进了一个看不清的黑匣子。《深入理解Java虚拟机JVM高级特性与最佳实践第3版》要打开的就是这个黑匣子运行时数据区域怎么划分、对象什么时候会被判定为可回收、垃圾收集器怎么选、字节码在执行引擎里怎么跑、即时编译在哪一层生效、内存模型又怎么约束重排序整条链路摊开讲。它服务三类人要做 JVM 相关课程设计或毕业设计的学生、要出一份线上调优方案的初中级开发、以及准备把虚拟机作为技术主线的工程师。接下来这套内容讲的是怎么把这本书的内容重排成一份能交付、能验收的 JVM 调优方案。2. 方案骨架五大部分怎么映射成一条可交付的调优链路这本书分成走近 Java、自动内存管理、虚拟机执行子系统、程序编译与代码优化、高效并发五个部分作者在前言里也说得很明白各部分基本独立、可以从任意一个专题切入。但如果目标是交付一份有人验收的方案就不能按「哪章有意思读哪章」推进而要按「观察 → 定位 → 验证 → 收口」重排。骨架只有三层第一层管内存去哪了第二层管回收怎么发生第三层管代码怎么执行每一层都得配一个能拿得出手的观测手段和判定标准缺一层方案就是残的。2.1 内存区域与溢出复现先画一张责任田图第 2 章把运行时数据区域拆成程序计数器、虚拟机栈、本地方法栈、Java 堆、方法区、运行时常量池、直接内存七块。做方案时第一步不是背定义是画一张责任田图每块归谁管、存什么、什么条件下会溢出、溢出时抛什么错、用什么手段看。这张表是后面所有参数的落点没有它-Xmx、-Xss、-XX:MaxMetaspaceSize这些数字全是拍脑袋。区域主要存放内容溢出表现观察手段程序计数器当前线程执行的字节码行号不会溢出无需观察虚拟机栈栈帧局部变量表、操作数栈、动态连接、返回地址StackOverflowError / OOMjstack看线程栈深度本地方法栈native 方法的栈帧同上jstackJava 堆对象实例与数组OutOfMemoryError: Java heap spacejstat -gcutil、jmap -dump方法区类元信息、字段与方法描述OutOfMemoryError: Metaspacejstat -gc、jcmd运行时常量池字面量与符号引用常量池撑爆方法区随方法区一起看直接内存NIO 的直接缓冲区OutOfMemoryError: Direct buffer memoryNMT、-XX:MaxDirectMemorySize注意 JDK 8 之后永久代退场、元空间登场这一块在方案里必须写明版本前提因为同样的溢出错误在 JDK 7 和 JDK 11 上对应的参数完全不同。另外方法区在逻辑上属于堆的一部分但物理上不连续方案里把它和堆分开列排查时才不会把元空间泄漏误判成堆泄漏——这类翻车在真实项目里极常见。2.2 GC 选型从收集器清单压成一张决策表第 3 章的路径是引用计数 → 可达性分析 → 分代收集理论 → 标记-清除 / 标记-复制 / 标记-整理 → 根节点枚举、安全点、记忆集与卡表、写屏障、并发可达性分析 → 七款经典收集器 → 低延迟的 Shenandoah 与 ZGC。照着章节顺序读很容易变成背名词真正落地的做法是把它压缩成一张二维决策表横轴堆规模与核数纵轴延迟敏感度。场景类型典型堆规模首选组合选择理由关键参数客户端、嵌入式小于 1GSerial Serial Old无线程切换开销单核收益最高-XX:UseSerialGC批处理、离线计算1G8GParallel Scavenge Parallel Old吞吐量优先停顿可适度让步-XX:UseParallelGC常规 Web 服务4G16GG1分区回收停顿可预测-XX:UseG1GC -XX:MaxGCPauseMillis200超大堆、极低延迟16G 以上ZGC / Shenandoah并发标记整理停顿与堆规模弱相关-XX:UseZGC压测对照组任意Epsilon只分配不回收用来隔离 GC 影响-XX:UseEpsilonGC书里在「选择合适的垃圾收集器」一节点出了权衡思路但那是给读者的启发不是给评审的结论。方案里要写清楚的是为什么这个业务选 G1 不选 Parallel依据是延迟目标还是吞吐目标MaxGCPauseMillis是目标值而非承诺值写进 SLA 就是给自己挖坑。另外收集器日志在 JDK 9 之后换成了统一日志框架方案里如果还写-XX:PrintGCDetails在新版本上会被直接忽略这种细节不写清楚评审一问就露馅。2.3 执行子系统与编译优化把「看不见」的部分纳入可观测只写内存和 GC 的方案是不完整的因为真正让人抓瞎的问题往往出在执行侧。第 6 章拆 Class 文件结构从魔数、常量池、访问标志一路讲到字段表、方法表、属性表第 7 章讲加载、验证、准备、解析、初始化这五个阶段以及双亲委派模型和它被破坏的几种情形第 8 章讲栈帧结构与方法分派第 10、11 章讲 javac 的解析、注解处理、语义分析与字节码生成以及即时编译器的触发条件、方法内联、逃逸分析这些优化技术。这些内容在方案里的定位是「启动期与运行期性能爬坡」。类加载决定服务从进程启动到可接流量要多久即时编译决定从冷启动到稳态要多久两者叠加就是压测曲线上那段爬坡期。方案里至少要给出三层可观测划分进程级看jps和启动参数运行时数据区级看jstat和 GC 日志执行级看jstack、JIT 编译日志和字节码反编译结果。三层的采样频率、留存时长、责任人要写进方案否则出了问题只能靠猜。3. 关键实现四段能直接跑起来的验证代码方案里的每一句结论都应该有一个可复现的验证支撑这也是书里大量实战环节的价值所在。下面四段内容覆盖了内存、工具、收集器配置和类加载四个方向都可以直接拿去跑跑完把输出贴进方案的附录比任何形容词都有说服力。3.1 堆溢出与栈溢出的最小复现堆溢出的关键不在于写一个死循环而在于让对象始终被强引用持有否则收集器会在中途把垃圾清掉永远等不到溢出。import java.util.ArrayList; import java.util.List; /** * 堆溢出复现 * 启动参数-Xms20m -Xmx20m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/tmp * 说明-Xms 与 -Xmx 设成相同值关闭堆动态扩展避免干扰复现结果 */ public class HeapOOM { // 每个分片携带 1KB 载荷用来放大单个对象体积加快触发速度 static class Chunk { private final byte[] payload new byte[1024]; } public static void main(String[] args) { ListChunk holders new ArrayList(); while (true) { // 强引用持有保证对象始终可达不会被判定为可回收 holders.add(new Chunk()); } } }这段代码的判定点很直接控制台出现java.lang.OutOfMemoryError: Java heap space同时在/tmp下生成堆转储文件。参数上-XX:HeapDumpOnOutOfMemoryError负责在溢出瞬间落盘-XX:HeapDumpPath指定路径两个要一起写否则默认落在工作目录里容器环境下大概率找不到。堆转储的分析重点不是看总量而是看占用最大的那几类对象和它们的引用链jmap -histo:live可以先用一次快速直方图定位嫌疑对象。栈溢出的复现要注意递归深度受栈帧大小和平台影响不同 JDK 版本跑出来的数值并不一致方案里如果要写「预期深度」必须标注测试环境。/** * 栈溢出复现 * 启动参数-Xss256k * 说明调小单线程栈容量放大问题便于在测试环境快速暴露 */ public class StackOOM { private int depth 0; public void recurse() { depth; recurse(); // 无终止条件靠栈帧耗尽触发 StackOverflowError } public static void main(String[] args) { StackOOM demo new StackOOM(); try { demo.recurse(); } catch (Throwable t) { // 捕获后打印实际深度作为该环境下的参考基线 System.out.println(depth demo.depth); throw t; } } }-Xss控制的是每个线程的栈容量不是所有线程共享所以它的影响面是「线程数 × 单线程栈」在容器里算内存预算时不能漏掉这一项。这类递归写法只是复现手段生产代码里出现等价形态往往意味着某处缺失了终止条件排查时优先看递归、深层嵌套调用和第三方库的回调链。3.2 三段式取证从进程到线程的固定动作线上出问题最怕乱打命令一套固定顺序能把定位时间压到最短先确认进程再看运行时数据区的变化趋势最后抓内存快照和线程栈。# 第一步列出 Java 进程与主类拿到 pid jps -l # 第二步每秒采样一次共 10 次 # 各列含义S0/S1 幸存区使用率E 为 EdenO 为老年代M 为元空间 # YGC/FGC 是回收次数YGCT/FGCT 是累计耗时GCT 为总耗时 jstat -gcutil pid 1000 10 # 第三步之一导出存活对象快照live 表示先触发一次回收再导出 jmap -dump:live,formatb,file/tmp/heap.hprof pid # 第三步之二连续抓两次线程栈间隔 5 秒用于区分瞬时阻塞与真死锁 jstack -l pid /tmp/stack1.txt sleep 5 jstack -l pid /tmp/stack2.txt这里最容易忽略的是jstat的采样节奏。单次快照几乎没有信息量连续采样才能看出老年代使用率是不是单调上升、FGC 次数是不是在短时间内密集出现、元空间是否在缓慢增长。jstack抓两次是为了对比如果同一个线程在两次快照里都停在同一个方法上且状态是 BLOCKED 或 WAITING才谈得上定位只抓一次很容易把正常的短暂等待当成死锁。jmap -dump:live会先触发一次 Full GC对线上服务有停顿影响几百 G 的堆要慎重通常的做法是先在预发环境复现或者在低峰期执行。3.3 收集器切换与 GC 日志配置GC 日志是方案里唯一能长期留存的证据配置错了等于白开。JDK 9 之后统一日志框架替代了原来的PrintGC*系列参数写方案时必须按实际版本给配置。# JDK 9 及以上统一日志框架写法 # gc* 打开 GC 相关全部标签gcheap 输出堆分布gcage 输出年龄分布 # 冒号后依次是输出目标、装饰器、轮转策略 java -Xms4g -Xmx4g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -Xlog:gc*,gcheapdebug,gcagetrace:file/var/log/app/gc.log:time,uptime,level,tags:filecount5,filesize20M \ -jar app.jar逐项说明-Xms与-Xmx设成同值避免堆反复伸缩带来的额外回收-XX:UseG1GC是收集器切换开关-XX:MaxGCPauseMillis200是期望停顿目标收集器会按这个目标调整回收节奏但它不是硬保证-Xlog后面的filecount5,filesize20M是轮转策略防止日志文件把磁盘写满这一条在容器里尤其重要。旧版本项目对照着写另一份JDK 8 及以前用-XX:PrintGCDetails、-XX:PrintGCDateStamps、-Xloggc:/var/log/app/gc.log、-XX:UseGCLogFileRotation这一组。方案里把两套配置并列成一个小节标明适用版本评审时反而会加分因为这说明作者意识到参数是有生命周期的不是照抄一份模板走天下。3.4 类加载与字节码一个自定义加载器和一条反编译命令想验证类加载过程、双亲委派到底有没有生效最直接的做法是写一个自定义类加载器把findClass而不是loadClass重写掉。import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; /** * 自定义类加载器 * 重写 findClass 而非 loadClass保留父加载器优先的委派逻辑只扩展自己找不到时的行为 * 如果重写 loadClass就等于绕开双亲委派属于容器隔离场景的做法 */ public class LocalClassLoader extends ClassLoader { private final Path baseDir; public LocalClassLoader(Path baseDir, ClassLoader parent) { super(parent); // 显式指定父加载器默认是应用类加载器 this.baseDir baseDir; } Override protected Class? findClass(String name) throws ClassNotFoundException { try { // 把包名转成目录结构读取对应 class 文件的字节 byte[] bytes Files.readAllBytes(baseDir.resolve(name.replace(., /) .class)); return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } }这段代码的价值在于它把「委派」这件事变成了可观察的行为同一个类被两个不同的加载器加载会得到两个互不相同的 Class 对象instanceof判等会失败类型转换抛ClassCastException。方案里如果要设计插件隔离、热部署或者多版本共存这就是绕不开的起点。配合一条反编译命令把字节码结构也纳入验证范围# -v 输出完整信息常量池、访问标志、字段表、方法表、属性表 javap -v -p target/classes/com/example/Demo.class demo.txt # -c 只输出方法签名与字节码指令便于对照栈帧的入栈出栈过程 javap -c -p target/classes/com/example/Demo.class # 查看语法糖被还原后的真实形态比如泛型的类型擦除、自动装箱的调用点 javap -c -p target/classes/com/example/GenericDemo.class | grep -A 5 invokestatic-p表示连私有成员一起输出不加这个参数看到的成员是不全的很多人第一次用javap觉得对不上号就是漏了它。反编译输出适合放进方案附录用来证明「泛型在运行期被擦除」「字符串拼接在编译期被改写」这类结论不是背来的是看出来的。4. 故障模板把八个调优案例压成四类可复用的处置流程第 5 章给了八个案例加一个完整的 IDE 调优实战逐个记没有意义因为线上不会按书的目录出题。正确的做法是按故障类型归类每类沉淀一套固定动作出问题时照着走比临场发挥靠谱得多。归类之后会发现八个案例其实只落在四个格子里。4.1 内存类堆内、堆外与数据结构属于这一类的有大内存硬件上的程序部署策略、集群间同步导致的内存溢出、堆外内存导致的溢出、不恰当数据结构导致内存占用过大。共同特征是内存曲线异常但根因分布在不同区域。处置顺序建议固定为先看jstat -gcutil判断是老年代涨还是元空间涨老年代涨就jmap -histo:live看对象直方图取前 10 类直方图里如果出现大量业务对象往缓存和集合方向查如果出现大量byte[]且堆内用量对不上就往直接内存方向查元空间持续增长则重点看动态代理、反射生成类、脚本引擎这类会持续产生新类的代码路径。# 存活对象直方图快速定位占用最多的类型 jmap -histo:live pid | head -20 # 开启本地内存追踪后查看各区域的原生内存占用 java -XX:NativeMemoryTrackingsummary -jar app.jar jcmd pid VM.native_memory summaryjmap -histo:live会触发一次 Full GC线上执行要挑时间NMT 有大约 5%10% 的性能开销通常只在排查期临时打开长期开启要评估。集群间同步导致的那类溢出根因往往不在 JVM 参数而在同步策略本身——全量同步替代增量同步再大的堆也扛不住这种时候改代码比调参数有效。4.2 停顿类安全点与系统层属于这一类的有由操作系统虚拟内存导致的长时间停顿、由安全点导致的长时间停顿。共同特征是 GC 日志干干净净但业务线程确实卡住了。处置顺序先在 GC 日志里确认有没有长停顿记录如果没有就往安全点方向查。安全点相关的停顿通常表现为周期性的、持续时间在一秒以上的全局暂停日志里能看到 VM Operation 的进入耗时远大于执行耗时。常见诱因是循环边界的安全点轮询不足、偏向锁批量撤销、以及某些 JNI 调用长时间不返回。# JDK 9 及以上打开安全点日志 -Xlog:safepointinfo # JDK 8使用传统参数 -XX:PrintSafepointStatistics -XX:PrintSafepointStatisticsCount1注意安全点日志本身有开销只在排查窗口打开。这类问题的修复往往落在代码侧把超大的计数循环拆段、避免在循环体里做重量级同步、减少可偏向锁的批量撤销场景。4.3 崩溃与外部调用类先保现场再谈原因属于这一类的有虚拟机进程崩溃、外部命令导致系统缓慢。进程崩溃的第一现场是错误日志文件里面记录了触发问题的线程、信号类型、问题帧和当时的完整参数这份文件比任何事后复现都值钱第一件事是把它备份出来。外部命令导致的问题则是另一种形态通过运行时调用外部进程如果没有把标准输出和错误输出读干净缓冲区写满之后子进程会阻塞父进程跟着卡住。这类问题的现象是「系统变慢但 CPU 和内存都正常」排查时搜一遍代码里的进程调用点确认有没有消费输出流、有没有设置超时、有没有在 finally 里销毁进程。4.4 一份调优报告的固定格式案例讲完之后方案里要有一份固定的报告模板让每次调优的产出格式一致、可对比。这份模板通常长这样章节内容要求数据来源问题描述现象、影响面、发生时间窗、业务侧感知监控与工单现场数据GC 日志片段、jstat采样、线程栈、堆直方图3.2 节的取证动作假设与验证每条假设对应一个验证动作和验证结果复现环境变更内容参数、代码、部署方式的逐条变更变更单效果对比变更前后的 P99、FGC 次数、单次停顿、内存占用压测报告回滚方案触发条件、回滚步骤、验证方式预案这张表看着朴素但它解决了一个很实际的问题调优做完之后没人说得清到底改了什么、好了多少、坏了好不好退。有了固定格式第二个人接手时不用重新问一遍。5. 避坑与排查五个最容易翻车的配置与观测盲区书里的知识是完整的但落到项目上翻车点往往不在「不知道」而在「知道一半」。下面五条是这类方案里出现频率最高的坑每条都按现象、原因、解决来写可以直接搬进方案的「风险与对策」章节。5.1 把堆调大当成万能药现象-Xmx从 8G 加到 32GFGC 频率确实降了但单次停顿从 300ms 涨到 4s接口超时反而更多。原因单次回收的停顿时间和堆规模正相关尤其是标记和整理阶段要扫描的对象数量随堆增长。频率下降的代价是单次代价上升两者相乘未必是改善。解决先看 GC 日志确认停顿由哪种回收引发再决定方向。延迟敏感的服务优先换低延迟收集器或缩小堆、多实例部署吞吐优先的批处理任务才适合用大堆配 Parallel 系列。堆大小应该由停顿目标和对象存活率反推不是由机器内存反推。5.2 只盯堆漏掉元空间和直接内存现象堆使用率长期在 40% 以下但容器 RSS 一路涨到 limit最后被系统直接杀掉日志里连 OOM 都没有。原因-Xmx只管堆。元空间、直接内存、线程栈、JIT 代码缓存、GC 自身的辅助数据结构都不在它的管辖范围内容器看的是进程总内存。解决把-XX:MaxMetaspaceSize、-XX:MaxDirectMemorySize、-Xss、-XX:ReservedCodeCacheSize一起写进启动参数并按「堆 元空间 直接内存 线程数 × 栈大小 预留余量」算一遍总账。容器环境下这个总账要小于 limit 的 70% 左右剩下的留给系统页缓存和 JVM 自身的额外开销。5.3 安全点引起的「无 GC 卡顿」现象GC 日志里最长停顿不到 100ms但业务侧每隔一段时间就出现 13 秒的抖动监控上看不出任何异常。原因全局安全点需要所有线程到达轮询点才能进入如果某些线程长时间不经过安全点比如超长计数循环、大规模数组拷贝、JNI 调用整个虚拟机就得等它。这段时间不计入 GC 停顿所以 GC 日志里看不到。解决打开安全点日志看 VM Operation 的进入耗时和各个线程的到达时间把超长循环拆段、在循环体内插入可被轮询的操作、减少会触发批量偏向锁撤销的同步代码。5.4 旧版本的参数直接抄到新版本现象升级 JDK 后启动日志里冒出一串无法识别的参数警告某些参数被静默忽略行为和老版本对不上。原因永久代相关参数在 JDK 8 已被移除CMS 在后续版本被废弃GC 日志参数在 JDK 9 换成了统一日志框架类加载相关的模块化参数也是新加的。参数体系是有版本生命周期的。解决升级前用-XX:PrintFlagsFinal把最终生效的参数全量打出来和预期清单逐条比对把参数按 JDK 版本做成矩阵写进方案的部署章节。这一步花半小时能省掉升级后几天的排查。5.5 只看可视化工具不看原始输出现象图形化监控面板显示一切正常但问题在业务侧确确实实复现了。原因可视化工具普遍有采样间隔和平滑处理秒级的抖动会被平均掉一些工具默认只展示堆和线程数元空间、直接内存、安全点这些维度根本不在默认视图里。解决以 GC 日志和jstat的原始输出为准可视化工具只作辅助展示。方案里明确写一句「判定依据以原始日志为准」能避免很多扯皮。6. 验收与答辩用「三表一图」把方案讲圆方案写完只是第一步能不能被验收、能不能在答辩时站住取决于你能不能在三分钟内说清楚三件事问题是什么、依据是什么、改完好了多少。我的做法是把整份方案压缩成「三表一图」所有细节都挂在这四样东西下面讲的时候按图展开被追问时按表回落。三张表分别是区域责任表、收集器决策表、故障处置表。区域责任表就是第 2 章那张讲清楚七块内存各自负责什么、溢出什么表现收集器决策表是第 2 章那张二维表讲清楚选型依据故障处置表是第 4 章那张报告模板讲清楚每类问题怎么走流程。一张图指的是调优链路图用文字描述就够从监控告警触发到进程级确认到运行时数据区采样到执行级取证到假设验证到变更与回滚七个节点首尾相接每个节点标注输入、输出和责任人。答辩常见问题准备材料回答要点为什么选这个收集器收集器决策表 压测对比说清延迟目标与吞吐目标的取舍给出对照组的实测数据这些数据从哪来GC 日志片段 jstat采样 堆直方图指明采集时间窗、采集命令、采集环境承认采样本身的开销怎么证明改完变好了变更前后对比表用同一套压测脚本、同一批流量、同一时间长度做对比出问题怎么退回滚方案给出触发条件、回滚步骤和回滚后的验证动作答辩时最容易失分的不是答不上来而是答得太满。比如把MaxGCPauseMillis说成「停顿一定不超过 200ms」或者把某次压测的优化幅度说成「性能提升 300%」却不给测试条件评委一问细节就崩。稳妥的说法是给出区间和前提在什么样的堆规模、什么样的对象存活率、什么样的流量模型下P99 从多少降到多少方差多大。还有一个容易被忽略的点把「没做成的事」也写进方案。哪些假设验证失败了、哪些参数调了没效果、哪些方向因为风险太高主动放弃这些内容看着不体面但它恰恰证明你真的动过手。我见过一份方案附录里列了七次失败的参数组合反而成了整份材料里最有说服力的部分。从那以后我每次出调优方案都强制先把压测基线和回滚开关写进第一页再动笔写任何一条优化建议——没有基线就没有对比没有回滚就没有底气。希望帮到你。本文还有配套的精品资源点击获取