searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

并发场景下 Set 去重的四种方案对比

2026-06-18 18:00:19
0
0

背景:为什么并发去重不简单

先明确一个前提: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
  • 追求极致节省内存 → 布隆过滤器

选型的本质不是选"最好的",而是选"最匹配当前约束的"。想清楚数据量、读写比例、精度要求和内存限制,答案自然就出来了。

0条评论
0 / 1000
c****t
916文章数
1粉丝数
c****t
916 文章 | 1 粉丝
原创

并发场景下 Set 去重的四种方案对比

2026-06-18 18:00:19
0
0

背景:为什么并发去重不简单

先明确一个前提: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
  • 追求极致节省内存 → 布隆过滤器

选型的本质不是选"最好的",而是选"最匹配当前约束的"。想清楚数据量、读写比例、精度要求和内存限制,答案自然就出来了。

文章来自个人专栏
文章 | 订阅
0条评论
0 / 1000
请输入你的评论
0
0