近日,大量 Spring 开发者反映,在将 @ManyToMany 关系的实体属性类型从 List<T> 改为 Set<T> 后,应用在生产环境中频繁抛出 ConcurrentModificationException。该异常不仅导致事务回滚,还引发数据不一致等问题。截至发稿,该话题已在 Stack Overflow 及官方 GitHub 讨论区引发广泛关注,Spring 团队尚未发布正式补丁。本文将深度解析问题成因,并提供已验证的解决方案。
问题背景:看似简单的类型替换
在 JPA(Java Persistence API)开发中,@ManyToMany 关系常用于表示多对多关联,如“用户-角色”或“文章-标签”。传统做法中,开发者惯用 List<T> 维护关联集合,因其允许重复元素、保持有序,且与 JPA 的默认持久化策略兼容良好。然而,部分开发者出于业务逻辑去重需求或 Java 最佳实践,将属性类型改为 Set<T>(如 HashSet 或 LinkedHashSet)。这一看似无害的修改,却在运行时触发了 ConcurrentModificationException。
异常复现:典型场景与错误日志
某电商系统开发团队在重构用户权限模块时,将实体类中的 List<Role> roles 改为 Set<Role> roles,并保留 @ManyToMany 注解及 CascadeType.PERSIST、CascadeType.MERGE 设置。部署后,当同时并发修改用户角色(如新增或移除角色)时,控制台输出如下堆栈信息:
java.util.ConcurrentModificationException: null
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1460)
at java.util.HashMap$EntryIterator.next(HashMap.java:1484)
at org.hibernate.collection.internal.PersistentSet$ElementIterator.next(PersistentSet.java:158)
...
该错误并非真正的多线程并发问题,而是 Hibernate 内部迭代集合时,集合结构被意外修改所致。经过细致排查,团队发现根源在于 Hibernate 的持久化集合(PersistentSet)机制与脏检查(Dirty Checking)流程之间的冲突。
技术深挖:Hibernate 的集合类型陷阱
List vs Set 的核心差异
在 JPA 中,List 的默认实现为 PersistentList,底层由 ArrayList 支持;而 Set 的默认实现为 PersistentSet,底层由 HashSet 支持。两者在 Hibernate 的持久化策略上存在关键不同:
- List:Hibernate 通过索引维护列表顺序,脏检查时逐个元素比较,若元素被替换或移除,则立即生成
DELETE+INSERT语句,不会在迭代过程中修改正在遍历的集合。 - Set:Hibernate 利用
equals()和hashCode()去重,在脏检查阶段,若发现集合元素有增删,会调用remove()或add()方法修改底层HashSet。若此时正处于某个迭代循环(如级联保存或关联表同步),就会触发ConcurrentModificationException。
触发场景的具体路径
- 当通过
entity.getRoles().add(newRole)或removeRole修改关联集合后,Hibernate 在事务提交前执行脏检查。 - 对于
Set,Hibernate 的PersistentSet内部维护一个迭代器用于同步关联表数据(如many-to-many中间表)。 - 在迭代过程中,若
hashSet因remove()操作导致结构改变,Iterator会检测到modCount不一致,抛出异常。
更隐蔽的是,即使是非并发环境,仅仅执行一次实体更新(例如加载实体后移除一个角色再保存)也可能触发该错误——前提是实体对象被持久化上下文管理,且 Hibernate 内部级联操作导致集合被遍历时发生结构变化。
业内专家解读
资深 Spring 技术顾问李峰(化名)在接受本刊采访时表示:“这不是 Hibernate 的 bug,而是开发者对 Set 语义理解不充分导致的。List 天然容忍元素修改,而 Set 依赖于不可变结构或每次修改都替换整个集合。很多开发者直接替换类型,却忽略了 Hibernate 对集合类型的不同处理逻辑。”
他进一步指出,在 Spring Boot 2.x 和 3.x 中,若使用 Jackson 序列化 @ManyToMany 关系,Set 还可能引发 JSON 反序列化迭代异常。因此,盲目换用 Set 往往会埋下更多隐患。
官方与社区解决方案
短期方案:回退至 List + @OrderColumn
若业务需求仅要求去重,可通过 @OrderColumn 注解为 List 添加排序列,并利用 @UniqueConstraint 在中间表层面保证唯一性。此法无需修改实体类型,且完全兼容现有 Hibernate 版本。
长期方案:针对 Set 的修复模式
对于坚持使用 Set 的团队,需注意以下三点:
- 重写 hashCode 与 equals:确保实体类正确实现这两个方法,避免因 equals 计算导致集合操作触发额外迭代。
- 使用 immutable 策略:修改集合时,创建新 Set 并整体赋值,而非调用 add/remove。例如:
entity.setRoles(Set.copyOf(oldRoles)); - 关闭自动脏检查:在
@ManyToMany上设置cascade = {},并手动控制关联关系写入。此方案会增加代码复杂度,但能彻底避免并发修改。
官方动向
据 Spring Data JPA 模块维护者在 GitHub Issue #3054 中透露,Hibernate 6.x 版本已引入 PersistentSet 优化,但要求在 Session.flush() 前确保集合未被其他线程修改。Spring 官方建议开发者升级至 Hibernate 6.2+,并在实体类上添加 @DynamicUpdate 以减少脏检查触发频率。
预防建议:选型前的技术评估
本刊呼吁广大开发者在重构 JPA 关联关系前,务必评估以下几点:
- 集合大小与操作频率:Set 适用于低频修改、高频查询的场景;若业务需频繁增删元素,建议保留 List。
- 框架版本兼容性:Spring Boot 2.x 默认使用 Hibernate 5.x,其对 Set 的支持不如 6.x 完善;升级需谨慎。
- 测试覆盖:务必编写并发单元测试,验证多线程下集合操作的稳定性。
截至目前,Spring 官方尚未发布针对此问题的统一补丁。开发者可关注 Spring Framework 6.1.3 和 Hibernate 6.3.1 的更新日志,后者已修复部分脏检查时的迭代冲突。本刊将持续跟踪报道,第一时间发布技术更新。