深度解析:递归调用引发的栈溢出问题案例研究

344 阅读4分钟

本文取材自一个真实的案例,我将尽可能通过还原该案例的排查过程,呈现一套排查这类问题的思路,以及相关工具的使用技巧。您需要具备一些 Spring Bean 管理的基础知识,从而更好的理解案例。

1、问诊


当我开始调查时,我的几位同事已经为这个问题奋战了一整天。因此,我直接获得了三条重要的信息。

序号信息
1应用启动过程中就抛出了 java.lang.StackOverflowError,无法成功启动
2唯一的代码变更就是在 pom 文件中引入了一个新的 JAR 包依赖
3Intel 芯片上能成功复现,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 的线程栈空间上限调大[1]^{[1]},应用果然启动成功了。

针对第三条信息的分析和实验结果,给了我们一些新的提示:

  • 不同 CPU 架构下的表现不同,似乎进一步证实了代码存在深度递归调用的可能性[2]^{[2]}

  • 栈空间不是无限增长的,否则即使调大栈空间上限也无法解决问题。但栈溢出确实是因为引入新 JAR 包后导致的,唯一的变化就是 SOA Facade 接口声明的数量。因此,有理由怀疑栈空间的增长可能与接口数量有关。

于是,我带着这些猜想,正式开始排查问题。

2、全面检查


2.1、Thread dump 分析

既然出现了 StackOverflowError(栈溢出)异常,我们首先需要查看异常发生时栈内的具体内容。

启动应用并等待 StackOverflowError 异常抛出,然后使用 jstack 工具将栈信息捕获到 thread_dump 文件中[3]^{[3]}

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图 2所示。

图 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() 方法中。

进一步深入思考,如果我们暂时忽略中间的方法调用,递归过程可以精简为以下几个步骤,如3图3所示:

  1. 调用 doGetBean() 方法创建 ReferenceBean 的一个实例,假设为实例 AA

  2. 在实例 AA 上执行 doAfterPropertiesSet()

  3. 递归调用 doGetBean() 创建新的 ReferenceBean 实例,假设为实例 BB

图 3: 精简的递归调用过程

那么,实例 AA 和实例 BB 是相同的 ReferenceBean 实例,还是不同的 ReferenceBean 实例呢?

我们可以大胆推测,这是不同的 ReferenceBean 实例,基于以下两点原因:

  1. 如前所述,问题与 "JAR 包中 SOA Facade 声明的数量" 有关。按照设计,不同的 Facade 声明会被封装成不同的 ReferenceBean 实例,且每个 Facade 对应的 ReferenceBean 实例都是单例。

  2. Spring 框架可以识别 “正在创建中” 的 bean,以避免不必要的递归调用。因此,如果 doGetBean() 获取的是相同的实例,不应该产生大量的递归调用栈。

如果这一推测成立,这说明 “不同 ReferenceBean 的实例化过程存在依赖传递” 是导致递归调用的关键原因。例如在 doGetBean(A) 时,需要先调用 doGetBean(B),而调用 doGetBean(B) 又需要先调用 doGetBean(C)……以此类推。

了解这些信息后,我们可以更高效地进行代码调试:

  1. 递归调用路径明确指示了我们在调试时需要重点关注哪些代码,时刻记得回头参考一下递归调用的栈信息;

  2. 既然问题源自 doAfterPropertiesSet() 方法,那么在调试时应特别关注 doAfterPropertiesSet() 之后的方法调用,以降低调试的复杂度;

  3. 调试的关键目标是找到 ReferenceBean 实例创建过程中依赖传递导致递归调用的原因;

通过明确这些调试方向,我们可以更有针对性地排查和解决问题。OK! Let's get our hands dirty!

2.2、代码 Debugging

2.2.1、初步调试

基于前面的分析,我们进行以下两个断点的设置:

  1. cn.huolala.arch.hermes.compatible.config.ReferenceBean.doAfterPropertiesSet(ReferenceBean.java:36)

  2. org.springframework.beans.factory.support.AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643)

前者(ReferenceBean.java:36)是问题的起点,设置这个断点后可以查看到问题开始的具体位置,见4图4

对于熟悉 hermes 框架的人来说,这段代码很容易理解。由于 SOA 调用可能失败,hermes 框架提供了 fallback 能力,而 fallback 的实现是通过 @Fallback 注解标注的一系列 FallbackFactory 类型的实例(即 Spring Bean)。在这段代码中,就是从 Spring Context 中找到所有带该注解的 Bean 的名称,并在后面的代码中创建 fallback bean,赋值给 ReferenceBean 实例的 fallbackFactory 字段。

图 4

等待断点

后者(AbstractBeanFactory.java:1643)是实际递归调用的位置。设置断点是为了确认调试时是否实际发生了递归调用。不过由于这是 Spring 框架内部的代码,即便不是在创建 ReferenceBean 实例时也有可能执行到该方法,这可能会干扰调试。为了解决触发时机的问题,可以使用 “添加触发的断点-等待断点” 的技巧。

具体方法如5图5所示,添加等待断点,设置该断点在 ReferenceBean.class [4]^{[4]} 的第 36 行的断点(即4图4 中的断点)触发后自动触发。

图 5

设置好这两个断点后,可以运行代码并确认断点能按预期方式工作。

6图6所示,当ReferenceBean.class:36行的断点被触发时,可以在左侧监视窗口中看到当前正在创建的实例是 VehicleAbilityLogoFacade

图 6

此时还不需要立即进行单步调试,而是点击 “继续执行(到下一个断点)”。代码如预期会在 AbstractBeanFactory.class:1643 行的断点处暂停,见7图7。这表明递归调用如预期触发。从左侧监视窗口可以看到,此时 doGetBean() 正在创建另一个名为 UserOrderListSOAService 的 bean,而不是 VehicleAbilityLogoFacade。这验证了之前关于 “不同 ReferenceBean 实例化过程存在依赖传递” 的推测。

图 7

通过这样的断点设置和初步调试过程,我们能够更清晰地理解问题发生的具体情境,并验证我们对递归调用原因的推测。

2.2.2、单步调试

保持已有的断点不变,重新运行代码。

代码首先还是会在 ReferenceBean.java:36 处暂停,如8图8所示[5]^{[5]}

图 8

继续单步调试,会跳转到 applicationContext.getBeanNamesForAnnotation(...) 方法实现,如9图9所示。

图 9

显然这只是一个转发逻辑,无需耗费太多精力。根据递归调用栈的信息指引,跳转到 getBeanNamesForAnnotation(...) 的实现,进入 DefaultListableBeanFactorygetBeanNamesForAnnotation(...) 方法,如10图10所示。

图 10

递归调用栈提示我们接下来会执行 652 行的 findAnnotationOnBean(...) 方法,但该方法在一个 for 循环中。继续调试之前,最好先了解循环的对象 beanDefinitionNames 的内容。

最简单的方法是在调试控制台中评估该变量的值,如11图11所示。

图 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 行“添加条件断点-表达式” 来过滤非 ReferenceBeanbeanName,如12图1213图13所示。

图 12

图 13

点击 “继续执行(到下一个断点)”,仅当 beanName 是以 “-clientProxy” 结尾时,断点才会触发。触发条件断点时,如14图14所示。

图 14

继续单步调试,跳转到 findAnnotationOnBean(...) 方法实现。如15图15所示,这也是一个转发方法,真正的逻辑在 findMergedAnnotationOnBean(...) 方法中。

图 15

跳转到 findMergedAnnotationOnBean(...) 方法实现,如16图16所示。

图 16

参考递归调用栈,接下来执行的是 689 行的 getType(...) 方法。因此,直接跳转进 getType(...) 方法内,如17图17所示。

图 17

由于又是一个转发方法,继续跳转进入 659 行调用的 getType(...) 方法实现,如18图18所示。

图 18

这个方法的实现复杂,但递归调用栈提示我们接下来会调用 704 行的 getTypeForFactoryBean(...) 方法。因此,通过 “运行到光标处” 指令,可以将代码执行到 704 行。然后进入 getTypeForFactoryBean(...) 方法内部,如19图19所示。

图 19

这个方法的实现同样有些复杂,不过可以从递归调用栈得知,接下来会执行 895 行的 super.getTypeForFactoryBean(...) 方法。而这里的 super.getTypeForFactoryBean(...) 方法,正是 AbstractBeanFactory 中触发递归调用的那个 getTypeForFactoryBean(...) 方法[6]^{[6]}。因此,883-897 行的代码就成为了解释发生递归调用的关键。

通过 “运行到光标处” 指令直接从 883 行开始继续往下调试。如20图 20allowInit = true,因此走入 if 语句块,成功获取到 factoryBean

图 20

我们知道代码最终会运行到 895 行,而前提条件是 889 行 getTypeForFactoryBean(...) 返回 null。继续单步调试,并跳转进入 getTypeForFactoryBean(...) 方法,最终发现是 factoryBeangetObjectType() 方法返回了 null,如21图21所示。

图 21

接下来我们探讨为什么 getObjectType() 会返回 nullgetObjectType() 方法源自 Spring 的 FactoryBean 接口。ReferenceBean 实现了这个接口,并重写了 getObjectType() 方法,如22图22所示。

图 22

它调用了内部方法 getInterfaceClass(),该方法的实现如23图23所示。

图 23

从 图23 可以看到,`interfaceClass` 是 `ReferenceBean` 的一个实例属性,需要通过 `setInterfaceClass(...)` 方法赋值(代码的 164 行)才能使用。

而此时 factoryBean 是通过 getSingletonFactoryBeanForTypeCheck(...) 方法获得的。根据方法注释的说明(如24图24所示):

图 24

Obtain a "shortcut" singleton FactoryBean instance to use for a {@code getObjectType()} call, without full initialization of the FactoryBean.

获取一个 “未完成初始化” 的 FactoryBean 实例,仅用于 getObjectType() 方法调用。

在之前的语境中,我为了方便叙述,有意模糊了 “实例化(Instantiation"“实例化(Instantiation)"“初始化(Initialization"“初始化(Initialization)" 两个概念之间的区别(请留意,除了本小节以外,当本文其他地方提到 “实例化” 这个词时,均表示获得一个 ready-to-use 的 Bean 实例)。但在这里必须要加以区分。

为此,需要补充一些有关 Spring Bean 生命周期管理的基础知识。

Spring Bean 的生命周期

宏观上,可以将 Spring Bean 的生命周期概括为25图 25

图 25

  1. 应用上下文创建:首先,Spring 的应用上下文(Application Context)被初始化,它充当了管理Spring Beans的容器;

  2. Bean 定义(Bean Definitions)加载(步骤1) :Spring 从配置文件中读取 Bean 定义(例如,XML、注解)。这包括描述每个 Bean 的属性、作用域和依赖关系的元数据;

  3. Bean 定义(Bean Definitions)处理(步骤2) :加载后,Spring 会处理这些 Bean 定义。这涉及检查任何必需的依赖关系和生命周期方法(例如 init-method 和 destroy-method);

  4. Bean 实例化(Instantiation,步骤3) :在处理完成后,Spring 实例化 bean,可以是急加载(在应用启动时),也可以是懒加载(首次被请求时);

  5. 依赖注入(步骤4) :一旦 Bean 实例化,Spring 执行依赖注入,在这一步骤中,Spring 使用配置元数据(例如,XML 配置、注解如 @Autowired@Inject 或 Java 配置)来设置 Bean 实例的属性(字段),包括注入其他 bean、原始值或其他资源;

  6. Bean 处理(步骤5) :此时 Bean 被处理,包括应用任何自定义逻辑,例如 BeanPostProcessor 中的方法,以在 Bean 创建后进行附加处理。afterPropertiesSet()@PostConstruct 标记的方法也在该阶段执行;

  7. Bean 准备使用(步骤6) :Bean 现在已经完全初始化,可以被应用使用,既可以是单例(默认)bean,也可以是原型 bean;

  8. Bean 使用(步骤7) :在整个应用运行期间,这些 Bean 在业务逻辑和操作中被积极使用;

  9. Bean 销毁(步骤8) :当应用上下文关闭时(无论是关闭还是上下文刷新),Bean 通过自定义销毁方法(如有定义)被优雅地销毁;

总结一下,在 “Bean 实例化(Instantiation,步骤 3)” 阶段,Spring 创建了 Bean 实例,但属性字段尚未填充。直到 “依赖注入(步骤 4)” 阶段,才会填充属性字段。

再来看 factoryBean, 注释说这是 "a FactoryBean instance without full initialization",那么它的属性字段 interfaceClassnull(导致 getObjectType() 返回 null),也就顺理成章了。

26图26所示,通过监视窗口查看 factoryBean 实例的信息,所有实例属性的值都是 null,其中也包括 interfaceClass

图 26

到这里,我已经基本可以构建出问题的全貌了。

3、诊断分析


3.1、递归是如何发生的?

如果把第二章中 “Thread dump 分析” 和 “代码 Debugging” 比作射线检查(DR 和 CT),那么拍出的片子就应该如27图27所示。

图 27

按照27图27中的编号顺序,可以更清晰的理解递归的整个过程:

序号调用分析
1AbstractBeanFactory#doGetBean()调用 doGetBean() 创建一个 ReferenceBean 实例。
2ReferenceBean#doAfterPropertiesSet()Spring 开始创建这个 Bean 的实例,并在 “Bean 处理(步骤 5)” 阶段调用 afterPropertiesSet() 方法。由于 ReferenceBean 类型覆写了 afterPropertiesSet() 方法,并在其中调用了 doAfterPropertiesSet() 方法,因此 doAfterPropertiesSet() 方法中的逻辑被执行。该方法目的是在创建 ReferenBean 之后,找到匹配的 Fallback 实现(一些由 @Fallback 注解标注的 bean)。因此,在 doAfterPropertiesSet() 方法中,调用 AbstractApplicationContext#getBeanNamesForAnnotation() 方法,找到所有带有 @Fallback 注解的 Bean 名称的数组。
3AbstractApplicationContext#getBeanNamesForAnnotation()执行该方法,它实际调用的是 DefaultListableBeanFactory#getBeanNamesForAnnotation() 方法。
4DefaultListableBeanFactory#getBeanNamesForAnnotation()该方法会遍历当前已加载的所有 beanName,并通过 findAnnotationOnBean() 方法确认这些 Bean 是否被 @Fallback 注解标记。它实际调用的是 DefaultListableBeanFactory#findMergedAnnotationOnBean() 方法。
5DefaultListableBeanFactory#findMergedAnnotationOnBean()要确认 Bean 是否被 @Fallback 注解标记,需要先获取这个 Bean 的类型信息,因此,需要先调用 AbstractBeanFactory#getType() 方法获取类型信息。
6AbstractBeanFactory#getType()getType() 方法被调用,由于此时只有 beanName,而非 Bean 的实例,要获取类型信息,需要先根据 beanName 加载这个 Bean 的实例。因此,继续调用 AbstractAutowireCapableBeanFactory#getTypeForFactoryBean() 创建 Bean 实例。
7AbstractAutowireCapableBeanFactory#getTypeForFactoryBean()此时,如果指定 beanName 的 Bean 实例已经存在,则返回该实例。否则,会执行初始化逻辑,如28图 28

图 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[7]^{[7]}。其中,A、B 表示已经实例化过的,而 #1,#2,#3,#4, #5... 表示尚未实例化的;

让我们从 doGetBean(#5) 开始,模拟整个递归过程以及在这个过程中栈的变化。

序号栈快照分析递归逻辑图
1 图 29此时,创建 #5 ReferenceBean 的方法调用上下文将依次被压入栈中。为了叙述简单,我们直接用 “#5” 代表这些栈帧。按照前面的分析,代码会走到 DefaultListableBeanFactory#getBeanNamesForAnnotation() 方法,该方法会遍历 beanDefinitionNames 中的元素,并逐个调用 getType() 方法获取这些 beanName 对应的 Bean 实例。 图 49
2 图 30根据 beanDefinitionNames 的元素顺序,A 首先被遍历到,并执行 getType(A)。根据前面的假设,A 是已经实例化过的 bean,因此 getType(A) 将直接返回这个 Bean 实例。从栈的视角来看,getType(A) 调用的上下文先被压入栈中,又随着 getType(A) 调用 return,这些上下文被 pop 出栈。栈又回到了图 29 所示的状态。
3 图 31接着遍历 B,情况和 A 类似,入栈再出栈。
4 图 32接着遍历 #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 图 33从头开始遍历 beanDefinitionNames,首先是 A,getType(A) 入栈,然后 return 出栈。
6 图 34继续遍历 B,getType(B) 入栈,然后 return 出栈。
7 图 35又遍历到了 #1。此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1)return,不会再次触发 doGetBean(#1) 递归调用。
8 图 36继续遍历 #2,由于 #2 又是一个未实例化的 bean,因此会再次触发 doGetBean(#2) 递归调用。参考 #1 的递归过程(表格中,序号 4 标注的行),我们可以得知,这会再次导致从头开始遍历 beanDefinitionNames。此外,#2 的上下文也会保留在栈中。
9 图 37从头开始遍历 beanDefinitionNames,首先是 A,getType(A) 入栈,然后 return 出栈。
10 图 38继续遍历 B,getType(B) 入栈,然后 return 出栈。
11 图 39又遍历到了 #1。此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1)return,不会再次触发 doGetBean(#1) 递归调用。
12 图 40又遍历到了 #2。此时,第 8 步(表格中,序号 8 标注的行)开始的 “#2 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#2)return,不会再次触发 doGetBean(#2) 递归调用。
13 图 41继续遍历 #3,由于 #3 是一个未实例化的 bean,再次触发 doGetBean(#3) 递归调用,再次导致从头开始遍历 beanDefinitionNames。#3 的上下文保留在栈中。
14 图 42从头开始遍历 beanDefinitionNames,首先是 A,getType(A) 入栈,然后 return 出栈。
15 图 43继续遍历 B,getType(B) 入栈,然后 return 出栈。
16 图 44又遍历到了 #1。此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1)return,不会再次触发 doGetBean(#1) 递归调用。
17 图 45又遍历到了 #2。此时,第 8 步(表格中,序号 8 标注的行)开始的 “#2 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#2)return,不会再次触发 doGetBean(#2) 递归调用。
18 图 46又遍历到了 #3。此时,第 13 步(表格中,序号 13 标注的行)开始的 “#3 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#3)return,不会再次触发 doGetBean(#3) 递归调用。
19 图 47继续遍历 #4,由于 #4 是一个未实例化的 bean,再次触发 doGetBean(#4) 递归调用,再次导致从头开始遍历 beanDefinitionNames。#4 的上下文保留在栈中。
20 图 48从头开始遍历 beanDefinitionNames,首先是 A,但此时栈空间已经用满,getType(A) 的调用上下文无法继续入栈,抛出 StackOverflowError

通过这个思想实验,我们可以清楚地发现,在递归和 for 循环的双重影响下,每次调用 doGetBean() 创建一个新的 ReferenceBean 实例时,相关的调用上下文都会在栈中保持较长的时间[8]^{[8]} ,从而极大地影响了栈空间的利用效率。因此,尽管不是无限递归[9]^{[9]},但随着需要加载的 ReferenceBean 数量增加,栈空间仍会被逐渐耗尽,最终引发 StackOverflowError

4、治疗和预防


针对本次的问题,我们可以通过分析 doAfterPropertiesSet() 方法的实现,来推测编写这段代码时开发人员的思路(详见50图50):

  1. 在代码的第 36 行,开发人员通过 getBeanNamesForAnnotation() 方法获取所有被 @Fallback 注解标记的 Bean 名称;

  2. 随后在第 38 行,遍历这些 Bean 名称,并使用 getBean() 方法获取相应的 Bean 实例。

图 50

可以推测,开发者的初衷是首先获取 fallbackBeanName,然后再通过 getBean() 方法实例化 fallback bean。他可能认为 getBeanNamesForAnnotation() 方法仅会返回带有 @Fallback 注解的 Spring Bean 名称,而未意识到该方法的内部实现可能会实例化一系列 bean,甚至包括那些没有 @Fallback 注解的 bean。

实际上,如果没有全局视角,很难察觉到 getBeanNamesForAnnotation() 的潜在风险:

  1. 回顾我们在第 2.2.2 节中的调试过程,导致递归调用 doGetBean() 方法以实例化 Bean 的根本原因是 getObjectType() 返回了 null,这是因为 ReferenceBean 重写了 getObjectType() 方法。开发人员需要不仅了解 getBeanNamesForAnnotation() 的具体实现细节,还需要认识到 ReferenceBean#getObjectType() 方法的风险点,方能发现这种潜在风险;

  2. 不仅如此,getBeanNamesForAnnotation() 方法的注释也具一定误导性(详见51图51)。

图 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() 调用。

一个可行的解决方案是先分别创建所有 ReferenceBeanFallbackFactory 类型的 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] 参考49图 49,其实这里的递归是有两个退出条件的:① Bean 实例已经存在 ② Bean 实例正在创建中。因此,思想实验中从 #5 到 #4,随着正在创建中的 Bean 实例越来越多,递归调用的次数和深度都是逐渐减少的;