货拉拉技术

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

本文取材自一个真实的案例,我将尽可能通过还原该案例的排查过程,呈现一套排查这类问题的思路,以及相关工具的使用技巧。您需要具备一些 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 接口的声明,如图1所示。这些声明仅是一些接口(interface),而非可以直接实例化的 Bean 类(Bean Class)。据我所知,这些接口声明会被视为一种 SOA 调用协议,并且由框架代码解析协议,将其组装成名为 ReferenceBean 的客户端实例(Bean 实例)。除此以外,并没有自定义的 Bean。

因此,可以初步排除循环引用的可能性,而更可能的原因是框架在装配这些 SOA 客户端实例时的代码中存在递归调用。

Image

图1

第三条信息则相当有趣。通常我们总说 “JVM 提供了跨平台抽象,使得同样的 Java 代码可以在不同平台上运行”,但从另一个角度来看,这也意味着 JVM 自身的实现需要针对不同平台进行适配。例如,由于不同 CPU 架构下的调用约定(Calling Conventions)和应用二进制接口(ABI, Application Binary Interface)不同,可能会导致每次方法调用的栈空间开销也有所不同。这似乎可以解释为什么在 Intel 芯片(x86_64)的 MacBook 上可以复现问题,而在 M3 芯片(ARM)上则不会。

于是,我让同事将 Intel 芯片上 JVM 的线程栈空间上限调大[1]。应用果然启动成功了。

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

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

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

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

2、全面检查


2.1、Thread dump 分析

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

启动应用并等待 StackOverflowError 异常抛出,然后使用 jstack 工具将栈信息捕获到 thread_dump 文件中[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)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)    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 线程的栈信息中,可以看到一段重复出现的调用模式(如第一个locked到第二段locked处,为了叙述方便,后文将其统称为 “递归调用栈”,或简称为 “栈信息”)。

这种模式从  AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:321)  开始,到  AbstractBeanFactory.getTypeForFactoryBean(AbstractBeanFactory.java:1643) 结束,然后又返回新的 AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:321) 调用。通过将首尾的 doGetBean 调用连起来观察,递归调用的模式就显现了。

为了进一步确认是递归调用,我们需要验证 getTypeForFactoryBean 方法确实调用了 doGetBean 方法。根据栈信息,我们找到 AbstractBeanFactory.java 文件的第 1643 行,发现正是这行代码调用了 doGetBean 方法,如图2所示。

Image

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

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

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

  2. 在实例 A上执行 doAfterPropertiesSet();

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

Image

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

那么,实例 A 和实例 B 是相同的 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!

Image

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。

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

Image

图4

等待断点

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

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

Image

图5

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

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

Image

图6

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

Image

图7

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

2.2.2、单步调试

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

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

Image

图8

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

Image

图9

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

Image

图10

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

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

Image

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

Image
图12
Image

图13

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

Image

图14

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

Image

图15

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

Image

图16

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

Image

图17

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

Image

图18

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

Image

图19

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

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

Image

图20

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

Image

图21

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

Image

图22

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

Image

图23

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

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

Image
图24

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

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

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

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

Spring Bean 的生命周期

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

Image
图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",那么它的属性字段 interfaceClass 为 null(导致 getObjectType() 返回 null),也就顺理成章了。

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

Image

图26

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

3、诊断分析


3.1、递归是如何发生的?

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

Image

图27

按照图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。

Image

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

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

栈快照

分析

Image

图29

此时,创建 #5 ReferenceBean 的方法调用上下文将依次被压入栈中。为了叙述简单,我们直接用 “#5” 代表这些栈帧。

按照前面的分析,代码会走到 DefaultListableBeanFactory#getBeanNamesForAnnotation() 方法,该方法会遍历 beanDefinitionNames 中的元素,并逐个调用 getType() 方法获取这些 beanName 对应的 Bean 实例。

Image

图30

根据 beanDefinitionNames 的元素顺序,A 首先被遍历到,并执行 getType(A)。

根据前面的假设,A 是已经实例化过的 bean,因此 getType(A) 将直接返回这个 Bean 实例。从栈的视角来看,getType(A) 调用的上下文先被压入栈中,又随着 getType(A) 调用 return,这些上下文被 pop 出栈。栈又回到了图 29所示的状态。

Image

图31

接着遍历 B,情况和 A 类似,入栈再出栈。

Image

图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 的调用上下文还在栈中。

Image

图33

从头开始遍历 beanDefinitionNames,首先是 A,getType(A) 入栈,然后 return 出栈。

Image

图34

继续遍历 B,getType(B) 入栈,然后 return 出栈。

Image

图35

又遍历到了 #1。

此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1) 将 return,不会再次触发 doGetBean(#1) 递归调用。

Image

图36

继续遍历 #2,由于 #2 又是一个未实例化的 bean,因此会再次触发 doGetBean(#2) 递归调用。

参考 #1 的递归过程(表格中,序号 4 标注的行),我们可以得知,这会再次导致从头开始遍历 beanDefinitionNames。

此外,#2 的上下文也会保留在栈中。

Image

图37

从头开始遍历 beanDefinitionNames,首先是 A,getType(A) 入栈,然后 return 出栈。

Image

图38

继续遍历 B,getType(B) 入栈,然后 return 出栈。

Image

图39

又遍历到了 #1。

此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1) 将 return,不会再次触发 doGetBean(#1) 递归调用。

Image

图40

又遍历到了 #2。

此时,第 8 步(表格中,序号 8 标注的行)开始的 “#2 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#2) 将 return,不会再次触发 doGetBean(#2) 递归调用。

Image

图41

继续遍历 #3,由于 #3 是一个未实例化的 bean,再次触发 doGetBean(#3) 递归调用,再次导致从头开始遍历 beanDefinitionNames。

#3 的上下文保留在栈中。

Image

图42

从头开始遍历 beanDefinitionNames,首先是 A,getType(A) 入栈,然后 return 出栈。

Image

图43

继续遍历 B,getType(B) 入栈,然后 return 出栈。

Image

图44

又遍历到了 #1。

此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#1) 将 return,不会再次触发 doGetBean(#1) 递归调用。

Image

图45

又遍历到了 #2。

此时,第 8 步(表格中,序号 8 标注的行)开始的 “#2 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#2) 将 return,不会再次触发 doGetBean(#2) 递归调用。

Image

图46

又遍历到了 #3。

此时,第 13 步(表格中,序号 13 标注的行)开始的 “#3 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 getType(#3) 将 return,不会再次触发 doGetBean(#3) 递归调用。

Image

图47

继续遍历 #4,由于 #4 是一个未实例化的 bean,再次触发 doGetBean(#4) 递归调用,再次导致从头开始遍历 beanDefinitionNames。

#4 的上下文保留在栈中。

Image

图48

从头开始遍历 beanDefinitionNames,首先是 A,但此时栈空间已经用满,getType(A) 的调用上下文无法继续入栈,抛出 StackOverflowError。

逻辑递归图
Image

                                图49 逻辑递归图

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

4、治疗和预防


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

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

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

Image
图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() 方法的注释也具一定误导性(详见图5)。

Image
图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 创建过程中发生嵌套依赖问题。示例代码如下:

@Componentpublic 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,其实这里的递归是有两个退出条件的:① Bean 实例已经存在 ② Bean 实例正在创建中。因此,思想实验中从 #5 到 #4,随着正在创建中的 Bean 实例越来越多,递归调用的次数和深度都是逐渐减少的;