本文取材自一个真实的案例,我将尽可能通过还原该案例的排查过程,呈现一套排查这类问题的思路,以及相关工具的使用技巧。您需要具备一些 Spring Bean 管理的基础知识,从而更好的理解案例。
1、问诊
当我开始调查时,我的几位同事已经为这个问题奋战了一整天。因此,我直接获得了三条重要的信息。
序号 | 信息 |
---|---|
1 | 应用启动过程中就抛出了 java.lang.StackOverflowError ,无法成功启动 |
2 | 唯一的代码变更就是在 pom 文件中引入了一个新的 JAR 包依赖 |
3 | Intel 芯片上能成功复现,M3 芯片则成功启动,无法复现 |
通过前两条信息,可以进行一些推测。
首先,启动时抛出 StackOverflowError
并导致启动失败,这表明问题很可能出现在主线程(main Thread)中。
其次,考虑到问题是在引入新的 JAR 包后开始出现的,可能的原因包括:
-
JAR 包中定义的 Spring Bean 之间,或者 JAR 包中的 Bean 与项目中其他地方定义的 Bean 之间存在循环引用;
-
或者,加载 JAR 包的框架代码存在缺陷,最有可能的问题是存在深度递归调用。
由于 JAR 包中仅包含一些调用下游 SOA 接口的声明,如所示。这些声明仅是一些接口(interface),而非可以直接实例化的 Bean 类(Bean Class)。据我所知,这些接口声明会被视为一种 SOA 调用协议,并且由框架代码解析协议,将其组装成名为 ReferenceBean
的客户端实例(Bean 实例)。除此以外,并没有自定义的 Bean。
因此,可以初步排除循环引用的可能性,而更可能的原因是框架在装配这些 SOA 客户端实例时的代码中存在递归调用。
图 1
第三条信息则相当有趣。通常我们总说 “JVM 提供了跨平台抽象,使得同样的 Java 代码可以在不同平台上运行”,但从另一个角度来看,这也意味着 JVM 自身的实现需要针对不同平台进行适配。例如,由于不同 CPU 架构下的调用约定(Calling Conventions)和应用二进制接口(ABI, Application Binary Interface)不同,可能会导致每次方法调用的栈空间开销也有所不同。这似乎可以解释为什么在 Intel 芯片(x86_64)的 MacBook 上可以复现问题,而在 M3 芯片(ARM)上则不会。
于是,我让同事将 Intel 芯片上 JVM 的线程栈空间上限调大,应用果然启动成功了。
针对第三条信息的分析和实验结果,给了我们一些新的提示:
-
不同 CPU 架构下的表现不同,似乎进一步证实了代码存在深度递归调用的可能性;
-
栈空间不是无限增长的,否则即使调大栈空间上限也无法解决问题。但栈溢出确实是因为引入新 JAR 包后导致的,唯一的变化就是 SOA Facade 接口声明的数量。因此,有理由怀疑栈空间的增长可能与接口数量有关。
于是,我带着这些猜想,正式开始排查问题。
2、全面检查
2.1、Thread dump 分析
既然出现了 StackOverflowError
(栈溢出)异常,我们首先需要查看异常发生时栈内的具体内容。
启动应用并等待 StackOverflowError
异常抛出,然后使用 jstack 工具将栈信息捕获到 thread_dump 文件中:
jstack $(jps | awk -F ' ' '/CommandApplication/ {print $1}') > thread_dump
查看 thread_dump 文件,很快便能找到有价值的线索。
"main" #1 prio=5 os_prio=31 tid=0x000000014b00e000 nid=0x1303 runnable [0x000000016fc50000]
java.lang.Thread.State: RUNNABLE
......
at org.springframework.beans.factory.support.AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.getTypeForFactoryBean(AbstractAutowireCapableBeanFactory.java:895)
at org.springframework.beans.factory.support.AbstractBeanFactory.getType(AbstractBeanFactory.java:704)
at org.springframework.beans.factory.support.AbstractBeanFactory.getType(AbstractBeanFactory.java:659)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.findMergedAnnotationOnBean(DefaultListableBeanFactory.java:689)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.findAnnotationOnBean(DefaultListableBeanFactory.java:682)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeanNamesForAnnotation(DefaultListableBeanFactory.java:652)
at org.springframework.context.support.AbstractApplicationContext.getBeanNamesForAnnotation(AbstractApplicationContext.java:1256)
at cn.huolala.arch.hermes.compatible.config.ReferenceBean.doAfterPropertiesSet(ReferenceBean.java:36)
at cn.huolala.arch.hermes.compatible.config.ReferenceBean.afterPropertiesSet(ReferenceBean.java:30)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1855)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1792)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:595)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:517)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory$$Lambda$240/369851597.getObject(Unknown Source)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
- locked <0x00000006c15ba4f0> (a java.util.concurrent.ConcurrentHashMap)
<!-- part start -->
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:321)
at org.springframework.beans.factory.support.AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.getTypeForFactoryBean(AbstractAutowireCapableBeanFactory.java:895)
at org.springframework.beans.factory.support.AbstractBeanFactory.getType(AbstractBeanFactory.java:704)
at org.springframework.beans.factory.support.AbstractBeanFactory.getType(AbstractBeanFactory.java:659)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.findMergedAnnotationOnBean(DefaultListableBeanFactory.java:689)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.findAnnotationOnBean(DefaultListableBeanFactory.java:682)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeanNamesForAnnotation(DefaultListableBeanFactory.java:652)
at org.springframework.context.support.AbstractApplicationContext.getBeanNamesForAnnotation(AbstractApplicationContext.java:1256)
at cn.huolala.arch.hermes.compatible.config.ReferenceBean.doAfterPropertiesSet(ReferenceBean.java:36)
at cn.huolala.arch.hermes.compatible.config.ReferenceBean.afterPropertiesSet(ReferenceBean.java:30)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1855)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1792)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:595)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:517)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory$$Lambda$240/369851597.getObject(Unknown Source)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
- locked <0x00000006c15ba4f0> (a java.util.concurrent.ConcurrentHashMap)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:321)
<!-- part end -->
at org.springframework.beans.factory.support.AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.getTypeForFactoryBean(AbstractAutowireCapableBeanFactory.java:895)
at org.springframework.beans.factory.support.AbstractBeanFactory.getType(AbstractBeanFactory.java:704)
at org.springframework.beans.factory.support.AbstractBeanFactory.getType(AbstractBeanFactory.java:659)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.findMergedAnnotationOnBean(DefaultListableBeanFactory.java:689)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.findAnnotationOnBean(DefaultListableBeanFactory.java:682)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeanNamesForAnnotation(DefaultListableBeanFactory.java:652)
at org.springframework.context.support.AbstractApplicationContext.getBeanNamesForAnnotation(AbstractApplicationContext.java:1256)
at cn.huolala.arch.hermes.compatible.config.ReferenceBean.doAfterPropertiesSet(ReferenceBean.java:36)
at cn.huolala.arch.hermes.compatible.config.ReferenceBean.afterPropertiesSet(ReferenceBean.java:30)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1855)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1792)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:595)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:517)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory$$Lambda$240/369851597.getObject(Unknown Source)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222)
- locked <0x00000006c15ba4f0> (a java.util.concurrent.ConcurrentHashMap)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:321)
at org.springframework.beans.factory.support.AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.getTypeForFactoryBean(AbstractAutowireCapableBeanFactory.java:895)
at org.springframework.beans.factory.support.AbstractBeanFactory.getType(AbstractBeanFactory.java:704)
......
从 main 线程的栈信息中,可以看到一段重复出现的调用模式(如 part start 到 part end ,为了叙述方便,后文将其统称为 “递归调用栈”,或简称为 “栈信息”)。
这种模式从 AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:321)
开始,到 AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643)
结束,然后又返回新的 AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:321)
调用。通过将首尾的 doGetBean
调用连起来观察,递归调用的模式就显现了。
为了进一步确认是递归调用,我们需要验证 getTypeForFactoryBean
方法确实调用了 doGetBean
方法。根据栈信息,我们找到 AbstractBeanFactory.java
文件的第 1643 行,发现正是这行代码调用了 doGetBean
方法,如所示。
图 2
这样,通过验证栈信息和源码,我们可以确认递归调用的发生。
经过仔细观察,我们发现调用路径上的大多数方法都是 Spring 框架内部的相互调用,只有两个方法来自我们自定义的框架代码:
at cn.huolala.arch.hermes.compatible.config.ReferenceBean.doAfterPropertiesSet(ReferenceBean.java:36)
at cn.huolala.arch.hermes.compatible.config.ReferenceBean.afterPropertiesSet(ReferenceBean.java:30)
熟悉 Spring 框架的人都知道,
afterPropertiesSet()
是InitializingBean
接口中定义的方法。Spring 容器会在一个 Bean 设置了所有必要的属性之后调用这个方法,从而允许该 Bean 执行任何必要的初始化或设置工作。
显然,ReferenceBean
实现了 InitializingBean
接口并重写了 afterPropertiesSet()
方法,真正的逻辑则在 doAfterPropertiesSet()
方法中。
这一点再次证明了之前的猜测:问题确实出在装配 ReferenceBean
的代码,即 doAfterPropertiesSet()
方法中。
进一步深入思考,如果我们暂时忽略中间的方法调用,递归过程可以精简为以下几个步骤,如所示:
-
调用
doGetBean()
方法创建ReferenceBean
的一个实例,假设为实例 ; -
在实例 上执行
doAfterPropertiesSet()
; -
递归调用
doGetBean()
创建新的ReferenceBean
实例,假设为实例 。
图 3: 精简的递归调用过程
那么,实例 和实例 是相同的 ReferenceBean
实例,还是不同的 ReferenceBean
实例呢?
我们可以大胆推测,这是不同的 ReferenceBean
实例,基于以下两点原因:
-
如前所述,问题与 "JAR 包中 SOA Facade 声明的数量" 有关。按照设计,不同的 Facade 声明会被封装成不同的
ReferenceBean
实例,且每个 Facade 对应的ReferenceBean
实例都是单例。 -
Spring 框架可以识别 “正在创建中” 的 bean,以避免不必要的递归调用。因此,如果
doGetBean()
获取的是相同的实例,不应该产生大量的递归调用栈。
如果这一推测成立,这说明 “不同 ReferenceBean
的实例化过程存在依赖传递” 是导致递归调用的关键原因。例如在 doGetBean(A)
时,需要先调用 doGetBean(B)
,而调用 doGetBean(B)
又需要先调用 doGetBean(C)
……以此类推。
了解这些信息后,我们可以更高效地进行代码调试:
-
递归调用路径明确指示了我们在调试时需要重点关注哪些代码,时刻记得回头参考一下递归调用的栈信息;
-
既然问题源自
doAfterPropertiesSet()
方法,那么在调试时应特别关注doAfterPropertiesSet()
之后的方法调用,以降低调试的复杂度; -
调试的关键目标是找到
ReferenceBean
实例创建过程中依赖传递导致递归调用的原因;
通过明确这些调试方向,我们可以更有针对性地排查和解决问题。OK! Let's get our hands dirty!
2.2、代码 Debugging
2.2.1、初步调试
基于前面的分析,我们进行以下两个断点的设置:
-
cn.huolala.arch.hermes.compatible.config.ReferenceBean.doAfterPropertiesSet(ReferenceBean.java:36)
; -
org.springframework.beans.factory.support.AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643)
;
前者(ReferenceBean.java:36
)是问题的起点,设置这个断点后可以查看到问题开始的具体位置,见。
对于熟悉 hermes 框架的人来说,这段代码很容易理解。由于 SOA 调用可能失败,hermes 框架提供了 fallback 能力,而 fallback 的实现是通过
@Fallback
注解标注的一系列FallbackFactory
类型的实例(即 Spring Bean)。在这段代码中,就是从 Spring Context 中找到所有带该注解的 Bean 的名称,并在后面的代码中创建 fallback bean,赋值给ReferenceBean
实例的fallbackFactory
字段。
图 4
等待断点
后者(AbstractBeanFactory.java:1643
)是实际递归调用的位置。设置断点是为了确认调试时是否实际发生了递归调用。不过由于这是 Spring 框架内部的代码,即便不是在创建 ReferenceBean
实例时也有可能执行到该方法,这可能会干扰调试。为了解决触发时机的问题,可以使用 “添加触发的断点-等待断点” 的技巧。
具体方法如所示,添加等待断点,设置该断点在 ReferenceBean.class
的第 36 行的断点(即 中的断点)触发后自动触发。
图 5
设置好这两个断点后,可以运行代码并确认断点能按预期方式工作。
如所示,当ReferenceBean.class:36
行的断点被触发时,可以在左侧监视窗口中看到当前正在创建的实例是 VehicleAbilityLogoFacade
。
图 6
此时还不需要立即进行单步调试,而是点击 “继续执行(到下一个断点)”。代码如预期会在 AbstractBeanFactory.class:1643
行的断点处暂停,见。这表明递归调用如预期触发。从左侧监视窗口可以看到,此时 doGetBean()
正在创建另一个名为 UserOrderListSOAService
的 bean,而不是 VehicleAbilityLogoFacade
。这验证了之前关于 “不同 ReferenceBean
实例化过程存在依赖传递” 的推测。
图 7
通过这样的断点设置和初步调试过程,我们能够更清晰地理解问题发生的具体情境,并验证我们对递归调用原因的推测。
2.2.2、单步调试
保持已有的断点不变,重新运行代码。
代码首先还是会在 ReferenceBean.java:36
处暂停,如所示。
图 8
继续单步调试,会跳转到 applicationContext.getBeanNamesForAnnotation(...)
方法实现,如所示。
图 9
显然这只是一个转发逻辑,无需耗费太多精力。根据递归调用栈的信息指引,跳转到 getBeanNamesForAnnotation(...)
的实现,进入 DefaultListableBeanFactory
的 getBeanNamesForAnnotation(...)
方法,如所示。
图 10
递归调用栈提示我们接下来会执行 652 行的 findAnnotationOnBean(...)
方法,但该方法在一个 for 循环中。继续调试之前,最好先了解循环的对象 beanDefinitionNames
的内容。
最简单的方法是在调试控制台中评估该变量的值,如所示。
图 11
此时得到了如下的 beanName
数组,总共有987个元素:
"org.springframework.context.annotation.internalConfigurationAnnotationProcessor"
"org.springframework.context.annotation.internalAutowiredAnnotationProcessor"
"org.springframework.context.annotation.internalCommonAnnotationProcessor"
"org.springframework.context.event.internalEventListenerProcessor"
"org.springframework.context.event.internalEventListenerFactory"
"commandApplication"
"org.springframework.boot.autoconfigure.internalCachingMetadataReaderFactory"
"addTipsAdapterImpl"
"addressModifyAdapterImpl"
"cancelOrderTransferImpl"
"cashierAdapterImpl"
...
"cn.huolala.***.UserOrderListSOAService-clientProxy"
"cn.huolala.***.VehicleAbilityLogoFacade-clientProxy"
"cn.huolala.***.VoucherSOAService-clientProxy"
"cn.huolala.***.NegotiatePriceQueryFacade-clientProxy"
"cn.huolala.***.AddPriceConfigService-clientProxy"
"cn.huolala.***.MultiOdRouteConfigService-clientProxy"
"cn.huolala.***.OrderIMContactFacade-clientProxy"
"cn.huolala.***.OrderFundsCoreSOAService-clientProxy"
...
显然我们不可能逐个调试所有元素。结合前面的调试经验,可以发现只有那些名字后缀带有 “-clientProxy” 的才是需要关注的 ReferenceBean
。
条件断点
因此,可以在 652 行“添加条件断点-表达式” 来过滤非 ReferenceBean
的 beanName
,如和所示。
图 12
图 13
点击 “继续执行(到下一个断点)”,仅当 beanName
是以 “-clientProxy” 结尾时,断点才会触发。触发条件断点时,如所示。
图 14
继续单步调试,跳转到 findAnnotationOnBean(...)
方法实现。如所示,这也是一个转发方法,真正的逻辑在 findMergedAnnotationOnBean(...)
方法中。
图 15
跳转到 findMergedAnnotationOnBean(...)
方法实现,如所示。
图 16
参考递归调用栈,接下来执行的是 689 行的 getType(...)
方法。因此,直接跳转进 getType(...)
方法内,如所示。
图 17
由于又是一个转发方法,继续跳转进入 659 行调用的 getType(...)
方法实现,如所示。
图 18
这个方法的实现复杂,但递归调用栈提示我们接下来会调用 704 行的 getTypeForFactoryBean(...)
方法。因此,通过 “运行到光标处” 指令,可以将代码执行到 704 行。然后进入 getTypeForFactoryBean(...)
方法内部,如所示。
图 19
这个方法的实现同样有些复杂,不过可以从递归调用栈得知,接下来会执行 895 行的 super.getTypeForFactoryBean(...)
方法。而这里的 super.getTypeForFactoryBean(...)
方法,正是 AbstractBeanFactory
中触发递归调用的那个 getTypeForFactoryBean(...)
方法。因此,883-897 行的代码就成为了解释发生递归调用的关键。
通过 “运行到光标处” 指令直接从 883 行开始继续往下调试。如,allowInit = true
,因此走入 if
语句块,成功获取到 factoryBean
。
图 20
我们知道代码最终会运行到 895 行,而前提条件是 889 行 getTypeForFactoryBean(...)
返回 null
。继续单步调试,并跳转进入 getTypeForFactoryBean(...)
方法,最终发现是 factoryBean
的 getObjectType()
方法返回了 null
,如所示。
图 21
接下来我们探讨为什么 getObjectType()
会返回 null
。getObjectType()
方法源自 Spring 的 FactoryBean
接口。ReferenceBean
实现了这个接口,并重写了 getObjectType()
方法,如所示。
图 22
它调用了内部方法 getInterfaceClass()
,该方法的实现如所示。
图 23
从 图23 可以看到,`interfaceClass` 是 `ReferenceBean` 的一个实例属性,需要通过 `setInterfaceClass(...)` 方法赋值(代码的 164 行)才能使用。而此时 factoryBean
是通过 getSingletonFactoryBeanForTypeCheck(...)
方法获得的。根据方法注释的说明(如所示):
图 24
Obtain a "shortcut" singleton FactoryBean instance to use for a {@code getObjectType()} call, without full initialization of the FactoryBean.
获取一个 “未完成初始化” 的 FactoryBean 实例,仅用于 getObjectType() 方法调用。
在之前的语境中,我为了方便叙述,有意模糊了 和 两个概念之间的区别(请留意,除了本小节以外,当本文其他地方提到 “实例化” 这个词时,均表示获得一个 ready-to-use 的 Bean 实例)。但在这里必须要加以区分。
为此,需要补充一些有关 Spring Bean 生命周期管理的基础知识。
Spring Bean 的生命周期
宏观上,可以将 Spring Bean 的生命周期概括为。
图 25
-
应用上下文创建:首先,Spring 的应用上下文(Application Context)被初始化,它充当了管理Spring Beans的容器;
-
Bean 定义(Bean Definitions)加载(步骤1) :Spring 从配置文件中读取 Bean 定义(例如,XML、注解)。这包括描述每个 Bean 的属性、作用域和依赖关系的元数据;
-
Bean 定义(Bean Definitions)处理(步骤2) :加载后,Spring 会处理这些 Bean 定义。这涉及检查任何必需的依赖关系和生命周期方法(例如 init-method 和 destroy-method);
-
Bean 实例化(Instantiation,步骤3) :在处理完成后,Spring 实例化 bean,可以是急加载(在应用启动时),也可以是懒加载(首次被请求时);
-
依赖注入(步骤4) :一旦 Bean 实例化,Spring 执行依赖注入,在这一步骤中,Spring 使用配置元数据(例如,XML 配置、注解如
@Autowired
、@Inject
或 Java 配置)来设置 Bean 实例的属性(字段),包括注入其他 bean、原始值或其他资源; -
Bean 处理(步骤5) :此时 Bean 被处理,包括应用任何自定义逻辑,例如
BeanPostProcessor
中的方法,以在 Bean 创建后进行附加处理。afterPropertiesSet()
、@PostConstruct
标记的方法也在该阶段执行; -
Bean 准备使用(步骤6) :Bean 现在已经完全初始化,可以被应用使用,既可以是单例(默认)bean,也可以是原型 bean;
-
Bean 使用(步骤7) :在整个应用运行期间,这些 Bean 在业务逻辑和操作中被积极使用;
-
Bean 销毁(步骤8) :当应用上下文关闭时(无论是关闭还是上下文刷新),Bean 通过自定义销毁方法(如有定义)被优雅地销毁;
总结一下,在 “Bean 实例化(Instantiation,步骤 3)” 阶段,Spring 创建了 Bean 实例,但属性字段尚未填充。直到 “依赖注入(步骤 4)” 阶段,才会填充属性字段。
再来看 factoryBean
, 注释说这是 "a FactoryBean instance without full initialization",那么它的属性字段 interfaceClass
为 null
(导致 getObjectType()
返回 null
),也就顺理成章了。
如所示,通过监视窗口查看 factoryBean
实例的信息,所有实例属性的值都是 null
,其中也包括 interfaceClass
。
图 26
到这里,我已经基本可以构建出问题的全貌了。
3、诊断分析
3.1、递归是如何发生的?
如果把第二章中 “Thread dump 分析” 和 “代码 Debugging” 比作射线检查(DR 和 CT),那么拍出的片子就应该如所示。
图 27
按照中的编号顺序,可以更清晰的理解递归的整个过程:
序号 | 调用 | 分析 |
---|---|---|
1 | AbstractBeanFactory#doGetBean() | 调用 doGetBean() 创建一个 ReferenceBean 实例。 |
2 | ReferenceBean#doAfterPropertiesSet() | Spring 开始创建这个 Bean 的实例,并在 “Bean 处理(步骤 5)” 阶段调用 afterPropertiesSet() 方法。由于 ReferenceBean 类型覆写了 afterPropertiesSet() 方法,并在其中调用了 doAfterPropertiesSet() 方法,因此 doAfterPropertiesSet() 方法中的逻辑被执行。该方法目的是在创建 ReferenBean 之后,找到匹配的 Fallback 实现(一些由 @Fallback 注解标注的 bean)。因此,在 doAfterPropertiesSet() 方法中,调用 AbstractApplicationContext#getBeanNamesForAnnotation() 方法,找到所有带有 @Fallback 注解的 Bean 名称的数组。 |
3 | AbstractApplicationContext#getBeanNamesForAnnotation() | 执行该方法,它实际调用的是 DefaultListableBeanFactory#getBeanNamesForAnnotation() 方法。 |
4 | DefaultListableBeanFactory#getBeanNamesForAnnotation() | 该方法会遍历当前已加载的所有 beanName,并通过 findAnnotationOnBean() 方法确认这些 Bean 是否被 @Fallback 注解标记。它实际调用的是 DefaultListableBeanFactory#findMergedAnnotationOnBean() 方法。 |
5 | DefaultListableBeanFactory#findMergedAnnotationOnBean() | 要确认 Bean 是否被 @Fallback 注解标记,需要先获取这个 Bean 的类型信息,因此,需要先调用 AbstractBeanFactory#getType() 方法获取类型信息。 |
6 | AbstractBeanFactory#getType() | getType() 方法被调用,由于此时只有 beanName,而非 Bean 的实例,要获取类型信息,需要先根据 beanName 加载这个 Bean 的实例。因此,继续调用 AbstractAutowireCapableBeanFactory#getTypeForFactoryBean() 创建 Bean 实例。 |
7 | AbstractAutowireCapableBeanFactory#getTypeForFactoryBean() | 此时,如果指定 beanName 的 Bean 实例已经存在,则返回该实例。否则,会执行初始化逻辑,如。 图 28 根据前面代码 Debugging 了解到的信息,当 beanName 是另外一个还未创建的ReferenceBean 实例时,会执行以下这条调用路径:> allowInit[true] |
→ mbd.isSingleton()? [true]
→ factoryBean = getSingletonFactoryBeanForTypeCheck()
→ factoryBean != null? [true]
→ type = getTypeForFactoryBean()
→ type != null? [false]
→ super.getTypeForFactoryBean()这里的
super
就是AbstractBeanFactory
,因此AbstractBeanFactory#getTypeForFactoryBean()
被调用。 | | 8 |AbstractBeanFactory#getTypeForFactoryBean()
| 该方法又会调用AbstractBeanFactory#doGetBean()
,导致递归调用。 |
3.2、递归是如何导致栈溢出的?
理解了递归如何发生之后,再来分析一下递归是如何导致 StackOverflowError
的。
请和我一起做一个思想实验:
假设
this.beanDefinitionNames
列表中的元素依次为 A, B, #1, #2, #3, #4, #5...,这些元素都是 将被装载为ReferenceBean
实例的 beanName。其中,A、B 表示已经实例化过的,而 #1,#2,#3,#4, #5... 表示尚未实例化的;
让我们从 doGetBean(#5)
开始,模拟整个递归过程以及在这个过程中栈的变化。
序号 | 栈快照 | 分析 | 递归逻辑图 |
---|---|---|---|
1 | 此时,创建 #5 ReferenceBean 的方法调用上下文将依次被压入栈中。为了叙述简单,我们直接用 “#5” 代表这些栈帧。按照前面的分析,代码会走到 DefaultListableBeanFactory#getBeanNamesForAnnotation() 方法,该方法会遍历 beanDefinitionNames 中的元素,并逐个调用 getType() 方法获取这些 beanName 对应的 Bean 实例。 | ||
2 | 根据 beanDefinitionNames 的元素顺序,A 首先被遍历到,并执行 getType(A) 。根据前面的假设,A 是已经实例化过的 bean,因此 getType(A) 将直接返回这个 Bean 实例。从栈的视角来看,getType(A) 调用的上下文先被压入栈中,又随着 getType(A) 调用 return ,这些上下文被 pop 出栈。栈又回到了图 29 所示的状态。 | ||
3 | 接着遍历 B,情况和 A 类似,入栈再出栈。 | ||
4 | 接着遍历 #1,由于 #1 是一个还未实例化的 bean,因此 getType(#1) 无法直接返回 Bean 实例,而是会触发递归调用,来从头开始实例化这个 bean。此时 #1 表示的栈帧大致是如下的样子:at ***.AbstractBeanFactory.getType(AbstractBeanFactory.java:704) // getType(A) at ***.AbstractBeanFactory.getType(AbstractBeanFactory.java:659) at ***.DefaultListableBeanFactory.findMergedAnnotationOnBean(DefaultListableBeanFactory.java:689) at ***.DefaultListableBeanFactory.findAnnotationOnBean(DefaultListableBeanFactory.java:682) // findAnnotationOnBean(A) at ***.DefaultListableBeanFactory.getBeanNamesForAnnotation(DefaultListableBeanFactory.java:652) // 于是,又会从头开始遍历 [A, B, #1, #2, #3, #4, #5] at ***.AbstractApplicationContext.getBeanNamesForAnnotation(AbstractApplicationContext.java:1256) at ***.ReferenceBean.doAfterPropertiesSet(ReferenceBean.java:36) // 实例化 #1 这个 ReferenceBean 的过程中又会执行 doAfterPropertiesSet() 方法 at ***.ReferenceBean.afterPropertiesSet(ReferenceBean.java:30) at ***.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1855) at ***.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1792) at ***.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:595) at ***.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:517) at ***.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:323) at ***.AbstractBeanFactory$$Lambda$240/369851597.getObject(Unknown Source) at ***.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222) - locked <0x00000006c15ba4f0> (a java.util.concurrent.ConcurrentHashMap) at ***.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:321) // doGetBean(#1) 递归调用开始 at ***.AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643) // getTypeForFactoryBean(#1) at ***.AbstractAutowireCapableBeanFactory.getTypeForFactoryBean(AbstractAutowireCapableBeanFactory.java:895) // getTypeForFactoryBean(#1) at ***.AbstractBeanFactory.getType(AbstractBeanFactory.java:704) // getType(#1) at ***.AbstractBeanFactory.getType(AbstractBeanFactory.java:659) // getType(#1) 从栈帧信息可以看到,在递归实例化 #1 的时候,由于 #1 是一个 ReferenceBean ,它的 doAfterPropertiesSet() 方法也会被调用到,而这个方法又会导致从头开始遍历 beanDefinitionNames 。注意,此时 getType(#1) 调用还没有 return ,因此,#1 的调用上下文还在栈中。 | ||
5 | 从头开始遍历 beanDefinitionNames ,首先是 A,getType(A) 入栈,然后 return 出栈。 | ||
6 | 继续遍历 B,getType(B) 入栈,然后 return 出栈。 | ||
7 | 又遍历到了 #1。此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1) 将 return ,不会再次触发 doGetBean(#1) 递归调用。 | ||
8 | 继续遍历 #2,由于 #2 又是一个未实例化的 bean,因此会再次触发 doGetBean(#2) 递归调用。参考 #1 的递归过程(表格中,序号 4 标注的行),我们可以得知,这会再次导致从头开始遍历 beanDefinitionNames 。此外,#2 的上下文也会保留在栈中。 | ||
9 | 从头开始遍历 beanDefinitionNames ,首先是 A,getType(A) 入栈,然后 return 出栈。 | ||
10 | 继续遍历 B,getType(B) 入栈,然后 return 出栈。 | ||
11 | 又遍历到了 #1。此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1) 将 return ,不会再次触发 doGetBean(#1) 递归调用。 | ||
12 | 又遍历到了 #2。此时,第 8 步(表格中,序号 8 标注的行)开始的 “#2 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#2) 将 return ,不会再次触发 doGetBean(#2) 递归调用。 | ||
13 | 继续遍历 #3,由于 #3 是一个未实例化的 bean,再次触发 doGetBean(#3) 递归调用,再次导致从头开始遍历 beanDefinitionNames 。#3 的上下文保留在栈中。 | ||
14 | 从头开始遍历 beanDefinitionNames ,首先是 A,getType(A) 入栈,然后 return 出栈。 | ||
15 | 继续遍历 B,getType(B) 入栈,然后 return 出栈。 | ||
16 | 又遍历到了 #1。此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1) 将 return ,不会再次触发 doGetBean(#1) 递归调用。 | ||
17 | 又遍历到了 #2。此时,第 8 步(表格中,序号 8 标注的行)开始的 “#2 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#2) 将 return ,不会再次触发 doGetBean(#2) 递归调用。 | ||
18 | 又遍历到了 #3。此时,第 13 步(表格中,序号 13 标注的行)开始的 “#3 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#3) 将 return ,不会再次触发 doGetBean(#3) 递归调用。 | ||
19 | 继续遍历 #4,由于 #4 是一个未实例化的 bean,再次触发 doGetBean(#4) 递归调用,再次导致从头开始遍历 beanDefinitionNames 。#4 的上下文保留在栈中。 | ||
20 | 从头开始遍历 beanDefinitionNames ,首先是 A,但此时栈空间已经用满,getType(A) 的调用上下文无法继续入栈,抛出 StackOverflowError 。 |
通过这个思想实验,我们可以清楚地发现,在递归和 for
循环的双重影响下,每次调用 doGetBean()
创建一个新的 ReferenceBean
实例时,相关的调用上下文都会在栈中保持较长的时间 ,从而极大地影响了栈空间的利用效率。因此,尽管不是无限递归,但随着需要加载的 ReferenceBean
数量增加,栈空间仍会被逐渐耗尽,最终引发 StackOverflowError
。
4、治疗和预防
针对本次的问题,我们可以通过分析 doAfterPropertiesSet()
方法的实现,来推测编写这段代码时开发人员的思路(详见):
-
在代码的第 36 行,开发人员通过
getBeanNamesForAnnotation()
方法获取所有被@Fallback
注解标记的 Bean 名称; -
随后在第 38 行,遍历这些 Bean 名称,并使用
getBean()
方法获取相应的 Bean 实例。
图 50
可以推测,开发者的初衷是首先获取 fallbackBeanName
,然后再通过 getBean()
方法实例化 fallback bean。他可能认为 getBeanNamesForAnnotation()
方法仅会返回带有 @Fallback
注解的 Spring Bean 名称,而未意识到该方法的内部实现可能会实例化一系列 bean,甚至包括那些没有 @Fallback
注解的 bean。
实际上,如果没有全局视角,很难察觉到 getBeanNamesForAnnotation()
的潜在风险:
-
回顾我们在第 2.2.2 节中的调试过程,导致递归调用
doGetBean()
方法以实例化 Bean 的根本原因是getObjectType()
返回了null
,这是因为ReferenceBean
重写了getObjectType()
方法。开发人员需要不仅了解getBeanNamesForAnnotation()
的具体实现细节,还需要认识到ReferenceBean#getObjectType()
方法的风险点,方能发现这种潜在风险; -
不仅如此,
getBeanNamesForAnnotation()
方法的注释也具一定误导性(详见)。
图 51
Find all names of beans which are annotated with the supplied Annotation type, without creating corresponding bean instances yet.
找到所有使用指定注解类型标记的 Bean 的名称,而暂时不会创建对应的 Bean 实例。
因此,预防此类问题的最佳****方式 ,还是通过优化设计范式,避免在创建一个 Bean 的过程中创建另一个 Bean。尽管在本例中,问题并不是由第 38 行实际创建 fallback Bean 引起的,但也正是因为试图创建这个 Bean,才引入了导致问题的 getBeanNamesForAnnotation()
调用。
一个可行的解决方案是先分别创建所有 ReferenceBean
和 FallbackFactory
类型的 Bean 实例,然后通过 SmartInitializingSingleton
等机制后置处理,将 FallbackFactory
类型的 Bean 赋值给 ReferenceBean
实例的 fallbackFactory
字段。这种方式可以有效地避免在 Bean 创建过程中发生嵌套依赖问题。示例代码如下:
@Component
public class FallbackInjector implements SmartInitializingSingleton {
private final List<ReferenceBean> refrenceBeanList;
private final List<FallbackFactory> fallbackFactoryList;
@Autowired
public FallbackInjector(List<ReferenceBean> refrenceBeanList, List<FallbackFactory> fallbackFactoryList) {
this.referenceBeanList = referenceBeanList;
this.fallbackFactoryList = fallbackFactoryList;
}
@Override
public void afterSingletonInstantiated() {
for (ReferenceBean bean : referenceBeanList) {
FallbackFactory factory = lookupFallbackFactory(bean);
if (factory == null) {
factory = getGlobalFallbackFactory();
}
bean.setFallbackFactory(factory.create());
}
}
}
当然,最终选择何种方案,还是需要相关研发综合考虑框架的整体设计再做决定。
5、结语
本文是 “代码侦探” 系列第二季的第一篇文章。本系列将专注于通过解析真实案例,深入探讨技术知识,并分享有趣的排查经历。
如果您对第一季的文章感兴趣,可以通过以下链接进行查阅:
最后祝大家每天都能获得成长!
脚注
[1] 设置启动参数 -Xss5120K
,将栈空间上限设置为 5MB;
[2] 递归调用的次数相同,但每次调用的栈空间开销不同,在 x86_64 架构的 Intel 芯片上单次调用的开销较大,用尽了栈空间导致 StackOverflowError
;
[3] 也可以通过 debugger 实时查看栈信息,这里通过命令捕获并输出到文件是为了更方便分析;
[4] 这里是 IDE decompile 的字节码,因此是 .class 文件后缀。实际上就是 ReferenceBean.java;
[5] 每次调试的情况可能有所不同,因为 Bean 的装配顺序并不总是固定的。这里只是碰巧和上一次调试一样;
[6] 这里 super
引用的就是 AbstractBeanFactory
;
[7] 即以 "-clientProxy" 结尾的 beanName,例如 "cn.huolala.***.UserOrderListSOAService-clientProxy”;
[8] 表格中,专门通过颜色标注出了创建不同 ReferenceBean
实例时,调用上下文在占中存活的生命周期;
[9] 参考,其实这里的递归是有两个退出条件的:① Bean 实例已经存在 ② Bean 实例正在创建中。因此,思想实验中从 #5 到 #4,随着正在创建中的 Bean 实例越来越多,递归调用的次数和深度都是逐渐减少的;