线上服务又在半夜告警了堆内存使用率冲到99%接口响应从几十毫秒变成几秒紧接着就是OutOfMemoryError。这种场景只要做Java后端超过两年多少都经历过。Java的内存管理号称“自动的”GC让我们不用像写C/C那样手动管内存但“自动”不等于“不用管”——恰恰相反没有深入理解JVM内存结构和GC机制遇到线上内存飙升、频繁FGC、OOM宕机的时候手里没工具、心里没章法就只能重启大法续命然后问题继续轮回。这篇内容我结合自己排查线上问题的经验来写从内存布局、GC机制讲到泄漏场景和排查工具链把那些报错背后真正发生了什么说清楚。不管是刚接触JVM调优的菜鸟还是已经被线上问题磨过几轮的开发应该都能从中找到能直接用的东西。1. 先把内存布局搞清楚JVM把对象到底放哪儿了很多人OOM了第一反应是加-Xmx但堆内存加了还是炸或者FGC越来越频繁为什么因为根本没弄明白内存的各个区域是怎么分工的。JVM把内存划成了好几块区域每块区域的管理方式、出错表现完全不一样你先得知道问题可能出在哪个区域。1.1 堆区对象的主战场也是绝大多数排查的焦点堆Heap是Java对象存放的主区域几乎所有的对象实例都在这里分配。堆内部又分了新生代Young Generation和老年代Old Generation新生代又进一步分成Eden区和两个Survivor区S0和S1也就是From和To。这个分代设计不是拍脑袋想出来的它基于一个重要的统计事实绝大部分对象的生命周期都非常短创建出来没被用多久就成了垃圾。那么把这些“朝生夕死”的对象集中在新生代处理用复制算法就能高效回收避免对老年代里长期存活的对象做无意义的扫描。对应的关键参数-Xms堆的初始大小默认是物理内存的1/64。-Xmx堆的最大大小默认是物理内存的1/4。-Xmn新生代大小典型设置是堆的1/3到1/4。-XX:SurvivorRatioEden区和单个Survivor区的比例默认8即Eden占新生代的8/10S0和S1各占1/10。我见过不少团队的配置是-Xms4g -Xmx4g初始和最大堆设成一样这倒不是随便设的——相同的好处是避免JVM在运行期间动态扩容堆时发生STWStop The World停止所有应用线程去做GC代价是启动就会占用4G的物理内存。动态扩容不是免费的扩容和缩容都会触发GC所以生产环境一般建议把Xms和Xmx设为相同值减少运行期抖动。堆区的OOM会直接抛java.lang.OutOfMemoryError: Java heap space这是最典型、最常见的OOM类型后面的排查章节我会重点展开。1.2 栈、元空间和直接内存容易被忽视的角落堆是重点但不是全部。虚拟机栈JVM Stack是线程私有的每有一个线程JVM就会分配一个栈栈里保存着一个个栈帧对应每一次方法调用。栈帧里存了局部变量表、操作数栈、方法返回地址等信息。如果你写了无限递归或者方法调用层次特别深就会抛StackOverflowError。注意这跟堆OOM是两码事体量不同、报错不同、排查方式也不同。Java虚拟机栈的深度可以由-Xss指定默认值通常比如512KB到1MB具体数值跟平台相关。方法区Method Area存储类元信息、常量、静态变量、JIT编译后的代码等。在HotSpot虚拟机里JDK 8以后方法区被实现为元空间Metaspace从原来的堆内移到了本地内存。JDK 7及以前是用永久代PermGen实现的导致很多开发者都遇到过java.lang.OutOfMemoryError: PermGen space。元空间默认是无限使用本地内存的大小只受本机可用内存限制但你当然不希望它无限涨上去所以需要配置-XX:MaxMetaspaceSize。动态生成类的框架比如某些字节码增强组件、动态代理如果使用不当很容易把元空间撑爆。直接内存Direct Memory是NIO使用堆外内存实现的缓冲区。DirectByteBuffer是直接通过操作系统分配的内存不受-Xmx限制但同样受本机物理内存和操作系统寻址空间的限制。很多开发者只盯堆内OOM结果堆明明很健康进程却被操作系统杀了就是因为堆外内存一直涨。排查时要记得把直接内存这块考虑进去-XX:MaxDirectMemorySize限制了它的最大值默认等于堆大小。2. GC回收机制怎么运转从分代收集到收集器选型只懂内存布局还不够你得知道垃圾是怎么被判定、怎么被回收的不然遇到“FGC频繁”“GC时间过长”这些现象时就是在裸猜。2.1 三条基础回收算法怎么配合分代模型垃圾回收的判定方法先要说清楚。主流做法是可达性分析——从一个叫GC Roots的根集合出发沿着引用链往下走凡是走不到的对象就算垃圾。可以作为GC Roots的包括虚拟机栈中引用的对象、静态属性引用的对象也就是方法区里的常量或静态变量引用的对象、JNI引用的对象、活跃线程等。判定完垃圾之后就要回收。三种基础回收算法标记-清除Mark-Sweep把垃圾对象标记出来然后直接清除。优点是简单缺点是产生大量内存碎片后面分配大对象时找不到连续空间再次触发GC。标记-复制Mark-Copy把内存分成两块只使用其中一块GC时把存活对象复制到另一块然后把原块全部清理。优点是不会有碎片缺点是浪费了一半空间而且存活对象多时复制成本高。标记-整理Mark-Compact标记完成后把存活对象往内存一端移动直接清理边界以外的内存。解决了碎片问题但移动对象需要更新引用成本也不低。实际JVM不会只用一种。新生代用标记-复制原因是新生代里存活对象少复制开销低即使浪费一小块Survivor空间也划算。老年代用标记-清除或标记-整理因为老年代存活率高不适合反复复制同时要兼顾碎片问题。这里给我的体会是抽象层面的三个算法最后落到的是一套具体GC引擎的策略组合。所以你在看GC日志时看到的行为其实是这套设计的具体表现。2.2 CMS、G1和ZGC收集器到底怎么选不同的垃圾收集器本质上是回收算法的工业级实现。JDK 8时代默认的是Parallel Scavenge Parallel Old也就是吞吐量优先。后来G1逐渐普及JDK 9以后G1成了默认收集器JDK 17进一步优化了G1同时ZGC在JDK 15以后转正。Parallel多线程并行回收追求高吞吐量适合后台批处理、计算密集型任务。在GC期间会STW。CMSConcurrent Mark Sweep并发标记清除追求低停顿适合交互型服务。但CMS有碎片问题且并发阶段会占用CPU资源JDK 9之后被标记为废弃JDK 14移除了。G1Garbage First把堆划分成多个Region通过跟踪各Region的回收价值优先回收空间大、停顿时间短的区域。可预测停顿时间通过-XX:MaxGCPauseMillis控制目标。G1同时解决了吞吐量、停顿时间和碎片问题的平衡是目前服务端的事实标准。ZGC追求极低停顿时间可以做到停顿不超过10ms但吞吐量和内存占用上仍然有代价适合超大堆比如几十GB以上且对延迟极度敏感的场景。给个我实际使用的选择建议列表场景推荐收集器参考参数批处理、离线任务Parallel-XX:UseParallelGC交互型Web服务8G以内堆G1-XX:UseG1GC -XX:MaxGCPauseMillis200超低延迟、超大堆ZGC-XX:UseZGC -Xmx32g很多人问我是不是堆内存设得越大越好GC停顿时间就越短。真不是。堆越大GC时扫描的存活对象就越多虽然对象密度可能低了但基数太大整体停顿时间照样拉长。合适的大小是通过压测和线上指标一点一点调出来的不是拍脑袋写个-Xmx8g就完事。2.3 堆参数配置参考结合业务特征调给一套我常用的初始配置模板适合大多数中等的Web服务-Xms4g -Xmx4g -Xmn1g -XX:MaxMetaspaceSize512m -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/opt/logs/app.hprof -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/opt/logs/gc.log注意最后三个参数在JDK 8里-XX:PrintGCDetails和-XX:PrintGCDateStamps配合-Xloggc可以输出详细GC日志JDK 9以后日志系统统一改成-Xlog:gc*比如-Xlog:gc*:/opt/logs/gc.log。每次遇到内存问题没有GC日志排查就是没有病历的会诊难度翻好几倍。3. 内存泄漏的典型场景先搞清楚自己可能栽在哪儿所谓内存泄漏就是本该被回收的对象因为还被某些不该存在的引用链挂着导致GC永远回收不掉。Java里严格意义上的泄漏不像C语言那么普遍但工程上因为使用不当造成的“逻辑泄漏”非常普遍。下面这几个场景是我在排查经历里见到的最高发类型每一个都值得细说。3.1 静态集合类背着不卸的包袱静态变量的生命周期从类加载开始到类卸载才结束。如果你把对象放进一个静态List或者静态Map里而且没有主动移除的逻辑这个对象就永远挂在GC Roots可达的路径上。最典型的例子静态缓存Map用用户名当key存了用户信息结果这个Map只增不减最终吃满老年代。模拟代码如下public class UserCache { private static final MapString, UserInfo CACHE new HashMap(); public static void cacheUser(String name, UserInfo info) { CACHE.put(name, info); } }只要有人调cacheUserUserInfo对象就永久驻留。如果这是历史代码改成弱引用缓存能比较好地缓解问题比如换成WeakHashMap或Caffeine这类带过期淘汰策略的缓存组件。这类问题的隐蔽性在于它不会像一次性大对象那样马上炸掉而是让老年代使用率缓慢上升伴随FGC越来越频繁最终老年代满了就OOM。画出来的内存使用曲线是长期斜向上的爬坡线。3.2 ThreadLocal与线程池比野指针更隐蔽的坑ThreadLocal漏内存这个话题在很多Java面试里都出现过但实际工作中真正在代码里踩过的人不少。ThreadLocal的底层实现是每个Thread对象里都有一个ThreadLocalMap如果你set了一个值并且没有调用remove那这个值就会一直存在线程的ThreadLocalMap里。如果是普通线程线程销毁时ThreadLocalMap随之销毁问题还不大。但如果是线程池里的线程线程是复用的池里的线程长期存活ThreadLocalMap里的Entry也一直存活。这里的核心是ThreadLocalMap的key是WeakReference指向ThreadLocal本身但value是强引用只要ThreadLocal不被移除value就无法回收。最可怕的结果是一个看似无害的ThreadLocal对象导致整个业务链路的数据value往往是大对象被线程池长期持有。处理方式其实是一句忠告用ThreadLocal一定要在finally里remove。private static final ThreadLocalSpan SPAN new ThreadLocal(); public void doSomething() { try { SPAN.set(new Span()); // 业务逻辑 } finally { SPAN.remove(); } }3.3 资源没关连接和流不是自动回收的数据库连接、IO流、网络连接这些资源很多人有一个误解既然JVM有GC那资源相关的对象不是最终会被回收吗但关键是这些资源对象内部往往持有操作系统级别的句柄或文件描述符如果只靠GC回收从对象被判为垃圾到真正执行finalize或者清理中间的时间完全不可控而且很多连接池的资源使用的是线程栈以外的强引用。最常见的情况是应用里拿了Connection没close或者从流里读数据没关闭最终连接池耗尽或者文件句柄耗尽整个应用挂掉。正确做法是try-with-resourcesJava 7try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { // 处理结果 }这个写法自动处理了关闭顺序比手动finally里逐个close可靠得多避免因为代码抛异常导致close被跳过。3.4 内部类、匿名内部类和监听器隐性持有的网非静态内部类隐含持有外部类的引用。比如一个非静态内部类Inner里面用了Outer.this那么只要Inner对象被某个全局静态集合引用外部类对象就跟着没法回收。匿名内部类同理特别是给某个组件添加事件监听器时监听器里如果不小心引用了外部活动的实例而监听器又被某个静态注册表持有就会形成一条链子把本该销毁的对象钉在内存里。Android开发里Activity泄漏是个高频问题Java Web里也有类似场景——定时任务里持有Servlet上下文、事件监听器没有反注册都是漏内存。解决办法也简单该反注册的一定要反注册用静态内部类加WeakReference代替非静态内部类事件的监听器和被观察对象生命周期要一致。3.5 缓存和全局对象没有淘汰机制的隐患很多人写缓存第一反应是HashMap加static。这种做法在小流量的程序里问题不大流量一大、数据维度一多缓存常驻对象越来越多旧数据又不会被访问反而占着老年代空间。就算不是缓存Map有些“全局配置”对象也一样一旦有集合类容器持有海量对象又没有失效机制就是泄漏。工程上的正确解法是使用带过期和淘汰策略的缓存框架或者在放数据时加上容量上限。比如用Caffeine配置最大容量和过期时间让框架自己处理淘汰不要自己用Map硬扛。4. 线上排查实录从异常到根因的完整路径这一章是真正的实操环节。前面讲完了理论现在说说真正在服务器上排查问题时我自己的操作顺序和判断思路。4.1 先分清是泄漏、对象膨胀还是并发压力大拿到“内存告警”第一反应不该是直接抓Hprof而是看GC日志和监控曲线。观察点如果堆内存到达一个峰值后回落比如每次FGC后回到几百MB说明对象能正常回收可能是并发流量导致的对象量大增属于正常现象。如果老年代使用量持续上涨每次FGC之后只下降一点点然后又慢慢涨上去到某个临界点又触发FGC这就是泄漏的典型曲线。如果Eden区频繁塞满Minor GC频率极高同时Survivor区晋升率很高可能新生代设置太小或者有大量大对象直接进老年代了这属于配置或代码设计问题不一定是泄漏。这一步不要省。很多新手一上来就jmap -dump结果抓堆的时候应用已经挂了或者FGC后抓到的堆是“干净”的白白浪费一次现场。4.2 工具组合拳jps / jstat / jmap 怎么按顺序用先找到进程号jps -l假设拿到了PID是12345先看堆使用情况和GC统计。jstat -gcutil 12345 1000 10这个命令意思是一秒打印一次GC统计共打印10次。看O那列老年代使用率如果一直在增长且FGC次数涨得凶泄漏基本实锤。然后看当前JVM参数是否生效jinfo -flags 12345要导堆快照jmap -dump:live,formatb,file/opt/logs/app.hprof 12345注意-dump:live表示只导出存活对象这会先触发一次FGC适合看存活部分如果想看全量对象分布不加live。生产上执行jmap有可能造成应用短暂的停顿尽量在低峰期做。拿到hprof之后分析有两种思路一种用Eclipse MAT的mat2或命令行工具一种用JVisualVM。两个之间我的习惯是MAT看泄漏嫌疑报告更直观JVisualVM看类实例分布更灵活。4.3 MAT里的关键操作如何一眼定位最大的嫌疑打开hprof之后第一件事看Overview里的“Leak Suspects”。MAT会用算法自动找出最可能的内存泄漏点往往是一群大对象的集合。然后切到Histogram视图按对象大小排序。看到占用实例数最多的大对象后右键选择“Merge Shortest Paths to GC Roots”并勾选“exclude all phantom/weak/soft etc. references”意思是用强引用链追踪到GC Roots看看到底是谁攥着这些对象不放。举个例子之前排查过一个模拟的微服务Histogram里出现几十万个byte[]大对象追踪它们到GC Roots后发现全部被一个ThreadLocal对象持有根路径指向某个线程池线程。再看业务代码一个处理Excel导出的工具类把单元格数据存在了ThreadLocal里搞完没有清理。排查清楚后改动只有一行在finally里调用remove()FGC从每分钟几十次降到了每十分钟不到1次。4.4 一个完整的排查案例从报警到修复的过程我按时间顺序展开这个案例你在线上遇到类似情况可以直接照着做。现象某服务凌晨3点开始告警堆使用率超过95%。服务当时用的G1MaxGCPauseMillis设的200msGC日志里能看到FGC次数每5分钟增加一次每次FGC后堆回收空间很少老年代使用率保持在90%以上。第一步先看GC日志确认是不是泄漏。日志里FGC后的堆使用率稳定在85%~93%之间没有明显回落到低位说明确实有大量老年代对象无法回收。不是普通流量抖动。第二步用jmap -dump:live,formatb,file/tmp/heap.hprof PID导出堆。当时堆大小是4G大概几秒钟执行完成应用在这期间有轻微停顿但在可接受范围内。第三步MAT打开堆快照。Histogram里看到大量java.util.concurrent.ConcurrentHashMap$Node对象Retained Heap很高。再往下钻发现这些Node的key是com.xxx.OrderDTO类型的对象。继续追踪GC Roots最终指向了两个入口一个静态的全局订单缓存Map里面存了所有历史订单还有一个定时任务的Runnable每5分钟扫描一次这个Map并往一个List里追加数据。这个List没有被消费也没有清理导致Map和List双份数据都长期驻留。第四步修复给缓存Map加上容量上限和过期策略定时任务改成只处理当前需要的数据不再做全局累积。上线后观察GC日志FGC频率降到正常老年代使用率稳定在40%以下。这种案例里最关键的排查逻辑就是用MAT从大对象往下钻找到具体引用链然后对着代码优化。不要靠猜也不要在没有证据的情况下盲目加大堆内存。加大堆很多时候只是把问题爆发的时间延后而不是解决。5. 防患于未然的实操建议与速查表如果每次问题都等到线上告警才去查那成本实在太高了。我自己的做法是在开发阶段就提前引入检查机制把很多潜在的内存泄漏问题在测试环境暴露出来。5.1 编码层面的几个容易做到的习惯静态集合能被final就final能用弱引用就用弱引用能限制容量就限制容量。不要在代码里写出“只增不减”的容器。ThreadLocal必须配合remove使用并且要在finally里。这一点在乱用线程池的服务里怎么强调都不过分。IO流、连接、Socket等一切资源统一使用try-with-resources。非静态内部类能改成静态的就改成静态的。如果必须持有外部信息显式用构造方法传进去不要隐式持有Outer.this。全局缓存一律使用专业缓存框架配置容量上限和过期时间不要自己用Map拼。发布前用压测跑一下长时间稳定场景观察GC次数的基线。上线后配好监控告警GC频率、老年代使用率、FGC后的回落幅度这三条曲线比任何人的直觉都准确。5.2 排查流程表格照着走就能少踩很多坑我把整个排查流程整理成了一张表方便你对照操作。步骤操作指令或动作观察点1. 确认进程jps -l找到目标PID2. 看GC统计jstat -gcutil PID 1000 10O列是否持续增长FGC次数是否快速上升3. 确认参数jinfo -flags PID确认堆大小、收集器等参数是否符合预期4. 抓堆快照jmap -dump:live,formatb,fileapp.hprof PID低峰期执行避免停顿影响5. 找泄漏嫌疑MAT打开hprof看Leak Suspects大对象、重磅引用链6. 溯源GC Roots右键Merge Shortest Paths to GC Roots定位到具体业务代码7. 修复并验证修改代码、发布、观察GC曲线老年代使用率回落FGC频率下降还有几条心得生产环境一定要开启-XX:HeapDumpOnOutOfMemoryError并设置-XX:HeapDumpPath。有了这个参数OOM发生的时候JVM会自动把当时的堆快照保存下来。这份现场资料是最有价值的东西能省去大量等待复现的时间。GC日志能开就开轮转策略配置好线上至少保留最近7天。日志默认可能比较大加-Xloggc:/opt/logs/gc.log -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles5 -XX:GCLogFileSize20M这类参数控制JDK 9以上是不带这些参数的用-Xlog:gc*:file/opt/logs/gc.log::filecount5,filesize20M就行。监控平台里如果有堆内存、GC次数、线程数、老年代使用率这些指标一定都要接上。内存泄漏的定位难点往往在于现场已经丢失监控指标就是最好的“事故录像”。6. 最后的几句实在话我记得最早被线上OOM折磨的时候第一反应是去搜“JVM参数怎么调”想把堆容量赶紧扩上去。后来踩过几次坑才慢慢明白堆大小只是最表层的东西真正的功夫在于看懂GC日志和堆快照找到谁在持久引用着那些不该存活的对象。内存管理的核心不是记住每个参数的含义而是建立一套排查的思维框架对象从哪里分配被谁引用什么时候本该释放为什么没释放。以后再遇到内存告警不用慌。先看GC曲线再抓堆快照顺着引用链找到根改代码观察验证。这套流程走通一次你就会发现所谓的内存泄漏排查其实是有规律可循的侦探工作而不是玄学。