一段代码把 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. 自测
- 三层安全分别保证什么?为什么"换了并发容器还是有 bug"多半落在第三层?
Collections.synchronizedMap与ConcurrentHashMap的核心差别是什么?各自适合什么场景?- 哪些操作真的原子?为什么"
get后再put"不是? - 计数偏少的机制是什么?为什么它在低并发下看不出来?
- 用可变对象做 key 会发生什么?为什么"取不到刚放的 key"?
- 判断"是否需要并发容器"的那个问题是什么?