【大白话说Java面试题 第148题】【06_Spring篇】第8题:可以通过多少种方式完成依赖注入?

📌 PDF :大白话说Java面试题 --- 06_Spring篇

第8题:可以通过多少种方式完成依赖注入?

📚 回答:

  • 核心考点 : 这个问题表面问"有几种方式",但大厂面试官真正想考察的是 Spring 官方推荐优先级背后的设计哲学 (为什么构造器注入是唯一推荐)、三种注入方式在源码层的处理差异 (ConstructorResolver vs AutowiredMethodElement vs AutowiredFieldElement)、以及 被忽略的第 4 种方式------方法注入(Method Injection)和接口注入(Interface Injection)。面试官真正想判断的是:你是否将注入方式视为设计决策,而非仅仅是语法选择。
1. 三种主流注入方式------Spring 官方立场

Spring 官方文档明确支持三种注入方式,但有严格的推荐优先级:citation:1citation:4

注入方式 Spring 推荐度 官方理由 处理类
构造器注入 ⭐⭐⭐⭐⭐ 唯一强烈推荐 不可变、非空保证、依赖可见、测试友好 ConstructorResolver
Setter 注入 ⭐⭐⭐ 可选场景使用 灵活性高,适合可选依赖 AutowiredMethodElement
字段注入 ⭐ 不推荐 隐藏依赖、破坏不可变性、测试困难 AutowiredFieldElement

Spring 官方原文:"The Spring team generally advocates constructor injection as it enables one to implement application components as immutable objects and to ensure that required dependencies are not null."citation:1

2. 构造器注入------官方唯一强烈推荐
  • 2.1 为什么 Spring 强烈推荐? 构造器注入的优势不是"风格偏好",而是工程质量的硬性保障:

    优势 源码机制 工程影响
    不可变性 final 字段,JVM 保证引用不可变 线程安全,无需同步
    非空保证 构造器执行前参数已校验 运行时 NPE 归零
    依赖可见 参数列表即依赖清单 代码审查时一目了然
    测试友好 new Service(mockDep) 无需 Spring 容器,纯单元测试
    循环依赖早暴露 ConstructorResolver 检测 启动时失败,而非生产环境偶发
    java 复制代码
    @Service
    public class OrderService {
        private final UserRepository userRepository;
        private final PaymentService paymentService;
    
        // Spring Boot 2.x+ 单构造器可省略 @Autowired
        public OrderService(UserRepository userRepository, PaymentService paymentService) {
            this.userRepository = userRepository;
            this.paymentService = paymentService;
        }
    }
  • 2.2 构造器注入的源码处理 ConstructorResolver.autowireConstructor() 在 createBeanInstance() 阶段执行:citation:3

    java 复制代码
    // AbstractAutowireCapableBeanFactory.createBeanInstance()
    protected BeanWrapper createBeanInstance(String beanName, RootBeanDefinition mbd, Object[] args) {
        Constructor<?>[] ctors = determineConstructorsFromBeanPostProcessors(beanClass, beanName);
        // 解析构造器参数类型,递归 resolveDependency()
        return autowireConstructor(beanName, mbd, ctors, args);
    }

    关键特征 :构造器注入在对象实例化阶段完成,这意味着对象创建时所有依赖必须已就绪。

3. Setter 注入------可选依赖场景
  • 3.1 适用场景 Setter 注入适用于依赖可选 或运行期可动态替换的场景:

    java 复制代码
    @Service
    public class DataSourceConfig {
        private DataSource dataSource;
    
        @Autowired(required = false)  // 可选依赖
        public void setDataSource(DataSource dataSource) {
            this.dataSource = dataSource;
        }
    }

    典型场景:

    • 配置类中的可选数据源;
    • 插件化架构中可选的功能模块;
    • 运行期需要动态切换的实现(如 A/B 测试)。
  • 3.2 Setter 注入的源码处理 AutowiredMethodElement.inject() 在 populateBean() 阶段执行:citation:3

    java 复制代码
    protected void inject(Object bean, String beanName, PropertyValues pvs) {
        Method method = (Method) this.member;
        Object[] arguments = resolveMethodArguments(method);
        ReflectionUtils.makeAccessible(method);
        method.invoke(bean, arguments);  // 调用 Setter 方法
    }

    关键特征 :Setter 注入在对象创建后执行,依赖可以为 null,运行期可通过再次调用 Setter 替换。

4. 字段注入------不推荐但广泛误用
  • 4.1 字段注入的 5 大缺陷 IDEA 会提示 "Field injection is not recommended",原因如下:citation:2citation:5

    缺陷 具体表现 后果
    破坏不可变性 字段不能声明 final 对象可变,线程不安全
    隐藏依赖关系 依赖分散在私有字段中 无法通过公共 API 识别依赖
    NPE 风险 构造器中访问注入字段为 null 启动时无法检测,运行时偶发 NPE
    测试困难 必须通过反射注入 Mock 测试代码复杂,或依赖 Spring 容器
    掩盖设计问题 参数过多无直观提示 类职责膨胀,违反 SRP
    java 复制代码
    @Service
    public class UserService {
        @Autowired  // ⚠️ IDEA 警告:Field injection is not recommended
        private UserRepository userRepository;
    }
  • 4.2 字段注入的源码处理 AutowiredFieldElement.inject() 在 populateBean() 阶段通过反射直接设置字段值:citation:3

    java 复制代码
    protected void inject(Object bean, String beanName, PropertyValues pvs) {
        Field field = (Field) this.member;
        Object value = beanFactory.resolveDependency(
            new DependencyDescriptor(field, this.required), beanName);
        ReflectionUtils.makeAccessible(field);
        field.set(bean, value);  // 反射直接设置私有字段
    }

    关键特征:反射设置私有字段,绕过 Java 访问控制,破坏了封装性。

5. 第 4 种方式:方法注入(Method Injection)------被忽略的高级特性

除了三种主流方式,Spring 还支持 方法注入 (Method Injection),用于解决 prototype Bean 注入到 singleton Bean 中的生命周期不匹配问题:citation:6

  • 5.1 问题场景 singleton Bean 持有 prototype Bean 的引用,导致 prototype 无法被 GC:

    java 复制代码
    @Component
    @Scope("prototype")
    public class Command { }
    
    @Component
    public class CommandManager {
        @Autowired
        private Command command;  // ❌ 只注入一次,后续复用同一个 prototype 实例
    }
  • 5.2 解决方案:@Lookup 方法注入 Spring 通过 CGLIB 代理重写方法,每次调用都返回新的 prototype 实例:

    java 复制代码
    @Component
    public abstract class CommandManager {
        // Spring 通过 CGLIB 代理实现此方法,每次调用创建新的 Command
        @Lookup
        protected abstract Command createCommand();
    
        public void process() {
            Command command = createCommand();  // 每次获取新实例
            // ...
        }
    }

    原理 :Spring 使用 CGLIB 生成子类,重写 @Lookup 标注的方法,方法体从容器中获取 Bean。citation:6

  • 5.3 另一种方案:ObjectFactory / Provider 注入

    java 复制代码
    @Component
    public class CommandManager {
        @Autowired
        private ObjectFactory<Command> commandFactory;  // 延迟获取
    
        public void process() {
            Command command = commandFactory.getObject();  // 每次获取新实例
            // ...
        }
    }
6. 三种主流注入方式深度对比
对比维度 构造器注入 Setter 注入 字段注入
Spring 推荐度 ⭐⭐⭐⭐⭐ 唯一强烈推荐 ⭐⭐⭐ 可选场景 ⭐ 不推荐
注入时机 实例化阶段(createBeanInstance) 属性填充阶段(populateBean) 属性填充阶段(populateBean)
不可变性 ✅ final 字段 ❌ ❌
NPE 风险 无(构造器校验) 有(可能未调用 Setter) 有(构造器中访问为 null)
依赖可见性 高(参数列表) 中(Setter 方法) 低(隐藏字段)
单元测试 new Service(mock) 调用 Setter 传入 Mock 反射注入或依赖容器
循环依赖检测 启动时暴露 运行时暴露(三级缓存解决) 运行时暴露(三级缓存解决)
可选依赖 ❌ 不支持 ✅ @Autowired(required=false) ✅ @Autowired(required=false)
动态替换 ❌ 不支持 ✅ 运行期可再次调用 Setter ❌ 需反射修改
代码简洁度 中 低 高
7. 生产环境避坑指南
  • 7.1 构造器参数过多 = 设计异味 如果构造器参数超过 4-5 个,说明类可能承担了过多职责:citation:4

    java 复制代码
    // ❌ 违反单一职责原则
    public class OrderService {
        public OrderService(UserRepo userRepo, ProductRepo productRepo,
                            InventoryRepo inventoryRepo, PaymentRepo paymentRepo,
                            LogRepo logRepo, NotificationRepo notificationRepo) { }
    }
    
    // ✅ 拆分为多个小类
    public class OrderCreationService { }
    public class OrderPaymentService { }
    public class OrderNotificationService { }
  • 7.2 混合使用多种注入方式的风险 一个类中同时使用构造器注入和字段注入,会导致依赖关系混乱:

    java 复制代码
    // ❌ 反模式:混合使用
    @Service
    public class UserService {
        private final UserRepository userRepository;  // 构造器注入
    
        @Autowired
        private EmailService emailService;  // 字段注入
    
        public UserService(UserRepository userRepository) {
            this.userRepository = userRepository;
        }
    }

    问题:依赖关系分散在构造器和字段中,代码审查时容易遗漏字段注入的依赖。

  • 7.3 @Autowired 与 @Resource 的选择

    维度 @Autowired @Resource
    来源 Spring 注解 JSR-250 标准
    匹配规则 先按类型,再按名称 先按名称,再按类型
    注入位置 构造器、Setter、字段 Setter、字段
    适用场景 Spring 项目 追求框架无关性
  • 7.4 循环依赖的正确处理 遇到循环依赖首先反思设计。如果确实需要:

    • 构造器注入:使用 @Lazy 延迟注入;
    • 字段注入:Spring 自动通过三级缓存解决,但建议重构消除。
8. 面试官追问与高分回答模板
  • 追问 1:"Spring 中有多少种依赖注入方式?"

    低分回答:"三种:构造器注入、Setter 注入、字段注入。"(忽略了方法注入)

    高分回答:

    "Spring 支持 4 种依赖注入方式:

    1. 构造器注入 :通过构造器参数注入,Spring 官方唯一强烈推荐的方式。优势是不可变性(final)、非空保证、依赖可见、测试友好。源码由 ConstructorResolver 在 createBeanInstance() 阶段处理。
    2. Setter 注入 :通过 Setter 方法注入,适用于可选依赖或运行期需动态替换的场景。源码由 AutowiredMethodElement 在 populateBean() 阶段处理。
    3. 字段注入 :通过 @Autowired 直接注入字段,代码最简洁但 Spring 官方不推荐。源码由 AutowiredFieldElement 通过反射 field.set() 直接设置私有字段,破坏了封装性。
    4. 方法注入(Method Injection) :通过 @Lookup 注解或 ObjectFactory 注入,用于解决 prototype Bean 注入到 singleton Bean 时的生命周期不匹配问题。Spring 通过 CGLIB 代理重写 @Lookup 方法,每次调用返回新实例。
      官方推荐优先级:构造器注入 > Setter 注入(可选依赖)> 字段注入(不推荐)。"
  • 追问 2:"为什么 Spring 强烈推荐构造器注入?字段注入有什么问题?"

    高分回答:

    "Spring 官方从 4.0 起明确推荐构造器注入,原因有五个:

    1. 不可变性 :构造器注入允许依赖声明为 final,对象创建后不可变,天然线程安全;字段注入无法使用 final。
    2. 非空保证:构造器注入在对象创建时就要求所有依赖就绪,没有依赖就无法创建对象,从根本上杜绝 NPE;字段注入在构造器中访问会得到 null。
    3. 依赖可见:构造器参数列表一目了然,类的所有依赖关系清晰透明;字段注入的依赖隐藏在私有字段中,代码审查时容易遗漏。
    4. 测试友好 :单元测试可以直接 new Service(mockDep),无需反射或 Spring 容器;字段注入必须通过反射注入 Mock,或依赖 @SpringBootTest 启动容器。
    5. 设计约束 :构造器参数过多(>4个)会直观提示类职责过重,促使开发者重构;字段注入没有这个'预警'机制,容易导致类膨胀。
      字段注入唯一的优点是代码简洁,但为了这点便利牺牲设计质量不值得。"
  • 追问 3:"构造器注入和 Setter 注入在循环依赖处理上有什么区别?"

    高分回答:

    "两者的核心区别在于循环依赖暴露的时机 和解决机制:

    • 构造器注入 :循环依赖在启动时 直接暴露。因为构造器注入在 createBeanInstance() 阶段完成,此时对象尚未创建完成(构造器还没执行完),无法放入三级缓存。如果 A 的构造器依赖 B,B 的构造器依赖 A,Spring 会直接抛出 BeanCurrentlyInCreationException。这是'早失败'原则,避免运行时偶发问题。
    • Setter/字段注入 :循环依赖在运行时 暴露,但 Spring 通过三级缓存 自动解决。因为字段注入在 populateBean() 阶段完成,此时对象已经通过无参构造器实例化,可以先放入三级缓存(singletonFactories),供循环依赖的对方获取早期引用。
      工程建议 :遇到构造器循环依赖,首先反思设计是否违反了单一职责原则。如果确实需要,使用 @Lazy 延迟注入其中一个依赖,而不是改用字段注入绕过问题。"
  • 追问 4:"@Lookup 方法注入是什么场景?怎么实现的?"

    高分回答:

    "@Lookup 方法注入用于解决 prototype Bean 注入到 singleton Bean 中的生命周期不匹配 问题。

    问题场景 :singleton Bean(如 CommandManager)持有 prototype Bean(如 Command)的引用。如果使用字段注入,Command 只在 CommandManager 创建时注入一次,后续所有调用复用同一个实例,失去了 prototype 的意义。

    解决方案:

    java 复制代码
    @Component
    public abstract class CommandManager {
        @Lookup
        protected abstract Command createCommand();  // 抽象方法
    }

    Spring 使用 CGLIB 动态代理 生成 CommandManager 的子类,重写 createCommand() 方法。方法体从容器中获取 Command Bean,由于 Command 是 prototype,每次调用都会创建新实例。

    替代方案是使用 ObjectFactory<Command> 或 Provider<Command> 注入,延迟获取 Bean。"

  • 追问 5:"如果构造器参数太多(比如 8 个),怎么办?"

    高分回答:

    "构造器参数超过 4-5 个是设计异味,说明类可能违反了单一职责原则。解决方案:

    1. 按职责拆分 :将大类拆分为多个小类。例如 OrderService 拆分为 OrderCreationService、OrderPaymentService、OrderNotificationService;

    2. 使用 Facade 模式 :创建一个聚合类,将相关依赖分组:

      java 复制代码
      public class OrderDependencies {
          private final UserRepo userRepo;
          private final ProductRepo productRepo;
          // ...
      }
      public class OrderService {
          public OrderService(OrderDependencies deps) { }
      }
    3. Builder 模式:如果确实需要大量参数,使用 Builder 模式构建对象;

    4. 检查是否过度抽象 :某些依赖可能是实现细节,不应作为构造器参数(如 ObjectMapper、Clock 等工具类)。
      核心原则:构造器参数是类的'契约',参数过多说明类的职责边界不清晰。"

  • 追问 6:"在一个项目中,三种注入方式混用有什么问题?"

    高分回答:

    "一个类中混用多种注入方式会导致以下问题:

    1. 依赖关系混乱:构造器注入的依赖在参数列表中可见,字段注入的依赖隐藏在类中。代码审查时容易遗漏字段注入的依赖,导致对类的依赖复杂度评估不准。
    2. 初始化顺序不一致 :构造器注入在实例化时完成,字段注入在 populateBean() 阶段完成。如果构造器中访问字段注入的依赖,会得到 null(因为字段注入尚未执行)。
    3. 不可变性破坏 :构造器注入的依赖可以是 final,字段注入的依赖不能是 final。混用导致类部分不可变、部分可变,线程安全模型混乱。
    4. 测试复杂度增加 :测试时需要同时处理构造器参数和字段注入,增加了 Mock 的复杂度。
      最佳实践:一个类只使用一种注入方式。强制依赖用构造器注入,可选依赖用 Setter 注入,绝不混用。如果遗留代码中已混用,重构时应逐步统一为构造器注入。"
9. 方案选型速查表
场景 推荐注入方式 核心理由
强制依赖(核心业务类) 构造器注入 不可变、非空保证、测试友好
可选依赖(配置、插件) Setter 注入 灵活性高,运行期可替换
同类型多实例 构造器注入 + @Qualifier 明确指定实现类
策略模式(多实现) 构造器注入 + List<Interface> 自动收集所有实现
prototype → singleton @Lookup / ObjectFactory 每次获取新实例
循环依赖(构造器注入) @Lazy + 构造器注入 保持构造器优势,延迟打破循环
配置属性注入 @Value + 字段/构造器 配置外部化
框架无关性要求 @Inject(JSR-330) 不绑定 Spring

💡 面试官想要的满分总结:

Spring 支持 4 种依赖注入方式:构造器注入、Setter 注入、字段注入、方法注入(@Lookup)。但官方只强烈推荐构造器注入,Setter 注入用于可选依赖,字段注入不推荐,方法注入用于特定生命周期场景。

构造器注入的优势不是"风格偏好",而是工程质量的硬性保障:不可变性(final)、非空保证、依赖可见、测试友好、循环依赖早暴露。这些特性通过 ConstructorResolver 在 createBeanInstance() 阶段实现。

字段注入虽然代码简洁,但隐藏依赖、破坏不可变性、测试困难、掩盖设计问题------IDEA 会明确警告 "Field injection is not recommended"。

@Lookup 方法注入是被忽略但重要的第 4 种方式,通过 CGLIB 代理解决 prototype Bean 在 singleton Bean 中的生命周期不匹配。理解注入方式的本质,是理解 Spring 设计哲学的关键------构造器注入不是选择,而是约束,它用编译期和启动期的硬性检查,换取运行时的稳定性。


觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯