📘内存分析
type
status
date
slug
summary
tags
category
icon
password
此版块系列作为前置知识的同时,会将涉及到的大部分指标和规范运用到本项目监控的策略中,也欢迎提交意见或错误
Spring Boot 内存泄漏分析
内存泄漏是指应用程序中某些对象虽然不再被使用,但由于仍然被其他活动对象引用,导致垃圾收集器(GC)无法回收其所占用的内存。随着时间的推移,这些未被回收的对象会持续累积,最终可能耗尽堆内存,引发java.lang.OutOfMemoryError 错误,并导致应用程序性能下降或崩溃。
1.1. 内存泄漏的常见原因与症状
开发者自身编码不当是导致内存泄漏的主要原因。以下是一些在 Spring Boot 应用中常见的内存泄漏场景:
- 静态字段持有对象引用:静态集合(如 List、Map)或其他静态字段如果持有大量对象的引用,并且这些引用在对象不再需要后未被清除,将导致这些对象常驻内存 1。例如,一个静态 List 不断添加元素而从不移除。
- 示例代码 2:
- 避免策略:最小化静态变量的使用。如果必须使用,确保在不再需要时从静态集合中移除对象,或将集合本身置为 null 。
- 未关闭的资源:数据库连接、文件流、网络连接(如 InputStream、OutputStream、Connection、Session)等资源在使用完毕后若未显式关闭,它们所占用的内存及关联的本地资源将无法释放 。
- 避免策略:始终在 finally 块中关闭资源,或使用 Java 7 及以上版本提供的 try-with-resources 语句,它能自动确保实现了 AutoCloseable 接口的资源被关闭。
- 不恰当的 equals() 和 hashCode() 实现 (Improper equals() and hashCode() Implementations):在将对象存入基于哈希的集合(如 HashSet、HashMap)时,如果对象的 equals() 和 hashCode() 方法实现不正确(例如,仅依赖可变字段或未同时重写两者),可能导致集合中存在逻辑上重复但无法被识别移除的对象,从而造成内存累积。
- 避免策略:确保正确实现 equals() 和 hashCode() 方法,遵守其约定(例如,相等的对象必须具有相同的哈希码),并优先使用不可变字段参与哈希计算。
- 引用外部类的内部类 (Inner Classes that Reference Outer Classes):非静态内部类会隐式持有其外部类实例的引用。如果一个非静态内部类的实例生命周期比其外部类实例更长,外部类实例将无法被回收。
- 避免策略:如果内部类不需要访问外部类的成员,将其声明为静态内部类(Static Nested Class)以解除对外部类实例的隐式引用。
- ThreadLocal 使用不当 (ThreadLocals):ThreadLocal 变量为每个线程提供独立的副本。如果在线程池环境下,线程被复用,而 ThreadLocal 存储的对象在使用完毕后未通过 remove() 方法清理,这些对象将与线程生命周期绑定,导致内存泄漏,尤其是在线程池中线程长时间存活的情况下。
- 避免策略:在线程完成任务后,务必在 finally 块中调用 ThreadLocal 实例的 remove() 方法,以清除线程局部变量 。
内存泄漏的典型症状:
- 应用程序运行时抛出 java.lang.OutOfMemoryError。
- 应用程序长时间运行后性能逐渐下降,响应时间变长,但在刚启动时表现正常。
- 垃圾收集(GC)的频率和持续时间随着应用运行时间的增长而增加。
- 连接池耗尽(如数据库连接、HTTP 客户端连接)。
- 应用程序意外崩溃或无响应。
需要注意的是,并非所有 OutOfMemoryError 都意味着内存泄漏。有时可能是因为为 JVM 分配的堆内存(通过 -Xms 和 -Xmx 参数设置)不足以支撑应用的正常运行负载。
1.2. 内存泄漏诊断:堆转储 (Heap Dump)
堆转储是分析内存泄漏最有效的手段之一。它是在特定时刻 JVM 堆内存中所有对象的快照。
1.2.1. 生成堆转储文件
有多种方法可以生成堆转储文件:
- JVM 参数自动生成:通过在启动应用时添加 JVM 参数 -XX:+HeapDumpOnOutOfMemoryError,可以在发生 OutOfMemoryError 时自动生成堆转储文件。可以使用 -XX:HeapDumpPath=<file-or-dir-path> 指定转储文件的路径和名称。这是生产环境中推荐的做法,因为它能在问题发生的确切时刻捕获现场。
- 命令行工具手动生成:
- jmap:JDK 自带工具,用于打印指定 Java 进程的内存映射信息或生成堆转储。
- jcmd:JDK 8 及以后版本推荐使用的多功能诊断命令工具,相较于 jmap,性能开销更小。
其中 live 选项表示只转储存活对象,format=b 表示二进制格式,<pid> 是 Java 进程 ID(可通过 jps 命令获取)。需要注意,
jmap 在某些 JDK 版本中被标记为实验性且不受支持,可能会对应用性能产生影响。
- 图形化工具生成:
- JVisualVM:连接到目标 Java 进程后,在“监视”选项卡中点击“堆 Dump”按钮,或在“应用程序”节点右键选择“堆 Dump” 。
- JConsole:连接到进程后,在 MBeans 选项卡中找到 com.sun.management -> HotSpotDiagnostic -> Operations -> dumpHeap,输入输出文件路径和是否只 dump live 对象参数后执行。
- Eclipse Memory Analyzer (MAT):MAT 自身也可以直接从本地运行的 Java 进程获取堆转储。
表1:堆转储生成方法总结
方法 | 工具/参数 | 优点 | 缺点 | 适用场景 |
OOM 时自动生成 | -XX:+HeapDumpOnOutOfMemoryError | 捕获问题发生的确切时刻,无需人工干预 | 仅在 OOM 时触发 | 生产环境监控 |
命令行手动生成 (jmap) | jmap -dump | 灵活控制生成时机,可 dump live 对象 | 可能影响应用性能,部分版本不受支持 | 开发、测试、按需生产诊断 |
命令行手动生成 (jcmd) | jcmd <pid> GC.heap_dump | JDK 推荐,性能影响较小 | JDK 8+ | 开发、测试、按需生产诊断 |
图形化工具 (JVisualVM) | JVisualVM 界面操作 | 直观易用,可结合其他监控信息 | 需要 GUI 环境,可能引入额外开销 | 开发、测试环境 |
图形化工具 (JConsole) | JConsole MBean 操作 | JDK 自带,无需额外安装 | 操作相对繁琐 | 开发、测试环境 |
图形化工具 (Eclipse MAT) | MAT 界面操作 | 可直接从本地进程获取并立即分析 | 需要安装 MAT | 开发、测试环境 |
1.2.2. 使用图形化工具分析堆转储
堆转储文件通常以 .hprof 或 .bin 结尾,可以使用专门的分析工具进行解读。
1.2.2.1. Eclipse Memory Analyzer (MAT)
MAT 是一款功能强大的 Java 堆分析器,尤其擅长检测内存泄漏和减少内存消耗。
MAT 分析步骤:
- 打开堆转储:通过 File > Open Heap Dump... 打开 .hprof 文件。MAT 会对文件进行解析,这可能需要一些时间,具体取决于堆转储的大小。
- 概览 :打开后首先展示的是概览页,包含堆大小、对象数量、类加载器数量等信息,以及一个显示最大对象的饼图。如果堆转储的总大小远小于文件大小,可能意味着 dump 时包含了大量待回收的垃圾对象。
- 泄漏嫌疑报告 :这是 MAT 的一个关键功能,可以自动分析堆并报告潜在的内存泄漏点。通常在打开堆转储后,向导会提示生成此报告。报告会列出问题嫌疑对象(Problem Suspects),并指出其占用的堆大小。如果某个嫌疑对象占用了堆内存的显著比例(例如 50% 或更多),那么它很可能就是泄漏源。
- 直方图 :显示每个类的实例数量、浅堆(Shallow Heap)大小和深堆(Retained Heap)大小。
- 浅堆:对象自身占用的内存大小。
- 深堆:对象自身以及它所持有的其他对象(仅被该对象持有)所占用的总内存大小。这是判断内存泄漏的关键指标。
- 可以通过右键点击类名,选择 List objects -> with outgoing references (查看对象引用的其他对象) 或 with incoming references (查看哪些对象引用了该类的实例)。
- 还可以按类加载器、包或超类对直方图进行分组,有助于缩小分析范围。
- 支配树 :显示堆中对象的支配关系。如果对象 A 支配对象 B,意味着所有指向 B 的路径都必须经过 A。支配树的根节点通常是占用内存最大的对象集合。通过支配树可以清晰地看到哪些对象阻止了其他大量对象的回收 。
- 到 GC Roots 的路径 :对于任何一个对象,都可以查询其到 GC Roots 的引用链。GC Roots 是垃圾收集器进行可达性分析的起点,如活动的线程、JNI 引用、JVM 系统类加载器加载的类等。如果一个不再需要的对象仍然存在到 GC Roots 的路径,就说明发生了内存泄漏。MAT 可以显示最短的引用路径,帮助定位泄漏源头 。
- 在直方图或支配树中选中可疑对象,右键选择 Path to GC Roots -> exclude all phantom/weak/soft etc. references 来查看强引用链。
表2:Eclipse MAT 关键特性
特性 | 描述 | 用途 |
泄漏嫌疑报告 | 自动分析并报告潜在的内存泄漏点,突出显示大对象和集合。 | 快速定位可能的泄漏源。 |
直方图 | 按类列出实例数量、浅堆和深堆大小。 | 识别占用内存过多的类。 |
支配树 | 展示对象的支配关系图,显示哪些对象因被其他对象持有而无法回收。 | 理解对象的持有结构,找到关键的内存持有者。 |
到 GC Roots 的路径 | 显示从选定对象到垃圾收集根的引用链。 | 确定对象为何没有被垃圾收集器回收。 |
OQL (Object Query Language) | 类 SQL 的查询语言,用于对堆进行复杂的查询。 | 灵活地查询和分析堆中对象。 |
比较堆转储 | 比较两个堆转储文件,找出增长的对象。 | 适用于分析一段时间内内存增长情况。 |
在使用 MAT 时,关注那些预期生命周期较短但实际存活时间很长,并且持有大量内存的对象。例如,在 New Relic 的一个案例分析中,通过 Leak Suspects 报告发现 com.newrelic.agent.TransactionService 持有大量内存,进一步通过 "List objects with outgoing references" 追踪到 ConcurrentHashMap 的 updateQueue 累积了大量事务对象。
1.2.2.2. Java VisualVM (JVisualVM)
JVisualVM 是 JDK 自带的多合一故障诊断和性能分析工具,提供了监控、CPU 采样、内存采样、线程分析和堆转储分析等功能。
JVisualVM 分析步骤:
- 连接进程并监控:启动 JVisualVM,它会自动发现本地运行的 Java 进程。双击进程名即可连接。在“监视”选项卡可以实时查看 CPU、堆内存、类、线程的使用情况。持续观察堆内存使用曲线,如果呈持续上升且GC后不明显下降的趋势,可能存在内存泄漏。
- 生成或加载堆转储:
- 在“监视”选项卡点击“堆 Dump”按钮。
- 或者,在左侧“应用程序”窗格中右键点击目标进程,选择“堆 Dump”。
- 也可以通过 File > Load... 加载已有的 .hprof 文件。
- 分析堆转储:
- 概要 :显示堆的基本信息、系统属性、线程列表等。
- 类 :按类名显示实例数量和总大小。可以按实例数量或大小排序,快速找到占用内存最多的类 。这是排查内存泄漏的常用入口。
- 实例 :选中某个类后,可以查看该类的所有实例。对于某个可疑实例,可以查看其字段值和引用关系。
- 查找 GC Root:在实例视图中,选中一个本应被回收但未被回收的对象,右键选择“显示最近的垃圾回收根节点”(Show Nearest GC Root)。JVisualVM 会展示出一条从该对象到某个 GC Root 的引用链,从而揭示该对象为何没有被回收 。
- 比较堆转储:JVisualVM 也支持比较两个堆转储,这对于观察一段时间内哪些对象数量显著增加非常有用。
表3:JVisualVM 关键特性
特性 | 描述 | 用途 |
实时监控 | CPU、堆内存、线程、类加载的实时图表。 | 观察应用运行状态,初步判断是否存在内存问题。 |
堆转储生成与分析 | 直接从运行中应用生成堆转储,或加载已有文件进行分析。 | 获取内存快照,进行离线分析。 |
类视图 | 按类显示实例数和大小,可排序。 | 快速定位占用内存最多的类。 |
实例视图 | 查看特定类的实例列表及其字段值。 | 深入检查对象状态。 |
最近的 GC Root | 显示从选定对象到 GC Root 的最短引用路径。 | 找出阻止对象回收的引用链。 |
OQL 查询 | 支持对象查询语言 (OQL) 进行高级查询。 | 灵活查询堆中对象。 |
JVisualVM 相较于 MAT 可能在处理超大堆转储时性能稍逊,且其泄漏分析的自动化程度不如 MAT 的 Leak Suspects Report。但其易用性和与 JDK 的集成使其成为开发和测试阶段快速诊断的便捷工具 。
1.2.3. 使用命令行工具分析堆转储 (初步分析)
- jhat:JDK 自带的堆转储浏览器(Java Heap Analysis Tool)。它解析堆转储文件并在本地启动一个 HTTP 服务器,用户可以通过浏览器查看堆的概要信息、类信息等。 Bash jhat <heap-dump-file.hprof>
然后访问 http://localhost:7000。jhat 对于非常大的堆文件处理能力有限,且分析功能不如 MAT 或 JVisualVM 强大,更多用于快速概览或在没有 GUI 环境时使用。
1.3. 内存泄漏解决方案与预防
解决内存泄漏的关键在于切断不再需要的对象到 GC Roots 的引用链。
- 静态集合:及时从静态集合中移除不再需要的对象,或者在适当的时候将静态集合置为 null。考虑使用弱引用 (WeakReference) 或软引用 (SoftReference) 缓存数据,允许 GC 在内存不足时回收它们。
- 资源关闭:使用 try-with-resources 或在 finally 块中确保所有外部资源(流、连接等)被关闭。现代 IDE 通常会对此类问题给出警告。
- equals() 和 hashCode():正确实现这两个方法,确保其行为符合约定,特别是在使用哈希集合时。
- 内部类:优先使用静态内部类,除非确实需要访问外部类实例。
- ThreadLocal:在线程任务结束时,务必调用 ThreadLocal.remove() 。
- 监听器和回调:确保在不再需要时注销事件监听器和回调,否则它们可能持有对其他对象的引用,阻止其被回收。
- 代码审查与静态分析:定期进行代码审查,并使用静态分析工具(如 SonarQube、PMD、FindBugs/SpotBugs)可以帮助在早期发现潜在的内存泄漏问题 。
- 防御性编程:在代码中加入检查,例如在向缓存添加数据前检查缓存大小,避免无限制增长。
Prev
快速开始
Next
Spring Boot 性能分析
Loading...