2418 字
约 8 分钟
14
S04-A16 · 线程安全容器:什么时候真的需要

一段代码把 HashMap 换成了 ConcurrentHashMap,理由是"多线程访问会出问题"。改完发现"计数偏少"的问题依旧存在——因为问题不在容器,而在 get 与 put 之间的那段业务逻辑:两个线程都读到 1,都写回 2。

线程安全容器保证的是单个操作的原子性,不是业务流程的原子性。这个错位有个很具体的成因:线程安全的讨论通常在"用什么容器"这一层进行,而真正的 bug 在"哪两步之间不能被打断"这一层。换容器是最容易做的动作,于是它成了默认答案。
把地图换成并发版,只解决了"容器内部不崩",没有解决"这段逻辑需要一把锁"。


1. 三层安全

层 保证什么
容器内部 单次操作不破坏结构
单键操作 putIfAbsent、compute 等组合操作原子
业务流程 读—判断—写整段原子

第一层是 ConcurrentHashMap 默认提供的,第二层要靠它的原子方法,第三层容器给不了——它只能靠外部锁、数据库约束或幂等设计。

多数"换了容器还是有 bug"的问题,都落在第三层。第二层的原子方法也不是万能的:compute 保证"按 key 原子的读改写",但它里面的函数会被串行执行。函数里有阻塞调用(远程请求、IO)时,整个 key 的操作会被拖住,甚至拖住同段的其它 key。

2. 四种容器的适用边界

容器 适用
HashMap 单线程,或只读共享
Collections.synchronizedMap 全表锁,简单场景
ConcurrentHashMap 高并发读写、按 key 独立
CopyOnWriteArrayList 读多写极少(如监听器列表)

第三项(ConcurrentHashMap)的边界在"分段":并发度由内部结构决定,理论上按 key 独立,但极端热点(同一个 key 被大量线程写)仍会退化,此时需要 LongAdder 这类分散争用的结构。
第四项的判据很硬:写操作会复制整个数组,所以"写多"时它是灾难。它适合"启动时注册、运行时只读"的场景。

第二项的问题是全表一把锁:并发上升后串行化明显,而它的价值只是"简单、不出错"。

3. 哪些操作真的原子

操作 原子性
put / get 是(单次)
putIfAbsent 是
compute / merge 是(按 key)
get 后再 put 否
size 的精确性 否(弱一致)

第四项是最常见的错误:"先查再写"看起来是两步,实际是一个业务流程,而它的原子性只能靠 compute 或外部锁获得。

第五项容易被误用:size() 在并发下是估计值第五项还有一个"监控"上的陷阱:用它做告警阈值(如"缓存条目超过 10 万就告警")会漏报或误报,因为它本身在变化中求和。要精确,就得维护一个单独的计数。
,用它做"容量上限判断"并不可靠。需要精确计数时应当用 LongAdder 或数据库。

// 反例:两步操作,非原子
if (!map.containsKey(k)) map.put(k, init());
// 正例:一条原子操作
map.computeIfAbsent(k, x -> init());
// 需要"仅当不存在时放入并返回是否成功":
boolean created = map.putIfAbsent(k, init()) == null;

4. 计数为什么总是偏少

写法 问题
map.get(k) + 1 再 put 丢失更新
map.put(k, v+1) 同上
compute(k, (x,v) -> v+1) 正确
LongAdder.increment() 正确且更快

这个机制有个通用名字:read-modify-write 竞态。它在"库存扣减""余额""配额"上表现一致,而修法也一致——要么原子操作,要么加锁,要么交给数据库。
丢失更新的机制:两个线程都读到 1,都算出 2,都写 2,而正确结果应为 3。这与容器是否并发无关。

第一项与第二项在低并发下几乎不出错,于是它长期"看起来正确"——而它只在压测或峰值时暴露。

// 计数:按场景选
ConcurrentHashMap<String, Long> m = new ConcurrentHashMap<>();
m.compute(k, (x, v) -> v == null ? 1 : v + 1);      // 按 key 原子
// 或:热点统一计数,用 LongAdder 更省争用
LongAdder total = new LongAdder(); total.increment();

还有一类"看起来原子其实不"的操作:remove 的条件版本。想"仅当值等于某值时才删除",remove(k) 会删错——要用 remove(k, v),它才保证"值与预期一致才删除"。

// 反例:先比较再删,中间可能被改
if (map.get(k) == null) map.remove(k);        // 无意义,但形式常见
if (expected.equals(map.get(k))) map.remove(k);  // 两步,非原子

// 正例:条件删除是原子操作
map.remove(k, expected);                       // 仅当当前值为 expected 才删
// 类似的还有:replace(k, old, new)

5. 复合键与分段

场景 做法
多个字段共同决定键 用不可变 record 做 key
需要按键分段计数 每段一个容器
需要全局一致 数据库或分布式锁
需要顺序 用队列单线程消费

第一项要注意 equals/hashCode:用可变对象做 key,改字段后它就"消失"了第二项(分段计数)的实现要点是"选段要均匀":按哈希取模分段通常够用,而"按业务字段分段"(如按用户 ID 前缀)在数据倾斜时会退化。
(落在错误的桶里,取不出来)。

第四项在多实例部署时是唯一可靠的做法:进程内的锁管不了另一台机器。

容器的"弱一致"也要知道:ConcurrentHashMap 的迭代器是弱一致,遍历期间看到的可能是"某个时刻的近似快照",不会抛 ConcurrentModificationException,但也不保证看到全部最新写入。

// 弱一致的含义(对排障很重要)
遍历时不抛异常            → 不会因并发修改失败
可能看不到刚写入的元素    → 不要用它做"精确快照"
size/遍历不保证同一时刻    → 需要精确统计时另建计数
// 反例:用遍历结果做"对账",会得出"少了 3 条"的假结论

6. 排错:五种"换了容器还是错"

现象 成因 处理方向
计数偏少 读改写非原子 compute 或 LongAdder
值被覆盖 两步操作 putIfAbsent
取不到刚放入的 key 可变 key 用不可变 key
内存持续增长 只增不删 加淘汰
单机对、多机错 进程内锁 分布式方案

还有一类"假共享"问题:两个实例各自的本地缓存,被当成"共享缓存"使用,于是"为什么另一台机器读到旧值"成了谜。这类问题的根因不是容器,而是把进程内状态当成了全局状态。
第五项是最容易被忽略的边界:ConcurrentHashMap 的原子性止于本进程。多实例下它退化成"每个实例各自安全",而业务需要的是全局一致。

在多实例部署下,还有一个"进程内锁"的常见错误:用 synchronized 保护"读数据库再写数据库"的流程,在单机看似正确,多机完全失效。此时唯一可靠的是数据库的行锁/唯一约束或分布式锁。

// 三层保证的适用范围
进程内锁(synchronized / ReentrantLock)→ 只在本 JVM 有效
并发容器                              → 只在本进程有效
数据库行锁 / 唯一约束 / 乐观锁          → 跨实例有效
// 判据:这个"不能被打断"的范围,会不会跨进程?

7. 边界:什么情况根本不需要并发容器

场景 为什么 做法
局部变量里的 Map 单线程使用 HashMap
启动后只读的配置 无写入 普通 Map + 不可变
由单线程消费的队列 无并发写 ArrayDeque
写极少、读极多 复制成本可接受 CopyOnWriteArrayList

判断口径一句话:这个容器会不会被两个线程同时写(不是同时读)。最后一条与"可读性"有关的边界:不必要的并发容器会让代码变复杂而不变安全。用普通 HashMap 加一句"此集合仅在方法内使用",比用并发容器加一段"为什么是并发但我又不确定"更有价值。
只在读上并发,普通 Map 加"构造后不再修改"就够;会同时写,才需要并发容器。


8. 自测

  1. 三层安全分别保证什么?为什么"换了并发容器还是有 bug"多半落在第三层?
  2. Collections.synchronizedMap 与 ConcurrentHashMap 的核心差别是什么?各自适合什么场景?
  3. 哪些操作真的原子?为什么"get 后再 put"不是?
  4. 计数偏少的机制是什么?为什么它在低并发下看不出来?
  5. 用可变对象做 key 会发生什么?为什么"取不到刚放的 key"?
  6. 判断"是否需要并发容器"的那个问题是什么?
S04-A16 · 线程安全容器:什么时候真的需要
https://www.clxhxhhr.top/posts/6503/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。