程序员scholar 程序员scholar
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • Web 标准

    • HTML
    • CSS
    • JavaScript
  • 前端框架

    • Vue2
    • Vue3
    • Vue3 + TS
    • 微信小程序
    • uni-app
  • 工具与库

    • jQuery
    • Ajax
    • Axios
    • Webpack
    • Vuex
    • WebSocket
    • 第三方登录
  • 后端与语言扩展

    • ES6
    • Typescript
    • node.js
  • Element-UI
  • Apache ECharts
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • Web 标准

    • HTML
    • CSS
    • JavaScript
  • 前端框架

    • Vue2
    • Vue3
    • Vue3 + TS
    • 微信小程序
    • uni-app
  • 工具与库

    • jQuery
    • Ajax
    • Axios
    • Webpack
    • Vuex
    • WebSocket
    • 第三方登录
  • 后端与语言扩展

    • ES6
    • Typescript
    • node.js
  • Element-UI
  • Apache ECharts
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
npm

(进入注册为作者充电)

  • Java底层 - JVM

    • JVM - Java体系结构
    • JVM - 类加载子系统
    • JVM - 运行时数据区概述及线程
    • JVM - 程序计数器
    • JVM - 虚拟机栈
    • JVM - 本地方法接口
    • JVM - 本地方法栈
    • JVM - 堆 (Heap)
    • JVM - 方法区
    • JVM - 对象实例化内存布局
    • JVM - 直接内存管理
    • JVM - 执行引擎
    • JVM - 字符串常量池 (StringTable)
    • JVM - 垃圾回收概述
    • JVM - 垃圾回收相关算法
    • JVM - 垃圾回收相关概念
    • JVM - 垃圾回收器
    • JVM - Class文件结构
    • JVM - 字节码指令集与解析
    • JVM - 类的加载过程详解
    • JVM - 再谈类的加载器
    • JVM - 调优概述
    • JVM - 监控及诊断工具cmd
    • JVM - 监控及诊断工具GUI
      • 1. 工具概述:从命令行到图形化界面
      • 2. JConsole:基础 JVM 监控面板
      • 3. VisualVM:多合一可视化监控与分析平台
      • 4. Eclipse MAT:专业的堆内存分析器
      • 5. 再谈内存问题:泄漏与溢出
        • 内存泄漏 (Memory Leak)
        • 内存溢出 (OutOfMemoryError, OOM)
        • 常见的 8 种内存泄漏情况
        • 栈内存泄漏案例分析 (Stack.java)
      • 6. 使用 OQL 语言查询对象信息
      • 7. JProfiler:商业级 Java 性能分析瑞士军刀
      • 8. Arthas:阿里开源的 Java 诊断神器
      • 9. Java Mission Control (JMC) 与 Java Flight Recorder (JFR)
      • 10. 其他相关工具与技术
        • 火焰图 (Flame Graphs)
        • TProfiler
        • BTrace
        • 其他工具
    • JVM - 运行时参数
    • JVM - 分析GC日志
  • Java底层
  • Java底层 - JVM
scholar
2024-01-31
目录

JVM - 监控及诊断工具GUI

# 1. 工具概述:从命令行到图形化界面

上一章节介绍了多种基于命令行的 JVM 监控和诊断工具(如 jps, jstat, jinfo, jmap, jstack, jcmd)。这些工具能帮助我们获取目标 Java 应用性能相关的基础信息,但在某些场景下存在局限性:

  1. 缺乏方法级分析:命令行工具通常无法提供方法级别的详细数据,例如方法间的调用关系、每个方法的调用次数和执行时间。而这些信息对于精确定位应用性能瓶颈至关重要。
  2. 登录限制:通常需要登录到目标 Java 应用所在的服务器(宿主机)才能使用这些命令,操作不便,尤其是在远程或隔离环境中。
  3. 结果展示不直观:命令行输出的纯文本数据不够直观,需要人工解析和关联,分析效率较低。

为了克服这些局限,业界涌现了许多图形化(GUI)的 JVM 监控和诊断工具。这些工具不仅提供了更友好的用户界面,还集成了更强大的分析功能,能够帮助开发者更高效地定位和解决内存泄漏、性能瓶颈等问题。

本章将重点介绍几款常用的 GUI 工具:

JDK 自带工具 (位于 JDK 的 bin 目录下):

  • JConsole: JDK 5 开始自带的可视化监控工具,提供基本的 JVM 概览、内存、线程、类加载等监控功能,基于 JMX。
  • VisualVM: 功能更强大的多合一工具 (JDK 6u7 后自带),集成了多种命令行工具的功能,并支持插件扩展,提供性能分析(Profiling)、内存快照分析等。
  • JMC (Java Mission Control): Oracle JDK 7u40 后自带 (Java 11 后开源),内置强大的 JFR (Java Flight Recorder),以极低的性能开销收集详细的运行时数据,适用于生产环境。

第三方工具:

  • MAT (Memory Analyzer Tool): 基于 Eclipse 的强大堆内存分析器,专门用于分析 Heap Dump 文件,查找内存泄漏和优化内存消耗。免费且功能丰富。
  • JProfiler: 著名的商业 Java 性能分析工具,功能极其强大全面,覆盖 CPU、内存、线程、数据库、网络等多个方面,但需要付费。
  • Arthas: 阿里巴巴开源的 Java 诊断工具,虽然主要是命令行交互,但提供了 Web Console,并且可以在不重启应用、无需预先配置参数的情况下动态诊断线上问题,功能独特。

# 2. JConsole:基础 JVM 监控面板

官方文档参考 (Java 7): https://docs.oracle.com/javase/7/docs/technotes/guides/management/jconsole.html

JConsole 是自 Java 5 起 JDK 内置的一个标准可视化监控和管理工具。它基于 JMX (Java Management Extensions) 技术,允许用户连接到本地或远程的 JVM 进程,实时监控其运行概况,包括内存使用情况(堆、非堆)、线程状态、类加载数量、CPU 占用率以及 MBean 信息。

启动方式:

直接在命令行运行 jconsole 命令,或者进入 JDK 的 bin 目录双击 jconsole.exe (Windows) 或执行 jconsole (Linux/macOS)。

连接方式:

JConsole 支持三种连接方式:

  1. 本地进程 (Local Process):

    • 自动检测并列出当前用户在本地系统上运行的 JVM 进程。
    • 选择要监控的进程即可连接。
    • 连接基于文件系统授权,简单方便,但仅限于监控同一台机器上的进程。
    • 注意:执行 JConsole 的用户需要与目标 Java 进程的运行用户相同。
  2. 远程进程 (Remote Process):

    • 通过 RMI (Remote Method Invocation) 连接器连接到远程 JVM 暴露的 JMX 代理。
    • 需要目标 JVM 启动时配置了 JMX 相关参数(如 -Dcom.sun.management.jmxremote.port=端口号, -Dcom.sun.management.jmxremote.authenticate=false (不推荐,无认证), -Dcom.sun.management.jmxremote.ssl=false (不推荐,无加密) 等)。
    • 连接地址格式通常为:service:jmx:rmi:///jndi/rmi://<hostName>:<portNum>/jmxrmi。
    • 如果远程 JMX 配置了用户名/密码认证,需要在 JConsole 连接时提供凭据。
  3. 高级 (Advanced):

    • 允许使用自定义的 JMX 连接器 URL 进行连接。
    • 适用于需要通过非标准 RMI 连接器或连接到实现了 JMX 但非标准 RMI 方式暴露的 JMX 代理(例如某些旧版本应用服务器或自定义实现)的场景。

主要界面功能:

JConsole 连接成功后,会展示多个标签页,提供不同维度的监控信息:

  • 概述 (Overview):

    • 展示 CPU 使用率、堆内存使用情况、活动线程数、已加载类数量的实时曲线图。
    • 提供 JVM 基本信息(版本、供应商、启动时间等)。
    • 可以快速了解 JVM 的整体运行状态。 image-20210505141631635 图:JConsole - 概述标签页
  • 内存 (Memory):

    • 提供各内存池(如 Eden Space, Survivor Space, Old Gen, Metaspace/PermGen)的使用情况曲线图和详细数据(已用、已提交、最大值)。
    • 可以手动执行 GC 操作(谨慎在生产环境使用)。
    • 底部可以选择不同的内存区域查看详细信息。 image-20210505141726143 图:JConsole - 内存标签页
  • 线程 (Threads):

    • 显示当前所有活动线程的列表及其状态 (Runnable, Waiting, Blocked, Timed Waiting)。
    • 可以查看每个线程的堆栈跟踪信息。
    • 提供检测死锁 (Detect Deadlock) 的功能。
    • 显示峰值线程数和当前活动线程数。 image-20210505141924211 图:JConsole - 线程标签页
  • 类 (Classes):

    • 显示当前已加载类的总数和已卸载类的总数。
    • 提供类加载活动的实时曲线图。 image-20210505141950000 图:JConsole - 类标签页
  • VM 摘要 (VM Summary):

    • 汇总显示 JVM 的详细信息,包括:
      • 虚拟机版本、供应商、运行时间。
      • JVM 参数 (VM arguments)。
      • 类路径 (Classpath)、库路径 (Library path)。
      • 系统属性 (System properties)。
      • 操作系统信息。
      • 内存和 GC 配置概要。 image-20210505142050157 图:JConsole - VM 摘要标签页
  • MBean (Managed Bean):

    • 提供对 JVM 内部所有已注册的 MBean 的访问。
    • 可以通过 MBean 查看更底层的 JVM 指标、执行特定的管理操作(如修改某些属性值、调用管理方法)。
    • 例如,可以查看 java.lang:type=Memory 获取内存信息,java.lang:type=Threading 获取线程信息,java.lang:type=GarbageCollector 获取 GC 信息等。

JConsole 功能相对基础,但作为 JDK 自带工具,无需额外安装,是快速了解 JVM 运行状态的便捷选择。

# 3. VisualVM:多合一可视化监控与分析平台

官方地址:https://visualvm.github.io/index.html

VisualVM 是一个功能更强大的多合一故障诊断和性能监控可视化工具。它整合了多个 JDK 命令行工具的功能(如 jps, jinfo, jstat, jstack, jmap),并提供了更友好的图形界面。自 JDK 6 Update 7 以后,VisualVM 作为 JDK 的一部分随之发布(位于 JDK 的 bin 目录下),完全免费。

核心特性:

  • 集成多种工具能力:在一个界面中完成进程查看、环境配置查看、CPU/内存/线程/类监控、线程 Dump、堆 Dump 等操作。
  • 性能分析 (Profiling):可以对 CPU 和内存进行抽样 (Sampling) 或插桩 (Instrumentation) 分析,找出性能瓶颈和内存分配热点。
  • 内存快照分析:可以直接生成和打开 Heap Dump 文件,进行基本的内存泄漏分析。
  • 插件化扩展:支持安装插件来扩展功能,例如著名的 Visual GC 插件可以动态可视化 GC 过程和内存分布。
  • 支持本地和远程连接:与 JConsole 类似,支持监控本地和远程 JVM 进程(远程连接同样需要目标 JVM 配置 JMX)。

插件安装:

VisualVM 的强大之处在于其插件系统。

  • 在线安装:通过菜单 工具 (Tools) -> 插件 (Plugins) -> 可用插件 (Available Plugins),选择需要的插件(如 Visual GC)进行在线安装。
  • 离线安装:从 VisualVM 插件中心 (opens new window) 下载插件文件 (.nbm 格式),然后在 插件 (Plugins) 对话框的 已下载 (Downloaded) 页面,点击 添加插件 (Add Plugins...) 进行安装。

强烈建议安装 Visual GC 插件,它可以非常直观地展示堆内存的分代布局、对象分配和 GC 活动。

在 IDE 中集成 (以 IntelliJ IDEA 为例):

  1. 在 IDEA 的插件市场搜索并安装 VisualVM Launcher 插件。 image-20220131224447624
  2. 重启 IDEA。
  3. 配置 VisualVM 的可执行文件路径:File -> Settings -> Tools -> VisualVM Launcher,指定你 JDK bin 目录下的 jvisualvm.exe (Windows) 或 jvisualvm (Linux/macOS) 路径。 image-20220131224552415
  4. 之后就可以在运行/调试配置中,或者直接右键点击运行中的应用,选择 Run with VisualVM 或 Debug with VisualVM 来启动并自动连接 VisualVM。

连接方式:

  • 本地连接:启动 VisualVM 后,它会自动发现本地运行的 Java 进程,列在左侧的“应用程序 (Applications)”窗格中,双击即可连接。

  • 远程连接:

    1. 右键点击“远程 (Remote)”节点,选择“添加远程主机 (Add Remote Host...)”,输入远程服务器的 IP 地址或主机名。
    2. 确保远程 JVM 进程已配置 JMX 端口并已启动。
    3. 右键点击添加的主机节点,选择“添加 JMX 连接 (Add JMX Connection...)”,输入 JMX 服务的端口号(以及可能的用户名/密码),进行连接。
    • (对于 Tomcat 等应用服务器,可能需要修改其启动脚本如 catalina.sh 或 catalina.bat 来添加 JMX 参数,并可能需要在 conf 目录下配置 jmxremote.access 和 jmxremote.password 文件进行安全认证。同时确保防火墙和安全组策略允许 JMX 端口的访问。)

主要功能界面:

连接到一个 Java 进程后,VisualVM 会显示多个标签页:

  • 概述 (Overview):显示 JVM 基本信息、JVM 参数、系统属性等,类似于 jinfo 的功能。 image-20220131225514738
  • 监视 (Monitor):
    • 提供 CPU、内存(堆、元空间/永久代)、类加载、线程数量的实时监控图表,类似于 jstat 和 JConsole 的功能。
    • 可以手动执行垃圾回收。
    • 可以生成堆 Dump (Heap Dump) 文件。 image-20220131225731574
  • 线程 (Threads):
    • 实时显示所有线程的状态(列表和时间线视图)。
    • 可以选择单个线程查看其堆栈跟踪。
    • 可以生成线程 Dump (Thread Dump),相当于执行 jstack。 image-20220131225659672
  • 抽样器 (Sampler) / Profiler:
    • CPU 分析:通过抽样或插桩的方式,分析哪些方法消耗了最多的 CPU 时间。
    • 内存分析:分析对象的创建频率、内存占用,查找内存分配热点。
    • 性能分析会对目标应用产生一定开销,特别是插桩模式。
  • Visual GC (需要安装插件):
    • 动态、可视化地展示堆内存(Eden, S0, S1, Old)和元空间的使用情况。
    • 实时显示 GC 活动(Minor GC, Full GC)的次数和耗时。
    • 非常直观地理解 GC 过程和内存分配模式。 image-20220131232928306
  • 快照 (Snapshots):
    • 可以在这里查看已生成的堆 Dump 或线程 Dump 文件。
    • VisualVM 提供了基本的堆 Dump 分析功能,可以查看对象列表、查找大对象等。

VisualVM 功能比 JConsole 更全面,尤其是在性能分析和 GC 可视化方面,是 Java 开发和调优的常用工具。

# 4. Eclipse MAT:专业的堆内存分析器

官方地址: https://www.eclipse.org/mat/downloads.php

MAT (Memory Analyzer Tool) 是一款功能强大的、免费开源的 Java 堆内存分析器。它基于 Eclipse 平台开发(可以作为 Eclipse 插件使用,也可以作为独立应用运行),专门用于深入分析 Heap Dump 文件 (.hprof)。

核心目标:

  • 查找内存泄漏 (Memory Leaks):通过分析对象引用关系、可达性、支配树等,帮助定位不再被需要但无法被 GC 回收的对象。
  • 减少内存消耗 (Reduce Memory Consumption):识别占用内存过多的对象、重复对象、优化集合类使用等。

获取 Heap Dump 文件的方式:

MAT 本身不直接监控运行中的 JVM,它需要一个 .hprof 格式的堆转储文件作为输入。获取 dump 文件的方式有多种:

  1. jmap 命令:

    jmap -dump:live,format=b,file=heap.hprof <pid>
    
    1
  2. JVM 参数自动 Dump:

    # OOM 时自动 dump
    -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/heapdump.hprof
    # Full GC 前自动 dump (某些场景下有用,但会影响性能)
    # -XX:+HeapDumpBeforeFullGC
    
    1
    2
    3
    4
  3. VisualVM / JConsole:这些工具提供了手动触发生成 Heap Dump 的按钮。

  4. MAT 直接获取:MAT 也可以连接到正在运行的 Java 进程(通过 JMX 或其他方式)并直接触发和获取 Heap Dump。

  5. Arthas 命令:heapdump /path/to/heapdump.hprof。

考虑到生产环境中直接在线分析大型 dump 文件不现实,最常见的组合是使用 jmap 或 JVM 参数在生产环境生成 dump 文件,然后将文件下载到本地使用 MAT 进行离线分析。

MAT 核心功能与界面:

  1. 打开 Dump 文件:启动 MAT,选择 File -> Open Heap Dump... 选择 .hprof 文件。MAT 在打开大型 dump 文件时可能需要较多内存和时间。 image-20220131232636697

  2. 概览 (Overview):

    • 打开 dump 文件后首先看到的界面。
    • 显示 dump 文件的基本信息:大小、创建时间、对象数量、类数量、类加载器数量等。
    • 提供一个饼图展示最大的几个对象(按 Retained Heap 排序)。
    • 包含几个关键的快捷操作入口:
      • Histogram: 查看所有类的实例数量和大小。
      • Dominator Tree: 查看对象支配树。
      • Top Consumers: 按类或类加载器聚合,查看内存消耗大户。
      • Leak Suspects: MAT 的核心功能之一,自动分析并报告可能的内存泄漏点。 image-20220131232737535 image-20220131233225517 image-20220131233533551 image-20220131233645314
  3. 直方图 (Histogram):

    • 列出堆中每个类的实例数量 (Objects)、浅堆大小 (Shallow Heap) 和深堆大小 (Retained Heap)。
    • 可以通过类名、包名进行分组和过滤。
    • 右键点击某个类,可以进行更多操作:
      • List objects: 查看该类的所有实例。
        • with outgoing references: 查看该实例引用的其他对象。
        • with incoming references: 查看引用该实例的其他对象。
      • Merge Shortest Paths to GC Roots: 查找该类的实例到 GC Roots 的最短引用路径,这是分析对象为何没有被回收的关键。
      • Show objects by class in Dominator Tree: 在支配树视图中定位该类的实例。 image-20220131233915003
  4. 支配树 (Dominator Tree):

    • 将复杂的对象引用关系转换为一棵树状结构,清晰地展示对象的支配关系。
    • 定义:如果所有指向对象 B 的路径都必须经过对象 A,那么对象 A 支配 B。离 B 最近的支配者称为直接支配者。
    • 关键特性:
      • 对象 A 的子树(所有被 A 支配的对象)构成了 A 的保留集 (Retained Set),子树中所有对象的浅堆大小之和就是 A 的深堆大小 (Retained Heap)。
      • 深堆大小表示如果对象 A 被回收,能够释放多少内存。
      • 支配树是查找内存消耗大户(Retained Heap 大的对象)的有力工具。
    • 视图中会列出所有对象(或按类分组),按 Retained Heap 大小排序。 image-20220131234920860 (图:右键菜单,可查看支配关系)
  5. 线程概览 (Thread Overview):

    • 列出 dump 时所有线程的信息,包括线程名、状态和线程栈信息。
    • 可以查看每个线程栈帧中的局部变量。这对于分析某些线程持有大对象或特定状态很有用。 image-20220131234401041

核心概念:浅堆 (Shallow Heap) 与 深堆 (Retained Heap)

理解这两个概念对于使用 MAT至关重要:

  • 浅堆 (Shallow Heap):

    • 指对象本身占用的内存大小,不包括它引用的其他对象。
    • 计算方式:对象头大小 + 各个成员变量大小的总和 (+ 可能的对齐填充)。
    • 例如,一个 String 对象本身的浅堆可能只包含其内部 char[] value 的引用、int hash 等字段的大小以及对象头,与其包含的字符串长度无关。
  • 保留集 (Retained Set):

    • 指仅能通过某个对象 A 直接或间接访问到的所有对象的集合(包含 A 自身)。
    • 换句话说,如果对象 A 被垃圾回收,那么它的 Retained Set 中的所有对象也将因为不再可达而被回收。
  • 深堆 (Retained Heap):

    • 指对象 A 的保留集中所有对象的浅堆大小之和。
    • 深堆代表了如果对象 A 被回收,GC 能够释放的总内存大小。这是衡量一个对象“真实”内存占用的关键指标。
  • 对象实际大小 (Actual Size) (非 MAT 标准术语,易混淆):

    • 指一个对象能访问到的所有对象的浅堆大小之和(包括可以通过其他路径访问的对象)。
    • 这个概念与垃圾回收关系不大,不如深堆有用。

图解区分:

image-20220201003432711

  • A 的浅堆: 只有 A 本身的大小。
  • A 的实际大小: A + C + D 的浅堆之和。
  • A 的保留集: {A, D} (因为 C 还可以通过 B 访问)。
  • A 的深堆: A 的浅堆 + D 的浅堆。
  • B 的保留集: {B, E} (因为 C 还可以通过 A 访问)。
  • B 的深堆: B 的浅堆 + E 的浅堆。

练习题图解:

场景 1: GC Roots 同时引用 A, B, D image-20220201003650210

  • A 的 Retained Set = {A} => A 的 Retained Size = A 的 Shallow Size
  • B 的 Retained Set = {B, C} (C 只能通过 B 访问) => B 的 Retained Size = B 的 Shallow Size + C 的 Shallow Size
  • D 的 Retained Set = {D} => D 的 Retained Size = D 的 Shallow Size

场景 2: GC Roots 只引用 A, B image-20220201003643235

  • A 的 Retained Set = {A} => A 的 Retained Size = A 的 Shallow Size
  • B 的 Retained Set = {B, C, D} (C, D 只能通过 B 访问) => B 的 Retained Size = B + C + D 的 Shallow Size 之和

深堆与浅堆案例分析 (StudentTrace.java)

这个案例模拟了多个学生访问网页,并将访问历史记录在各自的 history (ArrayList) 中。WebPage 对象最初存储在一个静态 webpages 列表中,然后被添加到学生的 history 中。最后,静态列表 webpages 被 clear(),并执行 System.gc()。

import java.util.ArrayList;
import java.util.List;

/**
 * 模拟学生浏览网页记录的程序。
 * 用于分析对象引用和内存占用(深堆/浅堆)。
 * 启动参数示例: -XX:+HeapDumpBeforeFullGC -XX:HeapDumpPath=d:\student.hprof
 */
public class StudentTrace {
    // 静态列表,持有所有 WebPage 对象
    static List<WebPage> webpages = new ArrayList<WebPage>();

    /**
     * 创建 100 个模拟的 WebPage 对象并添加到静态列表中。
     */
    public static void createWebPages() {
        for (int i = 0; i < 100; i++) {
            WebPage wp = new WebPage();
            wp.setUrl("http://www." + Integer.toString(i) + ".com"); // 设置 URL
            wp.setContent(Integer.toString(i)); // 设置内容
            webpages.add(wp);
        }
    }

    public static void main(String[] args) {
        createWebPages(); // 创建 100 个网页

        // 创建 3 个学生对象
        Student st3 = new Student(3, "Tom");
        Student st5 = new Student(5, "Jerry");
        Student st7 = new Student(7, "Lily");

        // 模拟学生访问网页,将 WebPage 添加到各自的 history 列表
        for (int i = 0; i < webpages.size(); i++) {
            if (i % st3.getId() == 0) // 如果 i 能被 3 整除
                st3.visit(webpages.get(i));
            if (i % st5.getId() == 0) // 如果 i 能被 5 整除
                st5.visit(webpages.get(i));
            if (i % st7.getId() == 0) // 如果 i 能被 7 整除
                st7.visit(webpages.get(i));
        }

        // 清除静态列表的引用。此时 WebPage 对象仅被学生的 history 列表引用。
        webpages.clear();
        webpages = null; // 显式置 null 更好

        // 建议 GC,但不保证立即执行。Heap Dump 如果在这之后生成,应该只包含被学生引用的 WebPage。
        System.gc();

        // (实际 dump 可能在 GC 前或 OOM 时发生,取决于 JVM 参数)
        // 为了分析,可以加一个断点或 sleep 在这里手动 dump。
        try {
            Thread.sleep(10000); // 等待 dump
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class Student {
    private int id;
    private String name;
    // 每个学生持有一个 WebPage 列表作为访问历史
    private List<WebPage> history = new ArrayList<>();

    public Student(int id, String name) {
        super();
        this.id = id;
        this.name = name;
    }

    // Getters and Setters ...
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public List<WebPage> getHistory() { return history; }
    public void setHistory(List<WebPage> history) { this.history = history; }

    /**
     * 学生访问一个网页,将其添加到历史记录中。
     * @param wp 被访问的 WebPage 对象。
     */
    public void visit(WebPage wp) {
        if (wp != null) {
            history.add(wp);
        }
    }
}

class WebPage {
    private String url;
    private String content;

    // Getters and Setters ...
    public String getUrl() { return url; }
    public void setUrl(String url) { this.url = url; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }

    // 为了方便在 MAT 中查看,可以重写 toString
    @Override
    public String toString() {
        return "WebPage{" + "url='" + url + '\'' + '}';
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106

分析过程:

  1. 使用 JVM 参数 -XX:+HeapDumpBeforeFullGC -XX:HeapDumpPath=d:\student.hprof (或 OOM 参数,或手动 dump) 运行代码,生成 student.hprof 文件。
  2. 用 MAT 打开 dump 文件。
  3. 导航到 Dominator Tree 或 Histogram 视图,查找 Student 类的实例。
  4. 找到 st7 (id=7, name="Lily") 这个 Student 对象。查看其深堆 (Retained Heap) 大小。 image-20220201005307683 图:MAT 中显示 st7 的 Retained Heap 为 1288 字节

解释 1288 字节的来源:

  • st7 访问了索引能被 7 整除的 WebPage 对象 (0, 7, 14, ..., 98),共 15 个。
  • st7.history 是一个 ArrayList,其内部持有一个 Object[] elementData 数组来存储这 15 个 WebPage 对象的引用。
  • 哪些 WebPage 对象仅被 st7 持有?
    • 我们需要考虑 st3 (id=3) 和 st5 (id=5) 也访问了某些页面。
    • 如果一个页面的索引 i 只能被 7 整除,而不能被 3 或 5 整除,那么这个 WebPage 对象就只被 st7 引用(因为 webpages 列表已被清空)。
    • 在 0 到 99 之间,仅能被 7 整除的数有:7, 14, 28, 49, 56, 77, 91, 98。共 8 个。
    • 能被 7 和 3 整除的有:21, 42, 63, 84。 (属于 st7 和 st3 共有)
    • 能被 7 和 5 整除的有:35, 70。 (属于 st7 和 st5 共有)
    • 能被 3, 5, 7 整除的有:0。 (属于 st3, st5, st7 共有)
  • 计算 st7 的深堆 (Retained Heap):
    • st7 对象本身的浅堆 (Shallow Heap)。
    • st7.history (ArrayList) 对象本身的浅堆。
    • st7.history.elementData (Object[]) 数组本身的浅堆。
    • 仅被 st7 引用的 8 个 WebPage 对象的浅堆之和。
    • 每个 WebPage 对象包含 url 和 content 两个 String 引用,以及对象头。假设每个 WebPage 浅堆为 X 字节。
    • 每个 String 对象 (url, content) 也有浅堆,并且它们内部的 char[] 数组也有浅堆。这些也可能被计算在内,如果它们仅被对应的 WebPage 引用。
  • 反推 1288 字节:
    • 图中显示 st7 的 history 列表(elementData)有 15 个元素。
    • 如果 Retained Heap 只计算了仅被 st7 持有的对象,那么它应该包含 st7 自身、history 列表、elementData 数组,以及那 8 个“私有”的 WebPage 对象(及其可能包含的 String 和 char[])。
    • 原文档的计算过程似乎有些偏差,它提到 “15 个 WebPage,每个对应 152 个字节”,这可能是指 WebPage 的深堆(如果假设每个页面都是私有的)。
    • 然后计算了“共有”的页面 (7 个),用总的减去共有的,得到 2280 - 1064 = 1216。这个 1216 接近 1288,但还差 72。
    • 原文档解释 72 字节是 elementData 数组本身的开销(15个引用 * 4字节/引用 + 8字节对象头 + 4字节数组长度 = 72字节,按8字节对齐)。
    • 更合理的解释:st7 的 Retained Heap (1288 字节) = st7 自身浅堆 + history 浅堆 + elementData 浅堆 (72 字节) + 8 个仅被 st7 引用的 WebPage 对象及其内容的 Retained Heap 之和。 这 8 个 WebPage 及其内容的总 Retained Heap 应该是 1288 - 72 - (st7 浅堆) - (history 浅堆)。或者,如果 152 字节是 WebPage 及其内容 的 Retained Heap,那么 8 个私有页面贡献 8 * 152 = 1216 字节,加上 elementData 的 72 字节,总计 1216 + 72 = 1288 字节。这似乎是更可能的解释,即 MAT 计算的 st7 的 Retained Heap 主要包含了其“私有”的 WebPage 对象所占用的内存。

支配树 (Dominator Tree) 的应用:

image-20220201010504721 左图:对象引用图;右图:对应的支配树

  • 理解:支配树简化了引用关系,直接展示内存控制流。根节点(GC Roots)支配所有可达对象。如果 A 的子节点是 B,意味着要回收 B,必须先断开 A 到 B 的路径(或者 A 本身被回收)。
  • 查找大对象根源:在支配树视图中,可以直接看到哪个对象的 Retained Heap 最大。展开该对象的子树,可以看到是哪些被它支配的对象贡献了内存。
  • 分析案例: image-20220201010932876 图:在支配树中查看 st7 的 elementData
    • 这个视图显示了 st7 的 elementData 数组。展开它,可以看到它直接支配(即持有引用)的 15 个 WebPage 对象。
    • 支配树可以更清晰地展示哪些 WebPage 对象(及其内容)被计算在了 elementData 数组的 Retained Heap 中(即仅通过这个数组才能访问到的对象)。图中应该能看到 8 个 WebPage 出现在 elementData 的子树中,贡献了主要的 Retained Heap。

Tomcat 堆溢出分析案例 (基于图片解读)

这个案例分析了一个因请求压力过大导致 Tomcat 发生 OOM 的 Heap Dump 文件。

  1. 概览与最大对象:

    • 图 1 显示 MAT 概览,提示有一个 org.apache.catalina.session.StandardManager 对象占用了大量内存。
    • 图 2 Leak Suspects 报告也指向了同一个 StandardManager,暗示 Session 管理可能是问题所在。 image-20220201012013800 image-20220201012135383
  2. 深入 StandardManager:

    • 在 Dominator Tree 中找到 StandardManager,发现其内部持有一个名为 sessions 的 ConcurrentHashMap,这个 Map 占用了约 17MB 的 Retained Heap。 image-20220201011857288
  3. 分析 ConcurrentHashMap:

    • 展开 sessions (ConcurrentHashMap),可以看到它内部分为 16 个 Segment(这是旧版 ConcurrentHashMap 的实现方式)。
    • 每个 Segment 的 Retained Heap 大小都比较平均,约为 1MB。 image-20220201012354238
  4. 查看 Segment 内容:

    • 展开其中一个 Segment,可以看到它内部持有一个 HashEntry[] 数组,数组中存储了大量的 StandardSession 对象引用。 image-20220131012443880
  5. 统计 Session 数量和大小:

    • 切换到 Histogram 视图,搜索 org.apache.catalina.session.StandardSession。
    • 结果显示,堆中总共有 9941 个 StandardSession 实例。
    • 每个 StandardSession 的浅堆 (Shallow Heap) 很小 (如 112 字节),但深堆 (Retained Heap) 约为 1592 字节(这表示每个 Session 对象及其关联的、仅能通过它访问的数据占用了约 1.5KB)。
    • 所有 Session 的总 Retained Heap 约为 9941 * 1592 ≈ 15.8MB,与之前看到的 ConcurrentHashMap 的 Retained Heap (17MB) 基本吻合,说明内存主要被 Session 对象及其关联数据占据。这占用了当时堆大小 (约 30MB+) 的 50% 以上。 image-20220201012624399
  6. 关联 Session 属性:

    • 查看 StandardSession 对象的 attributes 属性(通常是一个 ConcurrentHashMap),可以看到 Session 中存储了哪些数据。 image-20220201012758369
  7. 结合时间信息推断压力:

    • 查看某个 StandardSession 的 creationTime 和 lastAccessedTime 属性,可以得到 Session 的创建时间和最后访问时间(通常是 long 型时间戳)。
    • 图 8 显示了 Session 的创建时间戳。通过比较最早和最晚 Session 的创建时间差(约 30 秒),结合 Session 总数 (9941),可以估算出 OOM 发生前约 30 秒内,平均每秒创建了 9941 / 30 ≈ 331 个 Session。这反映了当时 Tomcat 面临的请求压力。 image-20220201012222622

结论:该 Tomcat OOM 的直接原因是短时间内创建了大量 HTTP Session,并且这些 Session 及其关联数据占用了过多的堆内存,超出了 JVM 的 -Xmx 限制。可能的解决方案包括:增加堆内存、缩短 Session 超时时间、优化 Session 中存储的数据量、或者排查是否所有请求都需要创建 Session。

MAT 凭借其强大的堆分析能力,特别是 Leak Suspects 报告、Histogram 和 Dominator Tree 视图,是诊断 Java 内存问题的必备工具。

# 5. 再谈内存问题:泄漏与溢出

理解内存泄漏 (Memory Leak) 和内存溢出 (OutOfMemoryError, OOM) 的区别与联系对于排查问题至关重要。

# 内存泄漏 (Memory Leak)

  • 定义:指程序中某些不再被需要 (No Longer Needed) 的对象,由于仍然被可达的引用链(从 GC Roots 出发可以访问到)所持有,导致垃圾收集器 (GC) 无法回收它们占用的内存。
  • 本质:对象逻辑上已废弃,但物理上仍被引用。 image-20210505152542224
    • 图中对象 Y 已不再被程序逻辑需要 (生命周期结束),但由于对象 X (生命周期可能更长) 仍然持有对 Y 的引用,GC 无法回收 Y。如果 X 还引用了其他短生命周期的对象 (A, B, C),并且这些对象又引用了其他对象 (a, b, c),那么这条引用链会导致大量无用对象滞留内存。
  • 通俗比喻:“占着茅坑不拉屎”。内存被无效对象占据,无法被重新分配给新对象。
  • 后果:内存泄漏会逐渐消耗可用内存,导致 GC 越来越频繁,单次 GC 时间变长,应用性能下降,最终可能引发内存溢出 (OOM)。
  • 分类:
    • 经常发生 (Frequent):泄漏代码被反复执行,每次泄漏一小块内存,累积效应明显。
    • 偶然发生 (Occasional):仅在特定条件下触发。
    • 一次性 (One-time):泄漏代码只执行一次,影响固定,相对不严重。
    • 隐式泄漏 (Implicit Leak):严格来说不算泄漏,指对象生命周期过长,长时间占用内存但不释放,直到程序或某个长周期对象结束。如果执行时间极长,也可能耗尽内存。

# 内存溢出 (OutOfMemoryError, OOM)

  • 定义:指程序在尝试申请新的内存空间时(例如创建新对象、扩展数组等),发现可用内存不足以满足请求,JVM 无法继续分配,从而抛出的严重错误 (Error)。
  • 原因:
    • 内存泄漏累积:可用内存被泄漏的对象逐渐耗尽。
    • 堆空间不足:JVM 配置的堆大小 (-Xmx) 确实小于程序正常运行所需的内存。
    • 创建超大对象:程序试图创建一个巨大的对象(如超大数组),单次分配就超过了剩余可用内存或单个内存区域的限制。
    • 元空间/永久代溢出:加载过多的类或方法信息超出了 Metaspace 或 PermGen 的限制 (-XX:MaxMetaspaceSize / -XX:MaxPermSize)。
    • 栈溢出 (StackOverflowError):通常由过深的递归调用或过大的线程栈帧导致,虽然不是堆内存溢出,但也属于内存相关错误。
  • 通俗比喻:“坑位不够用了”。无论是有人占着茅坑不走(内存泄漏),还是同时来了太多人(内存需求过大),或者想建一个超大的坑位(创建大对象),最终导致没有可用的坑位。
  • 关系:内存泄漏是导致内存溢出的常见原因之一,但并非唯一原因。内存溢出是内存不足的结果。

# 常见的 8 种内存泄漏情况

  1. 静态集合类 (Static Collections):

    • 原因:如果 HashMap, ArrayList 等集合被声明为 static,它们的生命周期与 JVM 进程一致。如果持续向这些静态集合中添加短生命周期的对象引用,而没有及时移除,这些短生命周期的对象将因为被静态集合引用而无法被回收。

    • 示例:

      public class StaticCollectionLeak {
          // 静态列表,生命周期与类相同
          static List<Object> list = new ArrayList<>();
      
          /**
           * 这个方法每次调用都会创建一个新对象并添加到静态列表中。
           * 如果这个方法被频繁调用,list 会持续增长,其中的对象无法回收。
           */
          public void addToList() {
              Object obj = new Object(); // obj 是局部变量,方法结束时引用消失
              list.add(obj); // 但 obj 被添加到了静态 list 中,被长生命周期对象引用
          }
          // 需要在合适的时候手动从 list 中移除不再需要的对象,或者避免使用静态集合存储临时对象。
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
  2. 单例模式 (Singleton Pattern):

    • 原因:单例对象通常也是静态的,生命周期贯穿整个应用。如果单例对象持有对外部对象的引用(例如,传入的某个临时对象、回调接口等),并且没有在适当的时候释放这些引用,那么这些外部对象就无法被回收。

    • 示例:

      public class SingletonLeak {
          private static SingletonLeak instance = new SingletonLeak();
          private Object externalObject; // 持有外部对象的引用
      
          private SingletonLeak() {}
      
          public static SingletonLeak getInstance() {
              return instance;
          }
      
          /**
           * 如果这个方法持有了某个短生命周期的对象,并且没有后续置 null 操作,
           * 那么 externalObject 会一直引用它,导致泄漏。
           */
          public void holdObject(Object obj) {
              this.externalObject = obj;
          }
          // 需要在 externalObject 不再需要时,显式地将其置为 null: this.externalObject = null;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
  3. 内部类持有外部类引用 (Inner Class Holding Outer Class):

    • 原因:非静态内部类(包括匿名内部类)会隐式地持有其外部类实例的引用。如果一个内部类实例的生命周期比其外部类实例长(例如,内部类实例被传递到其他地方并被长期持有),那么即使外部类实例本身已经不再被直接使用,它也无法被 GC 回收,因为它被其内部类实例引用着。
    • 常见场景:回调、监听器、异步任务(如 Handler、Runnable、Thread)中使用了匿名内部类或非静态内部类访问了外部类的成员。
    • 解决方法:
      • 将内部类声明为静态 (static),静态内部类不持有外部类的引用。
      • 在内部类中不直接引用外部类实例,可以通过构造函数传递必要的参数。
      • 在外部类生命周期结束时(如 Activity 的 onDestroy),确保解除对内部类实例的引用(如果外部类持有内部类引用的话),或者内部类主动断开对外部资源的引用。
  4. 各种连接未关闭 (Connections Not Closed):

    • 原因:数据库连接 (Connection)、网络连接 (Socket)、文件/IO流 (InputStream, OutputStream, Reader, Writer) 等资源,在使用完毕后必须显式地调用 close() 方法来释放底层资源和相关联的 Java 对象。如果忘记关闭,这些连接对象(以及它们可能占用的系统资源)将一直存在,无法被 GC 回收,导致泄漏。

    • 最佳实践:始终在 finally 块中关闭资源,或者使用 Java 7 及以上版本的 try-with-resources 语句自动管理资源关闭。

    • 示例 (try-finally):

      Connection conn = null;
      Statement stmt = null;
      ResultSet rs = null;
      try {
          // 1. 获取连接
          conn = DriverManager.getConnection("url", "user", "password");
          // 2. 创建 Statement
          stmt = conn.createStatement();
          // 3. 执行查询
          rs = stmt.executeQuery("SELECT * FROM users");
          // 4. 处理结果集...
      } catch (SQLException e) {
          e.printStackTrace();
      } finally {
          // 5. 在 finally 块中按逆序关闭资源
          if (rs != null) {
              try { rs.close(); } catch (SQLException e) { /* log or ignore */ }
          }
          if (stmt != null) {
              try { stmt.close(); } catch (SQLException e) { /* log or ignore */ }
          }
          if (conn != null) {
              try { conn.close(); } catch (SQLException e) { /* log or ignore */ }
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
    • 示例 (try-with-resources):更简洁、安全

      try (Connection conn = DriverManager.getConnection("url", "user", "password");
           Statement stmt = conn.createStatement();
           ResultSet rs = stmt.executeQuery("SELECT * FROM users")) {
          // 处理结果集...
          // conn, stmt, rs 会在 try 块结束时自动关闭 (即使发生异常)
      } catch (SQLException e) {
          e.printStackTrace();
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
  5. 变量作用域不合理 (Improper Variable Scope):

    • 原因:一个对象的引用变量的作用域(生命周期)远远超过了它实际需要被使用的范围,导致对象存活时间过长。特别是成员变量,其生命周期与所属对象一致。

    • 示例:

      public class UnreasonableScope {
          private String msg; // 成员变量,生命周期与 UnreasonableScope 对象相同
      
          public void processMessage() {
              readFromNet(); // 读取网络数据到 msg
              saveDB();      // 将 msg 保存到数据库
              // 此时 msg 变量中的数据逻辑上已不再需要,但 msg 变量本身仍然存在,
              // 并且持有对 String 对象的引用,直到 UnreasonableScope 对象被回收。
              // 如果 processMessage 被多次调用,msg 会被新数据覆盖,但旧的 String 对象可能暂时无法回收。
      
              // 改进 1: 将 msg 定义为局部变量
              // String localMsg = readFromNetAndReturn();
              // saveDB(localMsg);
              // localMsg 生命周期仅限于此方法
      
              // 改进 2: 使用后显式置 null (如果必须是成员变量)
              // this.msg = null;
          }
      
          private void readFromNet() {
              // 模拟从网络读取数据
              this.msg = "Data from network " + System.currentTimeMillis();
          }
          private void saveDB() {
              // 模拟保存到数据库
              System.out.println("Saving to DB: " + this.msg);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
    • 建议:尽量缩小变量的作用域,优先使用局部变量。如果必须使用成员变量存储临时数据,在不再需要时及时将其引用置为 null。

  6. 修改了放入 Set/Map 中对象的哈希码相关字段 (Changing Hash Code):

    • 原因:当一个对象被放入 HashSet 或作为 HashMap 的 Key 后,绝对不能修改那些参与计算 hashCode() 的字段。如果修改了,对象的 hashCode() 会变化,导致集合内部结构不一致。此时,即使使用该对象的引用去调用 remove() 或 contains() 方法,集合也可能因为哈希码对不上而找不到该对象,导致该对象永远无法从集合中移除,造成泄漏。

    • 为何 String 适合做 Key? 因为 String 是不可变的,其 hashCode() 一旦计算就不会改变。

    • 自定义类作 Key/存入 Set:必须确保参与 hashCode() 计算的字段是不可变的,或者在对象存入集合后不再修改这些字段。

    • 示例 1 (修改后 remove 失败):

      import java.util.HashSet;
      import java.util.Objects;
      
      public class ChangeHashCodeLeak {
          public static void main(String[] args) {
              HashSet<Person> set = new HashSet<>();
              Person p1 = new Person(1001, "AA");
              Person p2 = new Person(1002, "BB");
      
              set.add(p1);
              set.add(p2);
              System.out.println("Set after adding p1, p2: " + set); // [Person{id=1001, name='AA'}, Person{id=1002, name='BB'}]
      
              // 修改了 p1 的 name 字段,该字段参与了 hashCode 计算
              p1.name = "CC";
              // 此时 p1 的 hashCode 变了,HashSet 内部结构可能已不匹配
              System.out.println("p1 hashCode changed to: " + p1.hashCode());
      
              // 尝试移除 p1,HashSet 会根据 p1 当前的 hashCode (基于 "CC") 去找位置,
              // 但 p1 当初是根据 "AA" 的 hashCode 存进去的,位置不对,找不到!
              boolean removed = set.remove(p1);
              System.out.println("Removing p1 (name='CC') successful? " + removed); // 输出 false
              System.out.println("Set after trying to remove p1: " + set); // p1 依然在集合中,但 name 是 "CC" 了
                                                                          // [Person{id=1001, name='CC'}, Person{id=1002, name='BB'}] - 内存泄漏
      
              // 尝试添加一个新的 Person(1001, "CC")
              // HashSet 会根据新对象的 hashCode 查找,可能找不到与原 p1 冲突的位置,添加成功
              set.add(new Person(1001, "CC"));
              System.out.println("Set after adding new Person(1001, 'CC'): " + set); // 集合中可能有重复逻辑的对象
      
              // 尝试添加回 Person(1001, "AA")
              // HashSet 会根据 "AA" 的 hashCode 查找,可能会添加到另一个位置
              set.add(new Person(1001, "AA"));
              System.out.println("Set after adding new Person(1001, 'AA'): " + set);
          }
      }
      
      class Person {
          int id;
          String name; // name 参与 hashCode 计算
      
          public Person(int id, String name) { this.id = id; this.name = name; }
      
          @Override
          public boolean equals(Object o) {
              if (this == o) return true;
              if (o == null || getClass() != o.getClass()) return false;
              Person person = (Person) o;
              return id == person.id && Objects.equals(name, person.name);
          }
      
          @Override
          public int hashCode() {
              // hashCode 基于 id 和 name
              return Objects.hash(id, name);
          }
      
          @Override
          public String toString() {
              return "Person{" + "id=" + id + ", name='" + name + '\'' + '}';
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
    • 示例 2 (Point 类):

      import java.util.HashSet;
      import java.util.Objects;
      
      public class ChangeHashCodeLeak2 {
          public static void main(String[] args) {
              HashSet<Point> hs = new HashSet<>();
              Point cc = new Point();
              cc.setX(10); // 初始 hashCode (假设基于 x=10)
              hs.add(cc);
              System.out.println("Added Point with x=10, hashCode=" + cc.hashCode());
      
              // 修改了参与 hashCode 计算的字段 x
              cc.setX(20); // hashCode 变化了
              System.out.println("Changed Point x to 20, hashCode=" + cc.hashCode());
      
              // 尝试移除 cc,会根据 x=20 的 hashCode 查找,找不到原位置
              boolean removed = hs.remove(cc);
              System.out.println("hs.remove(cc where x=20) = " + removed); // 输出 false
      
              // 再次添加 cc (此时 x=20)
              // HashSet 会根据 x=20 的 hashCode 查找,发现没有这个 hashCode 的对象 (或者不在那个位置)
              // 于是将这个 cc (虽然是同一个对象引用,但状态变了) 作为一个新元素添加
              hs.add(cc);
              System.out.println("hs.size = " + hs.size()); // 输出 size = 2
      
              System.out.println(hs); // 输出 [Point{x=20}, Point{x=20}] - 逻辑上可能不希望有两个x=20的点,且第一个x=10的点无法移除
          }
      }
      
      class Point {
          int x; // x 参与 hashCode 计算
      
          public int getX() { return x; }
          public void setX(int x) { this.x = x; }
      
          @Override
          public boolean equals(Object o) {
              if (this == o) return true;
              if (o == null || getClass() != o.getClass()) return false;
              Point point = (Point) o;
              return x == point.x;
          }
      
          @Override
          public int hashCode() {
              // hashCode 基于 x
              return Objects.hash(x);
          }
      
           @Override
          public String toString() { return "Point{" + "x=" + x + '}'; }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
  7. 缓存泄漏 (Cache Leak):

    • 原因:将对象引用放入缓存(如 HashMap)中后,如果忘记在对象不再需要时从缓存中移除,这些对象就会因为被缓存引用而无法回收。特别是当缓存的生命周期很长时,问题会更严重。

    • 示例:程序启动时加载大量数据到静态 HashMap 缓存中,如果这些数据后续很少被访问或永不失效,就会一直占用内存。

    • 解决方案:

      • 使用带有淘汰策略的缓存实现(如 LRU - 最近最少使用)。
      • 使用弱引用 (Weak References) 或软引用 (Soft References) 来存储缓存对象,特别是使用 WeakHashMap。
        • WeakHashMap 的特点是:如果一个 Key 对象不再有任何强引用指向它(除了来自 WeakHashMap 内部的弱引用),那么在下一次 GC 发生时,这个 Key 及其对应的 Value 就可能会被自动从 Map 中移除。这非常适合做缓存,可以自动清理不再被外部使用的缓存项。
    • WeakHashMap 示例:

      import java.util.HashMap;
      import java.util.Map;
      import java.util.WeakHashMap;
      import java.util.concurrent.TimeUnit;
      
      public class CacheLeakExample {
          // 使用 WeakHashMap 作为缓存
          static Map<String, String> weakCache = new WeakHashMap<>();
          // 使用普通的 HashMap 作为对比
          static Map<String, String> strongCache = new HashMap<>();
      
          public static void main(String[] args) throws InterruptedException {
              // 创建 Key 对象,并放入缓存
              String key1 = new String("weakKey"); // 使用 new String 确保是新对象
              String key2 = new String("strongKey");
      
              weakCache.put(key1, "Weak Cache Data");
              strongCache.put(key2, "Strong Cache Data");
      
              System.out.println("Before GC:");
              System.out.println("WeakHashMap: " + weakCache);
              System.out.println("HashMap: " + strongCache);
      
              // 移除外部对 Key 的强引用
              key1 = null;
              key2 = null;
              System.out.println("\nRemoved external strong references to keys.");
      
              // 触发 GC (不保证立即执行,但增加可能性)
              System.out.println("Triggering GC...");
              System.gc();
              TimeUnit.SECONDS.sleep(1); // 等待 GC
      
              System.out.println("\nAfter GC:");
              // WeakHashMap 中的 key1 因为没有其他强引用,很可能被回收,导致 Entry 被移除
              System.out.println("WeakHashMap: " + weakCache);
              // HashMap 中的 key2 虽然外部引用没了,但 HashMap 内部仍然是强引用,不会被回收
              System.out.println("HashMap: " + strongCache);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40

      预期输出:GC 后,WeakHashMap 很可能变为空,而 HashMap 保持不变。

  8. 监听器和其他回调 (Listeners and Callbacks):

    • 原因:在事件驱动模型中,经常会将监听器(Listener)或回调(Callback)对象注册到事件源(Source)上。如果在监听器不再需要时(例如,UI 界面关闭),没有从事件源那里显式地注销 (unregister/remove) 监听器,那么事件源会一直持有对监听器的引用,导致监听器对象(以及它可能引用的其他对象)无法被回收。
    • 常见场景:Android 开发中的 Context 泄漏(Activity 作为 Listener 注册到某个生命周期更长的对象上,Activity 销毁时未注销)、各种事件总线、观察者模式实现。
    • 解决方案:
      • 在适当的生命周期结束点(如 onDestroy(), onStop())务必反注册/移除监听器。
      • 考虑使用弱引用来持有监听器对象,但这会增加实现的复杂性(事件源需要处理弱引用可能变为 null 的情况)。
      • 使用专门管理生命周期的库(如 Android Jetpack Lifecycle)。

# 栈内存泄漏案例分析 (Stack.java)

这个例子演示了一个自定义栈实现中可能存在的过期引用 (Obsolete Reference) 问题,这也是一种内存泄漏。

import java.util.Arrays;
import java.util.EmptyStackException;

/**
 * 一个简单的对象栈实现,演示过期引用导致的内存泄漏问题。
 */
public class StackLeak {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public StackLeak() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    /**
     * 入栈操作。
     * @param e 要入栈的对象。
     */
    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    /**
     * 出栈操作 - 存在内存泄漏的版本。
     * @return 栈顶对象。
     * @throws EmptyStackException 如果栈为空。
     */
    /* // 错误的版本
    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        // 直接返回 elements[--size],但并没有将原位置的引用置 null
        // elements 数组仍然持有对这个已弹出对象的引用
        return elements[--size];
    }
    */

    /**
     * 出栈操作 - 修复了内存泄漏的版本。
     * @return 栈顶对象。
     * @throws EmptyStackException 如果栈为空。
     */
    public Object pop() { // 正确的版本
        if (size == 0)
            throw new EmptyStackException();
        // 先将 size 减 1,获取栈顶元素
        Object result = elements[--size];
        // 将数组中对应位置的引用置为 null,断开对弹出对象的引用
        elements[size] = null; // 清除过期引用
        return result;
    }


    /**
     * 确保数组容量足够,如果不够则扩容。
     */
    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }

    public static void main(String[] args) {
        StackLeak stack = new StackLeak();
        // 入栈 20 个对象
        for(int i = 0; i < 20; i++) {
            stack.push(new byte[1024 * 1024]); // 假设每个对象 1MB
        }
        System.out.println("Stack size after push: " + stack.size); // 输出 20

        // 出栈 10 个对象
        for(int i = 0; i < 10; i++) {
            stack.pop();
        }
        System.out.println("Stack size after pop: " + stack.size); // 输出 10

        // 如果使用的是错误的 pop 版本:
        // 即使弹出了 10 个对象,stack.elements 数组的索引 10 到 19 的位置
        // 仍然持有对那 10 个 1MB byte[] 对象的引用。
        // 这些对象逻辑上已不在栈中,但 GC 无法回收它们,造成 10MB 内存泄漏。

        // 如果使用的是正确的 pop 版本:
        // 每次 pop 时,对应位置的引用被置 null,GC 可以回收弹出的对象。
        System.out.println("Check elements array after correct pop (conceptual):");
        // for(int i = 0; i < stack.elements.length; i++) {
        //     System.out.println("elements[" + i + "] = " + stack.elements[i]);
        // }
        // 预期:索引 0-9 有对象引用,索引 10-19 为 null。
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

问题分析:

  • 错误的 pop 方法仅仅是将 size 减 1,并返回了 elements[size] (减 1 之前的 size 位置)的对象。
  • 但是,elements 数组内部,索引为 size (减 1 之后) 的那个位置仍然持有对刚刚弹出的对象的引用。
  • 只要 StackLeak 对象本身存活,elements 数组就存活,那么数组中持有的这些“已弹出”对象的引用就一直存在。
  • 这些引用被称为过期引用 (Obsolete Reference),它们指向逻辑上已经不再使用但物理上仍然被引用的对象,导致内存泄漏。

图示:

  1. 栈增长后: image-20210505160114618
  2. 错误 pop 后: image-20220201160456212 图中灰色部分代表已弹出但仍被数组引用的对象(内存泄漏)。

解决方法:

在 pop 操作取出对象引用后,必须手动将数组中对应位置的引用设置为 null,断开这个过期引用。

public Object pop() {
    if (size == 0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // 清除过期引用,允许 GC 回收 result 对象
    return result;
}
1
2
3
4
5
6
7

修复后的 pop 操作示意图: image-20210505160423289

总结:对于集合类、缓存等自己管理对象引用的数据结构,需要特别注意及时清理不再需要的对象的引用,避免过期引用导致的内存泄漏。

# 6. 使用 OQL 语言查询对象信息

MAT 提供了一种强大的对象查询语言 (Object Query Language, OQL),它允许用户使用类似 SQL 的语法在加载的 Heap Dump 中进行复杂的对象查找和筛选。

基本语法结构:

SELECT <select_expression>
FROM <class_name_or_pattern | object_address_or_set> [[AS] <alias>]
[WHERE <filter_expression>]
1
2
3

SELECT 子句:

  • 指定要显示的结果列。
  • 可以使用 * 选择对象的所有属性。
  • 可以使用 OBJECTS 关键字,将结果项作为可检查的对象列表返回,而不是简单的文本输出。
  • 可以使用 AS RETAINED SET 获取结果对象的保留集大小。
  • 可以使用 DISTINCT 去除结果集中的重复对象。
  • 可以访问对象的属性,包括特殊属性(以 @ 开头)。

常用特殊属性:

  • @objectid: 对象的 ID。
  • @objectAddress: 对象的内存地址。
  • @shallowHeapSize: 对象的浅堆大小。
  • @retainedHeapSize: 对象的深堆大小。
  • @class: 对象所属的 Class 对象。
  • @length: (仅用于数组) 数组的长度。
  • @GCRootReason: (仅用于 GC Roots) GC Root 的类型。

示例:

  • SELECT * FROM java.util.Vector v
    • 查找所有 java.util.Vector 类的实例,并显示它们的属性。
  • SELECT OBJECTS v.elementData FROM java.util.Vector v
    • 查找所有 Vector 实例,并返回它们内部 elementData 数组的对象列表。
  • SELECT OBJECTS s.value FROM java.lang.String s
    • 查找所有 String 实例,并返回它们内部 value (char[]) 数组的对象列表。
  • SELECT AS RETAINED SET * FROM cn.kbt.mat.Student
    • 查找所有 cn.kbt.mat.Student 实例,并计算每个实例的保留集大小。
  • SELECT DISTINCT OBJECTS classof(s) FROM java.lang.String s
    • 查找所有 String 实例,获取它们对应的 Class 对象,并去除重复,最终只返回 java.lang.String 这个 Class 对象。(classof() 是 OQL 内置函数)。

FROM 子句:

  • 指定查询的源(从哪里查找)。
  • 可以是一个具体的类名 (java.lang.String)。
  • 可以使用正则表达式匹配类名 ("cn\.kbt\..*" 查找 cn.kbt 包下的所有类)。
  • 可以是一个对象的内存地址 (0x37a0b4d)。
  • 可以使用 INSTANCEOF 关键字查找某个类及其所有子类的实例 (INSTANCEOF java.util.Vector)。
  • 可以是一个对象集合(例如,另一个 OQL 查询的结果)。

WHERE 子句:

  • 指定过滤条件,只有满足条件的对象才会被返回。
  • 语法类似 SQL 的 WHERE 子句,支持 AND, OR, NOT。
  • 支持比较运算符 (=, !=, >, <, >=, <=)。
  • 支持 LIKE 操作符(参数是正则表达式)。
  • 支持 instanceof 操作符。
  • 可以访问对象的属性(包括 @ 特殊属性)。

示例:

  • SELECT * FROM char[] s WHERE s.@length > 10
    • 查找所有长度大于 10 的 char 数组。
  • SELECT * FROM java.lang.String s WHERE toString(s) LIKE ".*java.*"
    • 查找所有内容包含 "java" 子字符串的 String 对象。(注意:toString(s) 可能比直接访问 s.value 效率低)。
    • 更高效的方式可能是:SELECT * FROM java.lang.String s WHERE s.value != null AND s.value.@length > 0 AND toString(s.value).contains("java") (假设 toString 对 char[] 有效) 或通过更复杂的 OQL 操作 value 数组。
  • SELECT * FROM java.lang.String s WHERE s.value != null
    • 查找所有内部 value 数组不为 null 的 String 对象。
  • SELECT * FROM java.util.Vector v WHERE v.elementData.@length > 15 AND v.@retainedHeapSize > 1000
    • 查找所有内部数组长度大于 15 且深堆大小超过 1000 字节的 Vector 对象。

内置对象与方法:

OQL 提供了一些内置对象和方法方便查询:

  • 访问对象属性: [ <alias>. ] <field> . <field>
    • SELECT toString(f.path.value) FROM java.io.File f: 访问 File 对象的 path 属性(假设是 String 类型),再访问 path 的 value 属性(char[]),并转换为字符串。
  • 访问特殊属性(见上文 @ 属性)。
  • 内置函数:toString(), classof(), reachables() (查找可达对象) 等。

OQL 是 MAT 中非常强大的功能,熟练使用 OQL 可以极大地提高分析 Heap Dump 的效率。

# 7. JProfiler:商业级 Java 性能分析瑞士军刀

官网地址:https://www.ej-technologies.com/products/jprofiler/overview.html

JProfiler 是由 ej-technologies 公司开发的一款商业 (需要付费) 的 Java 应用程序性能诊断工具。它以其功能全面、易于使用和强大的分析能力而闻名,是许多专业开发团队的选择。

核心特点:

  • 使用方便,界面友好:提供了直观的图形界面和预设的分析模板。
  • 低性能影响 (可配置):提供 Sampling (抽样) 和 Instrumentation (插桩) 两种数据采集方式,Sampling 对应用影响较小。
  • 强大的分析能力:在 CPU、线程、内存分析方面尤为突出。
  • 广泛的子系统支持:支持对 JDBC (包括 SQL 语句分析)、NoSQL、JSP/Servlet、Socket、RMI、Web Services 等进行专门分析。
  • 多种分析模式:支持在线实时分析 (Live Profiling) 和离线分析 (Offline Profiling,基于快照)。
  • 支持本地与远程监控:可以监控本地 JVM,也可以通过 Agent 连接到远程 JVM。
  • 跨平台:支持 Windows, macOS, Linux 等多种操作系统。

image-20220201162722104 图:JProfiler 界面概览

主要功能模块:

  1. 方法调用分析 (CPU Views):

    • Call Tree (调用树):自顶向下展示方法的调用关系和累积耗时。
    • Hot Spots (热点):直接列出消耗 CPU 时间最多的方法。
    • Call Graph (调用图):可视化方法间的调用关系。
    • Method Statistics (方法统计):详细列出每个方法的调用次数、平均/总执行时间等。
    • 帮助定位 CPU 性能瓶颈。
  2. 内存分配分析 (Live Memory & Heap Walker):

    • All Objects / Recorded Objects: 查看堆上对象的实例数、大小、分配位置(调用栈)。
    • Allocation Call Tree / Hot Spots: 分析哪些代码路径或方法创建了最多的对象或占用了最多的内存。
    • Class Tracker: 跟踪特定类的实例数量随时间的变化。
    • Heap Walker: 强大的堆转储快照分析器,功能类似 MAT,可以查看对象、引用链、计算 Retained Size、执行 OQL 等,用于查找内存泄漏。
  3. 线程和锁分析 (Threads & Monitors/Locks):

    • Thread History / Monitor: 实时查看线程状态、活动时间线、当前执行的方法。
    • Thread Dumps: 获取线程快照。
    • Locking History / Graph: 分析锁竞争、等待情况、检测死锁。
  4. 高级子系统分析 (Databases, JEE & Probes):

    • JDBC/JPA/Hibernate: 分析数据库调用,找出慢 SQL、执行次数过多的 SQL。
    • Servlet/JSP: 按 URL 分析请求处理时间。
    • Socket/File I/O: 分析网络和文件读写操作。
    • Probes (探针): 提供对常用框架和技术的内置支持,进行更高级别的语义分析。

数据采集方式:

JProfiler 主要针对方法调用提供两种采集方式:

  • Instrumentation (插桩/重构模式):
    • 在类加载时,JProfiler 将分析代码(字节码)注入到需要分析的类中。
    • 优点:功能最全面,可以获取精确的方法调用次数、执行时间、内存分配细节等。调用栈信息准确。
    • 缺点:对应用性能有较大影响,特别是分析大量类时,CPU 开销可能很高。通常需要配合过滤器 (Filter) 精确指定要插桩的类或包,以减少开销。
  • Sampling (抽样):
    • JProfiler 定期(例如每 5ms)获取每个线程当前的方法调用栈。通过统计栈顶方法出现的频率来估算方法的执行时间。
    • 优点:对应用性能影响非常小,CPU 开销低,适用于生产环境或对性能影响敏感的场景。
    • 缺点:无法提供精确的方法调用次数和绝对执行时间,对于执行时间非常短的方法可能采样不到。数据是统计估算值。

JProfiler 界面示例:

  • 遥感监测 (Telemetries):提供 JVM 整体运行状态的概览图表,类似 JConsole 和 VisualVM 的 Monitor 视图。

    • 内存使用曲线: image-20210505164521410
    • GC 活动: image-20220201163821835
    • 类加载: image-20210505164815324
    • 线程状态: image-20210505164945192
    • CPU 负载: image-20210505165010529
  • 内存视图 (Live Memory):实时分析内存分配。

    • All Objects (所有对象视图): image-20220201164328735
    • Allocation Call Tree (分配调用树): image-20210505164554298
    • Allocation Hot Spots (分配热点): image-20210505165519790
  • 堆遍历 (Heap Walker):离线分析 Heap Dump 文件。

    • Classes (类视图): image-20210505165710620
    • References (引用视图):查看对象的传入传出引用、分析 GC Roots 路径。 image-20210505165823201
  • CPU 视图 (CPU Views):分析 CPU 耗时。

    • Call Tree (调用树): image-20210505170055722
    • Hot Spots (热点): image-20210505170141278
  • 线程视图 (Threads):分析线程活动和状态。

    • Thread History (线程历史时间线): image-20220201165337435
    • Thread Monitor (线程监控列表): image-20210505170739972
  • 监控和锁 (Monitors & Locks):分析锁竞争和死锁。

JProfiler 简单案例:

  • 案例 1:模拟频繁创建对象 (JProfilerTest.java)

    import java.util.ArrayList;
    import java.util.concurrent.TimeUnit;
    
    /**
     * 模拟在循环中不断创建大量临时对象的场景。
     * 用于观察 JProfiler 中的内存分配和 GC 活动。
     */
    public class JProfilerTest {
        public static void main(String[] args) {
            while (true) { // 无限循环
                ArrayList<Data> list = new ArrayList<>(); // 在循环内部创建列表
                for (int i = 0; i < 500; i++) {
                    Data data = new Data(); // 创建 Data 对象
                    list.add(data);
                }
                // list 和内部的 Data 对象在循环结束后理论上应可被回收
                try {
                    TimeUnit.MILLISECONDS.sleep(500); // 短暂休眠
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    class Data {
        private int size = 10;
        // 每个 Data 对象包含一个 1MB 的字节数组
        private byte[] buffer = new byte[1024 * 1024];
        private String info = "hello,kele";
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31

    JProfiler 分析结果:

    • 遥感监测 (Telemetries) 中的内存视图会显示典型的锯齿状图形,表示内存不断分配(上升),然后被 Young GC 回收(下降)。 image-20220201170051789
    • GC 活动视图会显示频繁的 Young GC。 image-20220201170106558
    • Live Memory 视图的 Allocation Hot Spots 可以看到 Data 对象的构造函数 (<init>) 是主要的内存分配来源。
  • 案例 2:模拟静态集合内存泄漏 (MemoryLeak.java)

    import java.util.ArrayList;
    import java.util.concurrent.TimeUnit;
    
    /**
     * 模拟使用静态集合导致内存泄漏的场景。
     */
    public class MemoryLeak {
        public static void main(String[] args) {
            while (true) {
                ArrayList<Bean> beanList = new ArrayList<>();
                for (int i = 0; i < 500; i++) {
                    Bean data = new Bean();
                    // 将一个 10KB 的 byte 数组添加到 Bean 的静态 list 中
                    data.list.add(new byte[1024 * 10]);
                    beanList.add(data); // beanList 是局部变量,会被回收
                }
                // beanList 及其包含的 Bean 对象引用在循环结束后消失,
                // 但 Bean.list 是静态的,它持有的 byte[] 数组无法被回收。
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    class Bean {
        int size = 10;
        String info = "hello,kele";
        // !!!问题所在:静态列表,生命周期与类相同!!!
        static ArrayList<byte[]> list = new ArrayList<>();
        // 正确的写法应该是实例变量:
        // ArrayList<byte[]> list = new ArrayList<>();
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35

    JProfiler 分析结果:

    • 内存视图会显示堆内存持续增长,即使发生 GC 也无法完全回收,因为 Bean.list 持有的 byte[] 越来越多。 image-20220201170352569
    • Heap Walker 分析会显示 Bean.list (ArrayList) 对象及其内部的 elementData (Object[]) 占用了大量的 Retained Heap,并且持有大量 byte[] 实例。 image-20220201170506350

JProfiler 是一款非常专业的工具,虽然需要付费和一定的学习成本,但其强大的功能对于深入分析和解决复杂的 Java 性能问题非常有价值。

# 8. Arthas:阿里开源的 Java 诊断神器

官方网站与文档:https://arthas.aliyun.com/doc/

Arthas (阿尔萨斯) 是阿里巴巴开源的一款 Java 在线诊断工具,旨在解决线上 Java 应用的常见问题,尤其是在不方便重启或添加日志的情况下。它深受开发者喜爱,被誉为“Java 诊断神器”。

解决痛点:

  • 无需远程连接配置:Arthas 直接 attach 到目标 Java 进程,无需预先在目标 JVM 中配置 JMX 或其他监控参数。
  • 无需重启应用:可以在应用运行时动态地进行诊断和分析。
  • 网络隔离环境友好:由于是 attach 到本地进程,不受网络隔离限制。
  • 动态代码跟踪与修改:可以在不修改源码、不重新部署的情况下,观察方法调用、参数、返回值,甚至热更新代码。
  • 实时 JVM 状态监控:提供丰富的命令实时查看 JVM 内部状态。
  • 开源免费。

核心能力:

  • 类加载问题排查:查看类从哪个 Jar 包加载,诊断 ClassNotFoundException, NoClassDefFoundError 等。
  • 代码执行路径确认:验证某段代码是否被执行,调用栈是怎样的。
  • 在线 Debug 模拟:观察方法的入参、返回值、内部变量、抛出的异常。
  • 全局运行状况监控:提供 Dashboard 查看系统实时状态(线程、内存、GC、CPU 等)。
  • 性能热点定位:快速找出消耗 CPU 最多的线程和方法,支持生成火焰图。

安装与启动:

Arthas 本质上是一个 Java Agent。

  1. 下载 arthas-boot.jar:

    # 从官方或镜像下载启动包
    wget https://arthas.aliyun.com/arthas-boot.jar
    # 或者使用国内镜像
    # wget https://arthas.gitee.io/arthas-boot.jar
    
    1
    2
    3
    4
  2. 启动并 Attach:

    • 方式一:交互式选择进程

      java -jar arthas-boot.jar
      
      1

      运行后,Arthas 会列出当前机器上所有正在运行的 Java 进程,提示用户输入要 Attach 的进程编号。 image-20220201172653188

    • 方式二:直接指定 PID

      # 先用 jps 或 ps -ef | grep java 找到目标进程 PID
      jps
      # 假设 PID 为 5826
      java -jar arthas-boot.jar 5826
      
      1
      2
      3
      4
  3. 进入 Arthas 控制台: Attach 成功后,会进入 Arthas 的命令行交互界面,显示 [arthas@<PID>]$ 提示符,可以在此输入各种诊断命令。

Web Console:

Arthas Attach 成功后,会自动在本地启动一个 Web 服务器(默认端口 8563)。可以通过浏览器访问 http://127.0.0.1:8563/ 来使用 Web 版的控制台,操作方式与命令行完全一致。

Arthas 常用指令 (部分):

(详细指令请参考官方文档,支持 Tab 自动补全)

  • 基础指令:

    • help: 查看命令帮助。
    • dashboard: 显示实时数据面板(线程、内存、GC、运行时信息等)。
    • quit / exit: 退出当前 Arthas 客户端连接(服务端仍在运行)。
    • stop / shutdown: 关闭 Arthas 服务端,所有客户端断开。
    • cls: 清屏。
    • history: 查看命令历史。
    • reset: 重置所有被 Arthas 增强过的类(恢复原始字节码)。
  • JVM 相关:

    • thread: 查看线程信息,thread -b 查看死锁,thread -n <N> 查看最忙的 N 个线程。
    • jvm: 查看 JVM 详细信息(内存、GC、类加载等)。
    • sysprop: 查看或修改系统属性。
    • sysenv: 查看环境变量。
    • vmoption: 查看或修改 VM 参数(可管理的)。
    • heapdump <file>: 生成 Heap Dump 文件。
  • 类/类加载器相关:

    • sc: 搜索类信息 (-d 查看详情, -f 查看字段)。
    • sm: 搜索方法信息 (-d 查看详情)。
    • jad <class>: 反编译类的源码。
    • mc <file.java>: 内存编译 Java 文件。
    • redefine <file.class>: 热更新(重新定义)已加载的类。
    • dump <class>: Dump 类的字节码到文件。
    • classloader: 查看类加载器信息。
  • 方法执行监控/观测/追踪 (核心功能):

    • monitor: 监控方法的调用次数、成功/失败率、平均耗时。
    • watch: 观测方法的入参、返回值、抛出异常、内部变量(使用 OGNL 表达式)。极其强大,可用于在线 Debug。
    • trace: 追踪方法内部的调用路径及各步骤耗时。
    • stack: 输出方法当前的被调用路径。
    • tt: 时空隧道,记录方法每次调用的详细信息(入参、返回值、耗时等),并可以重新“播放”(replay) 调用过程。
  • 性能分析:

    • profiler: 启动 async-profiler 进行 CPU 或内存采样,生成火焰图,定位性能热点。

Arthas 以其创新的非侵入式在线诊断能力,极大地提高了 Java 应用线上问题的排查效率,是现代 Java 开发者工具箱中的必备利器。

# 9. Java Mission Control (JMC) 与 Java Flight Recorder (JFR)

JMC 项目地址:https://github.com/JDKMissionControl/jmc

JMC (Java Mission Control) 是 Oracle 官方提供的一款先进的 Java 监控和管理工具套件。它起源于 JRockit 虚拟机(被 Oracle 收购),后来其核心特性被集成到 HotSpot VM 中。

  • 历史:Oracle JDK 7u40 之后内置 JMC。自 Java 11 起,JMC 及其核心组件 JFR (Java Flight Recorder) 已开源。在之前的 Oracle JDK 版本中,JFR 属于商业特性,需要通过 -XX:+UnlockCommercialFeatures 参数开启。
  • 组成:JMC 主要包含一个 GUI 客户端 和多个插件,其中最重要的插件是 JMX Console 和 Java Flight Recorder (JFR)。

核心优势:

  • 极低性能开销:JFR 采用事件采样 (Event Sampling) 而不是代码插桩技术,对正在运行的 Java 应用性能影响极小(官方称默认配置下平均低于 1%)。这使得它非常适合在生产环境下长时间开启,用于监控和诊断满负荷运行的应用。
  • 数据全面深入:JFR 能够直接访问 JVM 内部数据,记录非常详细的运行时事件,包括 Java 层面(线程、锁、IO、Socket 等)和 JVM 内部(对象分配、GC、JIT 编译、类加载、安全点等)。
  • 工具链完整:JFR 负责高效地收集数据(生成 .jfr 文件),JMC 则提供了强大的分析功能来解读这些数据。

Java Flight Recorder (JFR):

JFR 是 JMC 的数据收集引擎。启用后,它会记录一系列在 JVM 运行过程中发生的事件 (Events)。

JFR 事件类型:

  • 瞬时事件 (Instant Event):关心其发生与否,如异常抛出、线程启动/结束。
  • 持续事件 (Duration Event):关心其持续时间,如 GC、JIT 编译、锁等待、Socket 读写。
  • 计时事件 (Timed Event):是持续时间超过了预设阈值的持续事件。
  • 取样事件 (Sample Event):周期性地采样获取信息,如方法采样(定期获取线程栈)、对象分配采样。

方法抽样 (Method Sampling) 是 JFR 常用的分析热点代码的方式。通过定期获取所有线程的调用栈,如果某个方法在采样结果中反复出现,则可以推断该方法是性能热点。

使用 JFR 和 JMC:

  1. 启用 JFR 记录:

    • 启动参数方式 (推荐):

      # 启动时开启 JFR,记录数据到文件 myrecording.jfr
      java -XX:StartFlightRecording=filename=myrecording.jfr,duration=5m MyApp
      # 可以指定记录时长 (duration), 文件大小 (maxsize), 事件配置 (settings=profile/default) 等
      java -XX:StartFlightRecording=settings=profile,filename=myrec.jfr,dumponexit=true MyApp
      
      1
      2
      3
      4
    • 动态开启 (通过 jcmd):

      # 启动 JFR 记录,持续 60 秒,保存到 recording.jfr
      jcmd <pid> JFR.start name=myrec duration=60s filename=recording.jfr
      # 查看记录状态
      jcmd <pid> JFR.check
      # 停止记录
      jcmd <pid> JFR.stop name=myrec
      # Dump 记录到文件
      jcmd <pid> JFR.dump name=myrec filename=dumped.jfr
      
      1
      2
      3
      4
      5
      6
      7
      8
    • 通过 JMC GUI 开启:连接到目标 JVM 后,在 JMC 客户端中启动飞行记录。

  2. 使用 JMC 分析 .jfr 文件:

    • 启动 JMC (位于 JDK bin 目录下,jmc.exe 或 jmc)。
    • File -> Open File... 选择 .jfr 文件。
    • JMC 会自动对 JFR 数据进行分析,并以丰富的图表和列表形式展示结果。

JMC 分析界面示例:

JMC 提供了非常丰富的分析视图,包括但不限于:

  • 总览 (Overview):JVM 基本信息、主要发现。
  • 内存 (Memory):
    • 对象分配统计、压力最大的线程。
    • GC 时间、频率、原因分析。
    • 堆内存使用趋势。 image-20210505185941373
  • 代码 (Code):
    • 热点方法 (Hot Methods):通过方法采样找出 CPU 占用高的方法。
    • 异常统计。
    • 类加载信息。 image-20210505185954567
  • 线程 (Threads):
    • 线程活动时间线。
    • 锁竞争分析 (Lock Contention):找出哪些锁被激烈竞争,哪些线程等待时间最长。
    • 热点线程。 image-20210505190009274
  • I/O:
    • 文件读写统计。
    • Socket 读写统计。 image-20210505190023099
  • 系统 (System):CPU 负载、环境变量、系统属性。 image-20210505190037354
  • 事件 (Events):可以按类型查看 JFR 记录的所有原始事件。 image-20210505190052561 image-20210505190106004

JMC + JFR 因其低开销和数据全面的特点,特别适合用于生产环境的持续监控和问题诊断。

# 10. 其他相关工具与技术

除了上述主流工具外,还有一些其他值得了解的性能分析工具和技术:

# 火焰图 (Flame Graphs)

  • 概念:由 Brendan Gregg 发明的一种性能可视化技术,用于直观地展示 CPU 在程序运行期间的时间消耗分布。
  • 特点:
    • X 轴:表示 CPU 采样时间。一个方法的横条越宽,表示它(及其调用的子方法)占用的 CPU 时间越多。注意:X 轴通常不代表时间顺序,而是将同名函数合并后的宽度。
    • Y 轴:表示调用栈的深度。下面的函数调用上面的函数。
    • 颜色:通常没有特殊含义,用于区分不同的函数帧。
  • 解读:火焰图顶层(最宽的“火焰山”)代表了消耗 CPU 最多的代码路径。通过观察火焰的宽度和层次,可以快速定位 CPU 性能瓶颈。
  • 生成:通常需要结合采样工具(如 perf (Linux), DTrace (macOS/Solaris), async-profiler, JFR)获取调用栈采样数据,然后使用 Brendan Gregg 提供的脚本 (flamegraph.pl) 生成 SVG 格式的火焰图。Arthas 的 profiler 命令可以直接生成火焰图。 image-20210505190823214 图:火焰图示例

# TProfiler

  • 来源:阿里巴巴开源的 Profiler 工具 (https://github.com/alibaba/TProfiler)。
  • 特点:旨在解决特定场景下的性能问题,例如 GC 过于频繁。其核心特性之一是能够统计指定时间段内 JVM 的 Top Methods(调用最频繁或耗时最多的方法),这对于定位造成性能瓶颈(如大量临时对象创建)的代码非常有帮助。据称这是 JMC/JFR 等工具不直接支持的功能。
  • 应用场景:据案例分享,使用 TProfiler 成功定位并解决了因业务代码问题导致的频繁 GC,显著提升了 TPS。

# BTrace

  • 来源:曾经是 SUN Kenai 平台下的开源项目,现在可能由社区维护。
  • 定义:一个用于 Java 平台的安全、动态的追踪工具。
  • 原理:使用 Java Agent 技术和字节码注入,允许用户编写简单的 Java 脚本,在不停止目标 JVM 的情况下,动态地插入追踪代码到运行中的类方法中,以获取方法参数、返回值、执行时间、调用位置等信息。
  • 优势:动态、非侵入式(相对而言,仍有字节码修改开销),脚本语法类似 Java,相对易用。
  • 限制:为了安全,BTrace 脚本能做的事情受到严格限制(如不能创建新对象、不能修改目标程序状态、不能调用大部分 JDK 类库等)。
  • 现状:随着 Arthas 等更现代化工具的兴起,BTrace 的使用可能相对减少,但其动态追踪的思想仍然很有价值。 image-20210505192042974 图:BTrace 官方定义截图

# 其他工具

  • YourKit Java Profiler: 另一款功能强大的商业 Java Profiler,与 JProfiler 类似。
  • JProbe: 曾经流行的商业 Profiler,现在可能已被其他工具取代或整合。
  • Spring Insight: Spring 框架提供的应用性能监控工具,侧重于 Spring 应用内部的请求追踪和性能分析。

选择合适的工具取决于具体的需求、场景(开发/测试/生产)、预算以及团队的熟悉程度。掌握一到两款主流的 GUI 工具(如 VisualVM/MAT 或 JProfiler/JMC)并结合命令行工具和 Arthas,将能有效应对大部分 Java 性能问题。

编辑此页 (opens new window)
上次更新: 2025/04/05, 20:16:54
JVM - 监控及诊断工具cmd
JVM - 运行时参数

← JVM - 监控及诊断工具cmd JVM - 运行时参数→

Theme by Vdoing | Copyright © 2019-2025 程序员scholar
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式