背景:为什么并发去重不简单
先明确一个前提:HashSet 本身不是线程安全的。它的底层是哈希表,当多个线程同时执行 put 操作时,可能触发扩容,导致链表成环,进而引发死循环(早期 JDK 版本中确实存在这个问题)。即便在新版本中修复了这个 bug,并发写入依然会导致数据丢失或覆盖。
所以核心矛盾是:我们需要去重能力,同时需要线程安全,但不同方案在这两者之间的平衡方式完全不同。
下面逐一分析四种方案。
方案一:HashSet + 外部同步锁
这是最容易想到的方案:既然 HashSet 不安全,那就给它加一把锁。
具体做法是在所有访问 HashSet 的地方套上 synchronized 关键字,或者使用 ReentrantLock 手动控制。无论是读还是写,都必须获取同一把锁。
优点:
- 实现简单,几乎不需要学习新 API
- 逻辑直观,锁的粒度清晰
- 内存占用最低,就是一个普通 HashSet 的开销
缺点:
- 并发能力极差。所有线程串行执行,相当于把并发退化成了单线程
- 锁竞争激烈时,线程大量阻塞,上下文切换开销显著
- 读操作也被锁住了,即使只是查询是否存在,也要排队
适用场景:
并发量很低,或者去重操作本身耗时很短、几乎不成为瓶颈的场景。比如一个后台定时任务,每分钟处理一批数据,去重后写入数据库。这种情况下加锁的开销可以忽略。
但如果 QPS 达到几百甚至上千,这个方案就会成为系统瓶颈。
方案二:ConcurrentHashMap 的 KeySet 视图
ConcurrentHashMap 是 JDK 并发包中的核心工具之一。它的 KeySet 本身就是一个 Set,可以直接用于去重。
这是很多工程师在并发场景下的首选方案。
原理:
ConcurrentHashMap 内部将数据分段存储(JDK 8 之后改为 CAS + synchronized 节点级锁),不同段之间可以并行操作。它的 KeySet 继承了这种并发特性,多个线程可以同时进行 putIfAbsent 操作而不会相互阻塞。
优点:
- 真正的并发读写,吞吐量远高于方案一
- putIfAbsent 是原子操作,天然适合"不存在则插入"的去重语义
- 读操作不加锁,查询效率高
- JDK 原生支持,稳定性经过大量验证
缺点:
- 内存占用比 HashSet 高。ConcurrentHashMap 内部维护了额外的节点信息(如 forwarding pointer、hash 值等)
- 在极端写竞争下(大量线程同时写入同一个 key),依然会有 CAS 重试和自旋,但这种情况在去重场景中较少见
- 不能直接获取 size 的精确值,size() 方法是一个估算,遍历统计才准确
适用场景:
绝大多数并发去重场景的默认选择。无论是接口防重、消息去重还是用户行为去重,这个方案都能覆盖。特别是写多读少、或者读写比例接近的场景,表现非常稳健。
方案三:CopyOnWriteArraySet
这是一个思路完全不同的方案:写时复制。
原理:
CopyOnWriteArraySet 底层使用 CopyOnWriteArrayList 存储元素。每次修改(添加、删除)时,它不会在原数组上操作,而是复制一份新数组,在新数组上完成修改后,再替换引用。读操作直接访问当前数组,完全不加锁。
优点:
- 读操作零锁竞争,并发读性能极高
- 迭代器永远不会抛出 ConcurrentModificationException,遍历时不需要额外同步
- 适合读多写少的场景,读的吞吐量可以做到非常高
缺点:
- 写操作代价高昂。每次修改都要复制整个数组,如果 Set 中元素很多,开销会非常大
- 内存占用翻倍。修改瞬间存在新旧两份数据
- 不适合元素数量持续增长的场景,数组扩容的复制成本会越来越高
适用场景:
读远多于写的场景。比如一个配置中心的黑白名单,初始化后几乎不修改,但被大量线程频繁查询。或者一个本地缓存的去重集合,数据写入后长期只读不改。
如果你的场景是每秒写入几千次,那这个方案会把内存和 CPU 都打满,必须避开。
方案四:布隆过滤器(Bloom Filter)
前三种方案都是"精确去重"——确定一个元素是否存在。布隆过滤器走的是另一条路:概率去重。
原理:
布隆过滤器使用一个位数组和多个哈希函数。插入元素时,用哈希函数计算出多个位置,将对应位设为 1。查询时,同样计算这些位置,如果所有位都是 1,则"可能存在";只要有一位是 0,则"一定不存在"。
优点:
- 内存占用极低。一个可以存储千万级元素的布隆过滤器,可能只需要几 MB 内存
- 查询和插入都是 O(1),且无需锁,天然并发安全(位操作是原子的)
- 特别适合数据量巨大、但允许极低误判率的场景
缺点:
- 存在误判。可能把不存在的元素判断为存在(假阳性),但绝不会把存在的判断为不存在(假阴性为零)
- 不支持删除操作(标准布隆过滤器)。虽然有计数布隆过滤器等变种,但复杂度上升
- 误判率与位数组大小、哈希函数数量有关,需要根据数据量精心调参
适用场景:
海量数据去重,且可以容忍少量误判。典型案例:
- URL 去重:爬虫场景下,已爬取的 URL 数量可能达到十亿级,用 HashSet 根本存不下,布隆过滤器可以用很小的内存覆盖
- 恶意请求过滤:判断某个 IP 或用户 ID 是否在黑名单中,误判几次的代价远小于存储全部黑名单
- 缓存穿透防护:判断一个 key 是否一定不存在,避免无效查询打到数据库
四种方案横向对比
| 维度 | HashSet + 锁 | ConcurrentHashMap KeySet | CopyOnWriteArraySet | 布隆过滤器 |
|---|---|---|---|---|
| 线程安全 | 是(外部同步) | 是(内置) | 是(写时复制) | 是(无锁) |
| 去重精度 | 精确 | 精确 | 精确 | 概率(有假阳性) |
| 并发读 | 差(被锁阻塞) | 优(无锁读取) | 极优(零锁) | 极优(无锁) |
| 并发写 | 差(串行) | 优(分段锁/CAS) | 差(全量复制) | 优(位操作) |
| 内存占用 | 低 | 中 | 高(写时翻倍) | 极低 |
| 支持删除 | 是 | 是 | 是 | 否(标准版) |
| 实现复杂度 | 低 | 低 | 低 | 中(需调参) |
选型决策树
实际选型时,可以按以下逻辑快速判断:
第一步:数据量多大?
- 如果数据量在百万级以内,优先考虑方案二(ConcurrentHashMap KeySet),它在精度、并发能力、内存之间取得了最好的平衡。
- 如果数据量在千万甚至亿级,内存成为瓶颈,考虑方案四(布隆过滤器),但要接受误判。
第二步:读写比例如何?
- 写多读少 → 方案二
- 读多写少 → 方案三(CopyOnWriteArraySet),但要确保元素数量不会持续膨胀
- 几乎只读不写 → 方案三是最佳选择
第三步:是否允许误判?
- 不允许 → 排除方案四,在前三种中选
- 允许 → 方案四可能是最优解,尤其是数据量巨大时
第四步:是否需要分布式?
本文讨论的四种方案都是单机内存方案。如果去重需求跨越多个进程或多台机器(比如多个服务实例都需要判断同一个订单是否已处理),那就需要引入分布式 Set(基于分布式缓存实现)。这属于另一个话题,但核心思路是一致的:用分布式缓存的 Set 结构,配合过期时间,实现跨实例去重。
一些容易踩的坑
坑一:用 HashSet 做全局去重却忘了加锁。
这是最常见的线上事故来源之一。本地测试一切正常,上线后数据偶尔丢失,排查半天才发现是并发写入导致的。
坑二:CopyOnWriteArraySet 被用于写频繁的场景。
有开发者看到"CopyOnWrite"觉得很优雅,直接用在消息去重场景,结果内存飙升,GC 频繁,系统卡顿。一定要评估写操作的频率。
坑三:布隆过滤器的位数组大小算错。
位数组太小会导致误判率过高,太大又浪费内存。一般建议根据预期元素数量和可接受误判率(如 0.1%),用公式反推位数组大小和哈希函数个数。
坑四:把 ConcurrentHashMap 的 size() 当精确值用。
size() 是遍历多个段后累加的估算值,高并发写场景下与真实值可能有偏差。如果业务依赖精确计数,需要额外维护一个原子计数器。
写在最后
没有银弹。四种方案各自解决了不同约束下的问题:
- 追求简单且并发低 → HashSet + 锁
- 追求均衡 → ConcurrentHashMap KeySet(大多数场景的默认答案)
- 追求读性能极致 → CopyOnWriteArraySet
- 追求极致节省内存 → 布隆过滤器
选型的本质不是选"最好的",而是选"最匹配当前约束的"。想清楚数据量、读写比例、精度要求和内存限制,答案自然就出来了。