深度解析:递归调用引发的栈溢出问题案例研究
本文取材自一个真实的案例,我将尽可能通过还原该案例的排查过程,呈现一套排查这类问题的思路,以及相关工具的使用技巧。您需要具备一些 Spring Bean 管理的基础知识,从而更好地理解案例。
1、问诊
当我开始调查时,我的几位同事已经为这个问题奋战了一整天。因此,我直接获得了三条重要的信息。
序号 | 信息 |
|---|---|
1 | 应用启动过程中就抛出了 |
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 客户端实例时的代码中存在递归调用。
图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所示。
图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所示:
调用
doGetBean()方法创建ReferenceBean的一个实例,假设为实例 A
;在实例 A上执行
doAfterPropertiesSet();递归调用
doGetBean()创建新的ReferenceBean实例,假设为实例 B。
图3: 精简的递归调用过程
那么,实例 A 和实例 B 是相同的 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)是问题的起点,设置这个断点后可以查看到问题开始的具体位置,见图4。
对于熟悉 hermes 框架的人来说,这段代码很容易理解。由于 SOA 调用可能失败,hermes 框架提供了 fallback 能力,而 fallback 的实现是通过
@Fallback注解标注的一系列FallbackFactory类型的实例(即 Spring Bean)。在这段代码中,就是从 Spring Context 中找到所有带该注解的 Bean 的名称,并在后面的代码中创建 fallback bean,赋值给ReferenceBean实例的fallbackFactory字段。
图4
等待断点
后者(AbstractBeanFactory.java:1643)是实际递归调用的位置。设置断点是为了确认调试时是否实际发生了递归调用。不过由于这是 Spring 框架内部的代码,即便不是在创建 ReferenceBean 实例时也有可能执行到该方法,这可能会干扰调试。为了解决触发时机的问题,可以使用 “添加触发的断点-等待断点” 的技巧。
具体方法如图5所示,添加等待断点,设置该断点在 ReferenceBean.class[4] 的第 36 行的断点(即图4 中的断点)触发后自动触发。
图5
设置好这两个断点后,可以运行代码并确认断点能按预期方式工作。
如图6所示,当ReferenceBean.class:36行的断点被触发时,可以在左侧监视窗口中看到当前正在创建的实例是 VehicleAbilityLogoFacade。
图6
此时还不需要立即进行单步调试,而是点击 “继续执行(到下一个断点)”。代码如预期会在 AbstractBeanFactory.class:1643 行的断点处暂停,见图7。这表明递归调用如预期触发。从左侧监视窗口可以看到,此时 doGetBean() 正在创建另一个名为 UserOrderListSOAService 的 bean,而不是 VehicleAbilityLogoFacade。这验证了之前关于 “不同 ReferenceBean 实例化过程存在依赖传递” 的推测。
图7
通过这样的断点设置和初步调试过程,我们能够更清晰地理解问题发生的具体情境,并验证我们对递归调用原因的推测。
2.2.2、单步调试
保持已有的断点不变,重新运行代码。
代码首先还是会在 ReferenceBean.java:36 处暂停,如图8所示[5]。
图8
继续单步调试,会跳转到 applicationContext.getBeanNamesForAnnotation(...) 方法实现,如9图9所示。
图9
显然这只是一个转发逻辑,无需耗费太多精力。根据递归调用栈的信息指引,跳转到 getBeanNamesForAnnotation(...) 的实现,进入 DefaultListableBeanFactory 的 getBeanNamesForAnnotation(...) 方法,如图10所示。
图10
递归调用栈提示我们接下来会执行 652 行的 findAnnotationOnBean(...) 方法,但该方法在一个 for 循环中。继续调试之前,最好先了解循环的对象 beanDefinitionNames 的内容。
最简单的方法是在调试控制台中评估该变量的值,如图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 行“添加条件断点-表达式” 来过滤非 ReferenceBean 的 beanName,如图12和图13所示。
图13
点击 “继续执行(到下一个断点)”,仅当 beanName 是以 “-clientProxy” 结尾时,断点才会触发。触发条件断点时,如图14所示。
图14
继续单步调试,跳转到 findAnnotationOnBean(...) 方法实现。如图15所示,这也是一个转发方法,真正的逻辑在 findMergedAnnotationOnBean(...) 方法中。
图15
跳转到 findMergedAnnotationOnBean(...) 方法实现,如图16所示。
图16
参考递归调用栈,接下来执行的是 689 行的 getType(...) 方法。因此,直接跳转进 getType(...) 方法内,如图17所示。
图17
由于又是一个转发方法,继续跳转进入 659 行调用的 getType(...) 方法实现,如图18所示。
图18
这个方法的实现复杂,但递归调用栈提示我们接下来会调用 704 行的 getTypeForFactoryBean(...) 方法。因此,通过 “运行到光标处” 指令,可以将代码执行到 704 行。然后进入 getTypeForFactoryBean(...) 方法内部,如图19所示。
图19
这个方法的实现同样有些复杂,不过可以从递归调用栈得知,接下来会执行 895 行的 super.getTypeForFactoryBean(...) 方法。而这里的 super.getTypeForFactoryBean(...) 方法,正是 AbstractBeanFactory 中触发递归调用的那个 getTypeForFactoryBean(...) 方法[6]。因此,883-897 行的代码就成为了解释发生递归调用的关键。
通过 “运行到光标处” 指令直接从 883 行开始继续往下调试。如图 20,allowInit = true,因此走入 if 语句块,成功获取到 factoryBean。
图20
我们知道代码最终会运行到 895 行,而前提条件是 889 行 getTypeForFactoryBean(...) 返回 null。继续单步调试,并跳转进入 getTypeForFactoryBean(...) 方法,最终发现是 factoryBean 的 getObjectType() 方法返回了 null,如图21所示。
图21
接下来我们探讨为什么 getObjectType() 会返回 null。getObjectType() 方法源自 Spring 的 FactoryBean 接口。ReferenceBean 实现了这个接口,并重写了 getObjectType() 方法,如图22所示。
图22
它调用了内部方法 getInterfaceClass(),该方法的实现如图23所示。
图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() 方法调用。
在之前的语境中,我为了方便叙述,有意模糊了 “实例化(Instantiation)” 和 “初始化(Initialization)“ 两个概念之间的区别(请留意,除了本小节以外,当本文其他地方提到 “实例化” 这个词时,均表示获得一个 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),也就顺理成章了。
如图26所示,通过监视窗口查看 factoryBean 实例的信息,所有实例属性的值都是 null,其中也包括 interfaceClass。
图26
到这里,我已经基本可以构建出问题的全貌了。
3、诊断分析
3.1、递归是如何发生的?
如果把第二章中 “Thread dump 分析” 和 “代码 Debugging” 比作射线检查(DR 和 CT),那么拍出的片子就应该如图27所示。
图27
按照图27中的编号顺序,可以更清晰的理解递归的整个过程:
序号 | 调用 | 分析 |
|---|---|---|
1 |
| 调用 |
2 |
| Spring 开始创建这个 Bean 的实例,并在 “Bean 处理(步骤 5)” 阶段调用 该方法目的是在创建 |
3 |
| 执行该方法,它实际调用的是 |
4 |
| 该方法会遍历当前已加载的所有 beanName,并通过 |
5 |
| 要确认 Bean 是否被 |
6 |
|
|
7 |
| 此时,如果指定 beanName 的 Bean 实例已经存在,则返回该实例。否则,会执行初始化逻辑,如图 28。 图28 根据前面代码 Debugging 了解到的信息,当 beanName 是另外一个还未创建的
这里的 |
8 |
| 该方法又会调用 |
3.2、递归是如何导致栈溢出的?
理解了递归如何发生之后,再来分析一下递归是如何导致 StackOverflowError 的。
请和我一起做一个思想实验:
假设
this.beanDefinitionNames列表中的元素依次为 A, B, #1, #2, #3, #4, #5...,这些元素都是 将被装载为ReferenceBean实例的 beanName[7]。其中,A、B 表示已经实例化过的,而 #1,#2,#3,#4, #5... 表示尚未实例化的;
让我们从 doGetBean(#5) 开始,模拟整个递归过程以及在这个过程中栈的变化。
栈快照 | 分析 |
|---|---|
图29 | 此时,创建 #5 按照前面的分析,代码会走到 |
图30 | 根据 根据前面的假设,A 是已经实例化过的 bean,因此 |
图31 | 接着遍历 B,情况和 A 类似,入栈再出栈。 |
图32 | 接着遍历 #1,由于 #1 是一个还未实例化的 bean,因此 此时 #1 表示的栈帧大致是如下的样子: 从栈帧信息可以看到,在递归实例化 #1 的时候,由于 #1 是一个 注意,此时 |
图33 | 从头开始遍历 |
图34 | 继续遍历 B, |
图35 | 又遍历到了 #1。 此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 |
图36 | 继续遍历 #2,由于 #2 又是一个未实例化的 bean,因此会再次触发 参考 #1 的递归过程(表格中,序号 4 标注的行),我们可以得知,这会再次导致从头开始遍历 此外,#2 的上下文也会保留在栈中。 |
图37 | 从头开始遍历 |
图38 | 继续遍历 B, |
图39 | 又遍历到了 #1。 此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 |
图40 | 又遍历到了 #2。 此时,第 8 步(表格中,序号 8 标注的行)开始的 “#2 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 |
图41 | 继续遍历 #3,由于 #3 是一个未实例化的 bean,再次触发 #3 的上下文保留在栈中。 |
图42 | 从头开始遍历 |
图43 | 继续遍历 B, |
图44 | 又遍历到了 #1。 此时,第 4 步(表格中,序号 4 标注的行)开始的 “#1 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 |
图45 | 又遍历到了 #2。 此时,第 8 步(表格中,序号 8 标注的行)开始的 “#2 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 |
图46 | 又遍历到了 #3。 此时,第 13 步(表格中,序号 13 标注的行)开始的 “#3 的实例化” 还没有结束。Spring 能够识别到 “创建中” 的 bean,并避免重复创建。因此,在这里 |
图47 | 继续遍历 #4,由于 #4 是一个未实例化的 bean,再次触发 #4 的上下文保留在栈中。 |
图48 | 从头开始遍历 |
图49 逻辑递归图
通过这个思想实验,我们可以清楚地发现,在递归和 for 循环的双重影响下,每次调用 doGetBean() 创建一个新的 ReferenceBean 实例时,相关的调用上下文都会在栈中保持较长的时间[8],从而极大地影响了栈空间的利用效率。因此,尽管不是无限递归[9],但随着需要加载的 ReferenceBean 数量增加,栈空间仍会被逐渐耗尽,最终引发 StackOverflowError。
4、治疗和预防
针对本次的问题,我们可以通过分析 doAfterPropertiesSet() 方法的实现,来推测编写这段代码时开发人员的思路(详见图50):
在代码的第 36 行,开发人员通过
getBeanNamesForAnnotation()方法获取所有被@Fallback注解标记的 Bean 名称;随后在第 38 行,遍历这些 Bean 名称,并使用
getBean()方法获取相应的 Bean 实例。
可以推测,开发者的初衷是首先获取 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()方法的注释也具一定误导性(详见图5)。
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、结语
本文是 “代码侦探” 系列第二季的第一篇文章。本系列将专注于通过解析真实案例,深入探讨技术知识,并分享有趣的排查经历。
如果您对第一季的文章感兴趣,可以通过以下链接进行查阅:
深度解析:大对象分配引发的GC问题案例研究
货运研发部,公众号:货拉拉技术深度解析:大对象分配引发的GC问题案例研究 货运研发部,公众号:货拉拉技术聊一次线程池使用不当导致的生产故障 解决因主库故障迁移导致的核心服务不可用问题
货运研发部,公众号:货拉拉技术解决因主库故障迁移导致的核心服务不可用问题
最后祝大家每天都能获得成长!
脚注
[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 实例越来越多,递归调用的次数和深度都是逐渐减少的;