Spring IoC容器创建Bean的过程,绕来绕去最终都会卡在一个问题上:这个Bean到底该怎么new出来?之前在这个系列里,我们把createBean的入口、候选构造器的扫描、依赖注入前的准备阶段都过了一遍,今天正好把实例化前的最后一段路补完——构造器参数的TypeConverter类型转换,以及多个候选构造器之间靠什么“匹配权重”来定胜负。
这两块内容在源码里藏得比想象中深。很多人看createBean源码,看到createBeanInstance方法之后就开始犯迷糊:明明是找构造器,怎么突然冒出一个convertIfNecessary?为什么我传了个字符串给它,它还能自动转成Integer塞进构造器里?为什么构造器一多,Spring选出来的往往不是我以为的那个?这篇文章就用实际源码走读的方式,把这几个问题的答案一次性说透。
1. 先定位:createBean这一路,到底走到哪一步了
很多朋友读Spring源码喜欢从AbstractApplicationContext.refresh()一路往下追,但追到createBean这个方法时,前面几百行已经把人绕晕了。我建议换个思路:只盯着createBean到createBeanInstance再到autowireConstructor这一小段链路,把“找构造器+造参数+实例化”这三件事拆开理解,整个流程就清晰了。
1.1 从createBean到createBeanInstance的完整调用链
createBean定义在AbstractAutowireCapableBeanFactory里,是AbstractBeanFactory.doGetBean的下一层。每次通过getBean获取一个单例对象,如果缓存里没有,就会触发doCreateBean,而doCreateBean里最先做的事就是createBeanInstance。
我比较推荐在你自己的调试工程里给这几个方法打上断点:
- createBean
- doCreateBean
- createBeanInstance
- ConstructorResolver.autowireConstructor
- ConstructorResolver.createArgumentArray
跑起来之后你会发现,从createBean到真正执行构造器反射调用,中间其实只隔了createBeanInstance这一个大方法。而createBeanInstance内部干的活也很纯粹:先问问BeanPostProcessor有没有候选构造器返回,如果没有,再决定是走默认实例化还是走autowireConstructor。
这里有个关键点很多人会忽略:Spring的“自动装配模式”默认是AUTOWIRE_NO,但构造器的推断并不完全依赖这个开关。只要类里没有任何构造器,或者只有一个无参构造器,Spring就走instantiateBean;一旦有多个构造器、或者某个构造器上加了@Autowired注解,那就必需走autowireConstructor。
1.2 真正干活的autowireConstructor做了什么
进入autowireConstructor之后,代码就变得有意思了。这个方法的核心任务有三件事:找到最合适的构造器、为每个参数解析出实际值、把类型转换和匹配权重算明白,最后通过反射把对象new出来。
源码里这段遍历候选构造器的逻辑不算复杂,但信息量很大:
// 遍历所有候选构造器 for (Constructor<?> candidate : candidates) { // 参数数量不一致的直接跳过,参数数量一致的才进入权重比较 int typeDiffWeight = (argsToUse.length == paramTypes.length ? getTypeDifferenceWeight(paramTypes, argsToUse) : Integer.MAX_VALUE); if (typeDiffWeight < minTypeDiffWeight) { minTypeDiffWeight = typeDiffWeight; constructorToUse = candidate; argsToUse = args; } }这里就是“匹配权重”字面意思的源头。Spring并不是随便选一个构造器,而是把所有候选构造器都过一遍,然后用getTypeDifferenceWeight计算每个构造器的参数类型匹配程度,权重值越小越优先。如果算出来权重是Integer.MAX_VALUE,说明类型完全不匹配,这个构造器直接出局。
而类型转换发生在更前面一步。Spring为候选构造器解析参数值的时候,会调用createArgumentArray方法,这个方法内部有一个核心的convertIfNecessary调用链。参数值从配置文件、@Value注解、或者Bean依赖里取出来之后,往往不是构造器参数想要的最终类型,这时候TypeConverter体系就登场了。
所以这一段流程可以概括成一句话:先解析参数值,再经过类型转换,最后用匹配权重决定用哪个构造器。理解了这条主线,剩下的就是往每一环里填细节。
2. 类型转换:Spring是如何把“类型不匹配”变成“正好合适”的
类型转换这一块,是很多人读Spring源码时的第一个劝退点。因为Spring里跟类型转换相关的类实在太多:TypeConverter、TypeConverterDelegate、ConversionService、PropertyEditor、SimpleTypeConverter、BeanWrapperImpl……一会儿这个一会儿那个,很容易看到心态爆炸。
我想给你一个简单的心智模型:Spring的类型转换,本质上是“一个委托链”。BeanWrapperImpl是门面,TypeConverterDelegate是干活的小弟,PropertyEditorRegistrar和ConversionService是干活的工具人。你不需要把所有类都记住,只要盯住TypeConverterDelegate.convertIfNecessary这一个方法就够了。
2.1 TypeConverter体系的三个关键角色
先捋清楚这三个角色,后面看源码就不会迷路:
- TypeConverter:接口,定义了convertIfNecessary和convertIfNecessary的重载版本。BeanWrapperImpl实现了这个接口,所以任何持有BeanWrapper的地方都能调类型转换。
- TypeConverterDelegate:真正的执行者。BeanWrapperImpl内部持有一个TypeConverterDelegate实例,所有转换逻辑最后都委托给它。
- ConversionService:Spring 3之后引入的新一代转换器门面,内部管理一堆Converter、GenericConverter。如果项目里配了ConversionServiceFactoryBean,Spring会优先用它来做转换。
- PropertyEditor:JavaBeans时代的旧方案,Spring为了兼容老代码仍然保留支持,但新代码建议优先用Converter。
实际调用的时候,入口是BeanWrapperImpl.convertIfNecessary,内部直接转给TypeConverterDelegate.convertIfNecessary。这个方法很长,但核心判断顺序大致是这样:
// 1. 如果requiredType不存在,直接返回原值 // 2. 如果值已经是指定类型,也直接返回 // 3. 检查有没有自定义的PropertyEditor // 4. 检查ConversionService能不能完成转换 // 5. 尝试Spring内置的集合、数组、Map转换 // 6. 尝试通过字符串构造器、静态工厂等反射方式创建这条链路我第一次看的时候觉得啰嗦,后来用它排查过一个真实问题,才意识到Spring为什么要这么设计:每个项目引入的转换器来源不同,PropertyEditor可能是老系统遗留的,ConversionService可能是新模块加的,Spring不能只认其中一种,必须全部兼容,还要有一个明确的优先级顺序,否则就会转换混乱。
2.2 convertIfNecessary的完整判断流程
我们来看一段精简之后的源码逻辑:
public <T> T convertIfNecessary(String propertyName, Object oldValue, Object newValue, Class<T> requiredType, TypeDescriptor typeDescriptor) throws IllegalArgumentException { // 如果目标类型就是Object,或者值本身就是目标类型,直接返回 if (requiredType == null || !requiredType.isInstance(newValue)) { // 尝试用自定义编辑器转换 PropertyEditor editor = this.propertyEditorRegistry.findCustomEditor(requiredType, propertyName); // 尝试用ConversionService转换 ConversionService conversionService = this.propertyEditorRegistry.getConversionService(); if (conversionService != null && conversionService.canConvert(...)) { return (T) conversionService.convert(newValue, typeDescriptor); } // 兜底:处理String、数组、集合、枚举等常见类型 return doConvertValue(oldValue, newValue, requiredType, typeDescriptor); } return (T) newValue; }排在最前面的是“值已经是目标类型”的判断,这个很合理,避免无意义的转换。紧接着就是自定义PropertyEditor,然后是ConversionService。最后兜底的是Spring内部内置的一堆转换逻辑,比如把字符串转数字、把数组转List、把字符串转枚举等等。
在实际调试中,我发现最容易触发转换逻辑的场景有两类。一类是从配置文件里读字符串,比如@Value("${server.port}")注入到Integer字段;另一类是XML时代<constructor-arg value="18"/>这种写法,value永远是字符串,Spring必须把"18"转成Integer才能匹配到Student(String name, Integer age)这个构造器。
如果你自己实现了Converter接口,注意在调试时看它是被ConversionService调用的。Spring启动时如果识别到你配置了ConversionServiceFactoryBean,会把它注册到BeanWrapperImpl里,后续所有构造器参数转换都会优先走到conversionService.canConvert这个分支。
2.3 ConversionService与PropertyEditor的协作与优先级
很多文章会把ConversionService和PropertyEditor单独拆开讲,但我更建议把它们放到同一条链上看。因为在TypeConverterDelegate里,它们本来就不是“二选一”,而是“先试谁”的问题。
从源码顺序来看,PropertyEditor优先于ConversionService。为什么会这样?因为PropertyEditor是JDK 1.1时代就有的东西,Spring早期版本全靠它做类型转换。如果某个老项目里给Integer注册过CustomNumberEditor,Spring会先问它能不能处理。只有当PropertyEditor派不上用场时,才会走ConversionService。
我记得在一次代码走查里看到有人自定义了一个PropertyEditor,能处理String -> LocalDate的转换,同时项目里也注册了对应的Converter。按照这个优先级规则,PropertyEditor会先被命中,导致Converter写的逻辑一直没有执行。排查了半天才定位到是优先级问题,当时的经验是:新老两套转换机制不要同时为一个类型注册处理器,否则你很难判断实际走的是哪条分支。
另外还有一个细节,ConversionService能处理的不只是简单标量转换,它还支持泛型描述符,也就是TypeDescriptor。比如List<String>转成List<Integer>这种带泛型的转换,PropertyEditor是做不到的,只有ConversionService能完成。这也是Spring始终保留ConversionService体系的重要原因。
3. 匹配权重:多个构造器之间靠什么定胜负
构造器一旦多起来,选择问题就成了一个“算法问题”。Spring源码里并没有用特别复杂的机器学习或者概率模型,它用的是一套确定性规则:先按参数数量过滤,再按类型匹配程度打分,分数最低的获胜。
这个“打分”机制,就是标题里说的“匹配权重”。很多面试题会问“Spring有多个构造器时如何选择”,如果你能直接说出getTypeDifferenceWeight和Integer.MAX_VALUE这两个关键词,面试官基本就会觉得你确实读过源码。
3.1 构造器候选的初始排序规则
在进入权重计算之前,Spring首先会拿到一组候选构造器。这组候选构造器的来源有三个:@Autowired注解标注的构造器、构造函数上@Value注解参数、以及所有可见性为public的构造器。如果是通过determineCandidateConstructors方法拿到的,还会调用SmartInstantiationAwareBeanPostProcessor来增强。
拿到候选集合之后,Spring并不会先按参数个数从小到大排序,而是大致保持类定义顺序。真正对参数个数起作用的是autowireConstructor里的这段逻辑:如果一个构造器的参数数量和已解析参数个数不一致,直接跳过,不会进入权重比较。这就从源头上排除了很多干扰项。
这里有个容易踩坑的地方:Spring解析构造器参数时,除了构造器形参名,还会读取@ConstructorProperties注解。假设你定义了public Student(@ConstructorProperties({"name", "age"}) String name, Integer age),Spring会优先使用注解里写的属性名去容器里找依赖。
3.2 getTypeDifferenceWeight权重计算原理
getTypeDifferenceWeight是匹配权重最核心的方法,源码不算长,但每一行都有讲究:
protected int getTypeDifferenceWeight(Class<?>[] paramTypes, Object[] args) { int result = 0; for (int i = 0; i < paramTypes.length; i++) { if (!ClassUtils.isAssignableValue(paramTypes[i], args[i])) { return Integer.MAX_VALUE; } if (args[i] != null) { Class<?> paramType = paramTypes[i]; Class<?> argType = args[i].getClass(); if (paramType.isInterface() || paramType.isAssignableFrom(argType)) { // 接口类型或父类型能接收子类实例,权重加2 result += 2; } else { // 类型不完全匹配但可转换,权重加4 result += 4; } } } return result; }在这个方法里,Integer.MAX_VALUE代表“彻底没戏”。只要你传入的参数值无法赋值给某个参数类型,这个构造器的权重直接拉满,绝无翻身机会。剩下的情况分两档:参数类型能接受实际值(比如参数是CharSequence,实际传了String),权重加2;参数类型不直接兼容但能通过转换器转过去,权重加4。权重越小,匹配度越高。
也就是说,Spring在多个构造器都能“凑合”使用时,会优先选择“语义上最匹配”的那个。比如Student(String)和Student(CharSequence)同时存在,传入一个String对象时,前者权重是4,后者权重是2,Spring会选后者。这个结论我第一次测下来也觉得挺反直觉的,但源码就是这么写的。
还有朋友在评论区问过参数个数差异会不会影响权重。实际上,ConstructorResolver在比较构造器时还会累加参数数量差异权重PARAMETER_COUNT_DIFFERENCE,数值是1。具体逻辑在autowireConstructor里,构造器每多一个参数,总权重会多1。所以假设一个构造器参数类型匹配度完美(权重0),另一个构造器参数个数更接近但类型匹配一般,最终谁胜出取决于两者权重之和。
3.3 实战案例:模拟Spring选择构造器的全过程
我写了一个简单的示例,帮助大家把上面的算法串起来。
假设有这样一个类:
public class OrderService { private UserService userService; private PaymentService paymentService; public OrderService(UserService userService) { this.userService = userService; } public OrderService(UserService userService, PaymentService paymentService) { this.userService = userService; this.paymentService = paymentService; } }容器里UserService和PaymentService都只有一个实现Bean,没有设置@Primary。这时Spring遍历两个构造器:
- 第一个构造器和第二个构造器参数个数都匹配(容器中都能找到对应Bean),都进入权重计算。
- 两个构造器的参数都是接口类型,实际传入的都是实现类,
paramType.isInterface()为true,每个参数权重加2。 - 第一个构造器权重为2,第二个构造器权重为4。
- 最终Spring选择第一个构造器。
这个例子说明一个很重要的事实:Spring并不是参数越多越优先,而是权重越小越优先。在参数类型都能直接匹配的情况下,参数少的构造器权重反而更小,所以会被选中。
很多网上的资料说“Spring会优先选择参数最多的构造器”,这只是对“参数类型都完全匹配”这一特例的描述,并不严谨。当多个构造器参数数量不同、类型匹配程度也不同时,还是要以权重计算为准。
4. 实操复现与关键代码走读
读了源码不亲自动手走一遍,很容易觉得自己懂了,真到排查问题又抓瞎。我建议你弄一个最小Spring工程,用断点把构建过程一格格看完。这个环节我直接给出一套可以照着做的流程。
4.1 搭建一个最小的Spring源码调试工程
我平时调试Spring源码常用的方式,是用Maven引入spring-context依赖,然后把源码包下载下来。如果你手里已经有一套Spring源码工程,直接跑单元测试会更方便。
依赖版本我一般用5.3.x,功能完整且调试体验比6.x更友好。目录结构很简单:
src/main/java/com/example/demo/ DemoApplication.java Student.java UserService.java Main.javaStudent类故意设计成多个构造器且参数类型有差异:
@Component public class Student { private String name; private Integer age; public Student(CharSequence name) { this.name = name.toString(); } public Student(String name, Integer age) { this.name = name; this.age = age; } }UserService只是充当依赖对象:
@Component public class UserService { }然后写一个AnnotationConfigApplicationContext启动类,在createBeanInstance和getTypeDifferenceWeight两处打上断点,看Spring实际选择了哪个构造器。
4.2 断点走读自动刷新:从解析参数到选择构造器现场
启动Main方法后,第一次断点会停在createBeanInstance里。逐步往下走,你会看到Spring通过determineCandidateConstructors拿到Student类的所有候选构造器,然后把它们交给autowireConstructor处理。
进入autowireConstructor之后,观察候选构造器集合。此时集合里有两个构造器:Student(CharSequence)和Student(String, Integer)。继续走,Spring开始为第一个候选构造器解析参数。
参数解析会调用createArgumentArray --> resolveAutowiredArgument,在resolveAutowiredArgument里,需要把容器中已有的UserService之类的Bean找出来作为参数值。但这里Student构造器参数是String和Integer,不是Bean类型,所以会走另一条分支,从@Value配置或者类型转换器里取值。
我实际测试时给String参数配的是@Value("test"),Integer参数配的是@Value("20")。于是Spring解析出来的原始参数值分别是字符串"test"和字符串"20"。此时还没转换,Integer参数的类型是String,明显不是构造器想要的Integer类型。
紧接着,代码会走到TypeConverterDelegate.convertIfNecessary。这里你就能亲眼看到"20"这个字符串是怎么从String类型变成Integer类型的。Spring先检查有没有对应的PropertyEditor,然后检查ConversionService,最终用内置的NumberUtils把字符串转成Integer。
等到两个构造器都解析完参数,就到了getTypeDifferenceWeight的断点。你会在调用栈里看到ConstructorResolver循环调用这个方法的现场。结合刚才的例子,Student(CharSequence)的参数值"test"本身是String,CharSequence是接口且能接收String,权重加2;Student(String, Integer)的两个参数类型完全匹配,权重加0。
按权重越小越优先的规则,Spring会选Student(String, Integer)。这个结论也和源码的实际行为一致。
4.3 自定义Converter接入Spring转换体系
如果默认的类型转换满足不了业务,可以自己写一个Converter并注册到ConversionService里。我举一个很常见的需求:将前端传来的"2024-01-15 10:00:00"字符串转成LocalDateTime。
实现一个Converter:
@Component public class StringToLocalDateTimeConverter implements Converter<String, LocalDateTime> { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Override public LocalDateTime convert(String source) { return LocalDateTime.parse(source, FORMATTER); } }然后在配置类里注册ConversionServiceFactoryBean:
@Configuration public class ConversionConfig { @Bean public ConversionServiceFactoryBean conversionService() { ConversionServiceFactoryBean factoryBean = new ConversionServiceFactoryBean(); Set<Converter<?, ?>> converters = new HashSet<>(); converters.add(new StringToLocalDateTimeConverter()); factoryBean.setConverters(converters); return factoryBean; } }这样在任意构造器参数里使用@Value("2024-01-15 10:00:00") LocalDateTime createTime,Spring都会自动通过自定义Converter完成转换。
有一点要提醒大家:ConversionServiceFactoryBean生成的ConversionService默认会覆盖Spring内置的DefaultConversionService,所以如果你之前依赖了内置转换能力,最好让自定义Converter继承或额外加入内置转换器,否则可能导致原有转换失效。
5. 常见问题与排查技巧实录
源码看到最后,还是要落到排查问题上。这里我把自己实际踩过、以及帮别人排查过的几类高频问题做个汇总,基本覆盖了createBean阶段最常见的疑难杂症。
5.1 构造器异常盘点
| 异常信息 | 出现原因 | 排查方向 |
|---|---|---|
| No default constructor found | 类里有多个构造器,且没有无参构造器兜底,Spring又无法判断用哪个 | 检查是否缺少@Autowired标注,或检查候选构造器参数能否解析 |
| NoSuchBeanDefinitionException | 构造器参数在容器中找不到对应Bean | 检查参数类型、Bean的@Component/@Bean定义 |
| BeanCreationException: Error creating bean with name | 构造器本身执行抛异常 | 查看caused by,多半是构造器内部逻辑问题 |
| TypeMismatchException | 参数值无法转换成目标类型 | 检查@Value值是否合法,Converter是否注册 |
| UnsatisfiedDependencyException | 依赖不满足,通常伴随NoSuchBeanDefinitionException或循环依赖 | 检查构造器注入的依赖是否齐全 |
这里最容易被忽视的是第一条。假设你写了一个工具类,只定义了带参构造器,没写无参构造器,Spring默认不会傻傻地报“没有构造器”,而是会尝试用beanClass.getDeclaredConstructor()获取默认构造器,如果拿不到就抛NoSuchMethodException,最终包装成BeanCreationException。
5.2 类型转换失败的四个真实场景
场景一:@Value值带单位或千分位。比如@Value("1,000") Integer count,默认转换器会把这当成字符串转Integer,但它不认识千分位逗号,就会抛NumberFormatException。解决方法是写一个自定义Converter,先去掉逗号再转型。
场景二:枚举转换失败。Spring对枚举类型有一套内置转换逻辑,可以把字符串转成枚举常量。但如果枚举常量是纯数字,比如enum Status { 1, 2 }这种写法本身就不合法,更别提转换了。遇到枚举转换失败,先检查枚举命名是否符合Java规范。
场景三:集合泛型丢失。构造器参数是List<String>,但Spring解析到的是一个Object数组,两边类型对不上。这种建议用TypeDescriptor+ConversionService方式处理,或者明确指定@Value加上SpEL表达式,例如@Value("#{'${list}'.split(',')}")。
场景四:Date类型的时区问题。字符串转java.util.Date时,Spring默认可能会使用系统时区,导致时间偏差。建议业务代码统一使用LocalDateTime,并注册自定义Converter。
5.3 面试高频追问与源码级解答
有些问题如果你只背答案是撑不住追问的,我建议把源码逻辑一起记下来。
问:Spring为什么能自动识别构造器?
答:核心在determineCandidateConstructors。Spring会遍历BeanPostProcessor,尤其是SmartInstantiationAwareBeanPostProcessor,如果有@Autowired标注的构造器就直接返回;否则收集所有public构造器。如果只有一个构造器,直接确定;如果有多个构造器,会进入autowireConstructor做权重比较。
问:构造器参数类型转换失败会怎样?
答:异常会被包装成BeanCreationException,底层是TypeMismatchException或MethodArgumentConversionNotSupportedException。最直接的排查点是看caused by链,通常会在里面找到具体的NumberFormatException或ClassCastException。
问:@Primary注解在构造器选择中起到什么作用?
答:需要分清两个阶段。@Primary主要影响依赖解析阶段,也就是resolveDependency时从多个同类型Bean中选择一个。而在构造器匹配权重阶段,Spring计算的是“参数类型和实际参数值的匹配程度”,并不关心Bean是不是@Primary。所以即便某个Bean标了@Primary,它对构造器选择本身没有直接影响。
问:构造器注入为什么无法解决循环依赖?
答:三级缓存解决循环依赖的核心是提前暴露半成品对象,而构造器注入发生在对象创建的最开始,还没生成半成品就无法暴露给其他Bean。所以Spring官方一直建议循环依赖场景优先使用setter注入或@Lazy延迟注入。
结尾的小结与经验
源码读到这,createBean寻找构造器的最后两块拼图也算凑齐了。类型转换和匹配权重,一个解决“参数值怎么变成构造器想要的类型”,一个解决“多个构造器到底选谁”,两者叠加在一起,才构成了Spring灵活又复杂的构造器推断机制。
我个人在实际调试中的体会是:遇到构造器相关的问题,先不要急着搜报错信息,而是断点到getTypeDifferenceWeight和convertIfNecessary两个方法里看一遍。90%的疑惑看完源码自己就解开了。再有就是,写业务代码时不要依赖Spring“自动选构造器”这种魔法,尽量保持构造器数量少且目的清晰。如果一个类构造器超过三个,或者参数类型之间界限模糊,建议用静态工厂方法替代,代码可读性和可维护性都会好很多。
如果你正在啃Spring源码,或者准备面试时被问到底层原理,建议自己在本地跑一个最小Demo,跟着断点走一遍今天的例子。源码这个东西,看十遍不如调一遍,调一遍不如断点追一遍。