从刚把 Spring 和 SpringMVC 的 SSM 项目跑通,到真正弄明白容器初始化背后的机制,中间隔着一整段“特征驱动开发”的距离——配置能跑、但说不清为什么,一旦换个环境、换套配置就抓瞎。这篇文章我想把这个过程完整拆开:两个容器到底怎么创建的、父子关系从哪来、Bean 为什么放错位置就报错,以及排查时的完整思路。
1. 从 web.xml 那两行配置说起:双容器的第一印象
很多 SSM 项目里,web.xml 的开头都是固定套路:一段 context-param 配 root 配置文件的路径,然后挂一个 ContextLoaderListener;再往下是 DispatcherServlet 的 servlet 声明,带上 springmvc.xml 的 init-param。这两段配置看起来平平无奇,但项目启动时所有的初始化逻辑,全都藏在这几行 XML 里。
我刚接触这套东西的时候也以为项目里只有一个 Spring 容器——反正 Bean 都能拿到,管它几个容器呢。直到有一次把事务配置挪了个位置,启动时不报错,Service 里的事务却集体失效了,才被迫认真去看这背后发生了什么。实际上,一个典型的 SpringMVC 项目里至少有两个独立的 Spring 容器:一个由 ContextLoaderListener 创建的根容器,一个由 DispatcherServlet 创建的子容器。两者以父子关系挂在一起,共同撑起整个请求链路。
1.1 启动那一刻,Servlet 容器看见了什么
在服务器启动时,Servlet 容器(比如 Tomcat)负责三步走:读取 web.xml、实例化各类 Web 组件、按顺序触发它们的生命周期回调。Spring 就是在这套生命周期钩子上,借两个组件完成了容器初始化:
- ContextLoaderListener 实现了 ServletContextListener 接口,在 ServletContext 刚创建时触发 contextInitialized,此时 Spring 的根容器被初始化,并且被绑定到 ServletContext 的属性里。
- DispatcherServlet 本质上是一个 Servlet,默认在第一次请求到达时才初始化;如果配置了 load-on-startup,则在容器启动阶段就初始化。它的初始化动作里会创建第二个容器,并把根容器作为 parent 挂上去。
这个过程对环境是“无侵入”的:Tomcat 根本不关心你用的是不是 Spring,它只负责在正确的时间调用监听器和 Servlet 的回调方法。Spring 的所有初始化逻辑,都在这些回调内部完成。
1.2 加载顺序与分工:一张表说清
| 阶段 | 执行者 | 做了什么 | 关键产物 |
|---|---|---|---|
| 1 | Tomcat 解析 web.xml | 识别 context-param、listener、servlet 等标签 | 配置元数据 |
| 2 | ContextLoaderListener(contextInitialized) | 创建 root WebApplicationContext,加载 spring.xml | 根容器(父容器) |
| 3 | DispatcherServlet(若配置 load-on-startup) | 创建子 WebApplicationContext,加载 springmvc.xml | 子容器,parent 指向根容器 |
| 4 | DispatcherServlet(onRefresh) | 初始化 HandlerMapping、HandlerAdapter、ViewResolver 等 | Web 层核心组件 |
| 5 | 请求到达 | 子容器处理请求,找不到 Bean 时向上查找父容器 | 请求-响应链路 |
这里最重要的不是记住步骤,而是理解一个设计意图:根容器承载全项目的公共服务,子容器专注 Web 层。Controller 在子容器里,Service、DAO 在父容器里,子容器需要 Service 时能往上看,但父容器永远不会被要求往下找任何东西。
2. ContextLoaderListener:根容器在后台做的前置工作
根容器是整个 SpringMVC 项目的地基。它在 ServletContext 创建后立即初始化,不依赖任何请求,因此在监听器里能安全地访问 ServletContext 并装载配置。这个过程的核心源码链路,值得从头到尾走一遍。
2.1 源码视角的初始化链路
ContextLoaderListener 的入口是:
public void contextInitialized(ServletContextEvent event) { initWebApplicationContext(event.getServletContext()); }initWebApplicationContext的逻辑大致如下:
- 检查 ServletContext 里有没有已经存在的 ROOT WebApplicationContext,如果有,直接复用或报错;没有则继续。
- 根据配置的 contextClass(不配时默认 XmlWebApplicationContext)反射创建容器实例。
- 调用
configureAndRefreshWebApplicationContext完成配置装载:- 设置容器 ID(默认是
WebApplicationContext.ROOT); - 绑定 ServletContext;
- 读取 context-param 中
contextConfigLocation的值,按逗号、空格、分号切分成多个配置文件路径; - 最后调用
refresh()进入容器的核心初始化流程。
- 设置容器 ID(默认是
- 容器初始化完成后,把这个 WebApplicationContext 实例放到 ServletContext 的属性中,属性名是
WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE。
这就是根容器在后台的全部动作。如果你打开 debug 日志,会看到类似 “Root WebApplicationContext: initialization started” 的日志,紧接着是一堆 BeanDefinition 的解析和注册日志。
2.2 refresh() 的十三个步骤:不只是背给面试官听
refresh()是 AbstractApplicationContext 中最核心的模板方法。它不是一段“看似高深其实无用”的源码,而是真正决定了容器行为顺序、也决定了哪些扩展点何时生效的逻辑。
关键节点如下:
- prepareRefresh:准备容器环境,设置启动时间、初始化属性源,并标记容器为激活状态。这个阶段非常早,Environment 相关的自定义属性在这里做初步准备。
- obtainFreshBeanFactory:这一步决定了你改 XML 后是不是真正生效了。它会销毁已有 BeanFactory,重新创建并解析配置,把
<bean>和<context:component-scan>扫出来的类全部转成 BeanDefinition 注册进容器。 - prepareBeanFactory:给容器设置类加载器、SpEL 解析器、默认的 Environment 等基础设施;同时注册一些必要的 BeanPostProcessor,比如 ApplicationContextAwareProcessor、ServletContextAwareProcessor(在 Web 容器中会有额外扩展)。
- invokeBeanFactoryPostProcessors:执行所有 BeanDefinitionRegistryPostProcessor 和 BeanFactoryPostProcessor。
<context:component-scan>的核心处理器 ConfigurationClassPostProcessor 就是在这个阶段被调用,配置类里的 @Bean 方法也会在这里解析出来。 - registerBeanPostProcessors:实例化并注册所有 BeanPostProcessor。注意这里是注册,不是执行,真正的执行要等 Bean 创建时。
- initMessageSource、initApplicationEventMulticaster:初始化国际化资源和事件广播器。
- onRefresh:留给子类的扩展点。在 Web 容器里,各类 Web 特定组件挂在更后面的阶段初始化;但对普通的父容器而言,这里通常没太大动作。
- registerListeners:把容器里找到的 ApplicationListener 注册到广播器上。
- finishBeanFactoryInitialization:实例化所有非懒加载的单例 Bean。这是你 Service、DAO、配置类里的大部分普通 Bean 真正 new 出来的时机。
- finishRefresh:发布 ContextRefreshedEvent 事件,初始化生命周期处理器。
流水线的顺序不是随意的。BeanFactoryPostProcessor 必须在 BeanPostProcessor 之前执行,因为配置的解析必须先于 Bean 的创建;事件广播器必须先于 Listener 的注册;所有准备工作必须在单例实例化之前完成。如果你自定义过 BeanFactoryPostProcessor,却总在 Bean 属性注入阶段被覆盖,多半就是没搞清楚这个执行顺序。
2.3 根容器加载了什么,又没加载什么
根容器里通常放的是 spring.xml,典型内容包括:
- dataSource、事务管理器、SqlSessionFactory
<context:component-scan base-package="com.xxx.service, com.xxx.dao, com.xxx.config" />这类服务层扫描- AOP 配置、异步配置、事件监听器
但根容器不应该包含:
@Controller、@RestController的扫描(这是子容器负责的)- HandlerMapping、HandlerAdapter 等 Web 层组件
- 视图解析器、MultipartResolver、消息转换器
为什么?因为根容器是公共服务容器,它不直接处理请求,也不知道“请求”长什么样。把 Web 层组件硬塞进来,不仅多余,还会让父子容器职责混乱,引发后续一大堆 Bean 可见性问题。
3. DispatcherServlet 的子容器:请求处理器真正安家的地方
DispatcherServlet 的初始化比 ContextLoaderListener 更复杂,因为它有两个层次:FrameworkServlet(抽象层)负责容器的创建与合并,DispatcherServlet 本身负责 Web 组件的装配。两个层次各司其职,构成了完整的子容器初始化链。
3.1 从 FrameworkServlet 到 DispatcherServlet 的初始化链
FrameworkServlet 的initWebApplicationContext()逻辑可以概括为:
- 从 ServletContext 的属性中获取 rootContext,作为即将创建的子容器的 parent。
- 判断是否有现成的 WebApplicationContext 可以复用。这里有个细节:如果你在某个地方手动 new 了一个 WebApplicationContext 并通过 servlet 的 init-param 传入,Spring 会尝试复用;否则就创建一个新容器。
- 新建容器时:
- 根据 init-param 中的 contextClass 选择容器类型,默认 XmlWebApplicationContext;
- 设置 parent 为 rootContext;
- 读取 init-param
contextConfigLocation,把它作为配置文件路径传给容器;如果这个参数没有配置,Spring 会默认去 WEB-INF 下找[servlet-name]-servlet.xml,这个默认行为导致过很多新手困惑——明明没写配置文件,为什么启动时还是报了文件找不到; - 调用
refresh()触发完整的容器初始化。
- 初始化完成后,把子容器放入 ServletContext,再调用
onRefresh(ApplicationContext context),这个钩子是 FrameworkServlet 为 DispatcherServlet 准备的。
如果 Web.xml 里没有配置load-on-startup,那么以上所有逻辑都会推迟到第一个请求进来时才执行。所以在开发阶段,经常出现“打完包部署完以为好了,刷第一个页面时日志才刷出初始化过程”的现象。
3.2 onRefresh 阶段:HandlerMapping 与 HandlerAdapter 的“自动发现”
子容器创建完成后,DispatcherServlet 的初始化才真正开始。onRefresh()中一系列 initXxx 方法负责装配 Web 组件:
initMultipartResolver:处理文件上传的解析器,默认没有,需要手动在 springmvc.xml 里注册 MultipartResolver,否则取不到 MultipartFile。initLocaleResolver、initThemeResolver:国际化和主题支持,一般用默认实现即可。initHandlerMappings:把容器中所有 HandlerMapping 类型的 Bean 找出来,按 Order 排序后保存。默认会有 RequestMappingHandlerMapping 和 BeanNameUrlHandlerMapping(通常前者排在前面)。initHandlerAdapters:同上,负责找到能执行 Handler 的适配器。initHandlerExceptionResolvers:异常处理器,ControllerAdvice + ExceptionHandler 在这里被桥接。initViewResolvers:视图解析器。注意,这里只找容器中的 ViewResolver Bean,不是找 JSP 文件本身。initFlashMapManager:支持重定向时 Flash 属性传递。
其中值得特别注意的是detectAllHandlerMappings这个参数:默认 true,意味着只要是容器中 HandlerMapping 类型,不管 bean 叫什么名字都会收集进来;如果改成 false,则只找名为handlerMapping的 Bean。这个参数在排障时很关键——有些团队用自定义 HandlerMapping 并改了 bean 名,却忘了调整这个开关。
3.3 子容器为什么还要“向上看”
子容器创建时设置了 parent,这带来一个关键能力:子容器中查不到 Bean 时,会递归地到父容器中查找。Controller 注入 Service,本质上是子容器在getBean(service)时发现没有,于是向上找到根容器里的那个实例。
这对 SpringMVC 来说是刚需。因为 Web 层与业务层天然是分层依赖关系,Controller 必须能拿到 Service。但反过来不行:父容器不向下查找。所以,如果你在 Service 里注入了 Controller,那么无论怎么配置,启动时都会直接报 NoSuchBeanDefinitionException——因为父容器找不到子容器里的 Bean。
4. 父子容器分治的真正边界:Controller 归哪、Service 归哪
关于两个容器的归属,有一句老话:Controller 放子容器,Service、DAO 放父容器。但绝大多数人只是记住了这个结论,并不理解为什么。如果只知道结论而不知道边界规则,一旦项目里的 Bean 互相注入变复杂,就会踩到各种“配置看起来都对,但就是报错”的坑。
4.1 单向可见性规则决定 Bean 不能放错位置
先明确一条规则:子容器可见父容器,父容器不可见子容器。这句简单的话,是几乎所有 SSM 配置问题的根源。
顺着这条规则推演:
- Controller(子容器)注入 Service(父容器):可行,因为子容器向上能看到。
- Service(父容器)注入 Controller(子容器):不可行,父容器向下看不到。
- Controller(子容器)注入另一个 Controller(子容器):可行,两者在同一容器内。
- Service(父容器)注入另一个 Service(父容器):可行,同一容器内。
如果把这条规则和“扫描范围”结合起来,你就会明白为什么约定俗成都把 Controller 放子容器:因为 Controller 天生只属于 Web 层,它的依赖几乎全是业务层服务;而业务层服务不应该依赖任何 Web 组件,如果把 Controller 误扫进父容器,它的依赖都被迫要在父容器里存在,这个项目的分层就乱了。
4.2 双扫描的隐患:同一个 Service 出现两份代理
一个经典坏味道是在两个配置文件里都写上<context:component-scan>,一个扫 Controller,另一个扫 Service,自以为“两边互补”。
比如 spring.xml 扫描了com.xxx.dao, com.xxx.service,springmvc.xml 扫描了com.xxx.controller, com.xxx.service。两个配置文件都扫了 service 包,会发生什么?
- 父容器创建时扫入一份 Service 实例,并且因为事务配置在父容器里,这份实例经过 BeanPostProcessor 处理,生成了带有事务增强的代理对象。
- 子容器创建时又扫入一份 Service 实例。注意,子容器里的事务配置通常没有,所以这份 Service 只是个原始对象(或者只套了一层和事务无关的代理)。
- Controller 注入 Service 时,子容器优先用自己容器里的那份原始 Service。事务配置不但没生效,而且如果子容器那份没有经过代理,所有 @Transactional 方法相当于裸奔。
这个坑极其隐蔽:启动不报错、请求不报错,只是事务悄悄失效。数据多写入了半截却不会回滚,排查半天最后发现是配置扫重了。
所以务实的做法是:springmvc.xml 只扫描@Controller(或者干脆用<mvc:annotation-driven>搭配最小范围扫描),service、dao、component、config 全部留给父容器。
4.3 事务与 AOP 失效的现场还原
再展开一个更常见的 AOP 失效场景。假设你做了登录鉴权切面,切点表达式是execution(* com.xxx.service.*.*(..))。切面定义在 spring.xml(父容器),Service 也放在父容器,AOP 生效。看起来没问题,但如果某个同事为了跑通一个快速 Demo,把 Service 的扫描也放到了 springmvc.xml,那个 Service 的实例就“被复制”进了子容器。
Controller 调用的是子容器里的那个副本,切面在父容器里无法织入它,于是日志里切面逻辑一次都没执行。这种问题在 SSM 项目里相当常见,原因几乎都是双容器里出现了重复 Bean,而 Controller 在子容器的“就近查找”遮住了真正带有增强逻辑的父容器 Bean。
排障方法可以简单验证:在 Service 的无参构造函数里打印一行日志,看启动过程它被实例化了几次;如果打印了两次,几乎可以认定是双扫描。
5. 初始化阶段最容易踩的五个坑与排查链路
容器初始化问题有个共同特点:报错时机比问题真正的产生时机晚很多。你明明改的是配置文件,结果报错发生在启动末尾的 Bean 实例化阶段;或者启动时一切正常,请求到某个接口才突然炸掉。这是因为 IoC 容器把对象创建时机推迟到了一起,表象和根因分离。
下面这几个坑,是我在真实项目里反复遇到过的。各有各的“伪装”,但排查链路是有规律可循的。
5.1 坑一:NoSuchBeanDefinitionException 的三种可能
这类异常最常见的三种根因:
- 扫描路径写错,导致 Bean 根本没有注册(看 BeanDefinition 的数量就能发现)。
- Bean 在另一个容器里,而当前容器单向不可见。Service 注入 Controller 是典型代表。
- Bean 是懒加载或在特定 Profile 下创建,当前上下文里还没实例化。
排查时不要死磕报错的那一行,先打印一下容器中相关 Bean 的注册情况。在启动类里临时加一个 ApplicationRunner 或 CommandLineRunner,遍历beanFactory.getBeanDefinitionNames(),看目标类是否在其中。如果不在,再检查是否容器选错了,还是扫描路径不对。
5.2 坑二:容器初始化了两次,配置被重复加载
表现:启动日志中看到两次 “Root WebApplicationContext: initialization started”。根因一般有两个:
- web.xml 里重复配置了 ContextLoaderListener,或者部署描述符在打包时被合并;
- 某处在代码中手动调用了
new ClassPathXmlApplicationContext("spring.xml"),又同时依赖了 ContextLoaderListener 启动的容器。
第二种情况更隐蔽。如果代码里有new ClassPathXmlApplicationContext的全局容器,事务、Service、DAO 都被加载两次,一旦和 Web 容器里的根容器不是同一个实例,Controller 拿到的 Service 永远是手动创建的那份,事务自然失效。
排查方法:在AbstractApplicationContext.refresh()打上断点(或 debug 日志观察触发栈),看是谁触发了第二次 refresh。如果是自定义代码,优先重构为把 Bean 注入现有容器,而不是另起炉灶。
5.3 坑三:getBeanNamesForType 查不到父容器的 Bean
很多框架代码里会用applicationContext.getBeansOfType(SomeInterface.class)来做扩展点收集,但在 SSM 双容器环境下,这个方法有一个隐藏行为:它默认只在当前容器的 BeanDefinition 中查找,不会递归父容器。
如果你在子容器里执行这个方法,想拿到父容器中所有 Service 的实现类,结果只会拿到空集合。这不是 bug,而是 Spring 的设计:getBean会向上查找,但getBeanNamesForType系列方法不会。子容器内部也有自己的getBeanNamesForType缓存机制,而且不会越过容器边界。
解决方案也简单:在子容器场景下,需要先拿到根容器再分别查询;或者直接在父容器里做扩展点收集。写框架代码时尤其要注意这一点,不然你收集的总是“半个容器”的内容。
5.4 坑四:自定义 BeanFactoryPostProcessor 被多次触发
你写了一个自定义 BeanFactoryPostProcessor,想在容器启动时修改某些 Bean 定义。结果发现它被执行了两次,修改的效果也被叠加,出现诡异数据。
原因还是双容器:BeanFactoryPostProcessor 是在refresh()期间执行的,根容器 refresh 一次、子容器 refresh 一次,如果这个 PostProcessor 在两个容器里都被注册了,就会执行两次。更复杂的情况是,你把它定义在某个公共配置类里,这个配置类被两个容器都扫描了。
最直接的做法是把这类全局处理器只放在父容器的扫描范围内,或者在处理逻辑里判断当前容器的 ID,只对特定容器执行。
5.5 坑五:日志里看不到初始化过程,无法判断进度
很多人遇到启动挂起、加载缓慢,却看不到任何日志,原因是 Spring 默认日志级别可能是 INFO,而容器初始化的大量调试信息在 DEBUG 级别。
如果你不是用 logback、log4j2 管理日志,只是依赖 Spring 自带的 JUL,可以把org.springframework.web.context、org.springframework.beans.factory、org.springframework.context.support这几个包的日志级别调整到 DEBUG。启动过程中你会看到每一步的详细日志:
- 哪个配置文件被加载;
- 扫描到了多少 BeanDefinition;
- BeanFactoryPostProcessor 执行了什么;
- 单例实例化进度。
这对排查“到底是卡在扫描阶段还是卡在 Bean 初始化阶段”特别有用。你可以在日志里明确看到容器创建和刷新过程的关键节点。
6. 注解配置与 Spring Boot 时代:初始化过程去哪了
现在很多新项目已经不用 web.xml,Spring Boot 更是把开箱即用贯彻到底。有人会问:既然 Boot 里这些配置都没了,这套双容器初始化过程还有必要学吗?
我的看法是:没有了不等于不存在,而是被框架消化掉了。理解旧机制,能让你在新框架下遇到问题时不发怵。
6.1 WebApplicationInitializer 重构起了一套新启动链
Servlet 3.0 之后,web.xml 可以不存在了,替代者是ServletContainerInitializer和WebApplicationInitializer机制。Spring MVC 通过AbstractDispatcherServletInitializer(AbstractAnnotationConfigDispatcherServletInitializer是它的子类)在容器启动时自动注册 ContextLoaderListener 和 DispatcherServlet。
从机制上与 web.xml 没有本质变化,只是位置搬到了 Java 类里:
getRootConfigClasses()返回根容器需要的配置类;getServletConfigClasses()返回子容器需要的配置类;getServletMappings()指定 DispatcherServlet 映射路径。
容器加载顺序仍然是:先根容器后子容器,父容器与子容器的边界规则完全沿用传统 SSM 的那套。
6.2 Spring Boot 为何不再需要父子容器
Spring Boot 默认是把所有 Bean 放进同一个上下文,不再手动分成父子两层。DispatcherServlet 会被注册,但要扫描的 Bean 都在同一个 ApplicationContext 里。这种简化不是随意为之,而是因为 Boot 的自动配置统一接管了依赖管理,把 Controller、Service、DAO、数据源全部装进一个容器后,通过 BeanPostProcessor 和 Conditional 配置来分层管理。
好处是显而易见的:不会再有“Service 在两个容器里出现两份实例”的问题,事务配置也更不容易因为扫描重复而失效。坏处是:一些从旧项目迁移来的配置,尤其是依赖根容器与子容器隔离特性的老代码,迁移时会遇到 Bean 可见性变化带来的报错。
6.3 旧机制的知识复用价值
即便你只用 Spring Boot,也会遇到需要理解容器初始化原理的场景:
- 排查启动时 Bean 循环依赖;
- 理解为什么某些配置类会被重复初始化;
- 理解
ApplicationContext在 Web 环境中的生命周期与普通环境不同; - 编写基于 Servlet 容器的自定义集成,比如自行注册多个 DispatcherServlet(Boot 天然支持多 DispatcherServlet 扩展,只是知道父子容器机制的人更清楚各组件归属)。
框架在简化,但底层的 BeanFactory 生命周期、refresh 模板方法、BeanPostProcessor 扩展点这些东西只要存在,理解旧机制就不会白费。
个人习惯是,每次在项目里遇到 Bean 找不到、事务不生效、切面不拦截这类问题,都会先回到两个问题:我这些类分别在哪个容器里注册的?它们在运行时又是从哪个容器里取出来的?只要这两个问题能准确回答,大半初始化阶段的坑都能绕开。如果还想省点事,启动脚本里可以临时加一句-Dlogging.level.org.springframework.web.context=DEBUG,亲眼看看两个容器各自的启动日志,比背多少遍文档都管用。