为了账号安全,请及时绑定邮箱和手机立即绑定

线上Java服务突然OOM?我靠这5个排查步骤10分钟定位了根因

标签:
Java

在这里插入图片描述

本文核心价值:一套经过线上实战验证的OOM排查方法论——5步定位法,涵盖从现场保护到根因定位的完整流程,配有大量代码示例和实战案例,帮助Java开发者在10分钟内快速定位OOM根因。全文约15000字,建议收藏后阅读。

前言

凌晨三点,监控系统突然发出刺耳的告警声——线上核心Java服务 内存 使用率飙升至98%,紧接着服务实例开始接连宕机。睡眼惺忪的你被电话叫醒,面对屏幕上满屏的java.lang.OutOfMemoryError,手心瞬间冒出冷汗。

相信每一位Java后端开发者都有过 类 似的噩梦经历。OOM(OutOfMemoryError)作为Java线上最致命的故障之一,往往来得突然、影响范围广、排查难度大。如果没有一套系统化的排查方法论,很容易在慌乱中错失最佳定位时机,甚至因为操作不当破坏了现场,导致事后无法复盘。

本文将分享一套经过线上实战验证的OOM排查方法论——5步定位法。按照这套流程,即使是经验尚浅的开发者,也能在10分钟内快速定位OOM根因,为修复争取宝贵时间。


一、OOM到底是什么?为什么这么可怕?

在正式进入排查步骤之前,我们先花几分钟搞清楚OOM的本质。很多开发者天天把OOM挂在嘴边,但真要问起来OOM有多少种、分别对应JVM内存模型的哪个区域,未必能说得清楚。

1.1 JVM内存模型回顾

根据JVM规范,Java虚拟机在执行Java程序时会把它管理的内存划分为若干个不同的数据区域:

  • 堆(Heap):所有线程共享,用于存放对象实例和数组,是GC的主要区域

  • 方法区(Method Area):所有线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量等数据。JDK 8之后演变为元空间(Metaspace)

  • 虚拟机栈(VM Stack):线程私有,每个方法执行时都会创建一个栈帧,存储局部变量表、操作数栈、动态链接等

  • 本地方法栈(Native Method Stack):为Native方法服务

  • 程序计数器(Program Counter Register):线程私有,记录当前线程执行的字节码行号

  • 直接内存(Direct Memory):不属于JVM运行时数据区,但被频繁使用,NIO的DirectByteBuffer就在这里

不同区域的内存耗尽,会抛出不同类型的OOM异常。理解这一点,是快速定位的第一步。
在这里插入图片描述

1.2 OOM的危害

为什么线上OOM如此可怕?主要有以下几个原因:

第一,突发性强。 OOM往往没有明显预兆,可能前一秒服务还正常运行,后一秒就突然宕机。不像CPU飙高还能有个缓冲期,OOM常常是"秒崩"。

第二,影响范围广。 一旦服务发生OOM,轻则响应变慢、接口报错,重则整个实例宕机,导致业务中断。如果是核心服务,还可能引发雪崩效应,拖垮上下游。

第三,排查难度大。 OOM的原因千奇百怪,可能是代码bug、流量突增、配置不合理、依赖问题等等。如果没有系统的方法,很容易像无头苍蝇一样乱撞。

第四,现场易丢失。 很多人遇到OOM第一反应就是重启服务,但重启之后内存状态就清空了,堆转储文件也可能被覆盖。没有现场,事后排查就成了无源之水。

正是因为这些特点,掌握一套高效的OOM排查方法论就显得尤为重要。


二、步骤一:快速止损与现场保护

核心原则:先止损,再排查;先保现场,再分析。

当线上发生OOM时,很多人的第一反应是"赶紧重启"。但请记住:重启是最后手段,不是第一选择。 在重启之前,你必须先做好两件事:止损和保现场。

2.1 第一时间止损

OOM发生后,服务已经处于不正常状态,继续承接流量只会让情况更糟。所以第一步是快速止损,把影响范围控制住。

常见的止损手段:

  1. 流量切走:如果有负载均衡或网关,立即把出问题的实例摘下来,不让流量继续打进去

  2. 服务降级:如果是核心功能出问题,考虑开启降级开关,暂时关闭非核心功能

  3. 限流保护:如果流量突增导致OOM,立即开启限流,把QPS控制在安全范围内

  4. 扩容兜底:如果还有可用资源,紧急扩容几个实例来承接流量,保证业务可用

💡 经验之谈:止损的速度直接决定了故障的影响时长。平时就应该把各种止损预案准备好,比如一键摘流、一键降级、一键扩容,关键时刻才能秒级响应。

2.2 为什么不能立刻重启?

很多人会问:服务都挂了,不重启留着过年吗?

答案是:为了保留现场。

OOM的原因藏在内存里,一旦重启,内存中的所有对象信息都会消失,就像案发现场被破坏了一样。到时候你只能对着日志干瞪眼,却永远无法知道到底是哪个对象占满了内存。

所以正确的做法是:先把出问题的实例隔离(不接流量),然后保留现场,等dump文件生成完、关键信息收集完,再考虑重启。

2.3 生成堆转储文件(Heap Dump)

堆转储文件是OOM排查的"黄金证据"。它是JVM堆内存的一个快照,包含了当时所有对象的信息。有了它,我们就能知道到底是什么对象占了这么多内存。

方法一:自动生成(推荐提前配置)

最好的方式是在JVM启动参数中提前配置,让OOM发生时自动生成dump文件:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/java/heapdump.hprof

这两个参数的作用是:当发生OOM时,自动把堆内存快照输出到指定路径。

⚠️ 注意事项:

  1. dump文件大小约等于堆内存大小,确保磁盘有足够空间

  2. 生成dump文件会暂停应用,时间从几秒到几分钟不等

  3. 生产环境建议配置,这是线上排查的"救命稻草"

方法二:手动生成

如果没有提前配置自动dump,也可以手动生成:

# 先找到Java进程ID
jps -l

# 生成堆转储文件
jmap -dump:format=b,file=/path/to/heapdump.hprof <pid>

# 或者只dump存活对象(更快,但信息少一些)
jmap -dump:live,format=b,file=/path/to/heapdump.hprof <pid>

jmap是JDK自带的工具,-dump:format=b表示以二进制格式dump,live参数表示只dump存活的对象(会先触发一次Full GC)。

💡 小技巧:如果担心jmap执行时间太长影响服务,可以先把进程挂起(gcore),再慢慢分析。不过一般情况下,既然已经OOM了,服务基本也不可用了,直接dump就行。

2.4 收集关键信息

除了dump文件,还有很多信息需要同步收集,这些信息对后续排查至关重要:

1. JVM参数配置

# 查看JVM启动参数
jinfo -flags <pid>

# 查看堆内存配置
jmap -heap <pid>

知道了JVM是怎么配置的,才能判断是不是参数设置不合理导致的OOM。比如堆内存是不是设小了?元空间是不是不够?GC策略是不是选错了?

2. GC日志

如果配置了GC日志,赶紧把日志文件保存下来。GC日志能告诉你:内存是怎么增长的?GC的频率和耗时如何?是慢慢泄漏还是突然暴涨?

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-Xloggc:/var/log/java/gc.log

3. 应用日志和错误日志

查看OOM发生前后的应用日志,看看有没有异常栈、有没有什么特殊的业务操作、有没有报错信息。很多时候,OOM的异常栈本身就能提供重要线索。

4. 服务器基本信息

# 查看内存使用情况
free -h

# 查看CPU使用情况
top -p <pid>

# 查看磁盘使用情况
df -h

确认一下是不是机器本身的内存就不够了,或者是不是磁盘满了导致其他问题。

5. 业务上下文

问问业务方:OOM发生时在做什么操作?有没有上线?有没有搞活动?有没有批量任务在跑?这些信息能帮你快速缩小排查范围。

📝 检查清单:发生OOM后,先确认以下信息是否都收集到了:

  • 堆转储文件(.hprof)

  • JVM启动参数

  • GC日志

  • 应用日志/错误日志

  • 服务器基本信息

  • 业务上下文信息


三、步骤二:初步定位OOM类型

拿到现场信息后,先别急着打开MAT分析dump文件。第二步,我们先做个"初诊",判断一下是哪种类型的OOM,这样后续才能对症下药。
在这里插入图片描述

3.1 常见的OOM类型大盘点

Java中的OOM可不只有一种,不同类型的OOM对应不同的内存区域,排查思路也完全不同。

类型一:Java heap space(堆内存溢出)

java.lang.OutOfMemoryError: Java heap space

这是最常见的OOM类型,意思是堆内存不够用了。可能的原因:

  • 堆内存设置太小

  • 内存泄漏:对象无法被回收,越积越多

  • 突然创建了超大对象或大量对象

类型二:GC overhead limit exceeded(GC开销超限)

java.lang.OutOfMemoryError: GC overhead limit exceeded

这个OOM的意思是:GC占用了太多时间,却回收不了多少内存。JDK的默认阈值是:GC时间超过98%,但回收的内存不到2%,就会抛出这个错误。

通常这是内存泄漏的"前兆"——堆内存快满了,GC频繁执行但效果甚微,最后彻底撑爆。

类型三:Metaspace(元空间溢出)

java.lang.OutOfMemoryError: Metaspace

JDK 8之后,永久代被元空间取代,元空间使用的是本地内存。如果元空间溢出,说明加载的类太多了。可能的原因:

  • 元空间设置太小

  • 动态生成的类太多(比如CGLIB动态代理、反射、JSP等)

  • 类加载器泄漏

类型四:Direct buffer memory(直接内存溢出)

java.lang.OutOfMemoryError: Direct buffer memory

直接内存溢出,通常和 NIO 有关。Java的NIO可以使用DirectByteBuffer直接分配堆外内存,这部分内存不受堆大小限制,但受限于物理内存。如果直接内存用太多,就会报这个错。

常见于使用Netty、Mina等NIO框架的场景。

类型五:unable to create new native thread(无法创建新线程)

java.lang.OutOfMemoryError: unable to create new native thread

这个OOM的意思是:创建线程失败了。每个线程都需要占用一定的栈内存,当线程数量太多时,系统没有足够的资源来创建新线程。

可能的原因:

  • 线程池配置不合理,最大线程数设太大

  • 线程泄漏:线程创建了没销毁,越积越多

  • 系统限制:操作系统对进程的最大线程数有限制

类型六:StackOverflowError(栈溢出)

java.lang.StackOverflowError

严格来说这不是OOM,但也是常见的内存相关错误。栈溢出通常是因为方法调用层次太深,比如递归没有正确的退出条件。

3.2 从错误信息快速判断类型

其实最简单的方法就是直接看错误信息。上面列出的每种OOM都有明确的错误提示,看到错误信息基本就能判断类型。

比如看到Java heap space,那就是堆的问题,重点分析堆转储;看到Metaspace,就去查类加载的问题;看到Direct buffer memory,就去看NIO相关的代码。

3.3 分析GC日志,看内存变化趋势

如果错误信息不够明确,或者想进一步确认,就来分析GC日志。GC日志能清晰地展示内存的变化趋势,帮你判断是"慢慢泄漏"还是"突然撑爆"。

GC日志怎么看?

我们来看一段典型的GC日志(JDK 8,Parallel GC):

2024-01-15T10:23:45.123+0800: [GC (Allocation Failure) [PSYoungGen: 65536K->8192K(76288K)] 146432K->89088K(251392K), 0.0254321 secs] [Times: user=0.05 sys=0.01, real=0.03 secs]

我们来拆解一下:

  • 2024-01-15T10:23:45.123+0800:GC发生的时间

  • GC (Allocation Failure):这是一次Young GC,原因是分配失败

  • PSYoungGen: 65536K->8192K(76288K):年轻代GC前65536K,GC后8192K,总大小76288K

  • 146432K->89088K(251392K):整个堆GC前146432K,GC后89088K,总大小251392K

  • 0.0254321 secs:GC耗时

  • [Times: user=0.05 sys=0.01, real=0.03 secs]:用户态、内核态、实际耗时

Full GC的日志类似,只是开头是Full GC

关键观察点:

  1. 堆内存使用趋势:每次GC后,老年代的使用量是不是在持续上涨?如果每次GC后老年代回收不完,而且一次比一次高,那大概率是内存泄漏。

  2. GC频率:Young GC和Full GC的频率如何?如果Full GC越来越频繁,说明内存压力越来越大。

  3. GC耗时:GC耗时是不是越来越长?特别是Full GC,如果一次要好几秒甚至几十秒,那服务基本就卡得不能用了。

  4. 晋升速率:每次Young GC后,有多少对象晋升到老年代?如果晋升速率很高,说明对象存活时间长,老年代增长快。

GC日志 分析工具

手动看GC日志太累了,推荐用工具:

  • GCEasy:在线工具,上传GC日志就能生成可视化报告,非常好用

  • GCViewer:桌面端工具,开源免费

  • HPjmeter:功能强大的分析工具

💡 经验之谈:如果GC日志显示老年代内存是"阶梯式"上涨,每次GC后都比上一次高一点,那基本就是内存泄漏没跑了。如果是突然一下涨到顶,那可能是某一次操作创建了大量对象。

3.4 使用jstat实时监控

如果服务还没挂,只是内存飙高,那可以用jstat实时监控内存变化。

# 查看GC统计信息,每1秒输出一次,共输出10次
jstat -gcutil <pid> 1000 10

输出大概长这样:

S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00  98.31  96.23  85.67  76.43  73.21    123    2.345     5    1.234    3.579

各列含义:

  • S0S1:Survivor 0和Survivor 1区使用率

  • E:Eden区使用率

  • O:老年代使用率

  • M:元空间使用率

  • CCS:压缩类空间使用率

  • YGC:Young GC次数

  • YGCT:Young GC总耗时

  • FGC:Full GC次数

  • FGCT:Full GC总耗时

  • GCT:GC总耗时

通过jstat,你可以实时观察各个内存区域的变化情况,判断是哪个区域在涨。


四、步骤三:深入分析堆转储文件

如果初步判断是堆内存溢出,那第三步就是重头戏——分析堆转储文件。这一步是整个排查过程的核心,也是最能体现技术功底的地方。

4.1 堆转储文件是什么?

堆转储文件(Heap Dump)是JVM堆内存在某一时刻的快照,通常是.hprof格式的二进制文件。它包含了:

  • 所有的对象信息:类、成员变量、值

  • 所有的类信息:类加载器、类名、静态变量

  • 对象之间的引用关系

  • GC Roots信息

简单说,dump文件就是当时堆内存的"全景照片",有了它,我们就能知道内存里到底有什么。

4.2 常用分析工具对比

分析dump文件的工具有很多,各有特点:

工具 特点 适用场景
Eclipse MAT 功能强大,有泄漏嫌疑报告,支持OQL 最常用,推荐首选
VisualVM JDK自带,界面友好,功能全面 简单分析、监控
jhat JDK自带,命令行工具,生成网页 简单快速查看
YourKit 商业软件,功能强大,性能好 深度分析、性能调优
JProfiler 商业软件,功能全面 专业级分析

推荐首选Eclipse MAT(Memory Analyzer Tool),它是专门用来分析内存泄漏的工具,功能强大而且免费。下面我们就以MAT为例,讲讲怎么分析dump文件。

4.3 MAT工具深度使用指南

4.3.1 打开dump文件

打开MAT,选择File -> Open Heap Dump,选择你的.hprof文件。第一次打开会比较慢,因为MAT需要解析整个dump文件并构建索引。大的dump文件(几个G)可能需要十几分钟甚至更久,耐心等待。

打开之后,MAT会自动弹出一个向导,问你想做什么分析。一般选择Leak Suspects Report(泄漏嫌疑报告)就行,这是最常用的。

4.3.2 Leak Suspects Report (泄漏嫌疑报告)

这是MAT最贴心的功能——自动帮你找可能的内存泄漏点。报告会列出几个"嫌疑犯",每个嫌疑犯都是一个占用内存很大的对象,并且MAT会给出分析和建议。

报告通常包含:

  • Problem Suspect:嫌疑对象的描述,占用了多少内存,占总内存的百分比

  • Description:详细描述,比如哪个类的对象、有多少个实例

  • Shortest Paths To the Accumulation Point:从GC Roots到这个对象的最短引用链

  • Accumulated Objects in Dominator Tree:支配树中的对象统计

很多时候,看完泄漏嫌疑报告,你就能大概知道问题出在哪了。比如报告显示有一个HashMap占用了80%的内存,里面存了几百万个对象,那你就知道要去查这个HashMap是哪里来的。

4.3.3 Histogram(直方图)

如果想自己看所有对象的统计信息,就打开Histogram视图。直方图按类列出了每个类有多少个实例、占用了多少内存(浅堆和深堆)。

什么是浅堆(Shallow Heap)和深堆(Retained Heap)?

  • 浅堆:对象本身占用的内存大小,不包括它引用的对象

  • 深堆:对象被GC回收后,能释放的总内存大小,包括这个对象本身和它直接或间接引用的所有对象

简单说,浅堆是"自己有多大",深堆是"它带着多大的家当"。找内存泄漏主要看深堆,因为深堆大说明这个对象"hold住"了很多内存。

Histogram视图默认按类名排序,你可以点击列标题切换排序方式。通常我们会按深堆降序排列,看看哪些类的对象占用内存最多。

4.3.4 Dominator Tree(支配树)

支配树是MAT中一个非常重要的概念。简单说,支配树展示了对象之间的"支配关系"——如果所有到达对象B的路径都必须经过对象A,那A就支配B。

支配树的作用是:快速找到占用内存最多的对象,以及它们为什么还活着。

在支配树视图中,每个节点下面的子节点就是被它支配的对象。节点的大小表示它的深堆大小。这样你就能一眼看到:哪个对象是"内存大户",它又持有了哪些对象。

4.3.5 Path to GC Roots(GC Roots引用链)

找到了占用内存大的对象后,接下来要问:为什么这些对象没有被GC回收?

答案就在GC Roots引用链里。GC Roots是垃圾回收的起点,所有从GC Roots可达的对象都是"存活"的,不会被回收。

在MAT中,右键点击一个对象,选择Path to GC Roots -> exclude weak/soft references,就能看到从GC Roots到这个对象的完整引用链。

通过引用链,你就能知道:这个对象是被谁引用的?是被静态变量持有?还是被某个集合持有?还是被线程上下文持有?顺着引用链往上找,就能找到"罪魁祸首"。

💡 小技巧:排除弱引用和软引用,因为它们本来就可能被GC回收,通常不是泄漏的原因。重点看强引用链。

4.3.6 OQL(Object Query Language)

MAT还支持OQL,一种类似SQL的查询语言,可以用代码的方式查询对象。当你需要做一些复杂的筛选时,OQL非常有用。

常用OQL示例:

-- 查询所有String对象,按长度排序
SELECT s FROM java.lang.String s ORDER BY s.value.length DESC

-- 查询所有HashMap的大小
SELECT map, map.size FROM java.util.HashMap map

-- 查询包含"leak"关键字的字符串
SELECT s FROM java.lang.String s WHERE s.toString().contains("leak")

-- 查询某个类的所有实例
SELECT * FROM com.example.service.UserService

OQL的语法很灵活,熟练使用能大大提高分析效率。

4.4 常见的内存泄漏模式

分析dump文件多了,你会发现很多内存泄漏都是"套路"。下面总结几种最常见的内存泄漏模式,遇到类似情况可以直接对号入座。

模式一:集合类泄漏

这是最常见的泄漏模式。集合(HashMap、ArrayList等)持有了大量对象,但这些对象用完了却没有从集合中移除,导致越积越多。

典型代码:

public class CacheService {
    // 静态集合,生命周期和JVM一样长
    private static final Map<String, Object> cache = new HashMap<>();

    public void put(String key, Object value) {
        cache.put(key, value);
    }

    // 只有put,没有remove,也没有过期策略
    // 时间长了cache就会越来越大,最终OOM
}

特征:

  • dump中某个HashMap或ArrayList特别大

  • 里面的对象数量异常多

  • 引用链通常能追到一个静态变量或长生命周期对象

修复方法:

  • 使用带过期策略的缓存(如Guava Cache、Caffeine)

  • 设置最大容量,超过就淘汰

  • 用完及时remove

模式二:静态集合引用

和上面类似,但更隐蔽——静态变量持有集合引用,而集合里又持有了大量对象。因为静态变量的生命周期和类一样长,只要类不卸载,这些对象就永远不会被回收。

典型代码:

public class AppContext {
    // 静态的ApplicationContext,持有大量对象
    private static ApplicationContext context;

    // ...
}

特征:

  • 引用链的根是static字段

  • 通常是全局上下文、工具类、单例等

模式三:单例模式导致的泄漏

单例模式本身没问题,但如果单例持有了不该持有的对象,就会造成泄漏。比如单例持有了Activity的引用(Android场景),或者持有了大量业务数据。

典型代码:

public class Singleton {
    private static Singleton instance;
    private List<Object> data = new ArrayList<>();

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

    public void addData(Object obj) {
        data.add(obj); // 只加不删,越积越多
    }
}

模式四:未关闭的资源

数据库连接、文件流、网络连接等资源,如果用完不关闭,也会造成内存泄漏。虽然这些资源很多是堆外的,但它们在堆上也会有对应的对象,而且这些对象通常会占用不少内存。

典型代码:

public void readFile(String path) {
    try {
        FileInputStream fis = new FileInputStream(path);
        // 读取文件...
        // 忘记关闭fis了!
    } catch (IOException e) {
        e.printStackTrace();
    }
}

修复方法:

  • 使用try-with-resources自动关闭

  • 在finally块中关闭

  • 使用连接池,定期检查泄漏

模式五:监听器和回调未注销

注册了监听器或回调,但用完了没注销,导致发布者一直持有订阅者的引用,订阅者无法被回收。

典型代码:

public class UserService {
    public void register(EventListener listener) {
        eventBus.register(listener);
    }

    // 只有register,没有unregister
}

特征:

  • 事件总线、消息队列、观察者模式

  • 订阅者对象无法被回收

模式六:ThreadLocal泄漏

ThreadLocal是个好东西,但用不好很容易造成内存泄漏。ThreadLocal的原理是:每个Thread都有一个ThreadLocalMap,key是ThreadLocal的弱引用,value是实际的值。

如果ThreadLocal是静态的,或者线程是线程池中的线程(生命周期很长),那么即使ThreadLocal本身被回收了(弱引用),value也不会被回收,因为Thread还在。

典型代码:

public class UserContext {
    private static final ThreadLocal<User> userThreadLocal = new ThreadLocal<>();

    public static void setUser(User user) {
        userThreadLocal.set(user);
    }

    public static User getUser() {
        return userThreadLocal.get();
    }

    // 忘记remove了!线程池中的线程会一直持有User对象
}

修复方法:

  • 使用完ThreadLocal后,一定要调用remove()方法

  • 最好在finally块中remove

模式七:缓存泄漏

缓存是内存泄漏的重灾区。很多人做缓存时,只考虑了"存",没考虑"清",结果缓存越做越大,最后撑爆内存。

典型问题:

  • 缓存没有过期时间

  • 缓存没有最大容量限制

  • 缓存淘汰策略不合理

修复方法:

  • 使用专业的缓存框架(Guava Cache、Caffeine、Redis)

  • 设置合理的过期时间和最大容量

  • 选择合适的淘汰策略(LRU、LFU等)


五、步骤四:代码级根因定位

通过第三步的dump分析,你应该已经找到了"嫌疑对象"——比如某个HashMap特别大,或者某个类的实例特别多。但这还不够,你还需要找到对应的代码位置,搞清楚为什么会产生这么多对象。

这就是第四步:代码级根因定位。

5.1 结合业务场景缩小范围

找到了问题对象,先别急着翻代码。先结合业务场景分析一下,能帮你快速缩小范围。

问问自己这些问题:

  1. 什么时候发生的? 是刚上线就出问题?还是运行了好几天才出问题?还是某个时间点突然出问题?

    • 刚上线就出问题:大概率是新代码有bug

    • 运行几天才出问题:内存泄漏,慢慢积累

    • 某个时间点突然出问题:可能是某个活动、某个批量任务、某个特殊请求触发的

  2. 什么操作触发的? 是用户在做什么操作的时候出的问题?是某个接口?还是某个定时任务?

    • 如果是某个接口,就去查这个接口的代码

    • 如果是定时任务,就去看任务逻辑

  3. 涉及哪些模块? 从dump中看到的对象属于哪个业务模块?是用户模块?订单模块?还是某个中间件?

  4. 最近有没有变更? 最近有没有上线?有没有改配置?有没有换依赖版本?

把这些问题想清楚,排查范围就能从"整个项目"缩小到"某几个类",效率大大提升。

5.2 代码审查要点

缩小范围后,就开始看代码吧。重点关注以下几种"高危"代码:

5.2.1 大对象创建

有没有哪里创建了很大的对象?比如:

  • 一次性从数据库查出几万条数据,全部加载到内存

  • 读取大文件,整个读进内存

  • 构造巨大的List、Map

  • 字符串拼接,生成超长字符串

反例:

// 一次性查出所有用户,几百万条数据全部加载到内存
List<User> allUsers = userMapper.selectAll();

正例:

// 分页查询,每次只查一页
int pageSize = 1000;
for (int i = 0; ; i++) {
    List<User> users = userMapper.selectByPage(i * pageSize, pageSize);
    if (users.isEmpty()) {
        break;
    }
    // 处理当前页
    process(users);
}

5.2.2 循环中创建对象

有没有在循环里创建对象?特别是大循环,如果每次循环都创建对象,而且对象还不能被回收,那很快就OOM了。

反例:

List<String> result = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
    String s = "data_" + i + "_" + heavyComputation(i);
    result.add(s);
}

5.2.3 集合使用不当

集合是内存泄漏的高发区,重点检查:

  • 集合是不是只加不减?

  • 有没有设置最大容量?

  • 有没有过期清理机制?

  • 是不是静态集合?

  • key是不是正确重写了hashCode和equals?

反例:

private static final Map<String, Object> cache = new HashMap<>();

public void put(String key, Object value) {
    cache.put(key, value);
}

5.2.4 静态变量滥用

静态变量的生命周期和类一样长,持有对象很容易造成泄漏。重点检查:

  • 静态集合

  • 静态单例

  • 静态缓存

  • 静态的上下文对象

5.2.5 资源未关闭

文件流、数据库连接、网络连接、HttpClient等,用完了有没有关闭?

反例:

public String httpGet(String url) throws IOException {
    CloseableHttpClient httpClient = HttpClients.createDefault();
    HttpGet httpGet = new HttpGet(url);
    CloseableHttpResponse response = httpClient.execute(httpGet);
    return EntityUtils.toString(response.getEntity());
}

正例:

public String httpGet(String url) throws IOException {
    try (CloseableHttpClient httpClient = HttpClients.createDefault();
         CloseableHttpResponse response = httpClient.execute(new HttpGet(url))) {
        return EntityUtils.toString(response.getEntity());
    }
}

5.3 结合日志追踪

代码看了一圈,如果还没找到问题,就去翻日志。日志里往往藏着重要线索。

重点看什么?

  1. 异常栈信息:OOM的异常栈本身就很有价值,至少能告诉你OOM发生时代码在执行什么操作。

  2. 关键业务日志:OOM发生前后,有没有什么特殊的业务日志?

  3. 慢查询日志:看看是不是有SQL查了大量数据。

  4. GC日志:结合时间点和业务日志对照着看。

5.4 本地复现与调试

如果能在本地复现问题,那定位起来就容易多了。

怎么复现?

  1. 构造测试数据:模拟线上的数据量和场景。

  2. 调小堆内存:把本地的堆内存设小一点,让问题更容易暴露。

  3. 压测:用JMeter、Gatling等工具压测,模拟高并发场景。

  4. 添加探针:在关键位置加日志,打印对象大小、集合大小等信息。

本地调试工具:

  • JProfiler/YourKit:可以实时监控内存,还能看对象分配的堆栈

  • VisualVM:JDK自带,免费够用

  • Arthas:阿里开源的Java诊断工具,线上也能用

5.5 Arthas在线诊断神器

重点推荐一下Arthas,这是阿里开源的Java在线诊断工具,堪称"线上调试神器"。

常用命令:

# 启动Arthas
java -jar arthas-boot.jar

# 查看仪表盘
dashboard

# 生成堆转储
heapdump /path/to/heapdump.hprof

# 查看方法调用情况
watch com.example.service.UserService getUser

# 追踪方法调用路径和耗时
trace com.example.service.UserService getUser

# 查看对象的属性
vmtool --action getInstances

# 反编译线上代码
jad com.example.service.UserService

Arthas的功能非常强大,强烈建议每个Java开发者都学一下,关键时刻能救命。

💡 经验之谈:很多时候dump分析告诉你"是什么",但没告诉你"为什么"。这时候就需要结合代码、日志、业务场景一起分析,甚至加日志重新发布来验证。这个过程可能需要反复几次,要有耐心。


六、步骤五:验证修复与预防措施

找到根因了,改完代码就完事了?当然不是。第五步,我们要验证修复是否有效,并且建立预防机制,避免下次再踩同样的坑。

6.1 修复方案验证

改完代码,不能直接就上线,得先验证一下修复是不是真的有效。

验证方法:

  1. 单元测试/集成测试:针对修复的点写测试用例,确保逻辑正确

  2. 压测验证:这是最重要的。用压测工具模拟线上流量甚至更高的流量,跑一段时间,观察内存使用情况。

  3. 对比GC日志:修复前后各跑一次压测,对比GC日志,看看老年代的使用趋势是不是变好了。

  4. 堆转储对比:压测结束后,各dump一次,对比对象数量和大小,确认泄漏的对象没了。

压测注意事项:

  • 压测环境尽量和线上一致

  • 压测时间要足够长,内存泄漏可能要跑很久才会显现

  • 监控要跟上,实时观察内存、GC、CPU等指标

6.2 常见的修复策略

不同的OOM原因,修复策略也不同。这里总结几种常见的:

策略一:调整JVM参数

如果只是堆内存设置太小,业务本身没问题,那调大堆内存就行。

# 初始堆和最大堆都设为4G
-Xms4g -Xmx4g

# 元空间设为256M
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m

⚠️ 注意:调大堆内存只是"治标不治本"。如果是内存泄漏,调大堆只能延缓OOM的时间,不能根本解决问题。

策略二:优化代码

这是最根本的解决方式。根据根因,针对性地优化代码:

  • 集合泄漏:加过期、加容量限制、用完及时移除

  • 大对象:改成流式处理、分页处理

  • 资源未关闭:用try-with-resources,确保关闭

  • ThreadLocal泄漏:用完remove

  • 监听器泄漏:注销监听器

代码优化示例:

优化前(有泄漏):

public class CacheManager {
    private static final Map<String, CacheObject> cache = new HashMap<>();

    public void put(String key, Object data) {
        cache.put(key, new CacheObject(data, System.currentTimeMillis()));
    }

    public Object get(String key) {
        CacheObject obj = cache.get(key);
        if (obj != null) {
            return obj.getData();
        }
        return null;
    }
}

优化后(加过期和容量限制):

public class CacheManager {
    private static final int MAX_SIZE = 10000;
    private static final long EXPIRE_TIME = 3600 * 1000;

    private static final Map<String, CacheObject> cache = new LinkedHashMap<String, CacheObject>(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, CacheObject> eldest) {
            return size() > MAX_SIZE;
        }
    };

    public void put(String key, Object data) {
        cache.put(key, new CacheObject(data, System.currentTimeMillis()));
    }

    public Object get(String key) {
        CacheObject obj = cache.get(key);
        if (obj == null) {
            return null;
        }
        if (System.currentTimeMillis() - obj.getCreateTime() > EXPIRE_TIME) {
            cache.remove(key);
            return null;
        }
        return obj.getData();
    }
}

或者更简单,直接用Guava Cache:

LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(1, TimeUnit.HOURS)
    .build(new CacheLoader<String, Object>() {
        @Override
        public Object load(String key) throws Exception {
            return loadFromDb(key);
        }
    });

策略三:使用软引用/弱引用

如果是缓存类的场景,可以考虑用软引用或弱引用,让内存不够时GC能自动回收。

// 软引用:内存不足时会被回收
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024]);

// 弱引用:只要GC就会被回收
WeakReference<Object> ref = new WeakReference<>(new Object());

不过要注意,软引用和弱引用不是银弹,使用不当反而会带来更多问题。

策略四:优化缓存策略

如果是缓存导致的OOM,重点优化缓存策略:

  • 设置合理的过期时间

  • 设置最大容量,超过就淘汰

  • 选择合适的淘汰算法(LRU、LFU、FIFO等)

  • 冷热数据分离

策略五:资源池化

对于连接、线程、对象等创建成本高的资源,使用池化技术。

  • 数据库连接池:HikariCP、Druid

  • 线程池:ThreadPoolExecutor

  • 对象池:Apache Commons Pool

6.3 预防措施

“事后救火"不如"事前预防”。建立完善的预防机制,才能从根本上减少OOM的发生。

6.3.1 代码规范与Code Review

很多低级错误,通过Code Review都能发现。

  • 制定代码规范,明确哪些写法是禁止的

  • 严格执行Code Review,特别是核心模块

  • 新人培训,普及常见的坑

6.3.2 静态代码检查

用工具自动扫描代码中的问题,比人工review更高效、更全面。

  • SonarQube:功能强大的代码质量平台

  • FindBugs/SpotBugs:专门找Java bug的工具

  • Alibaba Java Coding Guidelines:阿里Java规范插件

6.3.3 压测常态化

不要等上线了才发现性能问题。把压测纳入发布流程,每次上线前都跑一遍。

  • 核心接口必须过压测

  • 压测要覆盖各种场景

  • 关注性能指标:QPS、响应时间、内存使用、GC情况

  • 建立性能基线,每次发布对比

6.3.4 完善的监控告警

等OOM了才发现就太晚了。好的监控能在内存异常时就发出告警。

需要监控的关键指标:

  • 堆内存使用率:超过80%就告警

  • 非堆内存使用率:元空间、直接内存等

  • GC情况:Young GC频率、Full GC频率、GC耗时

  • 线程数:线程总数、线程状态

  • 类加载数:监控元空间泄漏

  • 直接内存使用量:监控直接内存泄漏

6.3.5 定期堆转储分析

即使没有OOM,也可以定期dump一次堆内存,分析一下有没有泄漏的苗头。早发现早处理。

6.3.6 灰度发布

核心功能上线走灰度,先切一小部分流量,观察一段时间没问题再全量。

6.4 建立应急响应机制

就算预防做得再好,也不能保证100%不出问题。所以还要有完善的应急响应机制。

应急预案应该包括:

  • 止损流程:怎么摘流、怎么降级、怎么扩容

  • 排查流程:第一步做什么、第二步做什么、找谁协助

  • 联系人:各个模块的负责人是谁

  • 复盘机制:故障后要复盘,总结经验教训

💡 经验之谈:故障不可怕,可怕的是同样的故障反复发生。每次故障后认真复盘,把经验沉淀下来,团队的技术水平才能不断提高。


七、常见OOM场景实战案例

理论讲了这么多,不如来几个真实的案例更直观。下面分享几个我在工作中遇到过的真实OOM案例,每个案例都包含:现象、排查过程、根因、修复方案。

案例一:HashMap的key没重写hashCode和equals导致的泄漏

现象

线上一个服务,运行几天后内存缓慢上涨,最终OOM。dump分析发现有一个HashMap特别大,占了70%的内存,里面有几百万个Entry。但按业务逻辑,这个Map里最多应该只有几千条数据。

排查过程

  1. 看dump,发现HashMap的size是几百万,远超预期

  2. 看key的类型,是一个自定义的UserKey类

  3. 检查UserKey类,发现没有重写hashCode和equals方法

  4. 再看业务代码,发现每次请求都会new一个UserKey然后put到Map里

根因

HashMap的key如果没有重写hashCode和equals,就会用Object的默认实现——基于对象地址。这样即使两个UserKey的业务字段完全一样,它们的hashCode也不一样,equals也返回false。所以每次put都会当成新的key,Map就越来越大。

问题代码:

// 自定义key,没重写hashCode和equals
public class UserKey {
    private Long userId;
    private String tenantId;

    public UserKey(Long userId, String tenantId) {
        this.userId = userId;
        this.tenantId = tenantId;
    }
}

// 使用
Map<UserKey, UserInfo> cache = new HashMap<>();
cache.put(new UserKey(1L, "t1"), userInfo);
cache.put(new UserKey(1L, "t1"), userInfo); // 又存了一份!因为key不是同一个对象

修复方案

给UserKey重写hashCode和equals方法:

public class UserKey {
    private Long userId;
    private String tenantId;

    public UserKey(Long userId, String tenantId) {
        this.userId = userId;
        this.tenantId = tenantId;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserKey userKey = (UserKey) o;
        return Objects.equals(userId, userKey.userId) &&
               Objects.equals(tenantId, userKey.tenantId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(userId, tenantId);
    }
}

经验教训

  • 作为HashMap的key,一定要重写hashCode和equals

  • IDE自动生成就行,别偷懒手写,容易写错

  • 用Lombok的@Data注解更省事

案例二:ThreadLocal导致的内存泄漏

现象

一个Web服务,使用Tomcat线程池。运行一段时间后元空间OOM,dump分析发现有很多ClassLoader的实例,每个ClassLoader加载了很多类。

排查过程

  1. 元空间OOM,说明类加载太多

  2. dump分析,发现有大量的ClassLoader实例

  3. 顺着引用链找,发现这些ClassLoader被ThreadLocal持有

  4. 再看代码,发现有个工具类用ThreadLocal存了用户上下文,但用完没remove

根因

Tomcat的线程池中的线程是复用的。ThreadLocal用完如果不remove,线程下一次处理请求时,之前的值还在。更严重的是,如果ThreadLocal里存的对象引用了ClassLoader(比如动态代理类),那ClassLoader也无法被回收,导致元空间泄漏。

问题代码:

public class UserContext {
    private static final ThreadLocal<UserInfo> userThreadLocal = new ThreadLocal<>();

    public static void setUser(UserInfo user) {
        userThreadLocal.set(user);
    }

    public static UserInfo getUser() {
        return userThreadLocal.get();
    }

    // 没有remove方法!
}

// 使用
public void doFilter(ServletRequest request, ServletResponse response) {
    UserInfo user = getUserFromRequest(request);
    UserContext.setUser(user);
    // 处理请求...
    // 处理完就结束了,没remove
}

修复方案

添加remove方法,在请求结束时调用:

public class UserContext {
    private static final ThreadLocal<UserInfo> userThreadLocal = new ThreadLocal<>();

    public static void setUser(UserInfo user) {
        userThreadLocal.set(user);
    }

    public static UserInfo getUser() {
        return userThreadLocal.get();
    }

    public static void remove() {
        userThreadLocal.remove();
    }
}

// 使用
public void doFilter(ServletRequest request, ServletResponse response) {
    try {
        UserInfo user = getUserFromRequest(request);
        UserContext.setUser(user);
        // 处理请求...
    } finally {
        UserContext.remove(); // 一定要在finally里remove
    }
}

经验教训

  • ThreadLocal用完一定要remove

  • 最好在finally块中remove,确保一定会执行

  • 使用线程池时尤其要注意,因为线程是复用的

案例三:大文件处理导致的堆溢出

现象

一个批量导入功能,用户上传Excel文件,系统解析处理。文件不大的时候没问题,文件大了(几十MB)就OOM。

排查过程

  1. OOM发生在导入功能上

  2. dump分析,发现byte[]数组特别多,占了大部分内存

  3. 看代码,发现用POI解析Excel时,是把整个文件读进内存再解析

根因

POI的UserModel模式(HSSFWorkbook/XSSFWorkbook)是把整个Excel文件加载到内存中处理的。文件大了,内存自然就爆了。

问题代码:

public void importExcel(MultipartFile file) throws IOException {
    // 整个文件读进内存
    Workbook workbook = new XSSFWorkbook(file.getInputStream());
    Sheet sheet = workbook.getSheetAt(0);
    // 逐行处理...
}

修复方案

使用POI的SAX模式(事件驱动),流式解析,不用把整个文件加载到内存:

public void importExcel(MultipartFile file) throws Exception {
    OPCPackage pkg = OPCPackage.open(file.getInputStream());
    XSSFReader reader = new XSSFReader(pkg);
    SharedStringsTable sst = reader.getSharedStringsTable();
    XMLReader parser = XMLReaderFactory.createXMLReader();
    parser.setContentHandler(new SheetHandler(sst) {
        @Override
        public void processRow(List<String> row) {
            // 逐行处理,处理完就丢弃
            processRowData(row);
        }
    });

    Iterator<InputStream> sheets = reader.getSheetsData();
    while (sheets.hasNext()) {
        try (InputStream sheet = sheets.next()) {
            parser.parse(new InputSource(sheet));
        }
    }
    pkg.close();
}

或者用EasyExcel,它底层也是SAX模式,但API更友好:

EasyExcel.read(file.getInputStream(), DataDTO.class, new DataListener())
    .sheet()
    .doRead();

经验教训

  • 处理大文件一定要用流式处理,不要一次性加载到内存

  • 不仅是Excel,大文本、大图片、大日志都一样

  • 能用框架就用框架,别自己造轮子

案例四:数据库查询未分页导致OOM

现象

一个定时任务,每天凌晨跑一次,跑着跑着就OOM了。这个任务是同步数据,把A表的数据同步到B表。

排查过程

  1. OOM发生在定时任务执行期间

  2. dump分析,发现有大量的User对象,几百万个

  3. 看代码,发现同步任务是一次性把A表所有数据查出来,然后逐条处理

  4. A表的数据量已经增长到几百万条了

根因

一次性查出几百万条数据,全部加载到内存,不OOM才怪。

问题代码:

public void syncData() {
    // 一次性查出所有用户,几百万条!
    List<User> users = userMapper.selectAll();
    for (User user : users) {
        // 处理...
    }
}

修复方案

改成分页查询,每次只查一页:

public void syncData() {
    int pageSize = 1000;
    long lastId = 0;

    while (true) {
        // 按ID分页,每次查1000条
        List<User> users = userMapper.selectByIdGreaterThan(lastId, pageSize);
        if (users.isEmpty()) {
            break;
        }

        for (User user : users) {
            // 处理...
        }

        lastId = users.get(users.size() - 1).getId();
    }
}

💡 小技巧:用"游标分页"(基于lastId)比"偏移量分页"(limit offset)效率高,特别是数据量大的时候。offset大了MySQL会慢。

经验教训

  • 数据库查询一定要考虑数据量,不能想当然selectAll

  • 数据量会增长,今天没问题不代表明天没问题

  • 批量处理任务,优先考虑分页或流式处理

案例五:元空间溢出(动态类生成过多)

现象

一个使用了大量动态代理的服务,运行一段时间后报Metaspace OOM。

排查过程

  1. 错误信息是Metaspace溢出

  2. jstat看元空间使用率,确实一直在涨

  3. dump分析,发现有大量的动态生成的类,类名类似$Proxy123CGLIB$$EnhancerByCGLIB$$...

  4. 看代码,发现每次调用某个方法都会动态生成一个代理类

根因

动态代理(JDK动态代理、CGLIB等)会在运行时生成新的类。如果每次调用都生成新的代理类,而不是复用,那类就会越来越多,最终撑爆元空间。

问题代码:

public Object getProxy(Object target) {
    // 每次调用都创建新的Enhancer,生成新的代理类
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(target.getClass());
    enhancer.setCallback(new MyMethodInterceptor());
    return enhancer.create();
}

修复方案

缓存代理类,同一个Class只生成一次代理:

private static final ConcurrentHashMap<Class<?>, Class<?>> proxyCache = new ConcurrentHashMap<>();

public Object getProxy(Object target) {
    Class<?> targetClass = target.getClass();
    Class<?> proxyClass = proxyCache.get(targetClass);

    if (proxyClass == null) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(targetClass);
        enhancer.setCallback(new MyMethodInterceptor());
        proxyClass = enhancer.createClass();
        proxyCache.put(targetClass, proxyClass);
    }

    try {
        return proxyClass.newInstance();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

或者直接用Spring的AOP,它已经帮你处理好了这些问题。

经验教训

  • 动态生成类的框架(CGLIB、Javassist等)要注意类的复用

  • 元空间OOM优先排查类加载相关的问题

  • 可以加-XX:+TraceClassLoading参数观察类加载情况

案例六:直接内存溢出(Netty)

现象

一个基于Netty的网关服务,运行一段时间后报Direct buffer memory OOM。堆内存使用率其实不高。

排查过程

  1. 错误信息是Direct buffer memory

  2. 堆内存使用率不高,说明不是堆的问题

  3. 检查直接内存配置,发现没设置,默认是堆内存大小

  4. dump分析,发现DirectByteBuffer对象不多,但每个都很大

  5. 看Netty的配置,发现接收缓冲区设得太大,而且没有上限

根因

Netty使用直接内存来做缓冲区。如果配置不当,比如缓冲区太大、没有限制,或者有泄漏,就会导致直接内存溢出。

问题代码:

public class NettyServer {
    public void start() {
        ServerBootstrap b = new ServerBootstrap();
        b.group(bossGroup, workerGroup)
            .channel(NioServerSocketChannel.class)
            .option(ChannelOption.SO_RCVBUF, 1024 * 1024 * 10) // 接收缓冲区10M!
            .childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) {
                    // ...
                }
            });
    }
}

修复方案

1. 调整缓冲区大小到合理值

2. 设置直接内存上限

-XX:MaxDirectMemorySize=512m

3. 检查有没有ByteBuf泄漏(Netty的ByteBuf需要手动release)

public void channelRead(ChannelHandlerContext ctx, Object msg) {
    ByteBuf buf = (ByteBuf) msg;
    try {
        // 处理buf
    } finally {
        buf.release(); // 一定要释放
    }
}

或者用SimpleChannelInboundHandler,它会自动释放:

public class MyHandler extends SimpleChannelInboundHandler<ByteBuf> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
        // 处理完自动释放
    }
}

经验教训

  • 直接内存OOM,重点排查NIO、Netty、Mina等框架

  • Netty的ByteBuf要注意释放,不然会泄漏

  • 建议设置-XX:MaxDirectMemorySize限制直接内存大小

  • 可以用-Dio.netty.leakDetection.level=advanced开启泄漏检测


八、总结与最佳实践

文章到这里也差不多了,最后我们来总结一下,把这套方法论沉淀下来。

8.1 5步排查法回顾

再回顾一下我们的OOM排查5步法:
在这里插入图片描述

  1. 步骤一:快速止损与现场保护

    • 先止损:摘流、降级、限流、扩容

    • 保现场:生成堆转储、收集日志和配置

    • 关键原则:不要上来就重启

  2. 步骤二:初步定位OOM类型

    • 看错误信息判断类型

    • 分析GC日志看趋势

    • jstat实时监控

  3. 步骤三:深入分析堆转储文件

    • 用MAT等工具分析dump

    • 看泄漏嫌疑报告、直方图、支配树

    • 找GC Roots引用链

    • 常见泄漏模式对号入座

  4. 步骤四:代码级根因定位

    • 结合业务场景缩小范围

    • 代码审查高危点

    • 日志追踪

    • 本地复现与调试

    • Arthas在线诊断

  5. 步骤五:验证修复与预防措施

    • 压测验证修复效果

    • 针对性修复代码

    • 建立预防机制

    • 完善应急响应

8.2 排查思路总结

除了具体的步骤,更重要的是排查的思路。我总结了几条经验:

思路一:先宏观,后微观

不要上来就扎进代码里。先从整体上判断:是哪种OOM?是哪个区域的问题?大概是什么原因?有了方向再深入。不然就像大海捞针,效率很低。

思路二:先假设,后验证

根据现象先提出假设,然后想办法验证。比如"我怀疑是缓存泄漏",那就去查缓存的大小、过期策略、命中率,用数据说话。不要凭感觉。

思路三:善用工具,不要硬扛

Java生态这么成熟,有很多好用的工具。MAT、Arthas、VisualVM、JProfiler……熟练掌握几个工具,能让你的排查效率提升好几倍。

思路四:数据驱动,不要猜

所有的判断都要有数据支撑。dump数据、GC日志、监控数据、业务日志……用数据说话,不要拍脑袋。

思路五:耐心,耐心,还是耐心

复杂的OOM问题可能需要排查好几个小时甚至好几天。不要急躁,一步一步来,总能找到根因的。

8.3 最佳实践清单

最后,给大家一份OOM预防最佳实践清单,可以对照着检查一下自己的项目:

  • JVM配置了-XX:+HeapDumpOnOutOfMemoryError,OOM时自动dump

  • JVM配置了GC日志输出,方便事后分析

  • 核心服务有完善的监控告警(内存、GC、线程等)

  • 有一键摘流、一键降级、一键扩容的止损能力

  • 代码规范中有内存相关的要求

  • Code Review会关注内存风险点

  • 使用了静态代码检查工具

  • 核心接口上线前经过压测

  • 大文件、大数据量处理使用流式/分页方式

  • ThreadLocal用完都会remove

  • 资源(流、连接等)使用try-with-resources确保关闭

  • 缓存有过期时间和容量限制

  • 集合类不会无限增长

  • 线程池有合理的配置,不会无限创建线程

  • 有定期的性能巡检和堆转储分析

  • 有完善的故障应急预案

  • 故障后会复盘,沉淀经验

8.4 写在最后

OOM虽然可怕,但只要掌握了正确的方法论,其实也没那么难搞。关键是要形成一套系统化的排查流程,而不是靠运气瞎碰。

希望这篇文章能帮到你。下次再遇到线上OOM,别慌,按照这5步来,10分钟定位根因不是梦。

当然,纸上得来终觉浅,绝知此事要躬行。方法说得再多,不如自己亲手排查几次印象深刻。建议大家在平时的工作中多练手,把这些工具和方法用熟,关键时刻才能得心应手。

如果你有什么OOM排查的经验或者踩过的坑,欢迎在评论区分享,大家一起交流进步。

最后,祝大家的服务永不OOM,永远稳定运行!


推荐阅读:如果你想深入学习JVM和性能调优,推荐阅读《深入理解Java虚拟机》(周志明著)、《Java性能权威指南》、《实战Java虚拟机》等书籍。同时,Oracle官方的JVM文档也是非常好的学习资料。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
JAVA开发工程师
手记
粉丝
0
获赞与收藏
0

关注作者,订阅最新文章

阅读免费教程

  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消