admin管理员组

文章数量:1561477

最近体验了一把当医生的感觉,定位病根病因,感觉这种要揪出问题的感觉很爽,并不觉得麻烦,这里将整个排查过程记录一下,方便之后再遇到类似问题有应对之道。

问题背景

2023-07-18 早上还在睡梦中的俺被一条条报警消息铛铛铛的吵醒,这才发现我们的服务早上突然大量请求499,由于我们的服务相对基础,所以马上故障组拉群开始排查解决,先感觉分批次重启容器止损。这里get到的两个重点:

  • 监控一定要做,有了监控心里就有底了,且阈值设置的稍微低一些,这样可以赶在实际影响业务前得到通知
  • 遇到问题一定要及时摘流重启止损,问题分析现场留存先放放,当然如果监控阈值低的情况下,可以先摘流dump一下内存

我们当然是采取了及时止损的方式,当然也就丢失了现场,所以排查问题需要降低监控阈值再等一周发现后处理,不过这个没关系,首先不影响线上稳定才是第一要务!

排查过程

首先499的含义是客户端主动关闭连接,那是为什么呢?

1 观察7层的接口QPS

是QPS扛不住吗?并没有,实际上当天监控显示QPS才160多,远低于压测值。

2 观察容器内存容量

继而又观察容器本身的监控状态,这才发现容器内存已经飚到94%了,而且不止一台,所有容器都在94%左右,所以并非是单台容器的问题。

3 观察JVM使用情况

于是我们把目光转向了JVM:

这才惊讶的发现,老年代一直不回收,堆内存匀速上升,一直仰赖每周两次的发版上线让其恢复起点,18日这个周二早上的上一个发版日发版时间过早,就这么点儿时间差,堆内存直接飚了上来。于是乎我们大概知道了问题原因:堆内存一定发生了OOM,如何来判断OOM发生的原因?内存溢出是因为分配不够还是内存泄露

1 查看 JVM 内存分布

通过命令jmap -heap 进程PID,查看进程数据使用情况

[xxx@xxx ~]# jmap -heap 15162
Attaching to process ID 15162, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.161-b12

using thread-local object allocation.
Mark Sweep Compact GC

Heap Configuration:
   MinHeapFreeRatio         = 40 # 最小堆使用比例
   MaxHeapFreeRatio         = 70 # 最大堆可用比例
   MaxHeapSize              = 482344960 (460.0MB) # 最大堆空间大小
   NewSize                  = 10485760 (10.0MB) # 新生代分配大小
   MaxNewSize               = 160759808 (153.3125MB) # 最大新生代可分配大小
   OldSize                  = 20971520 (20.0MB) # 老年代大小
   NewRatio                 = 2 # 新生代比例
   SurvivorRatio            = 8 # 新生代与 Survivor 比例
   MetaspaceSize            = 21807104 (20.796875MB) # 元空间大小
   CompressedClassSpaceSize = 1073741824 (1024.0MB) # Compressed Class Space 空间大小限制
   MaxMetaspaceSize         = 17592186044415 MB # 最大元空间大小
   G1HeapRegionSize         = 0 (0.0MB) # G1 单个 Region 大小

Heap Usage:  # 堆使用情况
New Generation (Eden + 1 Survivor Space): # 新生代
   capacity = 9502720 (9.0625MB) # 新生代总容量
   used     = 4995320 (4.763908386230469MB) # 新生代已使用
   free     = 4507400 (4.298591613769531MB) # 新生代剩余容量
   52.56726495150862% used # 新生代使用占比
Eden Space:  
   capacity = 8454144 (8.0625MB) # Eden 区总容量
   used     = 4029752 (3.8430709838867188MB) # Eden 区已使用
   free     = 4424392 (4.219429016113281MB) # Eden 区剩余容量
   47.665996699370154% used  # Eden 区使用占比
From Space: # 其中一个 Survivor 区的内存分布
   capacity = 1048576 (1.0MB)
   used     = 965568 (0.92083740234375MB)
   free     = 83008 (0.07916259765625MB)
   92.083740234375% used
To Space: # 另一个 Survivor 区的内存分布
   capacity = 1048576 (1.0MB)
   used     = 0 (0.0MB)
   free     = 1048576 (1.0MB)
   0.0% used
tenured generation: # 老年代
   capacity = 20971520 (20.0MB)
   used     = 10611384 (10.119804382324219MB)
   free     = 10360136 (9.880195617675781MB)
   50.599021911621094% used

10730 interned Strings occupying 906232 bytes.

判断一下是不是分配的不够多,不过这种方式一般无法准确定位

2 排查是否有内存泄露

使用MAT分析导出的DUMP文件,jmap -dump:file=./jvmdump.hprof 15162

内存泄露

Java内存泄露是指在Java应用程序中,由于未正确释放不再使用的内存,导致内存占用不断增加,最终可能耗尽可用内存资源,导致应用程序性能下降甚至崩溃。内存泄露是一种常见的程序缺陷,可能由多种原因引起,以下是一些可能导致内存泄露的原因:

  1. 对象引用未被释放:如果在使用完一个对象后未显式将其引用置为null,该对象可能无法被垃圾收集器回收,从而导致内存泄露。

  2. 静态引用:静态变量持有对象的引用,如果没有正确管理静态引用,即使对象不再使用,也无法被垃圾收集器回收。

  3. 集合类未正确管理:集合类(如List、Map、Set)可能会持有对象的引用,如果在集合中添加了对象但未在后续操作中正确移除,这些对象可能会一直保持在内存中。

  4. 资源未释放:如果程序使用了文件、网络连接、数据库连接等资源,但未正确关闭这些资源,可能会导致资源占用不释放,从而引发内存泄露。

  5. 监听器未移除:如果在应用中注册了监听器(例如GUI事件监听器、定时任务等),但忘记在不需要时移除这些监听器,可能导致对象无法被回收。

  6. 循环引用:对象之间相互引用,形成循环引用时,即使对象本身不再被使用,由于彼此之间的引用关系,也无法被垃圾收集器回收。

  7. 使用缓存:虽然缓存可以提高性能,但如果未正确管理缓存的生命周期和大小,可能导致缓存中的对象一直保持在内存中,引发内存泄露。

  8. 异常处理不当:异常可能导致程序流程中断,如果异常处理不当,可能导致资源未正确释放。

要避免内存泄露,开发人员应该仔细管理对象的生命周期,确保及时释放不再需要的资源和引用。使用强引用、软引用、弱引用等不同类型的引用,可以帮助更好地控制对象的回收时机。同时,定期进行代码审查和性能分析,以及使用内存分析工具来检测和解决潜在的内存泄露问题也是非常重要的。

我们常用的Spring框架也有一些常见内存泄露常见,在Spring框架中,由于其底层使用了Java语言,内存泄露问题也可能出现。以下是一些常见导致内存泄露的Spring框架相关行为:

  1. 未关闭资源: Spring管理了许多资源,如数据库连接、会话、事务等。如果在使用完这些资源后未正确关闭,可能导致资源未释放,最终引发内存泄露。

  2. Spring Bean 生命周期管理不当: 如果在配置Spring Bean时没有正确设置作用域或生命周期,可能导致Bean无法正确地被销毁,从而引发内存泄露。

  3. 内部类持有外部类的引用: Spring中的一些组件,如事件监听器、AOP代理等,可能会导致内部类持有外部类的引用,从而阻止外部类的垃圾回收,导致内存泄露。

  4. 使用缓存不当: Spring提供了缓存支持,但如果在使用缓存时没有正确管理缓存的生命周期和大小,可能导致缓存中的对象一直保持在内存中,引发内存泄露。

  5. 定时任务未正确管理: Spring的定时任务功能可能导致内存泄露,如果任务未正确取消或管理,可能导致对象无法被垃圾收集器回收。

  6. 循环依赖: Spring容器中的循环依赖问题可能导致Bean无法正确销毁,从而引发内存泄露。

  7. 使用ApplicationContext引起的泄露: 如果在应用程序中持有了ApplicationContext的引用,而没有正确释放,可能会导致整个ApplicationContext及其管理的Bean无法被回收。

为了避免在Spring应用程序中出现内存泄露,开发人员应该注意上述问题,并采取适当的措施来管理资源、Bean的生命周期,以及其他Spring相关的组件。进行定期的代码审查和性能分析,使用内存分析工具,可以帮助发现和解决潜在的内存泄露问题。

标红重点!因为我们这次内存泄露就与此相关:继续提问:使用ApplicationContext引起的泄露举个例子

  • 使用ApplicationContext引起的泄露是指在应用程序中持有了ApplicationContext的引用,但没有正确释放,从而导致整个ApplicationContext及其管理的Bean无法被垃圾回收,进而导致内存泄露。以下是一个例子来说明这种情况:假设你有一个Spring Boot应用程序,其中包含一个自定义的ApplicationContextHolder类,用于持有ApplicationContext的引用:
public class ApplicationContextHolder {
    private static ApplicationContext applicationContext;

    public static void setApplicationContext(ApplicationContext context) {
        applicationContext = context;
    }

    public static ApplicationContext getApplicationContext() {
        return applicationContext;
    }
}

在应用程序启动时,你将ApplicationContext设置到ApplicationContextHolder中:

@SpringBootApplication
public class MyApp {
    public static void main(String[] args) {
        SpringApplication.run(MyApp.class, args);
        ApplicationContext context = SpringApplication.run(MyApp.class, args);
        ApplicationContextHolder.setApplicationContext(context);
    }
}

在某个地方,你可能会通过ApplicationContextHolder来获取ApplicationContext,例如在一个服务类中:

public class MyService {
    public void doSomething() {
        ApplicationContext context = ApplicationContextHolder.getApplicationContext();
        // 使用ApplicationContext执行一些操作
        // ...
    }
}

这种做法可能导致内存泄漏,因为ApplicationContext被持有并可能无法被垃圾收集器回收。例如,如果MyService的实例一直存在,它持有的ApplicationContext引用也会一直存在,从而阻止ApplicationContext及其管理的Bean被释放

要避免这种情况,应该尽量避免在应用程序中持有ApplicationContext的引用。ApplicationContext通常由Spring框架管理,它会在合适的时候进行销毁和垃圾回收。如果确实需要在某些地方获取ApplicationContext,应该在使用完之后及时释放引用,避免长时间持有引用导致内存泄漏。通常情况下,可以通过依赖注入(DI)等方式来获取所需的Bean,而无需直接持有ApplicationContext的引用。

DUMP内存

dump的方式有很多,很多公司内部集成了自己的工具,但dump时首先要干的是摘流,确保你dump内存时没有请求进来,不要影响业务的正常使用。当然通用的在Java中,可以使用一些内存分析工具和指令来监测和分析应用程序的内存使用情况。以下是一些常见的Java内存分析指令和工具:

  1. jps (Java进程状态工具):用于列出当前系统中运行的Java进程,并显示它们的进程ID和主类名称。

    示例用法:jps -v

  2. jstat (Java统计信息监视工具):用于收集和显示Java虚拟机(JVM)运行时的各种统计信息,如堆内存使用情况、垃圾回收统计等。

    示例用法:jstat -gc <pid> <interval> <count>

  3. jmap (Java内存映像工具):用于生成堆转储快照,可以用于分析堆内存使用情况、对象分布等。

    示例用法:jmap -dump:live,format=b,file=<filename> <pid>

  4. jhat (Java堆分析工具):用于分析jmap生成的堆转储快照,可以通过浏览器查看对象信息和引用关系。

    示例用法:jhat <heap_dump_file>

  5. jstack (Java堆栈跟踪工具):用于生成Java线程的堆栈跟踪信息,帮助识别死锁、线程等待等问题。

    示例用法:jstack <pid>

  6. VisualVM (Visual Java Monitoring and Management Console):一个图形化工具,可以用于监视和分析应用程序的性能和内存使用情况,提供了多种功能,包括堆转储、线程分析等。

  7. MAT (Eclipse Memory Analyzer):一个强大的内存分析工具,用于分析堆转储快照,帮助识别内存泄漏和优化内存使用。

这些工具和指令可以帮助开发人员诊断应用程序的内存问题,包括内存泄漏、垃圾回收性能等方面的情况。根据具体的问题和需求,你可以选择合适的工具和指令来进行内存分析。

MAT分析

在这里下载最新版本的MAT :Eclipse Memory Analyzer ,通过MAT分析

因为最新版的MAT仅支持JDK17及以上,所以在这里:JDK20下载下载最新版的JDK

还需要注意的是MAT需要进行配置,如果你电脑安装了多个JDK版本需要指定,并且如果你要分析的DUMP文件比较大,需要调大MAT的配置:这里我的配置参考如下:

-vm 
C:\Program Files\Java\jdk-20\bin\javaw.exe
-startup
plugins/org.eclipse.equinox.launcher_1.6.400.v20210924-0641.jar
--launcher.library
plugins/org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.700.v20221108-1024
-vmargs
--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED
-Xmx8192m

内存分配了8个G,然后指定了JDK运行版本,这样就可以了。

寻根定位

好的,现在dump文件也有了,MAT工具也就位了【附送网上找的MAT最全介绍一文深度讲解JVM 内存分析工具 MAT及实践】,那就开始着手分析,MAT会自动给出探测意见:

同时会告诉你三个内存泄露可能导致的实际原因

甚至会贴心的告诉你问题之间可能存在关联:

最厉害的是点击问题进入,它甚至能给你直接打印出堆栈!告诉你哪里的代码发生泄露了

从堆栈入口很容易就定位问题了。问题代码不便于贴出来,实际原因就是这里没有使用Spring的容器管理,而是通过通过AutowireCapableBeanFactory容器外注入的方式注入bean的。而applicationContext被获取用来管理这些bean,且applicationContext被FormFactory引用着。而某个大佬又在applicationContext里疯狂实例对象,三天左右大概1100万个对象。这个不泄露都说不过去

总结一下

照例总结一下,线上出了问题不要慌,也别想着保留现场,先止损!平时的报警机制要建立好且阈值要低些,这样才能先于业务发现并解决问题。还有就是MAT是真香!

本文标签: 使用指南内存MAT