在 Java 开发中,内存溢出(OutOfMemoryError,简称 OOM)是一个常见且棘手的问题。相比于数组越界、空指针等业务异常,OOM 问题通常更难定位和解决。本文将通过一次线上内存溢出问题的排查过程,分享从问题表现到最终解决的完整思路,希望能为遇到类似问题的开发者提供参考。
1 内存溢出与内存泄露
在 Java 中,与内存相关的问题主要有两种:内存溢出和内存泄露。
- 内存溢出(Out Of Memory):指应用程序申请内存时,JVM 没有足够的内存空间。可以形象地理解为“去蹲坑发现坑位满了”。
- 内存泄露(Memory Leak):指应用程序申请了内存但没有释放,导致内存空间浪费。可以形象地理解为“有人占着茅坑不拉屎”。
1.1 内存溢出
在 JVM 的内存区域中,除了程序计数器,其他内存区域都有可能发生内存溢出。Java 堆是存储对象实例的区域,只要不断创建对象,并确保这些对象与 GC Roots 之间存在可达路径,避免被垃圾回收机制清除,就一定会发生内存溢出。
例如,以下代码会不断创建对象,最终导致内存溢出:
public class OOM {public static void main(String[] args) {List<Object> list = new ArrayList<>();while (true) {list.add(new Object());}}
}
运行该程序时,可以通过设置 JVM 参数 -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
来限制堆内存大小为 20M,并在发生 OOM 时生成内存快照。
1.2 内存泄露
内存泄露是指程序中动态分配的堆内存由于某种原因未能释放,导致系统内存浪费,进而可能引发程序运行速度减慢甚至系统崩溃。简单来说,内存泄露是由于应该被垃圾回收的对象未能被回收,导致内存占用不断增加,最终可能导致内存溢出。
例如,以下代码中,数据库连接未关闭,导致内存泄露:
public class MemoryLeak {public static void main(String[] args) {try {Connection conn = null;Class.forName("com.mysql.jdbc.Driver");conn = DriverManager.getConnection("url", "", "");Statement stmt = conn.createStatement();ResultSet rs = stmt.executeQuery("....");} catch (Exception e) {// 异常日志} finally {// 1. 关闭结果集 Statement// 2. 关闭声明的对象 ResultSet// 3. 关闭连接 Connection}}
}
如果连接未关闭,GC 将无法回收相关对象(如 Connection
、Statement
、ResultSet
等),从而导致内存泄露。
换句话说,内存泄露不是内存溢出,但会加快内存溢出的发生。
2 内存溢出的表现
在生产环境中,内存溢出问题通常随着业务量的增长而频繁出现。例如,某应用程序从 Kafka 消费数据并进行批量持久化操作,随着 Kafka 消息量的增加,OOM 问题出现的频率也越来越高。虽然重启可以暂时解决问题,但这并非长久之计。
3 内存泄露的排查
为了排查内存泄露问题,首先需要分析运维收集的内存数据和 GC 日志。通过 jstat
工具可以发现,老年代的内存使用率即使在发生 Full GC 后仍然居高不下,且随着时间的推移逐渐增加。这表明应用程序中存在大量无法回收的对象。
4 内存泄露的定位
由于生产环境的内存快照文件较大(几十 GB),使用 MAT(Memory Analyzer Tool)进行分析耗时较长。因此,我们尝试在本地复现问题。通过将本地应用的最大堆内存设置为 150M,并模拟 Kafka 数据消费,使用 VisualVM 监控内存和 GC 情况。
经过多次尝试,发现只有在模拟生产环境的数据量(每次从 Kafka 取出几百条数据)时,才能复现内存溢出问题。通过 VisualVM 的 HeapDump 功能,发现 com.lmax.disruptor.RingBuffer
类型的对象占用了近 50% 的内存。
5 内存泄露的解决
通过代码审查,发现从 Kafka 取出的数据直接放入 Disruptor 环形队列中,而队列的大小配置为 1024 * 1024
,导致内存中积累了大量的对象。通过将队列大小调整为较小的值(如 2),问题得到解决。
Disruptor 是一个高性能的异步处理框架,它的核心思想是:通过无锁的方式来实现高性能的并发处理,其性能是高于 JDK 的 BlockingQueue 的。
6 总结
虽然最终只是修改了一行代码(或配置),但整个排查过程非常有意义。通过这次经历,我们可以更好地理解 JVM 内存管理的机制,并掌握排查内存溢出和内存泄露问题的基本方法。同时,也提醒我们在使用高性能框架(如 Disruptor)时,必须谨慎配置参数,避免因不当使用而导致内存问题。
7 思维导图
8 参考链接
一次内存溢出的排查优化实战,彻底干掉臭名昭著的 OOM