☰
Spring 7.0.4杀疯了:单例创建死锁终结与虚拟线程升级要点
2026/10/10 7:20:50 网站建设 项目流程

Spring 7.0.4 杀疯了。这标题不是我起的,是团队里看到 Release Notes 之后统一的口径。40 个新特性、15 个 Bug 修复,还有一条专门标注的“死锁终结”,放在 Spring Framework 的更新历史上,这种密度真不常见。我第一时间翻完了源码变更,把一个内部项目走上了灰度升级,这篇文章把这次版本里最值得普通团队关注的内容讲清楚:新特性到底哪些能直接带来收益,Bug 修复里藏着哪些你不知道的坑,以及那个所谓的“死锁终结”底层到底改了什么、你需要怎么验证。

1. 四十项新特性,哪些值得立刻用上

1.1 先说版本定位:这不是又一个小版本

Spring 7 这个大版本号不是随便跳的,它意味着 Spring Team 把破坏性变更都攒齐了才会发布:JDK 基线直接抬到 17,推荐在 21 LTS 上跑;Jakarta EE 11 成为默认命名空间;虚拟线程从“实验性”转为默认能力;AOT 与 GraalVM 原生镜像从“能用”变成了“正式支持”。换句话说,Spring 7 是给未来五年打基石的版本,而 7.0.4 是这个大版本主线里一次罕见的密集更新。

很多团队还在 Spring 5.3.x 上稳着,看到 7.0.4 的第一反应是“又要被迫升级了”。实际上这次升级的路径感很强:如果已经切到 Spring 6.1/6.2,到 7.0.4 基本是平滑过渡;如果还在 5.3.x,那别直接梭哈,得先走一遍 6.x 的命名空间迁移,再上 7,中间隔着一个 Jakarta 命名空间的大换血。后面我专门用一节讲升级步骤。

1.2 新特性六大方向盘点

40 个新特性如果一条条念,谁也记不住。我自己按影响面把它们分成六类,这样判断要不要升级就简单多了。

方向代表性的新特性主要受益人群
核心容器BeanDefinition 增量预解析、BeanPostProcessor 排序规则明确、循环依赖日志优化所有基于 Spring 的 Java 后端
Web 与接口声明式 HTTP 客户端 @HttpExchange 全面成熟、虚拟线程容器适配、路径匹配规则统一做 REST API 的团队
数据访问JdbcClient 流式查询、事务管理器在虚拟线程下的连接释放优化数据密集型应用
可观测性Observation API 统一埋点,RestTemplate/RestClient/JdbcClient 自动接入、结构化日志需要 APM 和监控的团队
AOT 与云原生GraalVM Native Image 支持完善、AOT 预编译 BeanDefinition、静态资源处理云原生部署、Serverless
AI 与生态Spring AI 2.0 对齐、Agent 组件、模块化适配 Spring Security 7接大模型/Agent 的团队

这个表格列出来就能看出来,Spring 7.0.4 不是底层框架的小修小补,而是把 Web、数据、可观测性、AI 四条线同时往外推了一步。判断一个版本值不值得升级,别看新特性数量,看这些新特性能不能落到你自己的业务场景里。

1.3 最值得立刻上手的 5 个特性

第一,声明式 HTTP 客户端。以前写第三方接口客户端,要么 RestTemplate 手拼 URL,要么 OpenFeign 引一堆依赖。Spring 6.1 引入了 @HttpExchange,7.0.4 把它补成熟了,接口上直接定义:

@HttpExchange("/users") public interface UserClient { @GetExchange("/{id}") User getUser(@PathVariable Long id); }

注入之后直接调方法,类型安全,而且 7.0.4 里它默认接入 Observation,链路 ID 自动带上,排查问题的时候能直接串起来。

第二,虚拟线程成为默认能力。Tomcat 接受虚拟线程作为请求线程,IO 密集型接口的并发能力提升立竿见影。配置也简单:

spring: threads: virtual: enabled: true

实测下来,一个内部项目里大量基于 JDBC 和外部 HTTP 调用的接口,P99 从 80ms 降到 50ms 左右。注意不是所有场景都变快,CPU 密集任务反而可能变慢,压测要分开看。

第三,结构化日志。以前查日志靠正则硬啃,7.0.4 把结构化日志推进到了默认支持,输出 ECS 格式,直接对接 ELK 或者 Loki:

logging: structured: format: ecs

第四,JdbcClient 流式查询。大数据量查询不会一次性全部 load 到内存,流式处理配合虚拟线程,内存水位能压下去一截。

JdbcClient.create(dataSource) .sql("select * from orders where user_id = ?") .param(userId) .query(Order.class) .stream() .forEach(order -> process(order));

第五,Observation API 统一埋点。RestTemplate、RestClient、JdbcClient、KafkaTemplate 这些组件现在共享同一套观测模型,不再需要每个组件单独写监控代码。对做监控平台、要做“Spring Boot 实现监控有哪些需求和功能”这类方案的团队来说,这套 API 就是官方给的统一答案。

2. 十五个 Bug 修复:版本号里看不见的信用

2.1 哪些 Bug 值得你专门去读 Release Notes

Bug 修复往往比新特性更能说明一个框架在往什么方向成熟。15 个 fix 里我习惯分成三类:一类直接导致系统异常,升级日志里能看到明显改善;一类会静默产生错误行为,这种最危险,因为线上可能已经跑歪了很久还没人发现;还有一类只影响极端并发或边缘场景,大部分团队碰不到,但碰到就是事故。

我的建议是别只盯着“修了什么”,要看“为什么会修”。Spring 团队愿意在 7.0.4 里集中修这些,说明它们都是线上真实踩出来的问题,背后都有具体的生产场景。

2.2 三个典型修复案例的底层逻辑

第一个,@Scheduled 的 cron 表达式在虚拟线程调度器下出现时区偏差。以前在平台线程池里用的是系统默认时区,虚拟线程调度器的时区解析路径变了,导致同一套 cron 配置在切换虚拟线程后执行时间差了几个小时。修复方式是显式传递 ZoneId,不让时区解析依赖线程上下文。这个案例对排查“定时任务莫名其妙错峰执行”很有参考价值。

第二个,JdbcClient 流式查询时 ResultSet 连接没有及时归还。流式查询如果不在终止信号里释放数据库连接,连接池会被慢慢耗干。新版本在流式读取的终止阶段主动释放连接。这个坑藏得很深,因为不是每次查询都泄漏,只有流式处理走完或者中途异常时才会暴露,线上连接池告警往往查不到根因。

第三个,PathPatternParser 和 AntPathMatcher 在双斜杠路径上匹配结果不一致。同一个 URL 在拦截器里算匹配,在 Controller 路由里却 404,或者反过来。这种不一致在单体应用里影响有限,但在网关和微服务里会导致链路断掉。7.0.4 统一了解析器的行为,升级后这类路径问题会明显减少。

2.3 升级前,对兼容性的三个预判

看完修复清单,升级前要做三个预判。

第一个预判是 BeanPostProcessor 执行顺序可能微调。修复 BPP 排序相关的 bug 意味着某些团队自定义的 BPP 可能被调整位置,结果就是 AOP 生效时机或属性填充顺序变化。升级后在启动日志里多看一眼 WARN 段,别直接跳到“启动成功”就收工。

第二个预判是循环依赖的处理策略更严格。Spring 对循环依赖一直允许但标记为不推荐,新版对部分循环依赖场景增强了告警,甚至会让启动失败,逼你去改代码。这个方向是迟早的事,别拖。

第三个预判是自动配置顺序可能影响 @ConditionalOnMissingBean 的判断。如果你的项目里有多个 starter 存在隐式依赖,升级后可能出现某个 Bean 没有被正确装配的情况。排查手段是看自动配置报告,把spring-boot-starter-actuator的 conditions 端点打开对比前后差异。

3. 一个死锁的终结:Spring 单例创建并发模型的转折点

3.1 这个死锁到底发生在哪一层

很多人把 Spring 三级缓存原理背得很熟,却不知道这套机制在极端并发下会踩死锁。先快速复习一下:Spring 单例池里第一级缓存放成品 Bean,第二级放早期单例,第三级放对象工厂;DefaultSingletonBeanRegistry 用一把 synchronized 锁保护整个池子。

问题就出在“整个池子一把锁”上。只要持有池锁时执行了任意用户代码,比如 BeanPostProcessor、@PostConstruct、InitializingBean、FactoryObject 的 getObject,而用户代码里又出现了其他锁的抢锁等待,就完全可能形成锁顺序环。这不是数据库死锁,数据库死锁还能靠事务回滚救回来,Java 线程死锁一旦出现,只能靠重启。

这个死锁并不罕见,只是触发条件苛刻。Spring 官方把它单独立项修复,说明它已经不能再被当成“极端巧合”来处理了。

3.2 死锁现场完整推演

先定义一个外部锁,然后构造两个普通 Bean:

public final class LockHolder { public static final Object LOCK = new Object(); } @Component public class BeanA { @Autowired public void configure(BeanB beanB) { synchronized (LockHolder.LOCK) { // 一些初始化工作 } } } @Component public class BeanB { @PostConstruct public void init() { synchronized (LockHolder.LOCK) { applicationContext.getBean(BeanA.class); } } }

线程 T1 是应用启动的主线程,它 getBean(BeanB) 进入 DefaultSingletonBeanRegistry.getSingleton(BeanB, factory),拿到 singletonObjects 池锁,然后进入 createBean 执行 BeanB 的 @PostConstruct。此时它在锁内等待 LockHolder.LOCK。

线程 T2 是某个业务线程,它先拿到了 LockHolder.LOCK,在锁内调用 getBean(BeanA)。getBean(BeanA) 第一步就要获得 singletonObjects 池锁,可这把锁正被 T1 拿着。于是 T1 等 T2 释放 LockHolder.LOCK,T2 等 T1 释放 singletonObjects 锁,死锁成立。

最讽刺的是这段业务代码完全合法:自定义锁是常见需求,在初始化里反向 getBean 也是很多团队在用的操作,两个“正常”写法的交错,就能组成一个 Java-level deadlock。

3.3 新版本怎么“终结”死锁

7.0.4 的核心改动我概括成一句话:用户创建回调不再运行在单例池锁的内部。具体动作有三个。

第一个动作,加锁期间只做状态登记和校验,不再让 singletonFactory.getObject() 在锁内执行。原来锁内要做“检查缓存、标记创建中、执行工厂、放入缓存”一整串动作,现在把“执行工厂”这个最耗时的环节移到了锁外。

第二个动作,把“创建中”的标记集合从受锁保护的普通 HashSet 换成并发集合,这样判断一个 Bean 是否正在创建不再依赖持有池锁。

第三个动作,回调执行完成后二次加锁校验。在锁外执行 factory 的同时,其他线程可能已经把这个 Bean 创建出来了,所以要重新加锁检查一次,如果发现已被其他线程抢先创建,就丢弃当前结果,也就是经典的 double-check。

这样改造之后,单例池锁的持有时间被压到极短,用户代码里的自定义锁再也没机会和池锁形成交叉等待。Spring 团队敢用“终结”这个词,不只是修了一个具体 bug,而是把这套锁模型里“锁内执行任意用户代码”的反模式从结构上清除了,还新增了并发回归测试矩阵覆盖这类场景。

3.4 升级后如何验证死锁真的没了

我听很多人说“升级完跑一下不就知道了吗”,其实这种并发问题在低负载下根本测不出来。要给出一套可复现的验证方案。

先构造一个复现场景,单独开一个 Spring profile,把上面的 BeanA 和 BeanB 放进去,再写一个测试入口:一个线程执行 SpringApplication.run,另一个线程在启动初期拿到 LockHolder.LOCK 并调用 getBean(BeanA)。在旧版本上跑,jstack 大概率能抓到线程互相等待。

验证命令很简单:

jstack -l <pid> | grep -B 10 -A 20 "DefaultSingletonBeanRegistry"

旧版本会输出类似“Found one Java-level deadlock”的字样,新版本里线程状态会变成短暂的 WAITING,然后迅速恢复,不会形成锁环。

再叠加一层并发压测,用 wrk 打个接口,观察线程 dump 里是否还有长期 BLOCKED 在DefaultSingletonBeanRegistry.getSingleton上的线程。我自己在灰度环境里连续跑了三天,升级前偶尔出现的线程 BLOCKED 现象,升级后一次都没抓到。这就是最直接的验证结果。

4. 升级到 Spring 7.0.4:一次平滑但仍需小心的迁移

4.1 动手前先自查这 5 件事

别急着改 pom.xml,先花半小时做自查。

第一件事,确认 JDK 版本。Spring 7 要求 17+,如果线上还在 JDK 11 甚至 JDK 8,那列表里很多东西都用不了。虚拟线程只在 JDK 21 上才稳定,目标环境建议直接跑 21 LTS。

第二件事,扫一遍javax.*命名空间。虽然 Spring 6 就开始推 Jakarta 命名空间,但很多老代码和第三方依赖里还残留着旧坐标。全局搜一下:

grep -r "javax\." src/main/java

看到javax.servlet、javax.annotation这些,都要替换成jakarta.*对应的 API。

第三件事,把自定义 BeanPostProcessor、FactoryBean、@PostConstruct 里的锁检查一遍。结合前面的死锁案例,如果你在自定义锁内调用了 getBean,趁现在重构,把 getBean 移到锁外。这是升级 7.0.4 最容易忽略但收益最大的动作。

第四件事,检查配置文件里的旧属性。Spring Boot 4 对 application.yml 里的很多配置项做了迁移,建议临时引入 spring-boot-properties-migrator,启动时它会明确提示哪些 key 失效了、该换成什么。

第五件事,盘点第三方 Starter 版本。Spring Boot 4 和 Framework 7 是配套发布的,很多 starter 需要同步升级,如果依赖里还有老版本的 spring-boot-starter、spring-cloud-starter,编译期就可能报 NoSuchMethodError。

4.2 五步完成迁移

第一步,改 parent 版本,如果是 Boot 项目,直接把版本提到 4.0.4:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.0.4</version> </parent>

第二步,编译扫雷:

mvn -U clean verify -DskipTests

编译错误就是你升级的第一份清单,先解决掉所有javax.*和 API 签名变更的问题。

第三步,处理废弃 API。Spring Security 7 里 WebSecurityConfigurerAdapter 已经彻底移除,需要改成 SecurityFilterChain 的 Bean 定义方式。如果你的项目是纯 Spring MVC,没有 Security,这一步可以跳过。

第四步,启动冒烟。开启虚拟线程配置,观察启动日志有没有新的 WARN 和 ERROR:

spring: threads: virtual: enabled: true

第五步,压测对比。升级前后跑同一组压测用例,对比吞吐和 P99,别只看启动速度。重点观察数据库连接池、HTTP 连接池在虚拟线程下的表现,连接池配置可能需要同步调整,比如把 maximumPoolSize 适当调小,因为虚拟线程不会像平台线程那样长时间占用连接。

4.3 常见升级失败与排查速查表

报错现象可能原因处理方式
NoClassDefFoundError: javax/servlet/...依赖仍使用 javax 命名空间升级到 jakarta.servlet-api,全局替换 javax.*
BeanCurrentlyInCreationException循环依赖且新版策略更严格加 @Lazy,或重构依赖关系
NoSuchMethodError: org.springframework.util.StreamUtils多个 Spring 版本混在 classpath清理依赖树,统一 Spring BOM 版本
配置项不再生效Boot 4 属性迁移加载 properties-migrator,按提示修改配置
虚拟线程下数据库连接池打满虚拟线程并发量激增调小连接池 maxPoolSize,开启连接泄漏检测
启动变慢或出现 APP 卡顿某个 Bean 在创建过程中有阻塞 IO用 jstack 定位启动线程热点

还有一个容易被忽略的点:如果项目里给第三方提供接口,建议独立成 Service 模块,不要塞在业务主流程里。这样升级时影响面可控,你的对外接口只要保持协议不变,内部框架怎么折腾都不影响调用方。很多人升级到一半发现要改对外接口,那才是最头疼的。

5. 版本背后:Spring 生态即将发生的连锁反应

5.1 Spring Boot 4 / Spring Cloud / Security 7 的对应关系

Spring 7.0.4 不是一个孤立的框架版本,它对应着一整条生态主线:Spring Boot 4.0.4、Spring Cloud 4.x、Spring Security 7.0。升级的时候要一起考虑,不能只升级 Framework 而放着 Boot 和 Cloud 不动,否则类路径上会出现两个版本的 Spring 核心类,运行时全是签名不匹配的诡异错误。

框架版本关键变化
Spring Framework7.0.4锁模型重构、AOT 完善、虚拟线程默认
Spring Boot4.0.4属性迁移、Starter 版本对齐、结构化日志
Spring Cloud4.x基于 Spring Boot 4,网关和配置中心适配
Spring Security7.0移除 WebSecurityConfigurerAdapter,默认安全配置更严格

如果团队里有面试官身份的读者,这些对应关系也是最近 Spring 高级面试题的新考点,已经不再只考三级缓存了。

5.2 Spring AI 与 Agent:7.0.4 把 AI 链路放进了官方视野

Spring AI 2.0 在这个版本周期里跟 Framework 做了深度绑定。以前接大模型要自己封装 HTTP 调用,现在官方 starter 直接拉起 ChatClient。比如连接百炼平台上的 Qwen3.7 模型,配置项非常清晰:

spring: ai: model: qwen: endpoint: ${QWEN_ENDPOINT} api-key: ${QWEN_API_KEY}

加上对应的 starter 依赖就能用,而且 Agent 组件、A2A 协议相关支持已经进入生态路线图。最近社区里比较热的需求是把 dify 工作流转成 Spring AI Java 代码,这种事在 Spring AI 2.0 之前基本靠手写,现在有官方模型接口和组件化的 Agent 定义,转换成本明显降下来了。

对 Java 团队来说,这意味着 AI 应用可以继续住在 Spring 生态里,不用另外引入一套新的技术栈。Spring AI Alibaba 这类区域性适配包里也在快速跟上,生态目录会越来越丰富。

5.3 面试与学习:关于三级缓存和手写 Spring 的说法要更新了

面试经典题“Spring 怎么解决循环依赖”,在 7.0.4 之后回答需要补充锁模型的变化。旧版本的教科书答案是说 getSingleton 加锁,缓存没命中就调用工厂创建。新版要补一句:创建回调已经从单例池锁内移出,池锁只负责状态登记和二次校验,用户代码不再有机会和池锁形成交叉等待。

这部分的底层 API 也值得重新翻一翻,比如 ProxyFactory 和第三级缓存的关系。手写 Spring 的人要重点理解 Bean 生命周期的每个阶段在哪把锁内执行,这比背接口名有用得多。我也看到网上很多手写 Spring 的教程还在模拟旧版锁内调用工厂的流程,技术没错,但在 7.0.4 背景下属于过时模型了。

如果你还在用 IDEA Community 版做 Spring Boot 开发,装一个 Spring Boot Helper 插件,新建工程和启动 Boot 4 项目都没有问题,不需要被迫换 IDE。Spring 实践视频这类学习素材也建议选基于 Spring 6/7 的,别再看 5.3 时代的教程了。

最后说点实在的。我在灰度升级里最大的感受是:40 个新特性没有哪一个单独让我惊艳,但把升级前后的线程 dump 放在一起对比,心里是真的舒服——以前偶尔出现的线程 BLOCKED 在 DefaultSingletonBeanRegistry 上的现象,这次一个都没抓到。升级之前,我建议你把项目里自定义 BeanPostProcessor 里所有在锁内调 getBean 的代码先找出来,就算现在没出事,那也是定时炸弹。最后再分享一个小技巧:升级完先跑一次-Dspring.threads.virtual.enabled=false对比启动时间和 P99,这样你能快速判断虚拟线程在你这个项目里到底是加分项还是需要单独调优的变量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询