List与Set大小不匹配:HashSet.size()返回值异常问题
关于HashSet size()与实际元素数不符的问题排查与解决
这种诡异的size不一致问题我之前在生产环境碰到过好几次,大概率是并发安全问题在搞鬼——HashSet本身不是线程安全的容器,当多个线程同时对它进行读写操作时,内部的哈希表结构很容易被破坏,导致size计算错误、元素丢失或者重复统计的情况,而且这种问题确实很难在单线程的独立测试环境复现。
核心原因分析
HashSet的底层依赖HashMap实现,而HashMap没有任何同步机制:
- 当多个线程同时执行
add()、remove()这类修改操作时,内部的size变量、modCount变量会出现竞态条件,导致读取到的size值和实际元素数量不符 - 更严重的情况是,并发修改会破坏哈希表的链表/红黑树结构,比如出现链表环、元素覆盖等问题,这时候遍历集合看到的元素数和
size()返回值就会完全对不上,甚至调试器显示的元素数量也可能是临时的、不准确的
排查方向
- 先检查代码中有没有多线程同时操作rSet的场景:比如一个线程在批量添加元素,另一个线程同时在执行拆分列表、调用
size()或者遍历集合的操作 - 哪怕是单线程,也要确认是不是在迭代rSet的同时进行了修改(比如foreach循环里调用
add()/remove()),这种操作虽然会触发fail-fast机制抛出ConcurrentModificationException,但有时候可能因为时序问题没有立刻抛出异常,而是留下了集合结构损坏的隐患
解决方案
根据你的场景,推荐几种可行的修复方式:
- 改用线程安全的集合:
- 如果是读多写少的场景,优先用
CopyOnWriteArraySet,它通过复制底层数组来实现线程安全,遍历操作完全不需要锁,性能更好 - 通用场景可以用
Collections.synchronizedSet(new HashSet<>()),它会给所有操作加上同步锁,保证线程安全
- 如果是读多写少的场景,优先用
- 手动加同步锁:如果必须保留HashSet,那在所有访问、修改rSet的代码块外面加上同步锁,比如:
synchronized(rSet) { // 这里执行add、remove、size()、创建ArrayList快照等操作 List<T> subList = new ArrayList<>(rSet); int actualSize = rSet.size(); } - 获取安全快照再操作:拆分列表时,先在同步块里获取集合的完整快照,后续对快照进行拆分,避免直接操作可能被并发修改的原集合
需要注意的是:调试器中看到的“实际1000条数据”,是因为调试器在遍历集合时尝试读取所有元素,但此时集合的结构已经被并发操作破坏,这个遍历结果也不一定是完全准确的;而size()方法是直接读取内部的size变量,这个变量在并发竞争下已经处于不一致的状态了。
内容的提问来源于stack exchange,提问作者Danielson
相关产品推荐
相关产品推荐

