JVM内存模型、垃圾回收与性能调优实战详解

JVM内存模型、垃圾回收与性能调优实战详解 1. 项目概述为什么我们需要深入理解JVM干了这么多年Java开发我越来越觉得JVMJava虚拟机就像是你家房子的地基和承重墙。平时写业务代码你可能感觉不到它的存在但一旦系统出问题——比如内存泄漏导致服务半夜挂掉或者大促时GC垃圾回收频繁导致接口响应飙到好几秒——你就会发现不懂JVM排查问题就像在黑暗中摸索连方向都找不到。这个“JVM详解”项目就是要把这个黑盒子彻底拆开给你看。它绝不仅仅是面试前背的“方法区、堆、栈”那几个名词。我们要搞清楚的是你的每一行Java代码在JVM里到底是怎么“跑”起来的对象是怎么生、怎么死的GC这个“清洁工”什么时候上班、怎么打扫才会不影响你的程序“住户”正常生活还有那些高级话题比如堆外内存直接内存为什么能让Netty、Kafka这些中间件飞起来以及我们到底该怎么给JVM“调参”让它既稳定又能扛住高并发。如果你满足以下任何一点这篇长文就是为你准备的正在被频繁的Full GC困扰的运维兄弟想写出更高效、更节省内存代码的开发工程师或者即将面对那些“刁钻”JVM面试题的求职者。接下来我会结合我踩过的无数个坑和填坑经验带你从内存模型一路走到性能调优实战。2. JVM内存模型运行时数据区的全景地图很多人一提到JVM内存脑子里就是“堆”和“栈”这太笼统了。JVM内存模型Runtime Data Area是一套精细的划分每个区域各司其职理解它们是所有优化的基础。2.1 核心区域深度解析程序计数器这是最小的一块内存你可以把它理解成当前线程执行的“行号指示器”。字节码解释器就是靠它来选取下一条需要执行的指令。它是线程私有的所以多线程切换时每个线程都能知道自己执行到哪儿了。为什么需要它因为CPU时间片是轮转的一个线程执行一会儿就可能被挂起等它再次被调度执行时得从上次中断的地方继续。如果没有这个计数器线程就“失忆”了。Java虚拟机栈这也是线程私有的生命周期与线程相同。它描述的是Java方法执行的内存模型每个方法在执行时都会同步创建一个栈帧。你可以把栈帧想象成一个方法执行的“工作台”。局部变量表存放方法参数和方法内部定义的局部变量。这里存放的是基本数据类型的值int, double等和对象引用reference类型指向堆中对象实例的地址指针。它的容量以变量槽为单位一个slot通常能存放32位的数据long和double这种64位的会占用两个slot。操作数栈方法执行过程中进行算术运算或调用其他方法传递参数时临时存放操作数的地方。比如执行iadd整数加法指令时就需要从操作数栈顶弹出两个整数相加后再把结果压回去。动态链接指向运行时常量池中该栈帧所属方法的引用。因为Java有多态方法调用在编译期无法完全确定需要在运行时将符号引用解析为直接引用。方法返回地址方法正常退出return或异常退出时都需要返回到调用它的位置这个信息就保存在这里。我们常说的“栈溢出”错误StackOverflowError通常就是递归调用层次太深栈帧不断创建把虚拟机栈的空间耗尽了。而如果线程请求的栈深度超过虚拟机允许的最大深度会抛出StackOverflowError如果虚拟机栈可以动态扩展但在扩展时无法申请到足够内存则会抛出OutOfMemoryError。本地方法栈作用和虚拟机栈非常相似区别在于虚拟机栈为Java方法服务而本地方法栈为JVM使用的本地Native方法服务。像Object类中的clone(),hashCode()等方法的底层实现可能就是C/C写的调用它们就会用到本地方法栈。Java堆这是JVM管理的最大一块内存也是GC工作的主战场。几乎所有的对象实例和数组都在这里分配内存。堆是线程共享的因此这里也是并发问题的高发区。堆内存的划分是GC算法的核心依据我们后面会详细讲。方法区它也是线程共享的用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。很多人会把方法区和“永久代”混淆。在JDK 8之前HotSpot虚拟机用“永久代”来实现方法区但这容易导致内存溢出著名的java.lang.OutOfMemoryError: PermGen space。从JDK 8开始HotSpot彻底移除了永久代改用元空间来实现方法区。元空间使用的是本地内存直接内存而不是JVM堆内存理论上只受本地内存大小限制大大降低了溢出的风险。运行时常量池它是方法区的一部分用于存放编译期生成的各种字面量和符号引用。字面量就是代码里直接写的字符串、被final修饰的常量值等。符号引用则包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。当类加载后这些符号引用的一部分会被翻译为直接引用内存地址。注意很多人误以为String s abc;这样的字面量abc是放在堆里的。实际上abc这个字符串对象本身在堆中但它的引用或者说这个字面量是存放在运行时常量池里的。String.intern()方法的核心作用就是动态地将堆中的字符串对象放入运行时常量池JDK 7后是将其引用放入常量池并返回该引用。直接内存这不是JVM运行时数据区的一部分也不是《Java虚拟机规范》定义的内存区域。但它被频繁使用而且可能导致OutOfMemoryError。像NIONew I/O类引入的基于通道与缓冲区的I/O方式可以使用Native函数库直接分配堆外内存然后通过一个存储在Java堆里的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景如网络传输、文件读写中避免在Java堆和Native堆中来回复制数据提升性能。它的分配不受Java堆大小限制但受本机总内存限制。如果忽略这部分内存的消耗也可能导致物理内存耗尽。2.2 从源码到内存一个对象的完整旅程让我们通过一行简单的代码MyObject obj new MyObject();来看看内存是如何协作的类加载JVM遇到new指令首先检查MyObject这个符号引用是否已在常量池中。如果没有则执行类加载过程将类的信息元数据加载到方法区。内存分配类加载检查通过后虚拟机将为新生对象在Java堆中分配内存。分配方式有“指针碰撞”堆内存规整或“空闲列表”堆内存不规整两种。同时虚拟机需要保证线程安全通常采用CAS配上失败重试的方式或者为每个线程预先分配一小块内存TLAB。内存空间初始化分配到的内存空间不包括对象头都初始化为零值。这保证了对象的实例字段在不赋初值的情况下也能直接使用。设置对象头虚拟机要对对象进行必要的设置例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等。这些信息存放在对象头中。执行init方法从虚拟机的视角看一个新的对象已经产生了。但从Java程序的视角看对象的创建才刚刚开始——执行构造函数即init方法按照程序员的意愿初始化对象。建立引用最后将堆中对象的起始地址赋值给栈帧局部变量表中的obj变量。至此一个可用的对象才完全构建出来。3. Java堆与垃圾回收对象的生老病死与城市清洁如果把Java堆比作一座城市那么对象就是城市里的居民和建筑。GC垃圾回收就是这座城市的清洁工和拆迁队负责回收那些已经“死亡”不再被使用的对象所占用的空间让新对象有地方住。3.1 堆的内存划分分代收集的理论基础现代商用JVM如HotSpot的堆内存普遍采用分代收集的设计。其核心思想是不同对象的生命周期差异很大应该采用不同的收集策略。因此堆被划分为以下几个区域新生代新创建的对象优先分配在这里。新生代又分为一个Eden区和两个Survivor区通常称为S0和S1或者From和To。绝大多数对象都是“朝生夕死”的所以新生代是GC主要是Minor GC发生最频繁的区域。HotSpot默认的-XX:NewRatio老年代/新生代是2即新生代占堆的1/3。而-XX:SurvivorRatioEden/Survivor默认是8即每个Survivor区占新生代的1/10。老年代在新生代中经历了多次GC默认15次通过-XX:MaxTenuringThreshold设置仍然存活的对象会被晋升到老年代。此外一些大对象如很长的数组也可能直接进入老年代通过-XX:PretenureSizeThreshold参数控制。老年代的空间通常较大且对象存活率高GCMajor GC/Full GC频率低但每次回收耗时更长。元空间在JDK 8及之后它替代了永久代用于存放类的元数据使用的是本地内存。3.2 对象生死判定如何判断一个对象是垃圾GC要回收垃圾首先得识别哪些对象是“垃圾”——即不可能再被任何途径使用的对象。主要有两种算法引用计数算法给对象添加一个引用计数器每当有一个地方引用它计数器就加1引用失效时减1。计数器为0的对象就是垃圾。这个方法实现简单判定效率高但它无法解决对象间循环引用的问题A引用BB引用A除此之外再无引用但它们的计数器都不为0因此主流Java虚拟机都没有采用它。可达性分析算法这是Java的主流判定方法。它通过一系列称为“GC Roots”的根对象作为起始节点集从这些节点开始根据引用关系向下搜索搜索过程走过的路径称为“引用链”。如果一个对象到GC Roots间没有任何引用链相连则证明此对象不可能再被使用。哪些对象可以作为GC Roots虚拟机栈栈帧中的局部变量表中引用的对象比如当前正在运行的方法中的参数、局部变量。本地方法栈中JNI即Native方法引用的对象。方法区中类静态属性引用的对象比如public static Object staticObj;。方法区中常量引用的对象比如字符串常量池里的引用。Java虚拟机内部的引用如基本数据类型对应的Class对象系统类加载器。所有被同步锁synchronized关键字持有的对象。3.3 垃圾收集算法清洁工的打扫策略确定了垃圾对象接下来就是如何回收。主要有三种基础算法标记-清除算法最基础的算法分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象标记完成后统一回收所有被标记的对象。它的主要不足有两个一是效率问题标记和清除两个过程的效率都不高二是空间问题会产生大量不连续的内存碎片导致以后需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次GC。标记-复制算法为了解决效率问题它将可用内存按容量分成大小相等的两块每次只使用其中一块。当这一块用完了就将还存活着的对象复制到另一块上然后把已使用过的内存空间一次清理掉。这样每次都是对整个半区进行回收分配内存时也就不用考虑碎片问题只要移动堆顶指针按顺序分配即可。HotSpot虚拟机的新生代采用的就是这种算法的改进版将新生代分为一个Eden和两个Survivor区默认8:1:1。每次使用Eden和其中一个Survivor。回收时将Eden和Survivor中存活的对象一次性复制到另一个Survivor空间然后清理掉Eden和用过的Survivor。当Survivor空间不够时需要依赖老年代进行分配担保。标记-整理算法标记过程与“标记-清除”一样但后续步骤不是直接清理而是让所有存活的对象都向内存空间的一端移动然后直接清理掉边界以外的内存。老年代一般使用这种算法因为老年代对象存活率高复制算法的代价会很大。3.4 经典垃圾收集器HotSpot的清洁工团队垃圾收集器是上述算法的具体实现。HotSpot提供了多种选择适用于不同场景。收集器作用区域算法特点适用场景Serial新生代复制算法单线程工作时必须暂停所有用户线程Stop The World。客户端模式简单高效。ParNew新生代复制算法Serial的多线程并行版本。与CMS搭配使用的主流新生代收集器。Parallel Scavenge新生代复制算法多线程目标是达到一个可控制的吞吐量运行用户代码时间 / (运行用户代码时间 GC时间)。后台运算、批处理任务。Serial Old老年代标记-整理Serial的老年代版本。客户端模式CMS的后备预案。Parallel Old老年代标记-整理Parallel Scavenge的老年代版本注重吞吐量。与Parallel Scavenge搭配用于吞吐量优先场景。CMS老年代标记-清除以获取最短回收停顿时间为目标。并发收集低停顿。过程复杂分为初始标记、并发标记、重新标记、并发清除。互联网B/S系统重视服务响应速度。G1全堆整体标记-整理局部复制面向服务端将堆划分为多个大小相等的Region可预测停顿时间模型。大内存、多核CPU服务器替代CMS成为默认收集器JDK9。ZGC/Shenandoah全堆染色指针等新技术超低停顿10ms的并发收集器几乎全程并发。超大堆内存TB级对停顿时间极其敏感的场景。CMS收集器详解与踩坑CMS是我早期项目中最常用的老年代收集器它的“并发低停顿”特性很吸引人但坑也不少。初始标记仅仅标记一下GC Roots能直接关联到的对象速度很快需要“Stop The World”。并发标记从GC Roots的直接关联对象开始遍历整个对象图这个过程耗时较长但可以与用户线程并发执行。重新标记修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段也需要“Stop The World”但时间远比并发标记短。并发清除清理删除掉标记阶段判断的已经死亡的对象。由于不需要移动存活对象所以这个阶段也可以与用户线程并发。CMS的典型问题CPU资源敏感并发阶段虽然不会停顿但会占用一部分线程资源导致应用程序吞吐量降低。默认启动的回收线程数是(CPU核心数 3) / 4当CPU核心数不足4个时对程序性能影响可能很大。无法处理“浮动垃圾”在并发清理阶段用户线程还在运行可能会产生新的垃圾对象这部分垃圾出现在标记过程之后CMS无法在当次收集中处理它们只能留到下一次GC。因此CMS不能像其他收集器那样等到老年代几乎满了再收集需要预留一部分空间供并发收集时程序运行使用。通过-XX:CMSInitiatingOccupancyFraction参数设置触发百分比如68%如果预留空间不够就会引发“并发模式失败”这时JVM会临时启用Serial Old收集器来重新进行老年代收集停顿时间会很长。空间碎片由于使用“标记-清除”算法会产生大量不连续空间碎片。当无法找到足够大的连续空间来分配大对象时会提前触发Full GC。CMS提供了-XX:UseCMSCompactAtFullCollection默认开启参数在Full GC时进行碎片整理但整理过程是单线程的且无法并发停顿时间会变长。G1收集器面向未来的设计G1的设计目标是取代CMS。它不再坚持固定大小和数量的分代区域而是把连续的Java堆划分为多个大小相等的独立区域Region每个Region都可以根据需要扮演Eden、Survivor或老年代角色。G1跟踪各个Region里面的垃圾堆积的“价值”大小回收所获得的空间大小以及回收所需时间的经验值在后台维护一个优先级列表每次根据允许的收集时间优先回收价值最大的Region。这种方式保证了G1在有限的时间内可以获取尽可能高的收集效率。G1的运作过程大致分为初始标记、并发标记、最终标记、筛选回收。其中筛选回收阶段会根据用户期望的停顿时间-XX:MaxGCPauseMillis默认200ms来制定回收计划选择一部分Region进行回收。4. 直接内存与性能调优实战4.1 直接内存绕过堆的“高速公路”直接内存不是JVM运行时数据区的一部分但它对性能的影响巨大。它的分配和回收不受Java堆大小的限制只受本机总内存和处理器寻址空间的限制。为什么需要直接内存传统的基于流的I/O如FileInputStream/FileOutputStream读写文件时数据需要在内核缓冲区和用户缓冲区JVM堆内存之间来回拷贝。一次读写操作可能涉及多次上下文切换和数据拷贝效率低下。 而NIO的DirectByteBuffer所关联的堆外内存可以被操作系统内核直接访问。在进行网络读写或文件读写时数据可以直接在这块内存和物理设备网卡、磁盘之间传输减少了数据在用户态和内核态之间的拷贝次数这就是“零拷贝”技术的核心之一。像Netty、Kafka等高性能框架都大量使用了直接内存。直接内存的分配与回收陷阱 直接内存的分配通过Unsafe.allocateMemory实现回收则需要显式调用Unsafe.freeMemory。DirectByteBuffer自身是一个Java对象在堆中分配当这个对象被GC回收时JVM会通过一个关联的Cleaner对象调用freeMemory来释放堆外内存。这里有个大坑因为堆外内存不受JVM GC管理如果大量创建DirectByteBuffer而不及时GC或者GC压力大来不及回收就可能导致物理内存被耗尽抛出OutOfMemoryError但你的Java堆内存使用率看起来却不高。监控与参数可以通过-XX:MaxDirectMemorySize参数来限制直接内存的大小。如果不指定默认与Java堆的最大值-Xmx一致。监控时除了关注堆内存也要关注进程的常驻内存集RSS可以使用NMTNative Memory Tracking工具进行跟踪-XX:NativeMemoryTrackingdetail然后通过jcmd pid VM.native_memory detail查看。4.2 JVM性能调优从参数到监控的完整链路调优不是玄学而是基于监控数据的目标明确的调整。核心思路是监控 - 分析 - 调整 - 验证。第一步确立调优目标没有目标的调优是盲目的。常见目标有低延迟GC停顿时间短特别是避免长时间的Full GC。例如要求99.9%的GC停顿小于100ms。高吞吐量在单位时间内GC时间占比尽可能小应用程序运行时间占比大。例如要求GC时间占比低于5%。最小内存占用在满足性能要求的前提下使用尽可能小的堆内存。这些目标往往是相互矛盾的需要根据业务特点权衡。电商交易系统可能更关注低延迟而离线的数据分析任务则更关注高吞吐量。第二步必备监控工具命令行工具jps查看Java进程ID。jstat最核心的GC监控工具。例如jstat -gcutil pid 1000 10每秒打印一次GC概况共10次。关注YGC/YGCTYoung GC次数/时间、FGC/FGCTFull GC次数/时间、各分区使用率。jmap生成堆转储快照jmap -dump:formatb,fileheap.hprof pid用于分析内存泄漏或大对象。查看堆内存概要jmap -heap pid。jstack生成线程快照用于分析CPU高、死锁、线程阻塞等问题。可视化工具JConsole / VisualVMJDK自带提供内存、线程、类的实时监控。GC日志这是调优的第一手资料必须开启。参数-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:gc-log-file-path。JDK 9 推荐使用统一日志框架-Xlog:gc*:filegc-log-file-path。通过工具如GCeasy、GCE Viewer分析日志可以清晰看到GC频率、停顿时间、晋升情况等。Arthas阿里开源的在线诊断工具功能强大可以动态跟踪方法调用、查看类加载信息、反编译代码等是生产环境排查问题的利器。第三步常见问题场景与调优参数Young GC频繁表现为YGC次数增长极快。可能原因是新生代太小或者有大量短命对象产生。对策适当增大新生代大小-Xmn或者增大整个堆-Xmx。但注意单次Young GC时间会随新生代增大而变长需要权衡。检查是否存在代码层面的问题如循环内频繁创建临时对象、不当的字符串拼接用在循环内拼接等。对象过早晋升老年代正常情况下对象应该在新生代经历多次GC默认15次才进入老年代。如果发现-XX:PrintTenuringDistribution显示很多对象在很低的年龄比如1或2就晋升了可能是Survivor区空间不足。对策增大Survivor区调整-XX:SurvivorRatio如从8调到6让Survivor变大或者直接使用-XX:NeverTenure/-XX:AlwaysTenure不推荐仅用于测试理解观察。更常见的是调整-XX:MaxTenuringThreshold来提高晋升年龄门槛。Full GC频繁或耗时过长这是最影响服务的问题。可能原因老年代空间不足可能是Young GC后存活对象太多Survivor放不下直接进入老年代“过早晋升”或“大对象”也可能是内存泄漏导致老年代被慢慢填满。对策检查内存泄漏用jmap分析堆快照调整新生代与老年代比例-XX:NewRatio避免创建大对象。元空间/永久代溢出JDK 7之前是PermGen space之后是Metaspace。可能是动态生成类过多如CGLib代理、JSP或加载的类太多。对策增大元空间-XX:MaxMetaspaceSize检查框架是否有类泄露。System.gc()调用某些第三方库或RMI等会触发。对策禁用显式GC调用-XX:DisableExplicitGC但注意使用NIO时Netty等框架可能依赖System.gc()来触发DirectByteBuffer的清理需谨慎。CMS并发模式失败或碎片化如前所述。对策调低-XX:CMSInitiatingOccupancyFraction提前触发CMS GC留更多空间开启碎片整理-XX:UseCMSCompactAtFullCollection并设置每次Full GC后都整理-XX:CMSFullGCsBeforeCompaction0默认为0即每次Full GC都整理。G1调优要点首要目标设置一个合理的期望最大停顿时间-XX:MaxGCPauseMillis200单位毫秒。G1会尽力达成但不保证。Region大小通过-XX:G1HeapRegionSize设置范围1MB到32MB应为2的幂。堆内存越大Region也应相应调大。并发周期触发阈值-XX:InitiatingHeapOccupancyPercent默认45%当整个堆的使用率超过此值时启动并发标记周期。可以适当调低以提早开始标记避免后续回收阶段时间紧张。Mixed GC相关-XX:G1MixedGCLiveThresholdPercent默认85%存活对象占比低于此值的Region才会被选入Mixed GC的回收集合。-XX:G1MixedGCCountTarget控制一次回收过程中的回收次数默认8次增大此值可以让每次回收的停顿更短。第四步一个简单的调优案例假设一个Web应用堆内存设置为4G-Xms4g -Xmx4g默认Parallel Scavenge Parallel Old收集器。监控发现在晚高峰时服务响应时间周期性变长GC日志显示大约每30秒发生一次Full GC每次停顿约2秒。分析每30秒一次Full GC说明老年代在约30秒内就会被填满。可能是每次Young GC后存活对象过多快速晋升到老年代。检查使用jstat -gcutil观察发现每次Young GC后老年代使用率OU都会增长几个百分点。使用jmap -histo查看存活对象发现大量某种业务缓存对象。对策业务缓存对象生命周期较长确实适合放在老年代。但晋升速度太快说明新生代可能太小或者Survivor区太小导致对象“逃”过早晋升。尝试调整新生代比例将新生代调大-XX:NewRatio1新生代与老年代1:1即各2G。调整Survivor区比例-XX:SurvivorRatio6Eden与单个Survivor比例为6:1:1让Survivor更大些。考虑更换为G1收集器其可预测的停顿可能更适合Web服务-XX:UseG1GC -XX:MaxGCPauseMillis200。验证调整参数后重新压测或观察线上流量对比GC日志。目标是Full GC频率显著降低如降到小时级或天级且Young GC停顿时间在可接受范围内。5. 常见问题排查与实战技巧实录理论懂了但线上问题往往千奇百怪。这里记录几个我亲身踩过的坑和排查思路。5.1 CPU占用率飙升但业务流量正常现象服务器监控显示某个Java进程CPU使用率持续100%以上多核但业务接口访问量和响应时间看起来正常。排查top -Hp pid找到占用CPU最高的那个线程ID十进制。将线程ID转换为十六进制printf %x\n thread_id。jstack pid thread_dump.log获取线程快照。在thread_dump.log中搜索刚才转换的十六进制线程ID找到对应的线程堆栈。可能原因与解决死循环堆栈显示线程停留在某个循环或自旋锁中。检查相关业务代码特别是while(true)或for(;;)循环是否有正确的退出条件。频繁的GC如果多个线程的堆栈都在GangWorker::run或类似GC线程的方法上说明GC非常频繁。此时应结合jstat -gcutil查看GC情况大概率是内存问题触发了频繁的Full GC。锁竞争激烈线程堆栈显示大量线程在BLOCKED状态等待同一个锁如synchronized或ReentrantLock。需要优化锁粒度或检查是否有线程持有锁后执行了耗时操作如IO。实操心得CPU高不一定是你写的业务逻辑循环第一时间先用jstack定位线程类型。如果是GC线程问题就变成了内存问题如果是业务线程再去看具体代码。5.2 服务间歇性卡顿但监控曲线平稳现象用户反馈每隔几分钟或几十分钟服务就会卡一下持续几秒。但监控平台上的平均响应时间、CPU、内存曲线都很平稳。排查这种“毛刺”很可能是由GC停顿特别是Full GC引起的。平均指标掩盖了瞬间的高延迟。开启并分析GC日志这是最直接的证据。查看在卡顿时间点附近GC日志中是否有Full GC记录并记录其耗时。检查是否由System.gc()引起有些第三方库或框架如RMI、JMX会定期调用System.gc()。可以在GC日志中搜索 “Full GC (System)” 字样。检查堆外内存如果堆内存使用正常但物理内存持续增长可能是直接内存泄漏。使用NMT或监控进程RSS。解决如果是Full GC按上一章节的思路进行调优。如果是System.gc()可考虑添加-XX:DisableExplicitGC参数禁用但需评估对NIO的影响Netty通常建议与-XX:ExplicitGCInvokesConcurrent配合使用。如果是堆外内存检查代码中DirectByteBuffer的创建和释放或调整-XX:MaxDirectMemorySize。5.3 内存泄漏的定位老年代使用率只升不降现象老年代使用率OU随着时间推移持续缓慢上升即使触发Full GC后使用率也只是小幅下降随后继续上升最终导致频繁Full GC。排查这是典型的内存泄漏症状即有些对象已经不再使用但由于被错误的引用持有GC无法回收。生成堆转储文件在内存使用率较高时使用jmap -dump:live,formatb,fileheap.hprof pid命令导出堆快照。live参数会触发一次Full GC只导出存活对象让分析更聚焦。使用分析工具用Eclipse MAT或JProfiler打开.hprof文件。定位泄漏点查看支配树找到占用内存最大的对象看它被谁引用。运行“泄漏嫌疑报告”MAT提供此功能能快速列出可能泄漏的对象。对比堆快照在不同时间点生成两个堆快照用MAT的对比功能找出持续增长的对象类和引用链。常见泄漏场景静态集合类如static Map cache new HashMap();不断往里放数据从不清理。连接未关闭数据库连接、网络连接、文件流等未在finally块中关闭。监听器未注销注册了事件监听器但在对象销毁时没有注销。ThreadLocal使用不当使用了线程池ThreadLocal变量在用完后未调用remove()导致线程复用时的内存泄漏。5.4 关于JVM面试题的深度思考面试中常问“JVM内存分为哪几个部分”如果你只答出名字那只是及格。能讲清楚每个部分的作用、为什么这么设计、可能出什么问题才是高手。比如为什么要有两个Survivor区这是为了解决内存碎片化并保证新生代复制算法的效率。单Survivor会导致一半空间闲置且复制时碎片问题严重。GC Roots有哪些为什么这些算根这关系到可达性分析的起点理解线程栈、静态变量等为什么是“根”才能理解对象存活的本质。CMS和G1的区别G1为什么能预测停顿时间这涉及到两种收集器完全不同的设计哲学和实现原理。G1的Region划分和回收价值评估模型是其可预测性的基础。理解JVM最终是为了写出更好的代码更高效地解决问题。它不是一个背诵的科目而是一套需要在实际的故障排查、性能优化中不断加深理解的内功。每次线上问题都是对这套理论的一次实战检验。调优没有银弹最好的参数永远是适合你当前业务场景的那一套而找到这套参数的过程就是不断监控、分析、假设、验证的循环。