
简介《深入理解Java虚拟机JVM高级特性与最佳实践第3版》PDF电子书面向具备Java基础、希望系统掌握JVM原理与性能调优的开发者及面试备考人群。压缩包内含1个PDF文件大小28.65MB内容对应周志明第3版覆盖Java技术体系、运行时数据区域、对象创建与内存布局等基础主题。书中结合OutOfMemoryError实战讲解堆、栈、方法区及直接内存溢出系统梳理引用计数、可达性分析与分代收集理论并比较Serial、CMS、G1、ZGC等收集器的适用场景。监控排错部分介绍jps、jstat、jmap、jstack及JConsole、VisualVM等工具的使用思路调优章节通过大内存部署、集群同步、堆外内存等案例演示问题定位与优化过程。已有747人学习适合用作JVM知识体系搭建、故障排查参考与性能调优实战手册。1. Java heap space 报错JVM 内存问题为什么总在半夜找上门凌晨两点被告警叫醒日志里躺着一行java.lang.OutOfMemoryError: Java heap space这类 JVM 内存报错几乎每个上过生产的人都遇到过。它让人头疼的地方不在于报错本身而在于同一段代码在测试环境跑得好好的换个数据量就炸重启能撑两小时改个-Xmx又能撑一天。堆、元空间、直接内存、线程栈四块地方哪个溢出的表现都能长成「内存不够」但处置方式完全不同照着别人的参数抄一遍往往只是把问题推迟了。下面按「先分清是哪块内存炸了、再看 GC 日志和工具链、最后落到参数和代码」的顺序把 JVM 排错里最容易翻车的几处拆开讲。2. OOM 报错分四类先定位是哪块内存溢出了看到 OutOfMemoryError 就条件反射去加-Xmx是新手最常见的误判。JVM 的运行时数据区域在书里被切成程序计数器、虚拟机栈、本地方法栈、堆、方法区JDK 8 之后是元空间、运行时常量池、直接内存每一块都有自己独立的溢出条件报错文案也不同。先把文案认全排查方向就少走一半弯路。2.1 堆溢出Java heap space 与 GC overhead limit exceeded堆溢出的触发条件只有两个要么存活对象真的超过了堆上限要么对象回收速度跟不上分配速度。日志里Java heap space是堆分配失败GC overhead limit exceeded则是另一个面孔——超过 98% 的时间在做 GC 却只回收了不到 2% 的堆虚拟机干脆提前抛错本质仍然是堆不够或有泄漏别被名字骗了。真正有用的动作不是重启而是在下次炸之前把现场留下来。启动参数里加上「事后取证」的两行# -Xms 与 -Xmx 设成一样避免堆伸缩带来的额外 GC 和抖动 # HeapDumpPath 指向独立磁盘分区hprof 体积接近堆上限别写进容器临时目录 java -Xms4g -Xmx4g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump/heap-%p.hprof \ -Xlog:gc*:file/data/logs/gc-%t.log:time,uptime:filecount5,filesize50M \ -jar app.jar-XX:HeapDumpOnOutOfMemoryError是排障中最有价值的开销OOM 瞬间自动导出快照不用赌能不能复现。%p是进程号占位符防止同名覆盖。-Xlog:gc*是 JDK 9 以后的统一日志写法JDK 8 要用另一套参数混用会直接启动失败后面第 5 章会细说。对于已经在跑的进程手工抓快照优先用jcmd# jcmd 走 attach 机制不强制 Full GC对大堆更友好 jcmd 12345 GC.heap_dump /data/dump/heap-manual.hprof # 老版本 JDK 只有 jmap 可用 # 注意 :live 会先触发一次 Full GC 再遍历堆大堆上可能造成秒级停顿 jmap -dump:formatb,file/data/dump/heap.hprof 12345拿到 hprof 之后用堆分析工具打开先看 Leak Suspects 给出的可疑对象再顺着支配树Dominator Tree往下找持有者最后看 GC Roots 到泄漏对象的引用链。判断标准很朴素如果某个业务对象的实例数量随请求量线性增长却从不下降那就是集合类没清理如果数量稳定但每个都巨大那是单条数据太大或缓存没有上限。2.2 栈溢出StackOverflowError 和 unable to create new native thread这两个报错经常被混为一谈其实一个是单线程栈帧太深一个是线程总数太多。StackOverflowError多半来自没有终止条件的递归、JSON 序列化时的循环引用、正则表达式的灾难性回溯。用一段最小代码就能量出当前配置下的深度public class StackDepth { private static int depth 0; public static void main(String[] args) { try { recurse(); } catch (StackOverflowError e) { // 捕获后打印深度用来反推单帧大小与 -Xss 的关系 System.out.println(栈深度约 depth); } } static void recurse() { depth; recurse(); } }配上java -Xss256k StackDepth跑一遍再换成-Xss1m跑一遍深度大致按倍数变化说明单帧大小基本固定。这里有个反直觉的点把-Xss调大确实能让递归更深但每个线程占用的栈内存也变大了同样物理内存下能创建的线程数反而变少unable to create new native thread往往就是这么被「优化」出来的。排查线程数上限的路径很短# 当前进程的线程总数 cat /proc/12345/status | grep Threads # 系统级与用户级线程数限制 ulimit -u # 线程池无界、每请求 new Thread都是线程数暴涨的常见来源 jstack 12345 | grep -c ^如果线程数逼近ulimit -u或容器 pids limit要么收敛线程池有界队列 合理的 maxPoolSize要么把-Xss从默认的 1M 降到 256k512k。但降栈只是腾地方根因还是创建线程的地方没有节制。2.3 元空间溢出Metaspace 与类加载器泄漏java.lang.OutOfMemoryError: Metaspace这个报错在业务代码里很少见一旦出现八成和动态生成类有关。常见来源包括 CGLIB/ByteBuddy 生成的代理类、反射调用产生的 MethodAccessor 膨胀、脚本引擎反复编译同一段表达式以及热部署场景下每次 reload 都新建一个类加载器、旧的却因为被引用而无法卸载。定位哪个类加载器在膨胀用类加载统计# 查看每个类加载器加载的类数量与占用按块大小倒序 jcmd 12345 GC.class_stats | sort -k3 -nr | head -20 # 老版本 JDK 用这个 jmap -clstats 12345如果看到某个自定义加载器的类数量持续上涨基本可以锁定热部署或动态代理泄漏。参数上要给它封顶# MetaspaceSize 是首次触发 Metaspace GC 的阈值不是初始大小 # 设得太小会导致启动期频繁 Full GCMaxMetaspaceSize 不设则只受本机内存限制 -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m很多团队只记得设-Xmx元空间放任不管结果容器内存缓慢上涨、堆却平平整整最后被内核干掉连一句 Metaspace 的报错都没留下——因为进程根本没机会打印。2.4 直接内存溢出Direct buffer memory 与堆外泄漏java.lang.OutOfMemoryError: Direct buffer memory的现场通常是 NIO 的ByteBuffer.allocateDirect、池化的网络缓冲区或者Unsafe.allocateMemory。这里有个坑中坑-XX:MaxDirectMemorySize不设时默认约等于-Xmx但Unsafe.allocateMemory的分配并不受这个参数约束只受物理内存和容器 limit 限制所以你会看到「堆没满、直接内存参数也没超进程却没了」。排查手段是打开 Native Memory Tracking# NMT 有 5%10% 的性能开销只在排障窗口开别长期挂在生产上 java -XX:NativeMemoryTrackingsummary -jar app.jar # 分区域看Java Heap / Class / Thread / Code / GC / Internal / Other jcmd 12345 VM.native_memory summary另一个高频诱因是-XX:DisableExplicitGC。这个参数会把System.gc()变成空操作而直接内存的回收恰恰依赖 Full GC 触发 Cleaner于是堆外内存迟迟不释放日志里也看不到任何 OOM。书里专门用一个堆外内存的调优案例讲过这个连锁反应堆参数看着完全正常问题出在堆之外。遇到这类现象先把DisableExplicitGC摘掉验证再决定是用池化复用还是显式调用 Cleaner。3. Full GC 频繁与长停顿GC 日志怎么开、数字怎么读内存溢出的另一面是「没溢出但卡」。接口 P99 从 80ms 涨到 2s监控上 CPU 不高、QPS 不高就是周期性地整站卡顿几秒。这类问题不看 GC 日志基本靠猜而 GC 日志的第一道门槛是参数写对——不同大版本之间日志参数是两套体系。3.1 开 GC 日志JDK 8 与 JDK 9 的参数不能混用# JDK 8 及以前 -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc-%t.log \ -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles5 -XX:GCLogFileSize50M# JDK 9 及以后统一日志框架 -Xlog:gc*:file/data/logs/gc-%t.log:time,uptime,level,tags:filecount5,filesize50M-Xlog的语法是-Xlog:标签:输出:装饰器:轮转策略。gc*表示打开所有 gc 相关标签包含分代细节和收集器阶段time,uptime分别打墙钟时间和进程启动后的相对时间比对业务日志时用前者、算停顿间隔时用后者filecount与filesize控制轮转避免日志把磁盘写满。这里必须强调-XX:PrintGCDetails在 JDK 9 之后是被移除而不是被弃用升级 JDK 后照抄旧启动脚本会得到Unrecognized VM option PrintGCDetails应用直接起不来。这类问题在灰度发布时最容易漏因为测试环境的启动脚本往往和线上不是同一份。3.2 从一行日志判断收集器、停顿与回收效果一段典型的 G1 日志长这样[2024-05-11T02:13:41.8820800][info][gc] GC(128) Pause Young (Normal) (G1 Evacuation Pause) 3821M-1204M(4096M) 23.451ms [2024-05-11T02:13:52.1170800][info][gc] GC(129) Pause Full (G1 Compaction Pause) 3890M-2100M(4096M) 1876.332ms3821M-1204M(4096M)这三个数字要连起来读回收前占用、回收后占用、堆总容量。第一次 Young GC 后还剩 1204M说明存活对象占了三成偏高第二次 Full GC 之后仍然有 2100M回收比只有 46%这就不像正常的朝生夕死了更像大量对象被长期持有。最后那个1876.332ms是本次停顿对在线服务来说接近两秒的 STW 已经足以打穿上游的超时阈值。几个可以直接套用的判据老年代占用呈阶梯上升且 Full GC 后不回落指向泄漏占用呈锯齿状反复起落是正常的新生代回收Young GC 频率极高但单次很短通常是 Eden 太小或者分配速率过大对象晋升年龄普遍偏低要去看 Survivor 空间比例和动态年龄判定。CMS 时代还有一个标志性日志concurrent mode failure出现它意味着并发清理期间老年代被填满退化成单线程 Full GC停顿会从几十毫秒跳到几秒这时候调-XX:CMSInitiatingOccupancyFraction提前触发才有意义。3.3 收集器怎么选从堆大小和停顿目标倒推选收集器不是选先进是选匹配。堆 100M 的客户端程序用 Serial 完全够用且开销最小几 GB 的在线服务在 G1 成熟之后基本没有留在 CMS 的理由几十 GB 的超大堆、又不能接受百毫秒级停顿时才轮到 ZGC 这类低延迟收集器上场。收集器典型堆规模停顿特征适用场景Serial / Serial Old百 MB 级单线程 STW停顿与堆成正比客户端、命令行工具、容器小规格边车Parallel Scavenge / Old数 GB多线程 STW追求吞吐批处理、离线计算、可接受秒级停顿CMS数 GB并发标记怕碎片与并发失败老系统维护新项目不建议G16GB数十 GB可设停顿目标分区回收主流在线服务默认选择ZGC超大堆亚毫秒到毫秒级大内存、低延迟敏感型服务G1 的起手式参数# MaxGCPauseMillis 是软目标不是保证设得过小会让 Young 区被压得很小、GC 反而更频繁 # IHOP 表示老年代占用到 45% 就启动并发标记周期比默认 45 更适合晋升快的应用 -XX:UseG1GC -XX:MaxGCPauseMillis200 \ -XX:G1HeapRegionSize8m \ -XX:InitiatingHeapOccupancyPercent45 \ -XX:ParallelRefProcEnabledG1HeapRegionSize建议按堆大小除以 2048 取 2 的幂堆 16G 时 8m 比较合适ParallelRefProcEnabled让软引用、弱引用的清理并行化对引用多的框架有明显收益。参数调完之后一定要回头用日志验证而不是调完就当解决了。3.4 用一段代码验证分配与晋升规则参数背后的规则是可以自己跑出来的。下面这段代码配合小堆能同时观察到「对象优先在 Eden 分配」和「大对象直接进入老年代」// -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio8 -XX:PrintGCDetails public class AllocationDemo { private static final int _1MB 1024 * 1024; public static void main(String[] args) { byte[] a1 new byte[2 * _1MB]; byte[] a2 new byte[2 * _1MB]; byte[] a3 new byte[2 * _1MB]; // 第四块 4MB超过阈值直接进老年代不经过 Eden byte[] a4 new byte[4 * _1MB]; } }Eden 按-Xmn10m和SurvivorRatio8算出是 8MB前三块 2MB 刚好填满第四块 4MB 放不下会触发一次 Young GC而如果打开-XX:PretenureSizeThreshold3m它会绕过新生代直接进老年代。注意这个参数只对 Serial 和 Parallel 生效G1 不认这也是很多人照着旧文章调参却看不到效果的原因。把这段代码在本地跑通再去读线上日志晋升行为和 GC 次数就对得上了。4. 排查工具链jps、jstat、jmap、jstack 的上场顺序工具不熟的时候最常见的做法是上来就jstack抓一大坨线程栈翻半天最后什么也没看出来。工具是有顺序的先确认进程和目标再看趋势判断是泄漏还是抖动然后才决定要不要付代价去 dump 或抓栈。顺序反了不仅浪费时间还可能给本来只是慢的应用补上一刀。4.1 jps 与 jstat三十秒内判断是泄漏还是抖动# -l 显示主类全名-v 显示 JVM 参数-m 显示 main 参数 jps -lvm # 每 1 秒采样一次共 10 次看各区域使用率百分比 jstat -gcutil 12345 1000 10-gcutil的输出列里E是 Eden、O是老年代、M是元空间都是百分比YGC/YGCT是年轻代回收次数与累计耗时FGC/FGCT是 Full GC 的。判断逻辑很直接如果O单调上升、FGC同步增长且FGCT累计时间肉眼可见地涨那是泄漏或老年代容量不足如果O在固定区间内来回锯齿、FGC长期为 0只是YGC很密那是分配速率大或新生代太小方向完全不同。需要看绝对容量时换jstat -gc它能给出各区域的当前容量、已用容量和本次回收耗时配合公式估算晋升速率。这一步的价值在于花三十秒就能把「要不要 dump」这个决策做掉而不是凭感觉。4.2 jmap 与堆快照什么时候该 dump什么时候千万别 dumpjmap -histo是最轻的对象直方图用来粗看谁占得多# 不带 :live 只扫一遍包含已死对象读数偏大但基本不停顿 jmap -histo 12345 | head -30 # 带 :live 会先触发一次 Full GC得到的是存活对象真实占用但会 STW jmap -histo:live 12345 | head -30这两条命令的差别在生产上非常关键。前者可以随手用后者等于主动制造一次 Full GC堆大的时候停顿能到秒级。同样地heap dump 在超大堆上耗时几十秒写入磁盘的 IO 还会挤占业务一定要在低峰期做或者干脆依赖HeapDumpOnOutOfMemoryError的自动导出。快照拿到之后分析顺序是「先看总量再看增量先看支配树再看引用链」。总量对比两次不同时间的 dump 能立刻区分泄漏和正常波动支配树能告诉你「回收掉这一个对象能释放多少内存」比按类统计更贴近真实收益最后顺着 shortest path to GC Roots 找到是谁在持有通常就是某个静态 Map、某个未关闭的监听器、某个 ThreadLocal 没 remove。4.3 jstackCPU 打满与线程卡死的定位套路CPU 100% 的标准动作是「线程号转十六进制再回查」# 1. 按 CPU 倒序列出进程内的线程 top -Hp 12345 -b -n 1 | head -20 # 2. 把十进制线程号转成十六进制jstack 里的 nid 是这个格式 printf %x\n 12389 # 3. 抓栈并定位那个 nid假设得到 306d jstack -l 12345 /tmp/stack-$(date %s).txt grep -A 30 nid0x306d /tmp/stack-$(date %s).txt-l会额外打印锁的附加信息包括Locked ownable synchronizers。要点是抓三次、间隔五秒看同一个 nid 是否反复出现在 RUNNABLE 且栈顶落在同一个方法上——一次快照可能只是恰好路过。死锁和锁竞争要分开看。jstack -l会在末尾自动打印Found one Java-level deadlock那是真正的循环等待而大量线程处于BLOCKED状态、卡在同一个waiting to lock 0x...地址上那是热点锁竞争通常是 synchronized 方法里塞了远程调用或者日志 IO。两者的处置方式完全不同前者要打破循环依赖或统一加锁顺序后者要把临界区缩小或换成分段锁。看到线程数量多就下「死锁」结论是很容易翻车的一步。4.4 JFR 与可视化工具低开销的持续观测线上问题往往不可复现最有价值的其实是「出问题那几十秒的连续记录」。飞行记录器就是干这个的# 启动即录采样配置用 profile 更细默认 duration 到期自动落盘 java -XX:StartFlightRecordingduration120s,filename/tmp/app.jfr,settingsprofile -jar app.jar # 对已在运行的进程追加录制 jcmd 12345 JFR.start duration60s filename/tmp/rec.jfr开销通常在 1%2%能同时看到 GC 事件、线程状态迁移、锁等待、异常抛出和方法采样相当于一个常开的黑匣子。用配套的可视化工具打开 jfr 文件时间轴上一眼就能把「接口变慢」和「Full GC」对齐。相比之下JConsole 和 VisualVM 更适合短时观察长期挂在生产上要注意 attach 带来的额外开销。5. 避坑清单JVM 排查里最容易翻车的五个动作有些坑不是知识不够是手太快。下面五条都是事后复盘时最容易拍大腿的每条都能对上一种具体的日志或现象。5.1 坑一容器里 -Xmx 顶到 limit进程被 OOMKilled现象是 Pod 反复重启、退出码 137、JVM 侧既没有 OutOfMemoryError 也没有 heap dump日志干净得可疑。原因很朴素容器 limit 设 1000Mi-Xmx也设 1000m但 JVM 的内存不止堆——元空间、线程栈、JIT 代码缓存、GC 自身的辅助结构、直接内存加起来还要几百兆超了 cgroup 限制就被内核直接杀掉JVM 连打印的机会都没有。解决办法是给堆留出余量用百分比而不是绝对值# 堆最多用到容器 limit 的 70%其余留给元空间、线程栈、代码缓存与直接内存 java -XX:MaxRAMPercentage70.0 \ -XX:MaxMetaspaceSize256m \ -XX:MaxDirectMemorySize256m \ -jar app.jarMaxRAMPercentage依赖容器感知JDK 8u191 之后默认开启更老的版本要手动加-XX:UseContainerSupport否则 JVM 会把宿主机内存当成可用内存参数再漂亮也没用。5.2 坑二大堆上直接 jmap -dump:live 把应用卡成假死现象是执行 dump 之后接口 P99 从 80ms 涨到数秒监控上出现一条明显的长停顿持续时间正好等于 dump 耗时。原因是-dump:live会先做一次 Full GC 标记存活对象再遍历写出堆到 16G 以上几十秒很正常。正确姿势是优先用jcmd GC.heap_dump它不强制 Full GC实在需要存活快照就避开高峰、先摘流量。另外千万别在 dump 失败时上jmap -F强制 attach 得到的是不完整甚至损坏的快照分析结论会把你带到更远的地方。5.3 坑三GC 日志参数照抄旧版本应用直接起不来现象是升级 JDK 后启动脚本报Unrecognized VM option PrintGCDetails一行参数卡住整次发布。原因是 JDK 9 起的统一日志框架把旧参数移除了不是弃用而是移除。解决是把-XX:PrintGCDetails-Xloggc这一套整体替换为-Xlog:gc*:file...并且不要新旧混用混用时的行为在不同小版本上并不一致排查起来更痛苦。5.4 坑四jstack 说没有死锁其实是锁竞争现象是jstack -l没有输出Found one Java-level deadlock但接口超时率上升、吞吐掉了一半。原因是死锁要求循环等待而大量线程BLOCKED在同一个监视器地址上是热点锁竞争工具不会把它标成死锁。定位方法是统计# 有多少线程卡在 BLOCKED jstack 12345 | grep -c java.lang.Thread.State: BLOCKED # 看是卡在同一个锁地址上还是分散在多个锁上 jstack 12345 | grep waiting to lock | sort | uniq -c | sort -rn | head如果几十个线程的 nid 各不相同但waiting to lock后面是同一个0x开头的地址结论就明确了把这段临界区里耗时的部分远程调用、日志刷盘、序列化挪出去。5.5 坑五元空间不设上限容器内存悄悄上涨现象是堆使用率平稳、GC 也正常容器内存却一天天往上涨最终被 OOMKilled 或者报 Metaspace 溢出。原因是动态生成类持续增加而MaxMetaspaceSize默认不限更隐蔽的情况是热部署或自定义类加载器没有释放旧加载器连同它加载的类一起常驻。解决是给它封顶并加监控告警同时用jcmd GC.class_stats找出膨胀的加载器。这一类问题的验证方式很直接重启后元空间归零、随后又沿同一斜率上涨基本可以确认是类加载器泄漏而非业务数据。6. 三分钟定位我的 JVM 故障处置清单真出事的时候没人有空翻文档所以我习惯把判断压成一张表从上往下走每一步都只回答一个问题是不是这类问题下一步做什么。现象第一条命令重点看什么下一步应用挂了日志有 OOMgrep OutOfMemoryError 日志报错后缀是 heap、Metaspace 还是 Direct按第 2 章对应小节处置接口周期性变慢jstat -gcutil PID 1000 10O 是否单调涨、FGC 是否同步增长涨则查泄漏不涨则查新生代与分配速率CPU 打满top -Hp PIDprintf %x同一 nid 是否反复在栈顶抓三次 jstack 对齐内存缓慢上涨但堆平稳jcmd PID VM.native_memory summaryClass、Thread、Internal 哪个在涨对应查元空间、线程数、直接内存启动即失败看启动日志首行是否为 Unrecognized VM option按 JDK 版本替换参数容器反复重启无日志kubectl describe/ 退出码是否 137被 SIGKILL核对 limit 与 -Xmx 比例走这张表有两个前提值得单独强调。第一是参数先于工具HeapDumpOnOutOfMemoryError、-Xlog:gc*、MaxRAMPercentage这三行应该在服务上线时就写进启动脚本而不是等出事再补因为很多故障只有一次现场错过了就只能靠推演。第二是一次只改一个变量调完-Xmx又顺手换了收集器问题如果没解决你连是哪一步起了反作用都说不清调完参数必须用日志里的 Young GC 次数、Full GC 次数和平均停顿三项指标做前后对比没有对比数据的「优化」只是换了个玄学。还有个小习惯所有 dump 文件、GC 日志、jstack 输出都按「进程名-时间戳-类型」命名并集中存放事后复盘时能把同一时刻的堆、线程、GC 三条线对齐看。我踩过最亏的一次是线上卡顿三天最后发现线程栈和 GC 日志的时间对不上——日志被轮转覆盖了一半等于看了个残卷。从那以后我每次上线新服务都会先把堆转储、GC 日志、容器内存比例这三件事过一遍再跑一轮压测看日志读数是否和预期一致。这十分钟的功夫比半夜被叫起来强太多。希望帮到你。本文还有配套的精品资源点击获取