JVM-(11)JVM-定位OOM问题

JVM-定位OOM问题

OOM 即 OutOfMemoryError(内存溢出),是由于 JVM 的垃圾回收器 (Garbage Collector) 无法回收出足够的空间来满足对象分配的请求,并且堆内存已经无法再扩大容量导致的异常。


如不了解JVM知识,请查看如下链接:
JVM-(1)JVM入门
JVM-(2)Class File Format
JVM-(3)Class 加载过程
JVM-(5)JVM内存模型
JVM-(6)JVM GC
JVM-(7)堆内存逻辑分区
JVM-(8)JVM启动的常用命令以及参数
JVM-(9)JVM诊断的常用命令以及参数

导致OOM问题原因有哪些?

一、堆内存(Heap)相关 OOM (Java heap space, GC overhead limit exceeded)

这是最常见的一类 OOM,发生在对象实例分配的区域。

1. 内存泄漏 (Memory Leak) - 最主要原因

概念:对象已经不再被应用程序使用,但由于疏忽或错误,它们仍然被 GC Roots (不了解GC Roots请查看 JVM-(6)JVM GC)引用,导致垃圾回收器无法回收它们。

哪些情况会导致内存泄漏?

  • 静态集合类滥用 :使用 static 修饰的 Map, List, Set 等,一旦将对象放入,其生命周期就与 JVM 一致,除非手动移除。

    复制代码
    public class MemoryLeak {
        public static Map<String, Object> staticMap = new HashMap<>(); // 危险!
        public void add(String key, Object value) {
            staticMap.put(key, value); // 只存放不移除,对象永远无法被回收
        }
    }
  • 未关闭的资源:数据库连接 (Connection)、文件流 (InputStream/OutputStream)、网络连接 (Socket) 等未在 finally 块或 try-with-resources 中显式关闭。这些对象不仅本身占内存,还可能附带巨大的底层资源。

  • 监听器和回调未注销:向全局管理器注册了监听器(如事件监听器),但在对象不用时没有注销,导致管理器依然持有其引用。

2. 内存需求预估不当
  • 堆大小设置不合理:应用本身需要 2G 内存才能平稳运行,但 JVM 堆最大只分配了 1G (-Xmx1g)。
  • 数据量激增
    • 流量陡增:正常系统突然遇到高并发请求,瞬间创建海量对象(如处理一次大促活动)。
    • 处理大文件/大数据集:一次性将一个大文件加载到内存或数据库查询结果全部加载到内存的 List 或 Map 中,而不是流式或分页处理。
    • 一次性任务:跑一个批处理job,需要处理的数据量远超平时。
3. 代码编写不当
  • 循环中创建大量对象:在循环体内频繁创建大对象,且无法快速回收。
  • 使用不当的数据结构:例如,使用 Vector 或 StringBuffer 时设置了过大的初始容量(capacity)。

二、非堆内存(Non-Heap)相关 OOM

1. Metaspace 溢出 (Metaspace / PermGen space)
  • 动态生成大量类:
    • 使用 CGLib、ASM、Javassist 等库进行动态代理、字节码增强,运行时生成了大量新类。
  • 反射:大量使用 Reflection 也可能增加 Metaspace 的负担。
  • 参数设置不当:-XX:MaxMetaspaceSize(或 JDK7 的 -XX:MaxPermSize)设置过小。
2. 直接内存溢出 (Direct buffer memory)
  • NIO 操作:显式调用 ByteBuffer.allocateDirect(size) 申请直接内存,但未妥善管理。
  • 使用 NIO 框架:如 Netty,其高性能的核心就是使用了直接内存池。如果在 Netty 的 ChannelHandler 中处理 ByteBuf 后没有调用 .release() 方法释放,就会造成直接内存泄漏。这是最常见的原因。
  • 参数设置不当:-XX:MaxDirectMemorySize 设置过小。
3. 线程栈溢出 (unable to create new native thread)
  • 创建过多线程:应用程序创建了成千上万个线程,超过了操作系统或 JVM 的限制。
  • 栈空间设置过大:每个线程都需要独立的栈空间(通过 -Xss 设置,如默认1M)。如果线程数多且 -Xss 值设得大,很容易耗尽用于线程栈的本地内存。
  • 系统限制:Linux 系统下通过 ulimit -u 命令查看的用户最大进程数限制。

三、特殊类型的 OOM

GC overhead limit exceeded

这是一种特殊的堆内存 OOM。它表示 JVM 花费了 98% 以上的时间进行垃圾回收,但每次回收只能释放不到 2% 的堆空间。这通常是内存泄漏的晚期症状,JVM 在绝望地做无用功。

如何定位OOM问题?

正常案例

1)设置 JVM 堆大小

本文使用文件下载的例子来描述oom问题是如何发生的,为方便快速得到实验结果,这里设置的堆内存大小为100M

复制代码
-Xms100m -Xmx100m

先看下正常的下载案例代码:

分批次从输入流中读取一定内容(1024 B)到内存,而不是一次性将整个文件加载到内存

Java 复制代码
@GetMapping("/download3/{filename}")
    public void downloadFile3(@PathVariable String filename, HttpServletResponse response) throws IOException {
        Path filePath = Paths.get(uploadDir).resolve(filename).normalize();
        // 读到流中
        InputStream inputStream = new FileInputStream(filePath.toFile());// 文件的存放路径
        response.reset();
        response.setContentType("application/octet-stream");
        response.addHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(filename, "UTF-8"));
        ServletOutputStream outputStream = response.getOutputStream();
        byte[] b = new byte[1024];
        int len;
        //从输入流中读取一定数量的字节,并将其存储在缓冲区字节数组中,读到末尾返回-1
        while ((len = inputStream.read(b)) > 0) {
            outputStream.write(b, 0, len);
        }
        inputStream.close();
    }

2)准备测试数据

本案例中下载的单个文件大小为 1.67M

3)使用 JMeter 测试

使用JMeter 模拟100个线程同时去下载该 1.67 M的文件

4)使用 Java VisualVM 工具 观察 jvm 堆内存使用情况

Java VisualVM 工具是 java 自带 jvm 分析工具,可以在 java/bin 目录找到,

异常案例

1)异常案例代码

Java 复制代码
@GetMapping("/download2/{filename}")
    public void downloadFile2(@PathVariable String filename, HttpServletResponse response) throws IOException {
        Path filePath = Paths.get(uploadDir).resolve(filename).normalize();
        /**
         * 高危写法
         */
        byte[] fileByte = Files.readAllBytes(filePath);
        // 设置response的Header
        response.setCharacterEncoding("UTF-8");
        // 指定下载文件名(attachment-以下载方式保存到本地,inline-在线预览)
        response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
        // 告知浏览器文件的大小
        response.addHeader("Content-Length", "" + fileByte.length);
        // 内容类型为通用类型,表示二进制数据流
        response.setContentType("application/octet-stream");
        OutputStream os = response.getOutputStream();
        os.write(fileByte);
        os.flush();
    }

2)测试

100个线程同时下载

3)使用 Java VisualVM 工具 观察 jvm 堆内存使用情况

如何定位

① 启动JVM时添加产生oom异常时生成HeapDump参数
复制代码
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D://test//dump//
② 使用 VisualVM 导入 HeapDump 文件

这里使用的是 VisualVM , java 自带的 Java VisualVM 也可以,操作方法类似,官网:http://visualvm.github.io/

③ 使用 VisualVM 分析 HeapDump

1)找到占用内存最大的对象

按 Size 倒序,这里占用内存最大的对象是 byte[]

2)根据该对象找到其对应的 GC Root,本处 byte[]就是 GC Root 对象

3)在 GC Root 上右键选择 Select in Threads,跳转到线程视图,可以找到使用该 GC Root 的代码块

可以看到 Files.readAllBytes 这段代码是在 DownloadController 类,downloadFile2 方法中调用的,假设这段代码不是自己写的,我们事先也不知道,但是当看到这段代码的时候就要提高警惕了,很明显它的意思是将文件全部加载到内存

相关推荐
唐古乌梁海19 小时前
【Java】JVM 内存区域划分
java·开发语言·jvm
众俗20 小时前
JVM整理
jvm
echoyu.20 小时前
java源代码、字节码、jvm、jit、aot的关系
java·开发语言·jvm·八股
代码栈上的思考1 天前
JVM中内存管理的策略
java·jvm
thginWalker2 天前
深入浅出 Java 虚拟机之进阶部分
jvm
沐浴露z2 天前
【JVM】详解 线程与协程
java·jvm
thginWalker2 天前
深入浅出 Java 虚拟机之实战部分
jvm
程序员卷卷狗3 天前
JVM 调优实战:从线上问题复盘到精细化内存治理
java·开发语言·jvm
Sincerelyplz3 天前
【JDK新特性】分代ZGC到底做了哪些优化?
java·jvm·后端
初学小白...4 天前
线程同步机制及三大不安全案例
java·开发语言·jvm