JVM深入解析:内存布局、GC调优与线上问题排查实战

📅 发布时间:2026/10/21 17:36:10
JVM深入解析:内存布局、GC调优与线上问题排查实战
JVM虚拟机这个词凡是写Java的兄弟应该都绕不开。从校招面试到日常工作内存模型、GC调优、类加载这些概念几乎被翻来覆去地问但说句实话能把原理讲明白、又能拿它解决线上问题的人真不多。很多人倒背如流“堆栈方法区”一遇到线上Full GC频繁、CPU飙高照样手足无措。这篇文章我不打算整一份教科书式的知识堆砌而是站在一线开发视角把JVM的运行时内存布局、垃圾回收、类加载机制、JIT编译这些核心机制拆开讲透同时穿插真实排查案例和调优参数推导过程。内容偏长但每一步都尽量说明背后的为什么。如果你刚入行可以当一份体系化学习路线如果你已经有三五年经验重点看调优思路和问题排查部分应该会有共鸣。1. JVM到底是什么从一个OOM事故说起1.1 一次线上事故看到“OutOfMemoryError”之后某天下午值班某支付系统的日志查询服务突然告警接口超时率直线上升。我登上去一看日志里密密麻麻堆着java.lang.OutOfMemoryError: Java heap space服务基本处于半瘫痪状态。第一反应是重启但重启只能缓解几分钟问题必然复现。于是边保留现场边排查jmap导出堆dump用MAT分析发现一个静态Map疯狂堆积业务对象定位到某段缓存代码缺少淘汰策略。这个问题的本质很简单就是代码在无限消费内存。从这里就能看出JVM真的不是“面试八股”那么简单。它决定了你的Java程序能不能稳定跑在线上决定了出问题时你能不能快速定位决定了你在设计高并发系统时对内存和GC的判断是否精准。JVM即Java Virtual Machine是Java程序运行的虚拟机环境核心工作就三块屏蔽底层操作系统差异、自动管理内存、动态编译执行字节码。理解这三块才算摸到门。1.2 JVM的三层职责跨平台、内存管理、编译执行先聊跨平台。Java源码编译后生成的不是机器码而是字节码.class文件。字节码通过JVM解释成具体平台的机器指令执行这就是“一次编译、到处运行”的来路。Windows、Linux、macOS各有对应的JVM实现只要你装对了JDK版本同一份jar包到处能跑。再聊内存管理。JVM负责给对象分配内存、追踪对象引用、回收不再使用的对象。开发者不写delete、不写free这部分脏活累活由GC完成。但正因为它替你做了很多人反而对内存失控——不知道对象什么时候被回收、什么时候堆会爆、不同对象放在哪个区域。所以后续章节我会把内存布局和垃圾回收详细展开。最后是编译执行。早期JVM只做字节码解释执行性能很一般。后来引入了JIT即时编译器对“热点代码”做编译优化把字节码编译成高效的本地机器码。从HotSpot的名字就能看出来这个虚拟机设计理念就是“让热点代码运行得更快”。到了JDK 9之后默认开启分层编译C1负责快速编译C2负责深度优化两者协同。1.3 学习路径怎么排先内存后GC再类加载与编译我见过不少朋友一上来就死磕G1日志参数结果连堆里Eden和Survivor是干嘛的都没搞清效果自然很差。我给的学习顺序是先理解运行时内存布局知道对象生存在哪里、栈和堆如何交互再学垃圾回收掌握判定死亡、分代收集、常见收集器接着是类加载机制搞懂.class怎么变成可运行对象、为什么有双亲委派然后是JIT编译与性能优化最后回归实战用工具排查线上问题。这个顺序是一环扣一环的没有内存布局的基础GC调优根本听不懂没有GC知识看类加载就是纯背概念。2. 运行时内存布局堆、栈、方法区的完整地图2.1 六大运行时数据区各自干什么、谁会OOMJVM运行时内存按线程共享还是线程私有来划分总共六大块。线程私有的有程序计数器、虚拟机栈、本地方法栈线程共享的有堆、方法区JDK 8后实现为元空间、运行时常量池。程序计数器是线程私有的行号指示器记录当前线程执行字节码的位置。它是唯一不会抛OutOfMemoryError的区域因为占用的内存极小生命周期就是线程的创建到销毁。有人把它理解成“当前执行到第几行”的计数器在方法调用、循环、异常跳转时都靠它恢复执行位置。虚拟机栈最容易被新手搞混。每个线程创建时都会分配一个栈里面存的是栈帧。每个方法从调用到结束对应一个栈帧的入栈和出栈。栈帧里保存局部变量表、操作数栈、动态连接信息、方法返回地址。局部变量表里存的不是对象本身而是引用句柄或直接指针和基本类型值。栈深度超出JVM允许范围时会抛StackOverflowError比如无限递归如果栈支持动态扩展但内存不够才会OOM。本地方法栈给native方法用HotSpot直接把本地方法栈和虚拟机栈合二为一了。堆是GC的主战场也是大家最熟悉的一块区域。所有对象实例和数组都是在这里分配内存。堆在物理上可以不连续逻辑上连续即可。从JDK 8开始字符串常量池被移到了堆中原来是在方法区的运行时常量池里。堆如果不设置大小默认按物理内存的一定比例分配生产环境一般要显式配置。方法区在JDK 8之后改叫元空间Metaspace存储类元信息、字段方法描述、静态变量、常量池等。注意它不再是JVM堆内存的一部分而是利用本地内存。以前永久代经常因为ClassLoader泄漏或动态代理类太多导致PermGen spaceOOM换成元空间后默认只受本地内存上限约束。运行时常量池就在方法区里保存编译期生成的字面量和符号引用。2.2 对象从new到回收一次完整旅程理解对象的一生比死记硬背分区定义有用得多。一个对象从new出来到被GC回收大致经历这么几步。首先是类加载检查。虚拟机遇见new指令会先去常量池定位这个类的符号引用检查类是否已加载、解析、初始化过。没加载就先触发类加载。然后是分配内存分配方式取决于堆是否规整。如果采用带压缩指针的Serial、ParNew等带紧凑能力的收集器堆内存是规整的用“指针碰撞”方式直接挪动分界指针如果堆不规整比如CMS就得用“空闲列表”维护可用内存块。为避免线程安全问题HotSpot默认给每个线程预留一块TLABThread Local Allocation Buffer线程本地分配缓冲在TLAB上分配可以做到无锁。TLAB空间不够时再在堆中直接分配。内存分配完成后JVM会把分配到的内存空间初始化为零值保证对象的实例字段在没用构造器赋值之前就能直接读取默认值int是0、boolean是false、引用是null。接着是设置对象头。对象头里存放hashCode、GC分代年龄、锁状态标识、类型指针等。新对象年龄为0每熬过一次Minor GC年龄加1。最后执行构造方法也就是init指令按程序员写的构造逻辑初始化。到这里对象才算真正可用。之后就是它的生命周期管理。一个普通对象大概率在Eden区出生Eden区满了触发Minor GC存活对象被复制到From Survivor区年龄加1再次GC时From和To交换存活对象在两个Survivor区间来回复制年龄继续累加。当年龄超过-XX:MaxTenuringThreshold默认15或者Survivor区放不下、动态年龄判断触发对象就晋升到老年代。大对象超过-XX:PretenureSizeThreshold直接进老年代避免在Eden区和Survivor之间来回复制浪费性能。这里有个实际经验很多人调参只盯堆大小忽略了Survivor区比例和晋升阈值。实际上Eden区太大Minor GC间隔变长但每次GC存活对象多、复制开销大Survivor太小对象容易提前晋升老年代导致后续Full GC。最理想的状态是Minor GC之后存活对象能舒适地待在Survivor区里。2.3 堆内存参数怎么配推导过程与常见误区堆参数是最常被“抄作业”的地方。很多人直接问别人线上-Xmx多少然后复制走。这其实很危险。我建议按这个思路推导。假设某服务高峰期QPS是5000平均每个请求在业务处理时创建的临时对象约64KB请求平均响应时间200ms。那么同时活跃请求约1000个活跃对象约64MB。再考虑缓存、连接池、静态数据等常驻对象假设200MB。那堆大小至少需要64MB 200MB也就300MB左右。可这是“理论存活”GC还需要内存支撑复制算法和碎片整理所以实际建议-Xmx和-Xms拉到512MB以上。你问我512MB够不够还要看GC停顿时间、Survivor存活率并非拍脑袋定的。常见参数组合是-Xms等于-Xmx避免运行期堆扩容触发STW-Xmn设置新生代大小一般是堆的1/3到1/4-XX:SurvivorRatio8表示Eden : Survivor 8 : 1 : 1-XX:MaxMetaspaceSize限制元空间防止类元数据无限膨胀-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs能在OOM时自动导出堆快照这个必开线上排障全靠它。-Xms512m -Xmx512m -Xmn256m -XX:SurvivorRatio8 -XX:MaxMetaspaceSize256m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs -XX:PrintGCDetails -XX:PrintGCDateStamps误区一堆越大越好。实际上堆过大会让单次GC扫描时间变长尤其老年代回收停顿时间显著上升。误区二完全忽略-Xmn。很多团队只设-Xmx不设-Xmn结果默认新生代占了整个堆的1/3左右对一些朝生夕灭的对象来说非常浪费。误区三盲目调低年轻代。新生代太小短期对象频繁晋升老年代老年代撑不住就一直Full GC性能反而崩掉。3. 垃圾回收从Serial到ZGC的演进与调优实战3.1 判断对象是否该死可达性分析里的门道GC第一步是判断对象还能不能被引用到。引用计数法算是最直观的思路每个对象记录被引用的次数次数为零就回收。但循环引用场景直接让引用计数法失效比如A引用B、B引用A外部没人引用它们两者计数却是1永远回收不掉。主流的HotSpot用的是可达性分析Reachability Analysis从一组称为GC Roots的对象出发沿着引用链向下搜索凡是走不到的对象都判定为可回收。GC Roots指的是这些源头对象虚拟机栈中局部变量表引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象、被同步锁持有的对象。这里有个容易踩的坑一个对象被某个存活对象引用但它本身没有GC Roots可达路径照样会被回收。很多内存泄漏就是“长命对象”背后引用了一堆“短命对象”让它们永远可达。排查起来一定要看引用链的头部是谁。在代码层面Java提供了四种引用强度。强引用就是new出来的普通引用GC绝不回收软引用SoftReference在内存即将溢出前会被回收适合做缓存弱引用WeakReference每次GC都可能被回收适合实现弱缓存和临时键值映射虚引用PhantomReference不能直接获取对象主要用于对象回收后的资源清理通知。了解这四种引用对设计缓存和池化结构非常有帮助。3.2 主流收集器选型对比CMS、G1、ZGC怎么取舍分代收集理论是绝大多数收集器的基础新生代对象“朝生夕灭”用复制算法效率高无碎片老年代对象存活率高、占比大用标记-清理或标记-整理。Serial是单线程收集器早期客户端模式默认选择GC时所有工作线程暂停Stop The WorldSTW。ParNew就是把Serial的多线程版本配合CMS使用。Parallel Scavenge关注吞吐量用户代码运行时间占总时间的比例适合对停顿不敏感的后台计算任务。CMS是第一个真正意义上的并发收集器目标是低停顿标记-清除算法但会产生内存碎片且在并发清除阶段可能触发Concurrent Mode Failure导致Full GC。JDK 9开始官方建议弃用CMS转而使用G1。G1把整个堆划分为若干大小相同的Region默认最多2048个大小为1MB到32MB分代概念变成了逻辑上的每个Region可能属于Eden、Survivor或Old。它可以预测停顿时间通过-XX:MaxGCPauseMillis控制目标停顿默认200ms。G1的Mixed GC会把年轻代回收和部分老年代Region回收一起做每次选择你期望的GC开销下收益最大的Region组合这个思路叫“优先回收高价值区域”。从JDK 9开始G1是默认收集器。ZGC则是面向大堆低延迟的收集器目标是TB级堆且STW不超过10ms。它依赖染色指针把引用信息直接编码在指针的高位里还在对象布局里加入转发指针。ZGC几乎是全并发回收GC阶段只有极少数很小的STW。JDK 15之后ZGC转正原生支持分代。选型时记住一句话小堆用Parallel也没问题中等堆优先G1超大堆且延迟敏感用ZGC。收集器回收算法优势适用场景Serial / Serial Old复制 / 标记-整理简单、无额外线程开销单核小内存、嵌入式、客户端Parallel Scavenge / Parallel Old复制 / 标记-整理高吞吐量最大化用户运行时间后台批处理、计算密集服务CMS标记-清除并发低停顿中型服务端堆被G1逐步替代G1Region化混合回收可预测停顿兼顾吞吐JDK 9默认选择通用服务端ZGC染色指针并发回收超大堆亚毫秒级停顿大内存、低延迟金融交易平台3.3 一次Full GC频繁的调优记录有一次我给某电商购物车的服务做性能治理现象是CPU使用率持续高位监控面板的Full GC曲线每隔几分钟就拉高一截。我先把堆dump拉下来看jmap -dump:live,formatb,fileheap.hprof导出然后用MAT分析大对象直方图。内存最多的不是业务实体而是一个日志场景里的临时JSON对象而且被放到一个静态ThreadLocal里反复追加导致对象无法释放。代码修完后我又调了参数。当前堆是2GB新生代默认偏小临时对象本应在年轻代就回收但因为晋升阈值判断很多对象提前进了老年代。我把-Xmn调到768MB-XX:MaxTenuringThreshold从15降到6让存活不久的对象尽量在新生代被清掉减少老年代压力。配合把-XX:UseStringDeduplication打开去重重复的字符串对象。上线后Full GC从每5分钟一次变成一天几次吞吐明显改善。这个案例给的经验是调GC前十有八九先排查代码里的持强引用、ThreadLocal滥用、缓存无上限。代码有问题光调参数只是把爆炸时间往后推。参数层面调优的核心思路是让短期对象死得越快越好长期对象尽早晋升、少被复制。3.4 GC日志怎么看字段含义与关键指标GC日志是调优的眼睛。JDK 8老式参数用-XX:PrintGCDetails -XX:PrintGCDateStampsJDK 9之后统一用-Xlog:gc*。日志里你会看到[GC (Allocation Failure) [PSYoungGen: 61440K-8191K(71680K)] 61440K-56320K(232448K), 0.0123456 secs]这段意思是年轻代GC原因是Eden区分配失败。年轻代从61440K降到8191K但是整个堆从61440K只降到56320K说明大量对象从年轻代晋升到了老年代。你看这个数字差就意识到“Survivor太小对象保不住”。Allocation Failure是触发GC的常见原因含义是新对象要分配内存但Eden区已经满了。[Full GC (Metadata GC Threshold) [Metaspace: 34818K-34818K(1069056K)] ...则表示元空间接近阈值触发Full GC。如果频繁出现要么类加载器泄漏要么字节码动态生成太多比如大量使用cglib或反射生成代理类。看GC日志时重点盯三个指标Minor GC频率、每次GC后的晋升大小、Full GC停顿时间。如果Minor GC频率很高但每次回收之后堆占用没降下来说明新生代比例不合理或晋升量过大如果Full GC频繁但回收前老年代占用并不高多半是元空间触发或显式System.gc调用需要加-XX:DisableExplicitGC慎用要配套确认没有代码依靠System.gc做内存规整。4. 类加载机制双亲委派为什么难打破4.1 加载、连接、初始化类的一生Java类从字节码到可运行对象要经过加载、连接验证、准备、解析、初始化三个阶段。加载阶段类加载器根据类的全限定名读取二进制字节流把字节流转化为方法区的运行时数据结构并生成这个类的Class对象。一个类的唯一性由“类加载器 全限定名”共同决定两个不同的类加载器加载同一个class文件得到的Class对象不是同一个instanceof结果也会不符。连接阶段里验证是为了字节码安全检查版本、类结构、语义、符号引用等准备阶段为类的静态变量分配内存并设置零值注意不是赋初始代码值而是默认值常量会直接赋初值解析阶段把符号引用替换为直接引用比如把接口方法名替换成实际的内存地址或句柄。初始化阶段会执行clinit方法把所有静态变量的赋值语句和静态代码块按顺序包起来。初始化时机有严格要求遇到new、getstatic、putstatic、invokestatic四条字节码指令时如果类没初始化先触发reflect反射调用要触发当父类未初始化时先初始化父类如果接口定义了默认方法实现类初始化前先初始化接口JVM启动时主类必须初始化。这里和final常量有个细节如果常量是编译期常量基本类型和String字面量使用它的类不会触发初始化因为值已经在编译期写入了常量池。4.2 双亲委派模型保障类一致性的关键JVM的类加载器有层级关系启动类加载器Bootstrap ClassLoader负责加载$JAVA_HOME/lib下的核心类如Object、String用C实现Java程序里拿不到扩展类加载器Extension/Platform ClassLoader加载lib/ext或指定扩展目录应用类加载器Application ClassLoader加载classpath中的自定义类。三者默认是父子关系。双亲委派的意思是一个类加载器收到加载请求时先把这个请求委派给父加载器。一直向上传递父加载器能加载就父加载器加载加载不了才往下返回。这样做最大的好处是保证核心类库的一致性比如你写了一个java.lang.String应用类加载器会把请求交到启动类加载器启动类加载器找到标准的String你的“假String”永远不会被加载。同时避免重复加载同一个类自上而下只被加载一次。那为什么说“难打破”因为双亲委派是loadClass方法的默认行为你要打破它就得重写loadClass一旦搞错核心类可能被加载成不同版本类之间直接互相不认。历史上Tomcat为了做Web应用隔离自己实现了WebAppClassLoader先加载Web应用自己的类再委派给父加载器这是对“应用类优先”的一种打破JDBC驱动的SPI机制则用了线程上下文类加载器让父加载器可以回调子加载器去加载数据库驱动。但即便有这些例外JVM核心模型始终靠双亲委派兜底。4.3 自定义类加载器一个隔离插件实战我曾经给某内部工具写过插件隔离功能。需求是第三方插件可以加载自己的类不影响宿主。核心思路很简单继承ClassLoader重写findClass方法通过全限定名读取字节码调用defineClass完成定义。public class PluginClassLoader extends ClassLoader { private final String basePath; public PluginClassLoader(String basePath, ClassLoader parent) { super(parent); this.basePath basePath; } Override protected Class? findClass(String name) throws ClassNotFoundException { String classFile basePath / name.replace(., /) .class; try (FileInputStream fis new FileInputStream(classFile)) { ByteArrayOutputStream bos new ByteArrayOutputStream(); byte[] buffer new byte[4096]; int len; while ((len fis.read(buffer)) ! -1) { bos.write(buffer, 0, len); } byte[] bytes bos.toByteArray(); return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } }使用时要保证父加载器优先宿主程序的类由Application ClassLoader加载插件的类由PluginClassLoader加载两边互不干扰。这样插件和宿主即使依赖同一个库的不同版本也能共存。一个容易被忽略的坑是自定义类加载器加载的类会因为加载器本身被回收而卸载但如果你把这个类加载器放进了静态Map里管理它会变成强引用根插件类永远不会被回收最终导致元空间OOM。所以自定义类加载器务必在使用完后置空引用或用WeakHashMap直接管理。5. 字节码与JIT让热点代码真正飞起来5.1 解释执行到分层编译JIT的演化字节码天然是解释执行的每个方法调用都要一条一条翻译成本地指令性能比编译型语言慢不少。HotSpot的解决方式是JITJust-In-Time编译在运行过程中识别热点方法把字节码编译成机器码下次直接执行机器码不再逐条解释。这就是“HotSpot”名字的由来也是判断类加载时用不用-Xint强制解释模式会有性能差异的原因。JIT的演化经历了从单一编译器到分层编译的过程。早期客户端模式用C1编译器编译速度快但优化水平一般服务端模式用C2编译器优化深度强但编译耗时长可能拖慢启动初期。JDK 7之后引入分层编译C1先快速编译让程序尽快跑起来同时收集统计信息当方法达到热点阈值再用C2做深度优化甚至替换掉C1编译的代码。测试环境下可以用-XX:PrintCompilation观察方法被编译的时间点、编译器类型和编译级别。热点判定依赖计数器。方法调用计数器统计“方法被调用的次数”服务端模式默认阈值10000。当调用次数超过阈值会提交编译请求同时进入解释执行或已编译代码的切换流程。回边计数器统计的是循环体执行次数用于触发OSROn-Stack Replacement——在栈帧内替换正在执行的代码版本。所以一个for循环里跑了大量逻辑即使方法只被调用一次也可能因为回边计数触发编译优化。5.2 逃逸分析、锁消除、标量替换编译器替你做优化JIT最核心的优化是Escape Analysis逃逸分析。它判断一个对象的作用域如果对象只在方法内部被创建和使用没有“逃逸”出方法也没有被外部线程访问那么它有机会做三个优化。第一个是栈上分配。对象不一定非得在堆上如果它不逃逸JIT可以直接在栈帧里分配内存方法结束栈帧出栈就自动“回收”根本不会进GC。第二个是同步消除。如果对象不会逃逸到其他线程那针对这个对象的加锁都没意义JIT可以把锁移除。很多时候我们写synchronized (obj)经过逃逸分析后锁会被直接去掉。第三个是标量替换。对象可以拆解成若干个原始数据类型标量比如一个Point对象含x和y两个intJIT直接分配两个局部变量对象本身就不需要存在了。这些优化对性能影响极大。我在实测一个简单对象数组求和时开启逃逸分析比关闭快了近一个数量级。所以写代码时不用过度担心“创建太多小对象”只要它们不逃逸方法编译器能帮你处理掉。但反过来如果对象被方法返回、被放到集合里或者被静态变量引用逃逸分析就会失效这时候该池化还是得池化。5.3 编译日志与内联诊断怎么观察JIT效果很多开发者不知道还能这么看JIT。加上-XX:PrintCompilation后启动后会输出一行行编译事件方法名、编译级别、字节码大小、编译耗时。加-XX:PrintInlining可以看哪些方法被内联了以及内联失败的原因。内联是JIT另一个重要的优化把被调方法的方法体直接嵌入调用处省去方法调用开销也是其他优化的前提。实际排查时你会发现哪怕是特别小的方法如果调用频繁也有必要让它被内联。方法超过-XX:MaxInlineSize默认35字节且在热点路径上就需要更高级的内联策略或调大参数。比如某海量日志过滤逻辑里有一个isValidLog方法用-XX:MaxInlineSize限制内联上限后性能掉了30%。这类细节不实际去盯编译日志是真的发现不了。工具方面可以用JDK自带的-XX:LogCompilation -XX:LogFilejit.log记录完整编译过程再配合jvisualvm的插件或开源分析工具查看。6. 线上问题排查实战OOM、GC、CPU飙升处置手册6.1 OOM四种典型场景排查步骤先记住一句话OOM是症状不是病因。代码里的强引用泄漏、堆参数不合理、线程数量爆炸都可能表现为OOM。第一种java.lang.OutOfMemoryError: Java heap space说明堆空间不足。排查流程是用jmap -head看堆使用情况确认堆大小和当前占用再用jmap -dump:live,formatb,fileheap.hprof导堆快照用MAT或JProfiler打开看支配树里最大对象是谁、大对象直方图Top N再追引用链找泄漏源头。线上I/O密集型服务常见情况是响应数据ByteBuffer没释放、请求对象被缓存、ThreadLocal里塞了大对象。第二种GC overhead limit exceeded。这是花98%以上的时间做GC但回收不到2%的堆空间。本质是内存泄漏导致GC一直徒劳。这个报错出现时堆dump本身就很难导出来建议先jmap -dump:live强制先做一次Full GC后再导出或者把-XX:-UseGCOverheadLimit临时关掉把现场保住再说。第三种MetaspaceOOM。元空间满了通常是动态生成类太多或自定义类加载器泄漏。用jstat -gcutil看Metaspace占用趋势配合-XX:TraceClassLoading和-XX:TraceClassUnloading来找是哪个类被疯狂加载。很多运行期依赖反射生成代理类的框架例如老版本cglib、ASM操作不当都容易中招。第四种unable to create new native thread。这是操作系统线程数达到限制。Linux下线程创建失败未必是内存问题可能是用户进程最大线程数受限ulimit -u或者线程栈默认大小太大导致虚拟内存耗尽。排查时ulimit -a看限制数jstack -l统计线程状态重点看是不是疯狂创建线程且不回收。病根大概率是线程池没有拒绝策略或连接池没设置上限。6.2 GC频繁与CPU飙升的定位链路GC高频会直接表现为CPU飙升因为GC线程本身就是CPU消费者。定位链路的正确姿势是先top看CPU高占用进程再top -Hp PID看进程内线程把CPU最高的线程号转十六进制printf %x\n PID用jstack导线程栈后搜索这个十六进制线程号看它停在哪个方法上。如果停在GC线程上说明CPU都给垃圾回收了转向分析GC日志和堆使用如果停在业务线程上说明代码存在热点死循环或锁竞争继续看调用栈深挖。举一个实际案例某后端服务CPU飙到90%以上top -Hp看到多个GC线程占满CPU。jstat -gcutil 1234 5000 20显示老年代占用从70%缓慢爬升Full GC频率从每小时一次变成每5分钟一次。堆dump出来发现某个报表服务的静态缓存Map有序地累积了上千万条记录但业务代码认为“反正每天重启会清空”结果线上跑了三天就爆了。清理代码后CPU立刻下降。6.3 常用诊断命令速查表命令作用典型用法jps -l列出Java进程PIDjps -l找到目标进程号jstat -gcutil 1234 5000每5秒输出GC占用统计看GC频率和老年代趋势jmap -heap 1234打印堆摘要看堆各区域容量和占用jmap -histo:live 1234打印存活对象直方图快速定位对象Top Njmap -dump:live,formatb,fileheap.hprof 1234导出堆快照配合MAT分析泄漏jstack -l 1234 thread.log导出线程快照定位死锁、锁竞争、死循环jcmd 1234 GC.heap_dump /data/heap.hprof动态导出堆JDK 8推荐避免jmap阻塞top -Hp 1234按线程维度显示CPU定位哪个线程耗CPU需要留意的是jmap -dump默认会触发一次Full GC对在线服务有停顿影响生产环境建议在低峰操作或用jcmd配合-XX:HeapDumpOnOutOfMemoryError事前开启自动导出。另一个习惯是打印GC日志要常态化不需要等到出问题再加参数那样很可能漏掉关键现场。日志轮转参数-Xlog:gc*:filegc.log:time,uptime,level:filecount10,filesize10m能滚动保留避免日志占满磁盘。我在实际使用中体会最深的一点是排查线上问题永远不要把单个命令当银弹要组合起来看。jmap告诉你堆里有什么jstack告诉你线程在干什么GC日志告诉你垃圾回收的节奏三者对在一起才能定位到真正的根因。调整参数也切忌一次动太多改一个参数、观察一段时间的GC曲线再动下一个。JVM调优本质上是在吞吐量、停顿时间、内存占用、代码复杂度之间找平衡没有一组参数适用所有系统。只要理解了每个参数背后的代价你就能为你的服务找到当前条件下最稳的组合。后续如果你们系统升级到新版本JDK强烈建议把ZGC找个非核心服务灰度试一下实测数据往往比别人告诉你“ZGC效果好”更真实。