VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

线程安全的集合怎么选:CHM 锁抽屉,COW 换公告——Java 工兵连第 8 篇

「Java 工兵连」第 8 篇,讲并发容器。第 5 篇讲过 HashMap 的头插成环,结论是「并发用 ConcurrentHashMap」——但真到选型时,问题比这多:CHM 和 CopyOnWriteArrayList 谁适合我的场景?阻塞队列四种放取方法什么区别?Collections.synchronizedList 算不算线程安全?

先立本文的核心认知:线程安全不是「有/无」的开关,是「保证了什么」的光谱——每个容器在锁粒度、读写代价、复合操作原子性上的取舍都不同。选型的钥匙是一句话:看它锁多粗、你读多还是写多、要不要复合原子。

一、先建直觉:共享文件柜的四种制度

四种并发容器的制度对比

  • 锁整柜(Vector / Hashtable / synchronizedXxx):整个柜子一把锁,一个人开柜,连看文件的人都排队;
  • 抽屉各自锁(ConcurrentHashMap):开 A 抽屉不影响开 B 抽屉,冲突只发生在同一格;
  • 公示栏(CopyOnWriteArrayList):要改内容就重印一整页换上去,看的人永远在看一份不变的快照——读永远无锁,写永远全页重印;
  • 传菜窗口(BlockingQueue):厨师做好放窗口,服务员从窗口取;窗口满了厨师等着(put 阻塞),空了服务员等着(take 阻塞)。

二、ConcurrentHashMap:抽屉各自锁

演进史一句话:JDK 7 分段锁(整个 Map 切 16 个 Segment,各自一把锁)→ JDK 8 升级为 CAS + synchronized 锁单个桶头——锁粒度从「片区」细化到「一格」,配合 HashMap 篇讲过的数组+链表+红黑树结构,冲突概率本就低,锁更是几乎不撞。

三个容易踩的知识点:

1. 禁 null。 key 和 value 都不许 null——并发下 get(k) 返回 null 分不清「不存在」还是「存的就是 null」,这种歧义在单线程可以用 containsKey 二次确认,并发下两次调用之间状态可能变。HashMap 允许 null 是单线程的特权。

2. size() 是估算。 高并发下精确计数本身就要争一把全局锁(违背抽屉锁的初衷),CHM 用分散计数(baseCount + CounterCells,LongAdder 同款思路)换吞吐——统计展示够用,精确对账别依赖它。

3. 复合操作必须用原子方法。 这是本篇最重要的一段:

// ✗ 检查再行动:两步之间别的线程可能已经塞进去了(和 volatile 篇的 count++ 同构)
if (!map.containsKey(userId)) {
    map.put(userId, buildNewUser(userId));
}

// ✓ 三选一,全是一次原子操作
map.putIfAbsent(userId, newUser);                      // 不存在才放
map.computeIfAbsent(userId, this::buildNewUser);       // 不存在才计算并放(懒加载缓存的标配)
map.merge(userId, 1L, Long::sum);                      // 计数器的正确姿势:存在则合并

「检查-再-行动」的竞态,容器已经替你原子化了,自己拼两步等于把保险拆了。另注意:computeIfAbsent 的映射函数里不要再去修改同一个 map(嵌套更新可能死锁/抛异常),要改就在返回之后改。

迭代器是弱一致的:不抛 ConcurrentModificationException,遍历的是创建时刻或其后部分完成的状态,不保证「最新」——换来的代价是遍历时不用加锁。

三、CopyOnWriteArrayList:公示栏制度

写操作:加锁 → 复制整个底层数组 → 在副本上改 → 原子替换引用;读操作:无锁,拿到哪个数组引用就看哪份快照。

  • 读极快:没有锁、没有可见性开销(数组引用是 volatile 的);
  • 写极贵:O(n) 复制 + 内存瞬时翻倍的垃圾;
  • 迭代绝对安全:遍历的就是历史快照,别人怎么改都不影响你——迭代器自然也不支持 remove(那是快照,删了也不影响真身)。

定位:读多写少的名单类数据。 监听器/观察者列表(注册一次、触发万次)、配置黑白名单(小时级更新、毫秒级查询)是它的主场。反过来,写多场景用它等于每次写都全页重印——灾难。

四、BlockingQueue:传菜窗口

生产者-消费者模型的官方答案,四组放取 API 按「满了/空了怎么办」划分:

行为 抛异常 返回特殊值 阻塞等待 超时等待
放入 add(e) offer(e) put(e) offer(e, t, u)
取出 remove() poll() take() poll(t, u)

记法:add/remove 猛(炸)、offer/poll 稳(false/null)、put/take 堵(等)、带参的会看表(超时返回)。线程池篇里 ArrayBlockingQueue 做工作队列,靠的就是 put/take 的背压:队列满时提交线程自己慢下来。

家族速览:ArrayBlockingQueue(数组有界)、LinkedBlockingQueue(链表、默认无界——Integer.MAX_VALUE,和 newFixedThreadPool 的坑同源:不限界的队列迟早吞掉你的堆,生产上必须手动设容量)、SynchronousQueue(零容量直交接,newCachedThreadPool 用它)、PriorityBlockingQueue(优先级)、DelayQueue(到期才能取——订单超时关闭、重试退避的现成零件)。

五、老古董为什么退役

Vector、Hashtable、Collections.synchronizedList(...) 的实现是每个方法套 synchronized——等于锁整柜:读和写全部串行,多核白买。更阴的是伪安全:单个方法确实安全,但 list.get(i) 接着 list.remove(i)、或 for-each 遍历中途删除,依然是竞态(迭代仍需外部手动加锁,文档里那行小字没几个人读过)。它们只保证「方法级安全」,而我们需要的是「操作级安全」——这正是原子复合方法(CHM)和快照(COW)真正的价值。新代码没有任何理由选用它们;看到老代码里有,标记为技术债。

六、选型决策树

要 Map?
├─ 并发读写 ────────── ConcurrentHashMap(复合操作用 putIfAbsent/computeIfAbsent/merge)
└─ 初始化后只读 ─────── 普通Map + 不可变结构(Map.of / unmodifiableMap),更简单更快

要 List?
├─ 读多写少(名单/监听器)─ CopyOnWriteArrayList
└─ 读写都多 ──────────── 停:多半该用队列或重新设计,而不是「更猛的List」

要排队?
├─ 生产者-消费者 ─────── ArrayBlockingQueue / 有界 LinkedBlockingQueue(一定设容量!)
├─ 零缓冲直交接 ──────── SynchronousQueue
└─ 延时/超时任务 ─────── DelayQueue

老兵点评

  1. 选并发容器先看锁粒度,再看读写比,最后看复合操作。锁整柜(退役)→ 抽屉锁(CHM)→ 无锁快照(COW)→ 无锁交接(队列),是一条「冲突越来越少」的演进线,你的场景落在哪一格,容器就选哪个。
  2. 容器的原子方法是留给你的礼物,别退回手工时代。computeIfAbsent 替代「查了再放」、merge 替代「取了再加」——多数并发 bug 的本质就是「检查-再-行动」中间被插队,容器已经把这两步焊死了。
  3. 队列无界是很多「内存缓慢上涨」的元凶。不止 LinkedBlockingQueue,各种 MQ 消费堆积、Executors 默认队列、自建缓冲 List——记住工兵连那条暗线:不限界的资源,迟早吞掉你的堆(第 1 篇讲过,第 10 篇 OOM 排查还会见)。

带走三句话

  1. CHM 锁抽屉(CAS+锁桶头,禁 null,size 是估算),复合操作用原子方法:putIfAbsent / computeIfAbsent / merge。
  2. COW 换公告:读无锁看快照,写 O(n) 全页重印——读多写少的名单专用,写多即灾难。
  3. 队列传菜要有界:put/take 天然背压;LinkedBlockingQueue 默认无界是暗雷,生产必设容量。

本篇是「Java 工兵连」第 8 篇。→ 上一篇:第 7 篇 · 分布式锁的三重门 | 继续阅读:第 9 篇 · 虚拟线程:等待不再占人 | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →