深入 Spring 10:作用域、对象身份与销毁责任
prototype 为什么仍被重复使用
订单服务是 singleton,订单计算票据 Ticket 是 prototype。服务构造时收到一个 Ticket,之后每次处理订单都读取同一个字段。即使 Ticket 的声明写着 prototype,第二次业务调用也没有再向容器请求对象。对象仍被服务字段持有,作用域声明不会改写普通 Java 字段访问。
问题可以缩成三个动作:何时向容器请求、请求后保留哪个引用、何时结束资源所有权。singleton 与 prototype 主要改变创建与复用规则;Provider 将请求推迟到调用时;scoped proxy 在每次被拦截的方法调用中解析当前作用域目标。几种机制叠加时,必须分别观察代理和目标的身份。
本文固定 Spring Framework 6.2.11、JDK 21,源码为 4c134254642d88e058aa004bdaf44168e1be7bb2。实验在 examples/spring-framework-lab/src/main/java/blog/spring/Chapter10.java,新增的 Spring Web 模块同为 6.2.11,Servlet API 为 6.0.0。实验使用真实 RequestScope 和 ServletRequestAttributes,但请求接口由只支持属性存储的动态代理实现,不启动 HTTP 服务器。
| 待判断的现象 | 必须观察的对象 |
|---|---|
| singleton 持有 prototype | 服务字段里的具体 Ticket |
| Provider 每次请求 | 每次 getObject 返回的 Ticket |
| 两次请求共享 scoped proxy | 代理引用与两次 id() 的目标结果 |
| 上下文已经关闭 | 每个资源对象的销毁状态 |
| singleton 被并发调用 | 共享字段的读写交错与最终值 |
Scope 决定查找与保留,不替应用更新字段
AbstractBeanFactory.doGetBean() 获得合并定义后按 scope 分支。singleton 使用单例注册表;prototype 执行创建并用前后标记保护当前创建状态;其他 scope 则取得已注册的 Scope 实现,调用 scope.get(beanName, objectFactory)。传给 Scope 的工厂负责真正创建对象,Scope 决定是否调用它以及保存在哪里。创建与 scope 分支源码
这个接口划分让容器保留统一的注入、初始化和后处理流程,而把“什么算同一使用范围”交给 scope。请求作用域把 Bean 存进请求属性;自定义会话范围可以按自己的会话标识存储。创建流程不必为每一种业务范围再实现一套。
singleton 缓存的键还包含容器边界和 Bean 名称的含义。同一个类注册两个不同定义,可能产生两个单例;两个互不相关的容器也各自拥有实例。将 singleton 解释为“整个 JVM 中这个类只有一个对象”,会在父子容器或重复配置时误判。
实验通过 supplier 创建 singleton Checkout,并在 supplier 中显式取得 prototypeTicket。服务创建完成后,Checkout.ticket() 只是 record 的访问器,没有容器调用。连续两次取得同一 Checkout,其 ticket 引用相同。随后两次独立 getBean("prototypeTicket") 返回不同对象。两组断言同时成立,说明定义的作用域规则与调用路径并不冲突。
Provider 改变请求时机,销毁责任仍需指定
实验的 TicketSource 持有按名称限定的 ObjectProvider<Ticket>。Qualifier 排除了 requestTicket,使对照只改变取得方式而不引入类型歧义。两次 provider.getObject() 分别经过容器取得 prototype,返回身份不同。
Provider 不是“每次都 new”的通用承诺。目标定义若改成 singleton,两次 getObject 会复用单例;Provider 延迟的是取得行为,最终对象仍遵守目标定义的 scope。使用 Provider 的业务方法也可能把第一次结果缓存在字段,后续绕过 Provider。检查注入点类型不足以判断真实创建频率,还需要检查调用次数。
资源责任在 registerDisposableBeanIfNecessary() 中有明确分支:prototype 不进入容器的销毁登记。singleton 的销毁适配器登记到单例注册表;其他 scope 的适配器交给 registerDestructionCallback()。对象初始化成功与容器承诺销毁,是两项不同契约。销毁登记源码
Ticket 实现 DisposableBean,destroy() 设置 closed 并增加计数。实验保留所有 prototype 引用,关闭 Context 后检查它们仍未关闭,再由所有者显式调用 destroy。这里的 Ticket 没有真实连接,状态用于验证回调归属;如果改成持有文件或连接的对象,应将同样的所有权边界落实到 close,并在 finally 或 try-with-resources 中释放。
模式提炼:取得一次,明确一个所有者
可以把关系写成 owner → acquire(resource) → use → release。普通 prototype、按需创建的临时解析器和手动打开的文件都需要这种配对。容器替应用完成构造不代表容器知道业务何时使用完毕。若业务必须跨多个调用保留资源,所有者应保留同一个实例并明确结束动作,而不是在清理时再从 Provider 取得另一个实例。
scoped proxy 复用代理,按请求查找目标
@Scope(value = "request", proxyMode = TARGET_CLASS) 使公开 Bean 暴露代理,实际目标有独立的 scoped 定义。ScopedProxyFactoryBean 内部使用 SimpleBeanTargetSource;该 TargetSource 取得目标时调用 BeanFactory.getBean。类代理默认值、代理实例缓存和目标名称可从工厂实现中直接核验。scoped proxy 工厂
request 分支最终由 AbstractRequestAttributesScope.get() 调用 RequestContextHolder.currentRequestAttributes()。当前请求属性已有对象便返回;未找到才调用 objectFactory 创建,再保存到请求属性中。因此代理本身能提前注入到 singleton,不要求启动时已经存在 HTTP 请求。第一次真正调用业务方法时,目标范围才必须可用。请求范围查找
实验取得同一个 requestTicket 代理,先在未绑定 RequestAttributes 时调用 id(),得到 ScopeNotActiveException。随后绑定第一个 ServletRequestAttributes,同一代理两次 id() 相同。完成并解绑第一个请求后,绑定第二个请求,相同代理的 id() 变了。这个结果比代理类名包含 CGLIB 更有解释力:代理身份不变,目标身份随作用域改变。
Ticket 的 id() 是可被类代理拦截的 public 方法。直接读取代理对象的字段,或把方法改成不能被覆盖的 final 方法,都不等价于这条目标解析路径。作用域代理建立在代理机制之上,不会令所有 Java 访问自动转发到当前目标。
请求结束怎样触发销毁
创建 request Ticket 时,容器把 DisposableBeanAdapter 交给 RequestScope,后者登记到当前 RequestAttributes。实验在 finally 中调用 requestCompleted(),随后清理 RequestContextHolder。第一个请求完成后销毁计数为一,第二个完成后为二;关闭整个 Context 不会替代这两个请求结束动作。
上游 RequestScopeTests.getFromScope() 和 destructionAtRequestCompletion() 也分别断言请求属性中的对象身份与完成时销毁。阅读这些测试能确认固定版本期望的范围;本文的 15 条本地断言独立运行,没有执行上游 Gradle 测试套件。上游请求作用域测试
本例手动绑定只是把 Servlet 集成中的生命周期拆开观察。真实应用由 DispatcherServlet、监听器或过滤器等组件建立相关边界。线程池任务不能仅凭“是这个请求提交的”就访问原线程绑定属性;异步传播与请求终止的竞争需要额外设计。这里证明的是手动绑定条件下的 Spring 生命周期,没有证明真实服务器的异步分派清理行为。
singleton 无法使复合更新自动变为原子操作
反例把 UnsafeCounter 注册成 singleton。两个线程分别读取 count,再在 CyclicBarrier 会合,最后分别写入“读到的旧值加一”。屏障要求两次读取都完成后才能发生写入,因此初值为零时,两个线程最终都写一。count 即使使用 volatile,结果也仍是一。
1 | |
volatile 的可见性规则不把多个动作合并成不可分割操作。这个反例关注丢失更新,屏障用于固定交错而非扩大碰撞概率;不存在依赖“多跑几次也许遇到”的测试假设。每个 await/get 都有五秒截止,线程池在 finally 中 shutdownNow 并等待终止。Java 21 内存模型与同步
模式提炼:分别验证数量与状态一致性
实例数回答共享范围,原子性回答状态更新是否能被并发交错破坏。无状态服务、不可变配置和原子计数器可能都采用 singleton,但安全原因分别来自无可变共享状态、不可变约束和原子操作,不能归因到容器只创建一次。
运行、反例题与练习
在实验工程目录使用 JDK 21 运行:
1 | |
完整输出保存在 evidence/10/local-20261002/run.txt,15 条 CHECK PASS,exit-code.txt 为 0。身份、销毁、并发终态分别有独立断言,不使用只有打印而无失败出口的演示。请求 thread state 最终为 null,计数实验的线程池已终止。
反例题:把 prototypeTicket 改为 singleton,但保持 Provider 注入不变,哪条断言首先不成立?两次普通 getBean 身份不同的断言先失败。Provider 路径随后也应得到相同实例。这说明声明注入为 Provider 不能自行定义生命周期。
可执行练习:在副本中把计数更新换成 AtomicInteger.incrementAndGet,保留两线程屏障和有界 Future.get,将期望改为二。再把请求完成操作移动到两次 id() 之间,记录失活范围被继续访问时的异常,finally 仍必须解绑线程状态。前一个练习验证更新原子性,后一个验证结束边界,不能用其中一个结果替代另一个。
| 排查入口 | 判据 | 对应修改位置 |
|---|---|---|
| prototype 看起来只有一个 | 调用是否重新请求容器 | 持有引用与 Provider 调用路径 |
| 代理存在但调用失败 | 当前范围是否已绑定 | 请求入口与任务线程 |
| close 后资源仍存活 | 谁登记销毁,谁拥有实例 | prototype 所有者或 scope 回调 |
| singleton 计数异常 | 复合操作是否允许交错 | 状态同步,而非 Bean 数量 |
参考资料
- 官方 Bean Scopes 契约,6.2 在线页会更新,内部行为以固定 SHA 为准。
- SimpleBeanTargetSource 固定源码。
- ServletRequestAttributes 固定源码。

