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

这令人困惑。请求率并不是特别高,但监控显示每秒分配近 2GB 内存。在升级之前,在类似的请求负载下,此分配率约为 100-200MB。所有这些额外的内存消耗来自哪里?
问题调查#
我们需要使用 jmap 命令检查内存中各种对象的统计数据。然而,我们不能只查看仅存活对象的统计,因为监控表明问题不是过多的老年代对象 - 提升率没有增加。理想情况下,我们希望从分析中排除当前存活的对象。此外,由于 GC 发生得相当频繁(大约每秒一次),我们不能期望在单次尝试中捕获所需的 jmap 数据。由于 jmap 导致所有线程进入 safepoint 并触发 STW(Stop-The-World),影响生产,我们不能太频繁地运行它。因此,我们采用了以下策略:
- 通过添加一个实例进行扩展,然后使用服务注册表和速率限制器将一半流量从特定实例重定向;
- 在此实例上,连续执行
jmap -histo(所有对象的统计)和jmap -histo:live(仅存活对象的统计); - 以 100ms、300ms、500ms 和 700ms 的间隔重复步骤 2 五次;
- 移除此实例上的速率限制并关闭新扩展的实例。
通过比较这些 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 用于完整堆分析不太适合,原因如下:
- 对象不再存活。MAT 更适合内存泄漏分析,而我们的问题涉及创建许多意外对象,这些对象消耗大量内存但很快变得不可达。
- 对于不再存活的对象,MAT 无法准确分析它们的创建者,主要是因为不确定转储是否捕获了我们所需的信息,或者数据中可能有太多噪音。
虽然这种方法不适合我们的问题,但我仍会展示我从收集的 jmap 转储中获得的 MAT 分析结果:
那么我们如何进行分析?这让我们回到了我们的老朋友:JFR + JMC。经常阅读的读者知道我经常使用 JFR 来排查生产问题。我们如何在这里使用它?虽然没有直接统计哪些对象被频繁创建的 JFR 事件,但有间接事件可以揭示谁在创建这么多对象。我通常按以下方式处理:
- 使用 Thread Allocation Statistics 事件来识别哪些线程正在分配过多对象。
- 使用 Method Profiling Samples 来分析哪些热代码路径可能正在生成这些对象。对于大量创建的对象,捕获 Runnable 代码有很高的概率被采样,并将在事件中显示高比例。
首先,检查 Thread Allocation Statistics 事件,我们发现基本上所有 servlet 线程(处理 HTTP 请求的线程 - 我们使用 Undertow,所以线程名称以 XNIO 开头)都在分配许多对象。这无助于定位问题:

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

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

我们发现调用者是 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.RELEASE 和 spring-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 时最直接的问题是具有不同类型但相同名称的属性无法复制。例如,在 int 和 Integer 之间复制将不起作用。深度复制行为也存在差异,需要彻底的单元测试。
实施这些更改后,问题得到解决。
问题后续#
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 更高效地查找忽略的属性来提高性能



