📘内存分析

type
status
date
slug
summary
tags
category
icon
password
此版块系列作为前置知识的同时,会将涉及到的大部分指标和规范运用到本项目监控的策略中,也欢迎提交意见或错误
 

Spring Boot 内存泄漏分析

内存泄漏是指应用程序中某些对象虽然不再被使用,但由于仍然被其他活动对象引用,导致垃圾收集器(GC)无法回收其所占用的内存。随着时间的推移,这些未被回收的对象会持续累积,最终可能耗尽堆内存,引发java.lang.OutOfMemoryError 错误,并导致应用程序性能下降或崩溃。

1.1. 内存泄漏的常见原因与症状

开发者自身编码不当是导致内存泄漏的主要原因。以下是一些在 Spring Boot 应用中常见的内存泄漏场景:
  1. 静态字段持有对象引用:静态集合(如 List、Map)或其他静态字段如果持有大量对象的引用,并且这些引用在对象不再需要后未被清除,将导致这些对象常驻内存 1。例如,一个静态 List 不断添加元素而从不移除。
      • 示例代码 2:
      • 避免策略:最小化静态变量的使用。如果必须使用,确保在不再需要时从静态集合中移除对象,或将集合本身置为 null 。
  1. 未关闭的资源:数据库连接、文件流、网络连接(如 InputStream、OutputStream、Connection、Session)等资源在使用完毕后若未显式关闭,它们所占用的内存及关联的本地资源将无法释放 。
      • 避免策略:始终在 finally 块中关闭资源,或使用 Java 7 及以上版本提供的 try-with-resources 语句,它能自动确保实现了 AutoCloseable 接口的资源被关闭。
  1. 不恰当的 equals() 和 hashCode() 实现 (Improper equals() and hashCode() Implementations):在将对象存入基于哈希的集合(如 HashSet、HashMap)时,如果对象的 equals() 和 hashCode() 方法实现不正确(例如,仅依赖可变字段或未同时重写两者),可能导致集合中存在逻辑上重复但无法被识别移除的对象,从而造成内存累积。
      • 避免策略:确保正确实现 equals() 和 hashCode() 方法,遵守其约定(例如,相等的对象必须具有相同的哈希码),并优先使用不可变字段参与哈希计算。
  1. 引用外部类的内部类 (Inner Classes that Reference Outer Classes):非静态内部类会隐式持有其外部类实例的引用。如果一个非静态内部类的实例生命周期比其外部类实例更长,外部类实例将无法被回收。
      • 避免策略:如果内部类不需要访问外部类的成员,将其声明为静态内部类(Static Nested Class)以解除对外部类实例的隐式引用。
  1. 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 进程的内存映射信息或生成堆转储。
    • 其中 live 选项表示只转储存活对象,format=b 表示二进制格式,<pid> 是 Java 进程 ID(可通过 jps 命令获取)。需要注意, jmap 在某些 JDK 版本中被标记为实验性且不受支持,可能会对应用性能产生影响。
    • jcmd:JDK 8 及以后版本推荐使用的多功能诊断命令工具,相较于 jmap,性能开销更小。
  • 图形化工具生成
    • 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 分析步骤
  1. 打开堆转储:通过 File > Open Heap Dump... 打开 .hprof 文件。MAT 会对文件进行解析,这可能需要一些时间,具体取决于堆转储的大小。
  1. 概览 :打开后首先展示的是概览页,包含堆大小、对象数量、类加载器数量等信息,以及一个显示最大对象的饼图。如果堆转储的总大小远小于文件大小,可能意味着 dump 时包含了大量待回收的垃圾对象。
  1. 泄漏嫌疑报告 :这是 MAT 的一个关键功能,可以自动分析堆并报告潜在的内存泄漏点。通常在打开堆转储后,向导会提示生成此报告。报告会列出问题嫌疑对象(Problem Suspects),并指出其占用的堆大小。如果某个嫌疑对象占用了堆内存的显著比例(例如 50% 或更多),那么它很可能就是泄漏源。
  1. 直方图 :显示每个类的实例数量、浅堆(Shallow Heap)大小和深堆(Retained Heap)大小。
      • 浅堆:对象自身占用的内存大小。
      • 深堆:对象自身以及它所持有的其他对象(仅被该对象持有)所占用的总内存大小。这是判断内存泄漏的关键指标。
      • 可以通过右键点击类名,选择 List objects -> with outgoing references (查看对象引用的其他对象) 或 with incoming references (查看哪些对象引用了该类的实例)。
      • 还可以按类加载器、包或超类对直方图进行分组,有助于缩小分析范围。
  1. 支配树 :显示堆中对象的支配关系。如果对象 A 支配对象 B,意味着所有指向 B 的路径都必须经过 A。支配树的根节点通常是占用内存最大的对象集合。通过支配树可以清晰地看到哪些对象阻止了其他大量对象的回收 。
  1. 到 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 分析步骤
  1. 连接进程并监控:启动 JVisualVM,它会自动发现本地运行的 Java 进程。双击进程名即可连接。在“监视”选项卡可以实时查看 CPU、堆内存、类、线程的使用情况。持续观察堆内存使用曲线,如果呈持续上升且GC后不明显下降的趋势,可能存在内存泄漏。
  1. 生成或加载堆转储
      • 在“监视”选项卡点击“堆 Dump”按钮。
      • 或者,在左侧“应用程序”窗格中右键点击目标进程,选择“堆 Dump”。
      • 也可以通过 File > Load... 加载已有的 .hprof 文件。
  1. 分析堆转储
      • 概要 :显示堆的基本信息、系统属性、线程列表等。
      • :按类名显示实例数量和总大小。可以按实例数量或大小排序,快速找到占用内存最多的类 。这是排查内存泄漏的常用入口。
      • 实例 :选中某个类后,可以查看该类的所有实例。对于某个可疑实例,可以查看其字段值和引用关系。
      • 查找 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...