
简介《深入理解Java虚拟机JVM高级特性与最佳实践第3版》PDF电子版面向已具备Java基础、希望系统掌握JVM原理与性能调优的开发者也可供面试进阶和技术架构参考。内容从Java技术体系与虚拟机家族切入串联运行时数据区域、对象创建与内存布局、内存溢出异常、垃圾收集算法与Serial至G1及Shenandoah、ZGC等收集器并讲解类加载机制、字节码执行引擎和jps、jmap、JConsole、VisualVM等监控工具。资源包共1个PDF文件大小约28.65MB单文件便于按目录检索和随时查阅。已有747人学习书中结合Eclipse调优、堆外内存溢出、集群同步等实战案例提供故障定位思路与参数选择依据适合查漏补缺并建立完整的JVM知识框架。1. 从一次压测前的 OOM 说起JVM 报错为什么总让人无从下手压测跑到第二十分钟日志里刷出java.lang.OutOfMemoryError: Java heap space服务重启后撑了半小时又挂。第一反应是调大-Xmx从 2G 提到 8G崩溃时间从半小时推迟到两小时——根因没动只是把爆炸往后挪了挪。JVM 这类故障最折腾人的地方在于信息极度不对称报错只有一行可能的原因却铺在堆、元空间、虚拟机栈、直接内存、垃圾收集器和类加载六个方向上。新手照着搜索结果挨个加参数常常越改越乱熟手靠jstat、jmap、jstack三条命令先缩小范围再决定动哪一块。这份 Java 虚拟机排错资源把内存区域划分、垃圾收集器、监控工具和调优案例按故障类型串成了一条线下面按我实际踩过的顺序拆开讲。2. 堆内存溢出从 Java heap space 到支配树上的那一个对象Java heap space是出现频率最高的一个 OOM也是最容易被「加内存」糊弄过去的一个。它至少有三条完全不同的触发路径先分清走的是哪条后面的命令才不会白敲。2.1 三种触发路径活太久、要太多、漏了第一种是对象活得太久。典型的是一张只进不出的静态缓存表key 是会话 IDvalue 是几百 KB 的报文没有淘汰策略也没有容量上限。静态字段本身就是 GC Root 的直接引用只要类不卸载里面的对象一个都回收不掉老年代占用一条直线往上走直到某次分配失败。// 典型泄漏静态 Map 只进不出value 还是大对象 public class SessionHolder { // static 字段直接挂在 GC Root 上类不卸载就永远可达 private static final MapString, byte[] CACHE new HashMap(); public void put(String sessionId, byte[] payload) { CACHE.put(sessionId, payload); // 既没有 remove也没有容量上限 } }第二种是一次要太多。一条没加限定的查询把几十万行记录一次性读进内存做聚合或者把整个 Excel 读成ListListString瞬时分配速率远超堆的剩余空间。这种溢出的特征是平时完全正常一到月底报表、批量导入就炸。第三种是真正的泄漏监听器注册了没注销、ThreadLocal用完没remove、连接池里的对象被业务代码长期持有。三种路径对应的修复方式完全不同——第一种要加淘汰策略第二种要改成分页或流式处理第三种才是去找那根没断的引用链。2.2 先用直方图再抓堆快照我一般的顺序是先看对象直方图十秒钟就能判断是不是某一类对象把堆撑爆了直方图看不出问题时再抓完整快照。# 1. 直方图按实例占用字节数排序只看前 30 行 # 注意 :live 会先触发一次 Full GC生产上尽量挑低峰期 jmap -histo:live pid | head -30 # 2. 抓完整堆快照formatb 表示二进制格式 # 文件大小约等于当时的堆使用量务必确认目标磁盘有足够空间 jmap -dump:formatb,file/data/dump/heap-$(date %s).hprof pid # 3. 生产环境更推荐 jcmd对目标进程的侵入比 jmap 小 jcmd pid GC.heap_dump /data/dump/heap.hprofjmap -histo输出的第一列是类名第二列是实例数量第三列是占用字节数。[B是 byte 数组、[C是 char 数组、java.util.HashMap$Node是哈希桶节点——看到[B排第一且数量惊人基本就是大对象缓存或文件读取的问题看到某个业务类实例数几十万就往那个类的生命周期上查。堆快照抓到之后用堆分析工具打开别急着翻「最大对象」列表先看支配树Dominator Tree。支配树的含义是只要这个对象被回收它下面挂着的所有对象都能一起被回收。真正压垮堆的那一个根节点往往不是占用最大的对象而是支配了大片内存的那个集合。按 Retained Heap 排序前五个通常就能锁定问题。2.3 参数收敛别用调大堆来掩盖泄漏定位清楚之后参数怎么给也有讲究。最常见的错误是只设-Xmx不设-Xms堆在运行期反复扩张收缩每次扩张都伴随一次 Full GC看起来像「内存够用但 GC 特别频繁」。java -Xms4g -Xmx4g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump \ -XX:ExitOnOutOfMemoryError \ -jar app.jar-Xms和-Xmx设成一样大是为了让堆在启动时就一次性提交到位避免运行期扩容带来的抖动。HeapDumpOnOutOfMemoryError让 JVM 在 OOM 发生的瞬间自动落盘快照这是最有价值的一颗后悔药——OOM 现场转瞬即逝等运维重启完再去抓什么都看不到了。HeapDumpPath必须指向一个确定存在、有写权限、空间充足的目录。ExitOnOutOfMemoryError让进程在 OOM 后立即退出交给编排层拉起比留着一个半死不活的进程继续接流量要安全得多。新生代大小通过-Xmn或者-XX:NewRatio控制。-Xmn调大能减少 Minor GC 次数但会挤压老年代空间对象晋升的临界点也跟着变。一个常见的误判是把-XX:MaxTenuringThreshold调到 15以为能让对象在新生代多待几轮实际上 HotSpot 在 Survivor 空间不足时会动态调整晋升年龄这个参数只是上限不是保证。提示看到 OOM 先别动-Xmx。把现场保住、把支配树看清楚再决定是改代码还是改参数。加堆只能延后崩溃改不了增长曲线的斜率。验证是否修好靠的是压测时的老年代曲线而不是感觉。压测期间跑jstat -gcutil pid 1000观察O这一列如果它在压测稳定后仍然持续单调上升、几轮 Full GC 之后也回不去说明还有活跃引用没释放如果上升后能在 GC 后回落并趋于平稳才算真正收敛。3. Metaspace、栈与直接内存三种「不在堆里」的溢出很多人一看到 OOM 就往堆上想结果把-Xmx加到 16G 依然报错因为溢出的根本不是堆。这三类溢出各自的报错文本不一样认准报错的那一行能省掉大半天。3.1 Metaspace OOM类加载器泄漏与动态类膨胀java.lang.OutOfMemoryError: Metaspace意味着方法区JDK 8 之后由元空间实现装不下了。元空间存放的是类的元数据正常业务应用的类数量是稳定的一旦它持续增长八成是动态生成类出了问题CGLIB 代理、脚本引擎每次编译生成新类、热部署反复加载同一个类都会留下新的类加载器。# 看类加载器数量和每个加载器加载了多少类 # 同一个类名的加载器数量持续增长 类加载器泄漏的铁证 jmap -clstats pid # 需要先加 -XX:UnlockDiagnosticVMOptions 才能用 # 输出类的实例数、大小等统计定位是哪个包在疯狂产类 jcmd pid GC.class_stats | sort -k3 -nr | head -20jmap -clstats的输出里有一列是每个类加载器已加载的类数量。如果看到几十个同名加载器、每个都挂着一批类且数量随运行时间递增那基本可以确定是动态代理或热部署没有释放旧加载器。修法是缓存代理类而不是每次新建或者用弱引用持有类加载器。# 元空间的两个关键参数 # MetaspaceSize 是首次触发元空间 GC 的阈值不是上限别当成上限用 # MaxMetaspaceSize 才是硬上限不设就是受限于本机物理内存 java -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -jar app.jar这里有个反复被误解的点-XX:MetaspaceSize控制的是「元空间涨到多少时第一次触发 GC」调大它会推迟第一次元空间回收反而可能让占用冲得更高。真正限制总量的是MaxMetaspaceSize。线上我一般把上限锁死在 512m 到 1g 之间让它在失控时快速失败而不是慢慢吃光整台机器的内存。3.2 StackOverflowError 与 unable to create new native threadStackOverflowError的触发条件非常直接单个线程的栈深度超过了-Xss的限制。最常见的场景是没有终止条件的递归、互相调用的equals、以及 JSON 序列化时对象之间存在循环引用。栈溢出的堆栈里会反复出现同一个方法名一眼就能认出来。另一类长得像但根因不同的是java.lang.OutOfMemoryError: unable to create new native thread。它不是栈太深而是线程太多每个线程都要占一块栈内存线程数乘上-Xss就是这部分的总开销。线程池没设上限、每次请求new Thread()、或者阻塞队列堆积导致线程被无限创建都会走到这一步。# 统计当前线程总数和状态分布先确认是不是线程数失控 jstack pid | grep -c java.lang.Thread.State # 按状态分组看看是卡在 WAITING 还是 RUNNABLE jstack pid | grep java.lang.Thread.State | sort | uniq -c如果线程数几千且大部分处于WAITING问题在线程池配置或下游调用超时如果线程数正常但栈溢出问题在递归逻辑。-Xss的默认值在 512KB 到 1MB 之间调小能容纳更多线程但会更容易触发StackOverflowError这个取舍要看具体场景不能一刀切。3.3 直接内存Direct buffer memory 与 Cleaner 的延迟java.lang.OutOfMemoryError: Direct buffer memory出自 NIO 的直接缓冲区。ByteBuffer.allocateDirect()分配的内存不在 Java 堆里不受-Xmx约束只在垃圾回收时由Cleaner间接释放。问题就出在「间接」两个字上直接内存的回收依赖老年代 GC 触发如果堆里很空、GC 长时间不发生这些堆外内存就一直挂着不还。# 限制直接内存上限默认约等于 -Xmx 的值 java -XX:MaxDirectMemorySize256m \ -XX:NativeMemoryTrackingsummary \ -jar app.jar # 运行时查看 JVM 各部分内存的真实占用包括线程栈、元空间、直接内存 jcmd pid VM.native_memory summaryVM.native_memory summary需要启动时开启NativeMemoryTracking它会把 JVM 的内存拆成 Java Heap、Class、Thread、Code、GC、Internal 等若干块哪一块涨得快一目了然。我遇到过一次堆内存平稳、容器内存却一直涨的情况就是靠这个把范围从堆排除掉最后定位到某个网络框架的缓冲区没有正确释放。排查直接内存泄漏重点是看谁在持有ByteBuffer的引用。引用一旦被业务对象长期持有Cleaner就没有机会执行MaxDirectMemorySize迟早被吃满。修法通常是显式管理缓冲区生命周期或者用池化方案替代每次分配。4. Full GC 频繁与长停顿jstat 读数、收集器迁移、GC 日志里的黑匣子接口偶发超时、监控上看到响应时间尖刺一看 GC 日志是几十秒一次的 Full GC——这类问题不会让进程崩溃但会持续消耗用户耐心而且特别难复现。判断依据全在jstat和 GC 日志里。4.1 jstat -gcutil 每一列怎么读# 每秒采样一次共采样 20 次观察各代使用率的变化趋势 jstat -gcutil pid 1000 20列含义判断参考S0 / S1两个 Survivor 区的使用百分比长期贴近 100% 说明对象在 Survivor 里反复复制EEden 区使用百分比增长速率反映分配速度是判断「要太多」的直接依据O老年代使用百分比关键指标GC 后仍单调上升即为泄漏信号M元空间使用百分比持续上升对应 3.1 节的类加载器问题YGC / YGCT年轻代 GC 次数与总耗时用 YGCT/YGC 算出平均单次停顿FGC / FGCTFull GC 次数与总耗时FGC 增长快于业务量增长就是问题GCT所有 GC 的总耗时除以运行时长得到 GC 占比超过 5% 要介入读这张表的顺序是先看O在 Full GC 之后能不能回落不能回落就是有活跃引用没释放再看FGC的增长斜率如果每分钟都在涨而堆使用率并不高多半是元空间或直接内存间接引发的System.gc()最后看YGCT/YGC的单次停顿超过 50ms 就要考虑换收集器了。4.2 从 CMS 迁到 G1 的翻车点参数迁移是最容易出事的一步。老配置里常见-XX:UseConcMarkSweepGC配-XX:CMSInitiatingOccupancyFraction在新版本 JDK 上这两个参数一个被移除、一个被忽略启动时直接报Unrecognized VM option或者更糟——静默回退到默认收集器性能数字对不上却查不出原因。收集器适用场景关键参数迁移注意Serial / Serial Old单核、小堆、客户端-XX:UseSerialGC堆超过 4G 不要用Parallel Scavenge追求吞吐、可忍受停顿-XX:MaxGCPauseMillis不生效停顿与吞吐二选一CMS老年代低延迟已废弃CMSInitiatingOccupancyFraction高版本需先确认是否被移除G1中大堆、可预测停顿MaxGCPauseMillis、G1HeapRegionSize停顿目标是软目标不是硬保证ZGC / Shenandoah超大堆、极低停顿-XX:UseZGC需要对应 JDK 版本支持# G1 的起步配置先给一个合理的停顿目标别一上来就抠细节参数 java -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:G1HeapRegionSize8m \ -XX:InitiatingHeapOccupancyPercent45 \ -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount5,filesize50m \ -jar app.jarMaxGCPauseMillis是期望值G1 会据此决定每次回收多少个 Region设得太激进比如 20ms会导致每次只回收很小一片回收速度赶不上分配速度反而触发更频繁的 Young GC。InitiatingHeapOccupancyPercent是启动并发标记周期的阈值默认 45老年代增长快的应用可以适当调低到 35 到 40让标记提前开始。日志参数里filecount5,filesize50m是滚动策略不做滚动的话 GC 日志能把磁盘写满这件事我见过不止一次。4.3 GC 日志字段对不上就是误判的开始# JDK 9 及以上统一日志框架同时开 gc、gcheap、gcage 三个 tag java -Xlog:gc,gcheap,gcagedebug:file/data/logs/gc.log:time,uptime:filecount5,filesize50m -jar app.jar # JDK 8 的老写法参数名完全不同别混用 # -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log日志里最关键的三个数字是回收前后的堆占用量、这次停顿的耗时、以及各分代的回收效果。看到Pause Young后面跟着 0.8 秒说明年轻代配置需要调整看到Pause Full频繁出现且老年代回收率很低说明有对象在持续存活。还要注意to-space exhausted这个标记它意味着晋升时 Survivor 或老年代空间不足往往伴随着一次意料之外的 Full GC。日志版本差异是个隐藏的坑。JDK 8 的PrintGCDetails和 JDK 9 之后的-Xlog是两套完全不同的参数体系照着旧文档配新版本往往一个日志都打不出来还以为是 GC 没发生。先在启动日志里搜一下Using关键字确认实际生效的是哪个收集器、哪套日志参数再开始分析。5. 排查避坑五个把人带沟里的现场下面这几条都是我在真实环境里翻过的车每一条都符合「现象看着像 A其实是 B」的特征。列在这里不是为了凑数是因为它们每一个都能让人多绕半天。5.1 现象加了 HeapDumpOnOutOfMemoryErrorOOM 时却没生成快照原因通常是三个HeapDumpPath指向的目录在容器里不存在、进程用户对该目录没有写权限、或者磁盘空间不够。还有一种更隐蔽的情况——同时开了-XX:ExitOnOutOfMemoryError进程在快照写完之前就被拉起流程杀掉文件残留在半截状态。解决方式很土但有效上线前先在目标目录里手写一个测试文件确认路径和权限快照目录单独挂一块盘不要和日志共用ExitOnOutOfMemoryError与自动快照同时开的时候给拉起策略加一个延迟或者干脆依赖 JVM 自己退出。5.2 现象jstack 抓下来全是 WAITING找不到那个卡死的线程原因多半是用了jstack -F。强制模式会直接 attach 到目标进程打印出来的调用栈不保证是安全点上的真实状态看起来一堆线程都在等待实际问题线程的栈信息被截断了。容器环境里还有另一个坑pid在主机的命名空间里和在容器里不是同一个数值。我的做法是优先用jcmd pid Thread.print它走的是 JVM 自己的诊断命令通道输出比jstack -F可靠。如果jcmd也连不上就发kill -3 pid让 JVM 自己把线程栈打到标准输出从应用日志里捞。容器里先ps -ef确认 PID别拿宿主机上看到的数字去 attach。5.3 现象容器限制 2GJVM 却按宿主机 16G 去算堆容器内存被 OOMKilled容器里起出来的 Java 进程却一脸无辜因为它看到的可用内存是宿主机的。在较早的 JDK 版本上JVM 默认不读取 cgroup 的内存限额-Xmx不显式指定就会按物理机内存的四分之一去申请。解决方式是显式声明容器感知并且用百分比而不是固定值-XX:UseContainerSupport -XX:MaxRAMPercentage75.0。留 25% 给元空间、线程栈、直接内存和操作系统本身这个比例不是固定的堆外用量大的应用要往下压到 60% 到 70%。改完之后在容器内跑一次jcmd pid VM.flags确认实际生效的MaxHeapSize是不是你期望的值。5.4 现象把 -Xmx 从 4G 调到 12G单次 Full GC 从 0.8 秒变成 3 秒堆越大标记和整理阶段要扫描的对象越多单次 Full GC 的停顿时间必然变长。这是一笔明账Full GC 的频率下降了但每次的代价上升了。如果原来 Full GC 就不频繁加堆纯粹是亏本买卖。判断标准是看FGCT占总运行时间的比例。原来 0.8 秒一次、一小时三次总停顿 2.4 秒改完 3 秒一次、一小时一次总停顿 3 秒账面上反而更差。这种情况下正确的方向是换低延迟收集器或者减少对象晋升而不是继续加堆。5.5 现象本地跑得好好的一上线就 NoSuchMethodErrorNoSuchMethodError和NoClassDefFoundError的根因几乎都是类加载器加载到了错误的版本同一个类被两个 jar 提供、依赖树里有版本冲突、或者容器把应用类和中间件类放在了不同的加载器里。定位靠两条命令配合。第一条看依赖树把可疑的类名丢进去查是哪个坐标引进来的mvn dependency:tree -Dverbose -DincludesgroupId:artifactId。第二条看运行时到底加载了哪个 jar启动时加-verbose:class在输出里搜类名会打印[Loaded xxx from file:/path/to/a.jar]路径和你预期不一致就是它了。生产环境不方便重启的话用jcmd pid VM.class_hierarchy 类名也能查出加载来源。修法是在构建里做依赖排除或统一版本管理别指望运行期靠类加载顺序碰运气。6. 快速定位十五分钟走完的排查路径真出故障的时候最怕的是没有顺序地乱敲命令。我现在固定按「先确认是哪一类、再抓现场、最后动参数」三段走大多数 JVM 故障能在十五分钟内把范围缩到一个方向上。症状第一条命令看什么进程直接退出日志有 OOMjcmd pid VM.flags配合快照目录检查快照有没有落盘实际生效的堆参数接口变慢但没崩jstat -gcutil pid 1000 30O列是否回落FGCT/GCT占比内存持续上涨jcmd pid VM.native_memory summary堆、Class、Thread、Internal 哪块在涨线程卡住无响应jcmd pid Thread.printRUNNABLE 线程卡在哪个方法类找不到或方法不存在-verbose:class/VM.class_hierarchy类是从哪个 jar 加载的# 出故障时按顺序跑这一组把输出统一存到一个目录事后可回溯 PID$(pgrep -f app.jar | head -1) OUT/data/diag/$(date %Y%m%d%H%M) mkdir -p $OUT jcmd $PID VM.flags $OUT/flags.txt # 实际生效的 JVM 参数 jcmd $PID VM.version $OUT/version.txt # 版本信息判断参数是否被支持 jstat -gcutil $PID 1000 30 $OUT/gcutil.txt # 30 秒的代际使用率趋势 jcmd $PID Thread.print $OUT/threads.txt # 线程栈 jcmd $PID GC.class_histogram $OUT/histogram.txt # 类实例直方图这段脚本的价值在于顺序先确认参数再确认版本然后拿趋势、拿线程、拿直方图。参数决定了后面所有现象的解释方式——比如你以为是 G1实际启动时因为参数冲突回退成了 Parallel那停顿时间的分析就全错了。版本决定哪些命令能用jcmd的子命令在不同 JDK 上是有差异的先跑jcmd pid help列一下可用项比照着旧文档试要快得多。还有一个常被忽略的验证手段改完参数之后不要只压一次就下结论隔一天再跑一次同样的压测对比两次的FGC和YGCT/YGC。GC 的表现受对象创建速率影响很大单次压测的样本量往往不够两次数据一致才算真的稳了。从那以后我每次上线新服务都强制自己先把jstat和 GC 日志接上、把快照目录和权限验一遍再放流量进来哪怕业务方催得再急。这些准备加起来不到十分钟但真出事的时候它能让你少熬一个通宵。希望帮到你。本文还有配套的精品资源点击获取