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
2
3
线程 A:读取 0 ── 等待屏障 ── 写入 1
线程 B:读取 0 ── 等待屏障 ── 写入 1
主线程:等待两个 Future 完成 ── 断言 count == 1

volatile 的可见性规则不把多个动作合并成不可分割操作。这个反例关注丢失更新,屏障用于固定交错而非扩大碰撞概率;不存在依赖“多跑几次也许遇到”的测试假设。每个 await/get 都有五秒截止,线程池在 finally 中 shutdownNow 并等待终止。Java 21 内存模型与同步

模式提炼:分别验证数量与状态一致性

实例数回答共享范围,原子性回答状态更新是否能被并发交错破坏。无状态服务、不可变配置和原子计数器可能都采用 singleton,但安全原因分别来自无可变共享状态、不可变约束和原子操作,不能归因到容器只创建一次。

运行、反例题与练习

在实验工程目录使用 JDK 21 运行:

1
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter10

完整输出保存在 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 数量

参考资料