跳过正文
Spring Boot 升级后内存问题排查:深入探讨 ResolvableType 对象创建
  1. 文章/

Spring Boot 升级后内存问题排查:深入探讨 ResolvableType 对象创建

·2212 字·5 分钟
NeatGuyCoding
作者
NeatGuyCoding

问题背景
#

然而,在升级到 Spring Boot 2.4.6 + Spring Cloud 2020.0.x 后,我们注意到 YoungGC 频率和对象分配率显著增加,而对象提升到老年代保持不变。这表明新创建的对象正在被快速垃圾收集。让我们检查我们处理大约每秒 100 个 HTTP 请求的其中一个进程的监控数据:

image

这令人困惑。请求率并不是特别高,但监控显示每秒分配近 2GB 内存。在升级之前,在类似的请求负载下,此分配率约为 100-200MB。所有这些额外的内存消耗来自哪里?

问题调查
#

我们需要使用 jmap 命令检查内存中各种对象的统计数据。然而,我们不能只查看仅存活对象的统计,因为监控表明问题不是过多的老年代对象 - 提升率没有增加。理想情况下,我们希望从分析中排除当前存活的对象。此外,由于 GC 发生得相当频繁(大约每秒一次),我们不能期望在单次尝试中捕获所需的 jmap 数据。由于 jmap 导致所有线程进入 safepoint 并触发 STW(Stop-The-World),影响生产,我们不能太频繁地运行它。因此,我们采用了以下策略:

  1. 通过添加一个实例进行扩展,然后使用服务注册表和速率限制器将一半流量从特定实例重定向;
  2. 在此实例上,连续执行 jmap -histo(所有对象的统计)和 jmap -histo:live(仅存活对象的统计);
  3. 以 100ms、300ms、500ms 和 700ms 的间隔重复步骤 2 五次;
  4. 移除此实例上的速率限制并关闭新扩展的实例。

通过比较这些 jmap 结果,我们发现在 jmap 统计中排名靠前的对象类型中,有一个 Spring 框架对象:

 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:       7993252      601860528  [B (java.base@11.0.8)
   2:        360025      296261160  [C (java.base@11.0.8)
   3:      10338806      246557984  [Ljava.lang.Object; (java.base@11.0.8)
   4:       6314471      151547304  java.lang.String (java.base@11.0.8)
   5:         48170      135607088  [J (java.base@11.0.8)
   6:        314420      126487344  [I (java.base@11.0.8)
   7:       4591109      110100264  [Ljava.lang.Class; (java.base@11.0.8)
   8:        245542       55001408  org.springframework.core.ResolvableType
   9:        205234       29042280  [Ljava.util.HashMap$Node; (java.base@11.0.8)
  10:        386252       24720128  [org.springframework.core.ResolvableType;
  11:        699929       22397728  java.sql.Timestamp (java.sql@11.0.8)
  12:         89150       21281256  [Ljava.beans.PropertyDescriptor; (java.desktop@11.0.8)
  13:        519029       16608928  java.util.HashMap$Node (java.base@11.0.8)
  14:        598728       14369472  java.util.ArrayList (java.base@11.0.8)

这些对象是如何创建的?如何追踪频繁创建但不再存活的对象,特别是当对象类型是框架内部的时候

首先,MAT(Eclipse Memory Analyzer)+ jmap dump 用于完整堆分析不太适合,原因如下:

  1. 对象不再存活。MAT 更适合内存泄漏分析,而我们的问题涉及创建许多意外对象,这些对象消耗大量内存但很快变得不可达。
  2. 对于不再存活的对象,MAT 无法准确分析它们的创建者,主要是因为不确定转储是否捕获了我们所需的信息,或者数据中可能有太多噪音。

虽然这种方法不适合我们的问题,但我仍会展示我从收集的 jmap 转储中获得的 MAT 分析结果:

那么我们如何进行分析?这让我们回到了我们的老朋友:JFR + JMC。经常阅读的读者知道我经常使用 JFR 来排查生产问题。我们如何在这里使用它?虽然没有直接统计哪些对象被频繁创建的 JFR 事件,但有间接事件可以揭示谁在创建这么多对象。我通常按以下方式处理:

  1. 使用 Thread Allocation Statistics 事件来识别哪些线程正在分配过多对象
  2. 使用 Method Profiling Samples 来分析哪些热代码路径可能正在生成这些对象。对于大量创建的对象,捕获 Runnable 代码有很高的概率被采样,并将在事件中显示高比例。

首先,检查 Thread Allocation Statistics 事件,我们发现基本上所有 servlet 线程(处理 HTTP 请求的线程 - 我们使用 Undertow,所以线程名称以 XNIO 开头)都在分配许多对象。这无助于定位问题:

image

接下来,我们通过单击 Method Profiling Sample 事件并查看堆栈跟踪统计来检查热代码统计,以查看哪些具有高比例。

image

我们发现排名靠前的方法似乎都与 ResolvableType 相关。为了进一步调查,我们双击第一个方法以查看调用堆栈统计:

image

我们发现调用者是 BeanUtils.copyProperties。检查其他与 ResolvableType 相关的调用,它们都与 BeanUtils.copyProperties 相关。此方法在我们的项目中经常用于相同或不同类型对象之间的属性复制。为什么此方法创建这么多 ResolvableType 对象?

问题分析
#

通过检查源代码,我们发现从 Spring 5.3.x 开始,BeanUtils 开始使用 ResolvableType 作为属性复制的统一类信息包装器:

/**
 * 
 * <p>As of Spring Framework 5.3, this method honors generic type information
 */
private static void copyProperties(Object source, Object target, @Nullable Class<?> editable,
		@Nullable String... ignoreProperties) throws BeansException {
}

内部源代码在每次复制操作期间为源和目标对象类型的每个属性方法创建新的 ResolvableType 实例,并且没有实现缓存。这导致单个复制操作创建大量 ResolvableType 实例。让我们进行一个实验:

public class Test {
    public static void main(String[] args)  {
        TestBean testBean1 = new TestBean("1", "2", "3", "4", "5", "6", "7", "8", "1", "2", "3", "4", "5", "6", "7", "8");
        TestBean testBean2 = new TestBean();
        for (int i = 0; i > -1; i++) {
            BeanUtils.copyProperties(testBean1, testBean2);
            System.out.println(i);
        }
    }
}

我们使用两个不同的依赖项执行此代码:spring-beans 5.2.16.RELEASEspring-beans 5.3.9,使用 JVM 参数 -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xmx512m。这些参数使用 EpsilonGC,当堆内存满时不执行 GC 而抛出 OutOfMemory 异常并终止程序,最大堆大小为 512MB。此设置基本上测试不同版本的 BeanUtils.copyProperties 在内存耗尽之前可以执行多少次

实验结果显示:spring-beans 5.2.16.RELEASE 执行了 444,489 次,而 spring-beans 5.3.9 仅执行了 27,456 次。这是一个显著差异

因此,我向 spring-framework GitHub 存储库提交了一个Issue

对于我们项目中经常使用 BeanUtils.copyProperties 的区域,我们用 BeanCopier 替换它们并创建了一个简单的包装类:

public class BeanUtils {
    private static final Cache<String, BeanCopier> CACHE = Caffeine.newBuilder().build();

    public static void copyProperties(Object source, Object target) {
        Class<?> sourceClass = source.getClass();
        Class<?> targetClass = target.getClass();
        BeanCopier beanCopier = CACHE.get(sourceClass.getName() + " to " + targetClass.getName(), k -> {
            return BeanCopier.create(sourceClass, targetClass, false);
        });
        beanCopier.copy(source, target, null);
    }
}

然而,重要的是要注意,用 BeanCopier 替换 BeanUtils.copyProperties 时最直接的问题是具有不同类型但相同名称的属性无法复制。例如,在 intInteger 之间复制将不起作用。深度复制行为也存在差异,需要彻底的单元测试。

实施这些更改后,问题得到解决。

问题后续
#

Spring 此后修复了此问题(在版本 v6.0.14 中):

  • 发布说明:https://github.com/spring-projects/spring-framework/releases/tag/v6.0.14
  • 相应提交:https://github.com/spring-projects/spring-framework/commit/09aa59f9e79e19a2f09e66002c665b6a5a03ae20

主要修复方法包括:

  • 通过避免对简单类型检查使用 ResolvableType 来减少内存使用
  • 通过使用 HashSet 更高效地查找忽略的属性来提高性能

相关文章

通过 SQL 优化发现的隐藏生产问题

·1800 字·4 分钟
当我们的运维团队带来一个执行时间极长的复杂 SQL 查询时,我们以为这只是一个性能问题。我们不知道,这次调查会发现一个深深隐藏的字符编码不匹配问题,它一直在我们的生产数据库中默默地导致全表扫描。

Spring Data Redis 连接泄漏之谜:当你的微服务失控时

·3499 字·7 分钟
生产事件调查,揭示 Spring Data Redis + Lettuce 在混合使用 SessionCallback 和 executeWithStickyConnection 操作时如何泄漏连接。深入探讨连接管理机制、JFR 分析技术和实用解决方案,防止你的 Redis 连接池成为黑洞。