【大白话说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)、非空保证、依赖可见、测试友好。源码由 ConstructorResolvercreateBeanInstance() 阶段处理。
    2. Setter 注入 :通过 Setter 方法注入,适用于可选依赖或运行期需动态替换的场景。源码由 AutowiredMethodElementpopulateBean() 阶段处理。
    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 拆分为 OrderCreationServiceOrderPaymentServiceOrderNotificationService

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

  • 追问 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)、非空保证、依赖可见、测试友好、循环依赖早暴露。这些特性通过 ConstructorResolvercreateBeanInstance() 阶段实现。

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

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


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