1. 从一个诡异的Bug说起:为什么我的Bean被创建了两次
前段时间帮一个朋友排查问题,场景很典型:他写了一个@Configuration配置类,里面用@Bean声明了两个Bean,其中一个Bean的方法体里直接调用了另一个@Bean方法。按他的理解,Spring容器里应该只有一个实例,结果他在两个Bean的构造函数里都打了日志,发现被new了两次。他第一反应是“Spring是不是有Bug”,第二反应是“是不是我的@Configuration没生效”。
问题最后定位到一行代码:@Configuration(proxyBeanMethods = false)。这行代码是他从某个示例里直接抄过来的,抄的时候没细看,结果就踩了坑。这个属性是Spring 5.2引入的,很多人知道它存在,但真正理解它背后机制的人并不多。今天我就把proxyBeanMethods这个属性从原理到实操彻底拆一遍,包括它为什么存在、什么场景该用true、什么场景该用false、用错了会出什么问题,以及怎么通过日志和断点去验证它的行为。
这篇文章适合已经用过Spring Boot、写过@Configuration和@Bean,但对容器内部Bean创建机制还停留在“会用”层面的开发者。如果你曾经疑惑过“为什么配置类里的方法调用没有走代理”“为什么@Bean方法返回的对象不是同一个”,那这篇内容应该能帮你把这块知识补上。
2. 先搞清楚@Configuration到底做了什么
2.1 从注解本身看设计意图
@Configuration这个注解从Spring 3.0就存在了,它的定位非常明确:标记一个类是Bean定义的来源。被它标记的类,Spring容器在启动时会对其进行解析,把里面所有@Bean方法返回的对象注册到容器里。但这里有一个关键点很多人忽略了——@Configuration本身是被@Component元注解标记的,也就是说它首先是一个组件,会被组件扫描机制发现。
真正让它区别于普通@Component的,是Spring对它的特殊处理。在ConfigurationClassPostProcessor这个后置处理器里,Spring会判断一个类是否被@Configuration标记,如果是,并且proxyBeanMethods为true,就会用CGLIB对它进行代理增强。这个增强的目的只有一个:拦截配置类内部的方法调用,确保每次调用@Bean方法时返回的都是容器里的单例,而不是重新执行方法体。
2.2 全模式与轻量模式的区别
Spring官方文档里把@Configuration分为两种模式:Full模式(全模式)和Lite模式(轻量模式)。这个区分就是由proxyBeanMethods决定的。
Full模式下,proxyBeanMethods = true(默认值),配置类会被CGLIB代理。你在配置类里直接调用另一个@Bean方法,比如userService()里调用userRepository(),这个调用会被代理拦截,代理会先去容器里找userRepository这个Bean,找到就直接返回,找不到才执行方法体。这样就保证了Bean的单例语义。
Lite模式下,proxyBeanMethods = false,配置类不会被代理。方法调用就是普通的Java方法调用,每次调用都会重新执行方法体,创建一个新对象。这个对象不会被注册到容器,只是一个普通的临时对象。如果你在方法里把它返回出去,那容器里注册的还是@Bean方法本身被容器调用时创建的那个实例,两者不是同一个。
注意:Lite模式下并不是说
@Bean方法失效了,容器依然会调用@Bean方法来注册Bean,只是配置类内部的直接方法调用不再有特殊语义。
2.3 为什么默认值是true
很多人会问,既然false性能更好,为什么Spring不把默认值设成false?原因在于兼容性和语义一致性。Spring 5.2之前没有这个属性,所有@Configuration类都是被代理的。如果突然把默认行为改成不代理,大量依赖“配置类内部方法调用返回单例”这个语义的代码会出问题。所以Spring选择保持默认true,把选择权交给开发者。
从语义上讲,@Configuration的“配置”二字本身就暗示了一种声明式的契约:我在这里定义Bean,你按我的定义来。Full模式更符合这种直觉。Lite模式更像是一个“带@Bean方法的普通类”,语义上更弱。
3. proxyBeanMethods的底层原理拆解
3.1 CGLIB代理是怎么介入的
要理解proxyBeanMethods,必须知道Spring是在哪个环节做代理的。整个流程大致是这样的:
- 容器启动时,
ConfigurationClassPostProcessor扫描所有候选配置类。 - 对每个被
@Configuration标记的类,检查proxyBeanMethods属性。 - 如果为
true,调用ConfigurationClassEnhancer对类进行CGLIB增强。 - 增强后的类会覆盖所有
@Bean方法,在方法调用时先走BeanMethodInterceptor。 BeanMethodInterceptor的逻辑是:判断当前调用是否来自容器外部,如果是,尝试从容器获取Bean;如果容器里没有,才执行原始方法。
这里有一个细节值得注意:代理类并不是在原始类上直接改,而是生成一个子类,子类里重写了@Bean方法。所以你的配置类不能是final的,@Bean方法也不能是final或private的,否则CGLIB无法代理。这是Full模式的一个硬性约束。
3.2 BeanMethodInterceptor的拦截逻辑
BeanMethodInterceptor是理解整个机制的核心。它的intercept方法大致做了这几件事:
- 首先判断当前方法是不是
@Bean方法,如果不是就直接放行。 - 然后判断当前调用是否发生在容器初始化过程中。如果是容器自己在调用
@Bean方法注册Bean,那就直接执行原始方法。 - 如果是配置类内部的方法调用,就构造一个
BeanMethodKey,去容器里查这个Bean是否已经存在。 - 如果存在,直接返回容器里的实例;如果不存在,执行原始方法并把结果注册到容器。
这个逻辑解释了为什么Full模式下能保证单例:因为内部调用被拦截后,走的是“先查容器”的路径,而不是“重新执行方法”。
3.3 代理带来的额外开销
CGLIB代理不是没有代价的。它需要在启动时生成字节码,增加了类加载和初始化的时间。对于大型项目,配置类可能有几十上百个,每个都生成代理类,启动时间会有可感知的增加。另外,代理类会占用额外的元空间内存。
还有一个容易被忽略的点:CGLIB代理会绕过构造器的某些优化,而且如果配置类里有复杂的初始化逻辑,代理可能会让调试变得困难。你在断点里看到的对象类型是YourConfig$$EnhancerBySpringCGLIB$$xxxx,而不是YourConfig,这对不熟悉的人来说会造成困惑。
4. 什么场景该用true,什么场景该用false
4.1 必须用true的典型场景
最典型的就是配置类内部有跨@Bean方法调用,并且你依赖单例语义。比如:
@Configuration public class AppConfig { @Bean public UserRepository userRepository() { return new UserRepository(); } @Bean public UserService userService() { return new UserService(userRepository()); } }这里userService()里调用了userRepository()。如果proxyBeanMethods = false,userRepository()会被执行两次:一次是容器注册userRepositoryBean时,一次是userService()调用时。结果就是容器里有一个UserRepository实例,UserService里持有的是另一个实例。如果UserRepository是有状态的,或者初始化成本很高,这就是Bug。
还有一种场景是配置类里有@Bean方法之间的依赖链,比如A依赖B,B依赖C,C依赖D。Full模式下这条链上的每个Bean都保证是容器里的单例,Lite模式下每一层调用都可能创建新对象。
4.2 适合用false的场景
如果你的配置类满足以下条件,就可以考虑用false:
- 配置类里没有跨
@Bean方法的直接调用,每个@Bean方法都是独立的。 - 你更在意启动速度,配置类数量多,想减少CGLIB代理开销。
- 配置类本身不需要被代理,比如它只是一个Bean定义的集合,没有任何内部依赖逻辑。
- 你在做原生镜像(Native Image)构建,CGLIB代理在GraalVM下需要额外配置,关掉可以简化。
Spring Boot自己的很多自动配置类就用了proxyBeanMethods = false。比如JacksonAutoConfiguration、DataSourceAutoConfiguration等。这些配置类通常每个@Bean方法都是独立的,方法之间没有直接调用,所以不需要代理。
4.3 一个容易踩的坑:@Bean方法参数注入
有一种情况很多人会混淆:@Bean方法的参数。比如:
@Bean public UserService userService(UserRepository userRepository) { return new UserService(userRepository); }这里的userRepository是方法参数,不是内部调用。Spring在调用这个@Bean方法时,会自动从容器里解析参数并注入。这种写法无论proxyBeanMethods是true还是false,都能拿到容器里的单例。所以如果你习惯用参数注入,其实可以放心用false。
实操心得:我个人的习惯是,新写的配置类如果没有任何内部方法调用,直接标
proxyBeanMethods = false。如果有内部调用,要么改成参数注入,要么保持true。参数注入是更推荐的方式,因为它不依赖代理,语义更清晰。
5. 动手验证:用代码和日志看清真相
5.1 搭建一个最小验证工程
光说理论不够直观,我们直接写代码验证。建一个Spring Boot项目,依赖只需要spring-boot-starter。然后写两个配置类,一个true一个false,对比行为。
@Configuration(proxyBeanMethods = true) public class FullConfig { public FullConfig() { System.out.println("FullConfig 构造器执行"); } @Bean public FullBeanA fullBeanA() { System.out.println("fullBeanA() 被调用"); return new FullBeanA(); } @Bean public FullBeanB fullBeanB() { System.out.println("fullBeanB() 被调用,内部调用 fullBeanA()"); FullBeanA a = fullBeanA(); return new FullBeanB(a); } }@Configuration(proxyBeanMethods = false) public class LiteConfig { public LiteConfig() { System.out.println("LiteConfig 构造器执行"); } @Bean public LiteBeanA liteBeanA() { System.out.println("liteBeanA() 被调用"); return new LiteBeanA(); } @Bean public LiteBeanB liteBeanB() { System.out.println("liteBeanB() 被调用,内部调用 liteBeanA()"); LiteBeanA a = liteBeanA(); return new LiteBeanB(a); } }启动应用后观察日志。FullConfig下,fullBeanA()只会被打印一次,fullBeanB()里拿到的FullBeanA和容器里的FullBeanA是同一个对象。LiteConfig下,liteBeanA()会被打印两次,liteBeanB()里拿到的LiteBeanA是一个新对象,和容器里的不是同一个。
5.2 通过断点看代理类的真面目
在fullBeanB()方法里打个断点,启动Debug模式。当断点停住时,看this对象的类型。你会发现它不是FullConfig,而是FullConfig$$EnhancerBySpringCGLIB$$开头的一长串。这就是CGLIB生成的代理类。
再在liteBeanB()里打断点,this的类型就是LiteConfig本身,没有代理。这个对比非常直观,一眼就能看出proxyBeanMethods到底做了什么。
你还可以在代理类的方法上打断点,观察调用栈。Full模式下,调用fullBeanA()时,栈里会多出BeanMethodInterceptor.intercept这一层。Lite模式下就是直接的方法调用,栈很干净。
5.3 用ApplicationContext验证Bean是否同一实例
除了看日志,还可以在启动完成后从容器里拿Bean做比较:
@SpringBootApplication public class DemoApplication implements CommandLineRunner { @Autowired private ApplicationContext context; public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } @Override public void run(String... args) { FullBeanA fullA1 = context.getBean(FullBeanA.class); FullBeanB fullB = context.getBean(FullBeanB.class); System.out.println("Full模式,容器A == B持有的A: " + (fullA1 == fullB.getA())); LiteBeanA liteA1 = context.getBean(LiteBeanA.class); LiteBeanB liteB = context.getBean(LiteBeanB.class); System.out.println("Lite模式,容器A == B持有的A: " + (liteA1 == liteB.getA())); } }运行结果会明确告诉你:Full模式输出true,Lite模式输出false。这个实验我做过很多次,每次给团队新人演示,效果都很好。
6. 常见问题与排查技巧实录
6.1 配置类里方法调用返回null或异常
有一种情况是配置类里调用了@Bean方法,但返回的是null或者抛异常。这通常是因为在Full模式下,BeanMethodInterceptor去容器里查Bean时,如果Bean还在创建中(比如循环依赖),可能会返回一个早期引用或者直接抛BeanCurrentlyInCreationException。
排查思路:先看异常栈里有没有BeanMethodInterceptor,如果有,说明是代理拦截导致的。然后检查是否存在循环依赖,比如A的@Bean方法里调用了B,B的@Bean方法里又调用了A。这种循环在Full模式下会被代理放大,解决方式是改用参数注入,或者用@Lazy打破循环。
6.2 加了proxyBeanMethods=false后单例失效
这是最常见的坑。表现是某个Bean在容器里存在,但另一个Bean持有的引用不是同一个。排查步骤:
- 确认配置类上是否标了
proxyBeanMethods = false。 - 检查配置类里是否有跨
@Bean方法的直接调用。 - 如果有,改成参数注入,或者把
false改回true。 - 用
context.getBean()和实际持有的引用做==比较,确认是否同一实例。
避坑技巧:如果你不确定该用哪个值,先保持默认的
true,等功能稳定后再评估是否改成false。改的时候一定要跑一遍集成测试,重点验证Bean的单例语义。
6.3 启动报错“Bean method is not eligible for getting processed”
这个错误通常出现在配置类被代理后,某些@Bean方法被过早调用。比如在BeanFactoryPostProcessor里依赖了某个@Bean,而这个@Bean所在的配置类还没被完全代理。Spring会警告“not eligible for getting processed”。
解决方式是避免在BeanFactoryPostProcessor或BeanDefinitionRegistryPostProcessor里依赖普通@Bean。如果必须依赖,考虑用@Lazy或者把逻辑后移到ApplicationListener里。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Bean被创建两次 | proxyBeanMethods=false且有内部调用 | 在构造器打日志,看执行次数 | 改true或改参数注入 |
| 启动变慢 | 配置类过多,CGLIB代理开销大 | 对比true和false的启动时间 | 无内部调用时设false |
断点看到$$EnhancerBySpringCGLIB$$ | Full模式代理 | 正常现象 | 无需处理,了解即可 |
| 循环依赖异常 | Full模式下内部调用形成环 | 看异常栈是否有BeanMethodInterceptor | 参数注入或@Lazy |
| Native Image构建失败 | CGLIB代理需要额外配置 | 看构建日志中的代理相关错误 | 设proxyBeanMethods=false |
7. 性能对比与选型建议
7.1 启动时间实测对比
我在一个中等规模的项目里做过对比测试,大约有40个配置类,其中一半有内部方法调用。测试环境是本地开发机,JDK 17,Spring Boot 3.1。
| 配置 | 启动时间(平均3次) | 配置类代理数量 |
|---|---|---|
全部true | 4.8秒 | 40 |
全部false | 4.2秒 | 0 |
混合(有调用的true,无调用的false) | 4.4秒 | 18 |
差距在0.6秒左右,对于大型项目可能更明显。但要注意,这个差距在整体启动时间里占比不大,如果项目本身启动要30秒,省0.6秒意义有限。所以选型时不要只看性能,更要看语义正确性。
7.2 我的选型原则
经过多个项目的实践,我总结了一个简单的判断流程:
- 配置类里有没有
@Bean方法之间的直接调用?有,用true。 - 没有直接调用,但配置类被其他配置类
@Import或@ComponentScan扫描?用false没问题。 - 配置类里有
@Autowired字段注入?这个和proxyBeanMethods无关,两种模式都支持。 - 配置类需要被AOP代理?
@Configuration类的代理和业务AOP是两回事,不要混淆。
个人经验:新项目我会默认用
false,然后如果发现某个配置类需要内部调用,再单独改成true。这样能最大化减少代理开销,同时保证语义正确。但如果是维护老项目,不要轻易批量改,因为老代码可能隐式依赖了Full模式的单例语义。
7.3 和@Bean方法参数注入的配合
最推荐的写法其实是完全避免内部调用,全部用参数注入:
@Configuration(proxyBeanMethods = false) public class BestPracticeConfig { @Bean public UserRepository userRepository() { return new UserRepository(); } @Bean public UserService userService(UserRepository userRepository) { return new UserService(userRepository); } }这种写法下,proxyBeanMethods设成false完全没问题,因为userService的参数由Spring容器解析注入,拿到的必然是容器里的单例。代码更清晰,也不依赖CGLIB代理,启动更快,调试更简单。我现在写新配置类基本都用这个模式。
8. 几个容易混淆的相邻概念
8.1 @Configuration和@Component的区别
很多人以为@Configuration就是特殊的@Component,其实在proxyBeanMethods=true时,它们的区别很大。@Component类里的@Bean方法不会被代理,内部调用就是普通方法调用。@Configuration类会被代理,内部调用有单例语义。
如果你把一个@Configuration类的proxyBeanMethods设成false,那它和@Component在行为上就非常接近了。区别只在于Spring对@Configuration的解析路径略有不同,但Bean注册的结果基本一致。
8.2 @Bean方法在Lite模式下的返回值
Lite模式下,@Bean方法被容器调用时,返回值会被注册到容器。但如果你在配置类内部直接调用它,返回的对象不会被注册,只是一个普通对象。这个区别很关键,很多人以为Lite模式下@Bean方法就完全失效了,其实不是,只是内部调用的语义变了。
8.3 和@Import的关系
@Import导入的类如果被@Configuration标记,同样受proxyBeanMethods影响。如果导入的是一个普通类,Spring会把它当作Lite模式的配置类处理,即使它没有@Configuration注解。这个细节在排查问题时很有用。
9. 从源码角度看ConfigurationClassEnhancer
9.1 增强器的核心逻辑
ConfigurationClassEnhancer是Spring内部负责生成代理类的类。它的enhance方法会创建一个CGLIBEnhancer,设置父类为原始配置类,然后设置回调。回调里最关键的是BeanMethodInterceptor和BeanFactoryAwareMethodInterceptor。
BeanFactoryAwareMethodInterceptor负责处理setBeanFactory方法,确保代理类能拿到BeanFactory。BeanMethodInterceptor负责拦截@Bean方法。这两个拦截器配合,才实现了Full模式的完整语义。
9.2 条件判断:什么时候不代理
即使proxyBeanMethods=true,Spring也不是无脑代理所有配置类。如果配置类是final的,或者没有@Bean方法,或者已经被代理过,Spring会跳过代理。这些判断在ConfigurationClassEnhancer里都有对应逻辑。
另外,如果配置类实现了EnhancedConfiguration接口,说明已经被增强过,不会重复代理。这个接口是Spring内部用的,普通开发者不需要关心,但看源码时会遇到。
9.3 代理类的命名规则
CGLIB生成的代理类命名规则是原始类名$$EnhancerBySpringCGLIB$$随机码。这个随机码是十六进制的,每次启动可能不同。如果你在日志里看到这个类名,就知道是Full模式的代理类。这个信息在排查“为什么Bean类型不对”这类问题时很有用。
10. 实际项目中的迁移经验
10.1 从默认true迁移到false的步骤
如果你决定把项目里的配置类改成false,建议按以下步骤来:
- 先梳理所有
@Configuration类,标记出有内部@Bean方法调用的。 - 对有内部调用的,改成参数注入,或者保持
true。 - 对没有内部调用的,批量改成
false。 - 跑全量集成测试,重点验证Bean的单例性和依赖关系。
- 观察启动日志,确认没有
BeanCurrentlyInCreationException。 - 对比启动时间,确认优化效果。
这个过程不要一次性全改,分批改、分批验证,出问题容易定位。
10.2 迁移中遇到的典型问题
我在迁移一个老项目时遇到过一个问题:某个配置类里用了@Autowired注入了一个@Bean,然后在另一个@Bean方法里直接调用了这个注入的字段。这种写法在Full模式下没问题,因为字段注入的是容器里的单例。改成false后,字段注入依然有效,但方法内部调用如果涉及@Bean方法,就会创建新对象。
解决方式是统一改成参数注入,把字段注入也去掉。这样配置类完全不依赖代理,行为最可预测。
10.3 团队协作中的约定
如果团队里多人维护配置类,建议在代码规范里明确:新配置类默认用proxyBeanMethods = false,如果需要内部调用,必须改成参数注入,不允许依赖Full模式的代理语义。这样能避免很多隐式的坑,也让代码更符合“显式优于隐式”的原则。
最后分享一个小技巧:你可以在配置类上加一个自定义注解,比如
@LiteConfiguration,它组合了@Configuration(proxyBeanMethods = false),这样团队里统一用这个注解,既简洁又不容易写错。这个做法我在几个项目里推广过,效果不错。