ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM内存与垃圾回收全链路解析:从对象分配到Full GC排查

JVM内存与垃圾回收全链路解析:从对象分配到Full GC排查 做了这么多年Java后端我越来越发现一个现象很多人张口就能背出“JVM内存模型分为堆、栈、方法区”真到了线上频繁Full GC、接口动不动卡死的时候却不知道从哪下手。面试题背得滚瓜烂熟但一打开GC日志就发懵dump文件出来了也不知道看哪里。说白了JVM内存和GC这套东西靠碎片化知识根本撑不起来你必须把“对象从哪来、怎么分配、什么时候被回收、被谁回收、回收完出现什么问题”这条完整的链路彻底打通才算真正掌握。这篇内容我打算按照一个Java对象从诞生到消亡的完整生命周期来展开把运行时数据区、对象内存布局、可达性分析、三大基础算法、主流垃圾回收器、GC日志解读、OOM排查实战一条线串下来。既照顾想系统学习JVM的初中级开发也适合正在做性能优化和故障排查的同学作为对照手册。我尽量把每个机制的“为什么这样设计”讲清楚而不是只罗列概念——理解了设计动机你面对任何诡异内存问题时才有推导能力。1. JVM内存战场运行时数据区全景剖析1.1 堆Heap所有Java对象的“生死场”堆是JVM管理内存中最大的一块也是GC的主战场。几乎所有对象实例和数组都在这里分配逃逸分析后栈上分配的情况除外后面细说。堆在物理上可以是不连续的内存逻辑上连续即可。从垃圾回收的角度堆被划分为新生代Young Generation和老年代Old Generation新生代又进一步分为Eden区和两个Survivor区S0、S1比例默认是8:1:1可以通过-XX:SurvivorRatio调整。为什么要把堆分代这是经过几十年实践验证的经验法则绝大多数对象“朝生夕灭”存活时间极短。把堆分成不同区域就可以对不同区域采用不同的回收策略——新生代对象存活率低用复制算法最省事老年代对象存活率高用标记-整理或并发标记清理更合适。不分代的话每次GC都要扫描整个堆成本高到没法接受。在参数层面-Xms和-Xmx分别控制堆的初始大小和最大大小。注意一个非常关键的实践点生产环境强烈建议把两者设为相同值原因有两点。第一避免运行期堆扩容带来的性能抖动——扩容本质上是重新申请内存并迁移对象这个操作会引入不必要的停顿第二扩容发生时JVM需要向操作系统申请内存如果物理内存紧张申请过程可能触发系统级别的内存交换服务延迟会明显上升。所以-Xms即-Xmx这是调优里最基础也是很多人最容易忽略的一条。1.2 虚拟机栈与本地方法栈线程的“执行舞台”每个线程创建时都会分配一个虚拟机栈VM Stack栈的生命周期和线程一致里面保存的是一个个栈帧Stack Frame。一次方法调用对应一个栈帧的压入方法返回对应栈帧的弹出。每个栈帧由局部变量表、操作数栈、动态链接、方法返回地址等几部分组成。局部变量表存放基本数据类型boolean、byte、char、short、int、float、long、double、引用类型reference和returnAddress。局部变量表的大小在编译期就确定了所以理论上栈帧大小也是确定的。但注意局部变量表里的引用变量是可以被GC“看见”的——一个对象的引用如果还留在局部变量表里那么这个对象就不会被回收。这里有个非常容易踩的坑栈空间设置。每个线程默认栈大小是1MB不同平台有差异Linux x64下通常是1MB。线程特别多的应用比如高并发的Web服务如果每个线程都默认1MB栈创建2000个线程就意味着2GB的虚拟内存被栈吃掉很容易触发“unable to create new native thread”。这种情况的正确处理方式是评估线程栈的真实深度适当调小-Xss比如256KB或512KB而不是一味加机器内存。递归调用没有出口就会抛StackOverflowError而如果创建线程时无法申请到新的栈空间会抛OutOfMemoryError。这两个异常的含义完全不同前者是栈深度不够后者是系统资源耗尽。1.3 方法区与元空间类元数据的“藏经阁”方法区存什么类的结构信息字段、方法、构造器、运行时常量池、静态变量、JIT编译后的代码缓存。JDK 8之前方法区的实现叫永久代PermGenJDK 8之后被元空间Metaspace取代最大的变化是永久代占用JVM堆内存而元空间使用本地内存Native Memory。为什么当初要费这么大劲把永久代去掉因为永久代大小不好控制。默认情况下永久代上限是有限的但像Spring、CGLIB这类框架运行时会大量动态生成类很容易把永久代撑爆典型报错是OutOfMemoryError: PermGen space。而元空间默认只受本地内存总大小限制类再多也有更充足的空间。另外把字符串常量池从永久代挪到堆、把静态变量挪到堆也让GC在回收这些对象时更统一、更高效。元空间虽然默认无上限但生产环境一定要设置-XX:MaxMetaspaceSize防止框架动态生成类失控把整台机器的内存打爆。同时还要理解-XX:MetaspaceSize的含义它并不是元空间的上限而是触发Full GC的“阈值水位线”。当元空间使用量达到这个值时JVM会认为类元数据膨胀了触发一次Full GC来尝试回收可以卸载的类。所以把MetaspaceSize设得太大或者太小都会影响GC频率一般建议和MaxMetaspaceSize拉开一定差距给Full GC留出反应空间。2. 对象的一生从类加载到内存布局2.1 类加载机制与对象创建的完整流程很多人把“new一个对象”理解得太简单了以为就是分配一块内存。实际上JVM在背后做了一整套流程。第一步是类加载检查。当JVM执行到new指令时它会先检查这个类是否已经被加载、解析、初始化过。如果没有先触发类加载过程。类加载分五个阶段加载Loading、验证Verification、准备Preparation、解析Resolution、初始化Initialization。准备阶段会为静态变量分配内存并设置默认零值初始化阶段才真正执行静态代码块和静态变量赋值。类加载检查通过后JVM开始为对象分配内存。分配方式有两种如果GC回收后堆内存是规整的比如用标记-整理算法就用“指针碰撞”简单说就是移动一个指针来划分可用内存如果堆内存不规整比如用标记-清除算法JVM就需要维护一个空闲列表从中找一块足够大的空间分配给对象。所以你看对象分配方式和堆是否规整强相关也就是和底层GC算法强相关。在高并发场景下多个线程可能同时new对象如果都去抢占同一片堆内存就会产生竞争。JVM的优化方案是TLABThread Local Allocation Buffer线程本地分配缓冲每个线程在Eden区预分配一块私有缓冲区对象先在TLAB中分配TLAB用完了才去Eden区公共区域申请新的缓冲区并通过CAS加锁保证并发安全。开启TLAB后绝大多数对象分配都不需要同步这也是Java并发分配性能的重要保障。可以用-XX:-UseTLAB关闭一般不建议通过-XX:PrintTLAB可以查看TLAB使用情况。2.2 对象的内存布局一次完整的“建筑设计”对象在堆内存中的存储结构可以分为三块对象头Header、实例数据Instance Data和对齐填充Padding。对象头又分两部分。第一部分是Mark Word它记录了对象的运行时数据哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。Mark Word的设计极其精妙它是一块很小的空间却要通过不同状态位存储不同信息所以不同锁状态下它的内容会复用。比如无锁状态下存hashCode和分代年龄偏向锁状态下存线程ID和epoch。第二部分是类型指针Klass Pointer指向方法区的类元数据JVM通过它来确定这个对象是哪个类的实例。如果对象是Java数组对象头里还必须有记录数组长度的一块数据。实例数据就是对象真正存储的业务字段内容存储顺序受字段声明顺序和JVM分配策略影响。对齐填充不是必然存在的它只是占位符——HotSpot要求对象大小必须是8字节的整数倍不足的部分就用对齐填充补上。这里必须提一个高频性能参数指针压缩-XX:UseCompressedOops。64位JVM中如果不用指针压缩一个类型指针要占8字节开启压缩后变成4字节。这意味着在同样堆大小下能存更多对象也更省内存。JDK 8默认开启指针压缩前提是堆大小不超过32GB。堆超过32GB后指针压缩自动失效这就是为什么32GB是一个常见的堆大小分水岭——一旦超过内存没增加多少对象密度反而降了性价比极低。2.3 一个对象是怎么一步步进入老年代的对象多数在Eden区出生但不是所有对象永远待在新生代。转入老年代有四种主要途径。第一年龄阈值。对象每经历过一次Minor GC且存活下来年龄就加1默认在-XX:MaxTenuringThreshold默认15时进入老年代。注意HotSpot并不是严格卡死在15它会根据Survivor区中各年龄对象的占用量做动态年龄判定——如果某年龄段的存活对象总大小超过Survivor区的一半就从该年龄及以上的对象直接晋升。第二大对象直接进入老年代。通过-XX:PretenureSizeThreshold设置阈值超过这个大小的字节数组或大对象直接分配到老年代。这么设计的初衷是避免大对象在Eden区和两个Survivor区之间频繁复制复制大对象的成本太高了。第三Survivor空间不足。当Minor GC结束后Survivor区放不下存活对象时多余的对象会通过分配担保机制直接进入老年代。第四动态年龄判定上面已经说了。理解这条晋升路径特别重要我见过很多GC问题本质就是对象晋升节奏失控——大量本该在新生代就被回收的对象因为Survivor区太小或者年龄阈值设置不合理过早进入老年代最终导致老年代频繁Full GC。这类问题从GC日志里看就是“晋升对象大小”长期偏高。3. 判断对象生死可达性分析与引用体系3.1 为什么不用引用计数法判断对象存活最简单直观的判断方法是为每个对象维护一个引用计数器被引用一次加1引用失效减1计数器为0就判定可以回收。引用计数法实现简单、判定效率高但有一个致命缺陷解决不了循环引用。举个例子对象A持有对象B的引用对象B持有对象A的引用除此之外两者再没有别的地方引用它们。按照引用计数法A和B的计数器都不是0永远不会被回收但逻辑上它们已经是垃圾了这就造成了内存泄漏。所以主流的HotSpot虚拟机都采用可达性分析算法。思路是从一组称为GC Roots的根节点出发通过引用链向下搜索形成一张引用图。那些从GC Roots无法到达的对象就被判定为可回收对象。哪些对象可以作为GC Roots主要有这么几类虚拟机栈栈帧中的本地变量表中引用的对象方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中JNI引用的对象Java虚拟机内部的引用比如基本数据类型对应的Class对象、常驻的异常对象NPE、OOM、系统类加载器所有被同步锁synchronized关键字持有的对象。可达性分析必须在一致性的快照中进行也就是说分析过程中整个对象引用关系不能变化否则结果不准。这就直接引出了“Stop The World”的概念——GC Roots枚举和遍历过程中所有用户线程必须暂停。这也是GC停顿的根本来源之一。3.2 Java四种引用与GC之间的微妙关系Java对引用做了进一步的细分强引用Strong Reference、软引用Soft Reference、弱引用Weak Reference、虚引用Phantom Reference强度依次递减。强引用就是最常见的Object obj new Object()只要强引用还活着GC永远不会回收被引用的对象哪怕OOM。软引用描述“有用但非必须”的对象在内存即将溢出之前JVM会把软引用关联的对象列入回收范围进行第二次回收如果这次回收后内存还是不够才抛OOM。软引用非常适合做缓存比如图片缓存、大对象缓存既利用了对象又不至于把内存撑爆。弱引用的生命周期更短当JVM进行垃圾回收时无论内存是否充足都会回收只被弱引用关联的对象。ThreadLocal的ThreadLocalMap键就是弱引用——但注意值不是弱引用所以ThreadLocal使用不当会造成值和键生命周期不一致最终内存泄漏。虚引用和前面三者完全不同它不影响对象的生命周期也无法通过get方法获取真正对象。它唯一的用途是在对象被回收时收到一个系统通知用于对象回收跟踪。NIO的DirectByteBuffer就是通过虚引用Cleaner机制来在对象回收时触发堆外内存的释放。我给你的实用建议是缓存设计优先用软引用追踪对象生命周期用虚引用而ThreadLocal用完一定要remove不要赌GC。4. 垃圾回收的三大基本算法与分代理论4.1 三种基础算法各自解决什么问题标记-清除算法Mark-Sweep是基础中的基础。分为两个阶段标记出所有需要回收的对象然后统一回收。优点是实现简单缺点有两个一是效率问题标记和清除两个过程效率都不高二是空间问题清除之后会产生大量不连续的内存碎片后续如果要分配大对象明明总空间够但找不到连续内存会触发一次新的GC。标记-复制算法Mark-Copy把内存按容量划分为两块等分区域每次只用其中一块。GC时把存活对象复制到另一块空区域然后一次性清空原区域。优点是实现简单、运行高效不会产生空间碎片代价是内存可用空间缩小了一半。正是基于“新生代对象存活率低”的特点JVM把新生代分为Eden和两个Survivor比例8:1:1把复制算法的空间浪费控制到了10%。标记-整理算法Mark-Compact是面向老年代的改进版。标记过程与标记-清除一致但后续不是直接清理而是让所有存活对象向内存一端移动然后直接清理掉边界以外的内存。这样既避免了碎片问题又不会像复制算法那样浪费空间。代价是移动对象和更新引用需要额外的开销。三种算法没有绝对好坏关键看应用场景。新生代用复制老年代用整理或并发清除这就是分代收集策略的简单概括。4.2 分代收集与跨代引用分代收集理论建立在两条假说上弱分代假说——绝大多数对象朝生夕灭强分代假说——熬过多次GC的对象越来越难死亡。基于这两条JVM才设计了新生代老年代分离的结构。但分代之后又引出一个新问题跨代引用。比如老年代的对象引用了新生代的对象那Minor GC时如果只扫描新生代怎么判断这个新生代对象是否还被老年代引用着如果扫描整个老年代来做可达性分析分代的意义就没了。JVM的解决方案是记忆集Remembered Set和卡表Card Table。卡表是记忆集的一种实现方式它把老年代划分为很多卡片Card每张卡片对应一块内存区域。只要老年代中有对象引用了新生代对象JVM就在写屏障的帮助下把对应卡片标记为脏卡。Minor GC时只需要扫描老年代的脏卡区域而不是整个老年代就能找到跨代引用从而把这个老年代对象也当作GC Roots的一部分。卡表本身也会带来额外开销写屏障会降低赋值操作性能脏卡过多时扫描成本增加。所以在写代码时要合理设计对象引用结构不要密集地让老年代对象频繁引用新生代对象这种代码写多了卡表就会变得巨脏GC扫描开销直线上升。4.3 理解STW停顿的本质Stop The World指的是一种全局暂停现象GC过程中JVM会暂停所有用户线程只有GC线程在运行。在CMS收集器出现之前所有回收器都是全程STW的CMS和G1实现了部分阶段并发但关键的标记和清理阶段依然需要停顿。为什么必须停顿保证一致性。可达性分析过程中如果用户线程还在运行对象引用关系就一直变化可能标记完的对象被新引用也可能未标记的对象变成垃圾。如果继续让用户线程写代码访问这些对象轻则回收出错重则破坏堆结构的正确性。所以GC选择“冻结世界”在稳定的状态下完成判断和回收。面试和工作中对GC调优本质就是在和STW时间做斗争。吞吐量优先的Parallel收集器宁可停顿时间稍长也要每次回收尽量多的垃圾延迟优先的CMS和G1则通过并发标记等机制尽量缩短停顿。理解这一点你就明白不同回收器的取舍逻辑了。5. 主流垃圾回收器深入对比与选型5.1 Serial、Parallel与CMS经典回收器家族Serial是单线程回收器GC时必须暂停所有用户线程只用一个GC线程回收。它是Client模式下JVM的默认新生代回收器简单高效但只适合单CPU、小内存的场景对服务端应用基本不具备实用价值。Serial Old是老年代版本同样单线程常用作CMS失败后的后备方案。Parallel ScavengeJDK8默认新生代回收器和Parallel Old是老搭档。它们关注吞吐量——吞吐量运行用户代码时间/(运行用户代码时间垃圾收集时间)。比如程序运行100分钟GC用了1分钟吞吐量就是99%。Parallel系列通过-XX:MaxGCPauseMillis和-XX:GCTimeRatio两个参数来间接控制吞吐量。注意MaxGCPauseMillis设得越小JVM为了追求低停顿会增大GC频率吞吐量反而下降这是一个直接矛盾。CMSConcurrent Mark Sweep是老年代并发回收器目标是获取最短回收停顿时间。它的运作分为四个步骤初始标记STW很短、并发标记和用户线程并发执行、重新标记STW修正并发标记期间的变动、并发清除和用户线程并发执行。CMS的优势是并发和低停顿但缺点也很明显对CPU资源敏感并发阶段会挤占应用线程CPU、无法处理浮动垃圾、基于标记-清除会产生大量内存碎片。CMS还有一个经典的“Concurrent Mode Failure”问题。并发清除阶段用户线程还在运行不断产生新垃圾如果此时老年代空间不足JVM会退化为Serial Old进行Full GC停顿时间瞬间飙升。所以要给CMS预留足够空间通常-XX:CMSInitiatingOccupancyFraction不要设太高JDK 8默认值大约92%这个值偏高了实践里压到70%-80%更稳妥。5.2 G1区域化思想带来的GC革命G1Garbage First从JDK 9开始成为默认回收器。它彻底抛弃了物理分代的概念把堆划分为一个个大小相等的Region每个Region独立可分配。逻辑上Region可以扮演Eden、Survivor、Old、Humongous大对象区专门存放连续Region存不下的大对象中的任何角色。G1的特点是可以建立可预测的停顿时间模型。它通过-XX:MaxGCPauseMillis参数指定期望的GC停顿时间默认200ms然后基于每个Region的回收价值回收所得空间大小与回收所需时间的比值来优先回收价值最大的Region。这就是“Garbage First”名字的由来——每次先回收最划算的那些Region。G1的核心机制有三个记忆集卡表维护跨Region引用SATBSnapshot At The Beginning并发标记算法在并发标记阶段开始时记录对象图快照避免并发修改导致对象消失写屏障维护卡表和SATB状态。G1把STW时间控制得比CMS更平滑同时解决了CMS碎片问题所以JDK 9之后取代CMS成为默认。G1也有坑。它最怕的是对象分配速率极高且停顿目标严苛如果每秒钟新分配的内存太多而MaxGCPauseMillis设得又太小G1根本来不及回收就会退化成Full GCserial old回收整个堆性能灾难。大对象分配也会带来问题Humongous Region直接跳过新生代进入老年代频繁分配大对象会造成老年代碎片。所以我建议使用G1时先观察分配速率再设置停顿目标不要盲目追求极低停顿。5.3 ZGC与新一代超低延迟回收器ZGC是JDK 11引入的实验性回收器到JDK 15转正目标是把STW时间控制在10ms以内且停顿时间不随堆大小增长而增长——哪怕堆上T级别也能保持极低停顿。ZGC的关键技术是着色指针Colored Pointer和读屏障Load Barrier。着色指针利用指针中未使用的位来标记对象的状态可访问、已重定位等垃圾回收过程变成了一种“指针自愈”机制。读屏障则是在读操作时检查指针状态如果对象被移动了就辅助完成重定位。由于这些机制ZGC能做到对象移动和访问并发进行用户线程几乎感受不到停顿。选择回收器时建议如果你的应用追求高吞吐、堆大小32GB以下、对停顿不敏感Parallel就很实用如果堆较大、延迟敏感、JDK 8环境G1是主流答案如果堆超64GB、追求极低停顿金融交易、实时推荐ZGC值得考虑。从JDK 17开始ZGC也已经相当成熟官方也在持续对它做优化。6. GC日志解读与调优实战6.1 拿到一段GC日志你要看什么很多人在生产环境压根没开GC日志出事时无从下手。这是大忌。无论什么环境务必开启GC日志。JDK 8常用的参数组合是-Xloggc:/path/to/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGCJDK 9及以后GC日志参数统一成了-Xlog-Xlog:gc*:file/path/to/gc.log:time,uptime,level,tags拿到日志后重点看什么举一段典型的新生代GC日志GC日志示例JDK8格式 2024-01-15T10:20:30.1230800: 5021.234: [GC (Allocation Failure) [PSYoungGen: 524288K-65536K(611840K)] 1048576K-589824K(2015232K), 0.0123456 secs] [Times: user0.02 sys0.00, real0.01 secs]逐字段拆解5021.234是JVM启动到GC发生的秒数PSYoungGen表示新生代回收524288K是回收前新生代占用65536K是回收后占用611840K是新生代总容量括号外1048576K-589824K(2015232K)是整堆的回收前后占用和总容量0.0123456 secs是GC耗时。如果Allocation Failure说明是因为Eden区无法分配对象触发的Minor GC。Full GC日志会有Full GC字样并显示老年代和元空间的变化。看到Full GC时重点判断触发原因是元空间阈值到了是老年代空间不足还是因为Metadata GC Threshold、G1 Evacuation Pause失败不同原因处理方向完全不同。6.2 堆大小与回收器参数设置的核心思路堆大小怎么设置业界没有银弹公式但有几个原则。第一大堆不等于好堆超过32GB会失去指针压缩的优势第二堆越大单次GC的停顿通常越久ZGC除外第三要从业务对象的存活情况和分配速率反推。实践上先观察压测数据如果Full GC后堆占用仍然很高说明需要扩容如果Minor GC后Survivor区频繁满说明新生代小了或者对象本身就应该晋升。用G1时建议关注这几个参数-Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:G1HeapRegionSize8m -XX:InitiatingHeapOccupancyPercent45 -XX:ConcGCThreads4InitiatingHeapOccupancyPercent控制并发标记的启动阈值默认45%意思是堆使用率达到45%时开始并发标记周期。这个值调低可以让G1更早地回收老年代但也会增加GC频率调高可以减少GC次数但可能等到堆吃紧才动手回收压力增大。实践中结合监控数据微调不要太激进。CMS调优则要控制-XX:CMSInitiatingOccupancyFraction为70%-80%同时开启-XX:UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction减少碎片影响。但说实话JDK 8以后的长期支持版本我更推荐G1CMS维护成本太高了。6.3 一个线上真实调优案例我之前处理过一个报表导出服务典型的OOM问题报错是xssfworkbook内存溢出——也就是用Apache POI的XSSFWorkbook读取大Excel时堆内存被打爆。XSSFWorkbook会把整个Excel文件结构全部加载到内存一个几十MB的Excel展开成对象模型后占用几百MB甚至上GB堆空间并发导出几份大文件堆直接吃满。当时的排查步骤是先看GC日志确认Full GC频率和OOM类型再用jstat观察各代使用情况最后通过jmap dump堆快照用MAT分析。最终定位到大对象是XSSFWorkbook的Sheet对象和Cell数组。解决方向有两个层面。代码上改用SXSSFWorkbook流式版本它只保留窗口内的行数据超出窗口的行会被刷到磁盘临时文件内存占用从“整表”变为“窗口行数”。参数上调大堆空间、给G1设置合理的Region大小和停顿目标。但必须强调的是调参只能缓解症状代码层面的内存占用模型才是根源。如果报表逻辑本身不优化堆调得再大也只是把崩溃时间往后推。这个案例想说明的是JVM调优是手段不是目的一定要先找到真实的内存消耗点再决定是改代码还是调参数。7. 常见问题与排查工具速查7.1 四类OOM的识别与应对我在群里常年看到有人截图报错问“什么是OOM”这里把高频的几类列一下方便你对照排查。报错信息含义常见诱因应对方向Java heap space堆内存耗尽对象泄漏、大对象过多、堆太小jmap dump分析、扩容、优化对象结构GC overhead limit exceededGC工作量大但回收效果差98%时间GC只回收不到2%堆堆接近满载、严重内存泄漏优先排查泄漏不要只调参Metaspace元空间耗尽动态类生成失控CGLIB、类加载器泄漏限制MaxMetaspaceSize、排查类加载器Direct buffer memory堆外内存耗尽NIO的DirectByteBuffer未释放检查ByteBuffer的回收机制、调-XX:MaxDirectMemorySizeunable to create new native thread线程无法创建线程数超限、进程limit过小、栈空间过大调-Xss、降低线程数、调整ulimitGC overhead limit exceeded是个很重要的信号它意味着GC做的是“无效功”堆里全是几乎无法回收的对象。这种状态十有八九是内存泄漏靠扩容只是拖延必须做堆转储分析。7.2 内存泄漏排查的完整实操流程第一步先用jstat观察各内存区域变化趋势jstat -gcutil pid 1000 10如果老年代O使用率持续走高、FGC次数不断增加而Minor GC回收后堆占用又降不下去基本可以判定存在泄漏或者存活对象增长失控。第二步jmap生成堆转储文件jmap -dump:live,formatb,file/path/heap.hprof pid注意生产环境执行jmap dump会触发一次Full GC并且dump期间应用可能会停顿谨慎操作。更稳妥的做法是给JVM加上-XX:HeapDumpOnOutOfMemoryError让OOM发生时自动生成快照这几乎是生产环境必须开启的参数。第三步用MATMemory Analyzer Tool或者VisualVM打开快照。重点看Leak Suspects报告和Dominator Tree支配树找占比最大的对象然后顺着引用链查是哪个业务对象把它们撑起来的。常见泄漏类型静态集合类持有对象不释放、ThreadLocal使用后未remove、数据库连接/IO流未关闭、监听器或回调注册后未注销。7.3 开发环境JVM参数设置与OOM预防开发环境最常见的问题就是你写代码写着写着IDEA崩了或者单元测试跑着跑着OOM了。IDEA自身的JVM配置在安装目录的idea.vmoptions文件不同版本位置略有差异典型配置如下-Xms1024m -Xmx2048m -XX:ReservedCodeCacheSize512m其中ReservedCodeCacheSize容易被忽略JIT编译的代码缓存不够时IDE会变得很卡甚至崩溃。如果是跑项目的OOM优先调整Run/Debug Configuration里的VM options给堆留出充足空间但更重要的是别让代码里出现无上限的集合、无节制的批处理和错误的缓存设计。很多OOM在代码审查阶段就可以避免。还有一个开发期非常实用的技巧在启动参数里加上自动dump和远程监控支持。-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/dumps/这样OOM瞬间能留下现场证据不用事后苦想。最后再分享一个心得学JVM内存与GC别只看书一定要自己在本地环境做实验。写一段产生大量对象的代码开不同的回收器参数打印GC日志对照观察故意写一个有循环引用的数据结构再配合工具看看它到底怎么被回收的。只有亲手操作过遇到生产故障时你才不会手忙脚乱因为你已经见过内存涨落、GC停顿、日志膨胀这些真实的样子了。
返回列表