You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot @ManyToMany实体关联引发ConcurrentModificationException问题排查

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.15 03:18:13