近日,大量 Spring 开发者反映,在将 @ManyToMany 关系的实体属性类型从 List<T> 改为 Set<T> 后,应用在生产环境中频繁抛出 ConcurrentModificationException。该异常不仅导致事务回滚,还引发数据不一致等问题。截至发稿,该话题已在 Stack Overflow 及官方 GitHub 讨论区引发广泛关注,Spring 团队尚未发布正式补丁。本文将深度解析问题成因,并提供已验证的解决方案。

问题背景:看似简单的类型替换

在 JPA(Java Persistence API)开发中,@ManyToMany 关系常用于表示多对多关联,如“用户-角色”或“文章-标签”。传统做法中,开发者惯用 List<T> 维护关联集合,因其允许重复元素、保持有序,且与 JPA 的默认持久化策略兼容良好。然而,部分开发者出于业务逻辑去重需求或 Java 最佳实践,将属性类型改为 Set<T>(如 HashSetLinkedHashSet)。这一看似无害的修改,却在运行时触发了 ConcurrentModificationException

异常复现:典型场景与错误日志

某电商系统开发团队在重构用户权限模块时,将实体类中的 List<Role> roles 改为 Set<Role> roles,并保留 @ManyToMany 注解及 CascadeType.PERSISTCascadeType.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

触发场景的具体路径

  1. 当通过 entity.getRoles().add(newRole)removeRole 修改关联集合后,Hibernate 在事务提交前执行脏检查。
  2. 对于 Set,Hibernate 的 PersistentSet 内部维护一个迭代器用于同步关联表数据(如 many-to-many 中间表)。
  3. 在迭代过程中,若 hashSetremove() 操作导致结构改变,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 的团队,需注意以下三点:

  1. 重写 hashCode 与 equals:确保实体类正确实现这两个方法,避免因 equals 计算导致集合操作触发额外迭代。
  2. 使用 immutable 策略:修改集合时,创建新 Set 并整体赋值,而非调用 add/remove。例如:
    entity.setRoles(Set.copyOf(oldRoles));
  3. 关闭自动脏检查:在 @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 的更新日志,后者已修复部分脏检查时的迭代冲突。本刊将持续跟踪报道,第一时间发布技术更新。