Spring Boot @ManyToMany实体关联引发ConcurrentModificationException问题排查
我来帮你分析下这个问题:你在Spring Boot里搭建REST API时,用@ManyToMany关联了Book和Category实体,虽然写了DTO和映射服务,但始终碰到ConcurrentModificationException。从异常栈能看出来,问题出在BookMapperService遍历Book的categories集合转DTO的时候——Hibernate的PersistentSet(懒加载集合的实现类)在加载元素时被并发修改了。
问题根源
Book实体的categories集合用了FetchType.LAZY懒加载,当你在Mapper里调用book.getCategories().stream()时,Hibernate会触发懒加载去数据库查询关联的Category数据。而Hibernate的PersistentSet在初始化加载元素的过程中,如果你同时对它做遍历操作,就会触发集合的并发修改检查,抛出这个异常。你之前尝试复制集合,但其实在调用stream()的时候已经触发了懒加载,此时Hibernate正在修改集合的内部状态,复制操作和加载操作产生了冲突。
解决方案
这里给你几个可行的解决办法,按推荐程度排序:
1. 提前用JOIN FETCH加载关联集合(最推荐)
修改你的BookRepository,添加一个带JOIN FETCH的查询方法,提前加载categories集合,避免懒加载时的并发问题:
import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; public interface BookRepository extends CrudRepository<Book, Long> { @Query("SELECT b FROM Book b JOIN FETCH b.categories") List<Book> findAllWithCategories(); }
然后在Controller里调用这个方法代替原来的findAll():
@Transactional(readOnly = true) @GetMapping("/") public ResponseEntity<List<BookResponseDto>> getBooks() { List<Book> books = this.bookRepository.findAllWithCategories(); List<BookResponseDto> bookResponseDtos = books.stream() .map(this.bookMapperService::toDataTransferObject) .toList(); return new ResponseEntity<>(bookResponseDtos, HttpStatus.OK); }
这样查询出来的Book对象的categories集合已经是加载完成的普通HashSet,不会有Hibernate懒加载集合的并发修改问题。
2. 手动初始化懒加载集合
在Mapper里先调用Hibernate的initialize()方法提前加载categories集合,再进行遍历转换:
import org.hibernate.Hibernate; @Service public class BookMapperService { private final CategoryMapperService categoryMapperService; @Autowired public BookMapperService(CategoryMapperService categoryMapperService) { this.categoryMapperService = categoryMapperService; } public BookResponseDto toDataTransferObject(Book book) { BookResponseDto bookResponseDto = new BookResponseDto(); bookResponseDto.setId(book.getId()); bookResponseDto.setTitle(book.getTitle()); bookResponseDto.setAuthor(book.getAuthor()); bookResponseDto.setDescription(book.getDescription()); // 手动初始化懒加载的categories集合,确保加载完成后再遍历 Hibernate.initialize(book.getCategories()); Set<CategoryResponseDto> categoryDtos = book.getCategories() .stream() .map(this.categoryMapperService::toDataTransferObject) .collect(Collectors.toSet()); bookResponseDto.setCategories(categoryDtos); return bookResponseDto; } // 其他方法不变 }
这个方法适合不想修改Repository查询的场景,需要确保操作在事务内进行(你的Controller已经加了@Transactional,所以没问题)。
3. 调整实体集合初始化方式(不推荐)
你可以尝试在Book实体里把categories的初始化改成普通HashSet,但注意Hibernate在懒加载时还是会把它替换成PersistentSet,所以这个方案只能作为临时 workaround,不如前两个稳定:
@ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "book_category", joinColumns = @JoinColumn(name = "book_id"), inverseJoinColumns = @JoinColumn(name = "category_id")) @JsonManagedReference private Set<Category> categories = new HashSet<>(); // 原本的初始化是对的,但这个方案作用有限
额外注意点
- 你用的
@JsonManagedReference和@JsonBackReference是用来解决JSON序列化的循环引用问题,和本次的并发修改异常无关,当前DTO的设计(CategoryResponseDto里没有Book引用)也不会引发循环序列化,所以这部分没问题。 - Controller上的
@Transactional(readOnly = true)是正确的,保证了懒加载操作在事务内执行,避免了懒加载初始化异常。
备注:内容来源于stack exchange,提问作者Kyle Myre

