ConcurrentHashMap 允许多个线程访问同一张表。这里看 Java 8 的实现,它与早期版本围绕 Segment 分段加锁的结构不同。

先用对复合操作

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentMapDemo {
    public static void main(String[] args) throws InterruptedException {
        ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counts.merge("java", 1, Integer::sum);
            }
        };
        Thread a = new Thread(task);
        Thread b = new Thread(task);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println(counts.get("java"));
    }
}

输出 2000。如果改成先 get(),加一后再 put(),依然可能丢失更新。线程安全的 get 和 put,并不能自动组成线程安全的递增。

putIfAbsent()、条件 replace()、compute()、merge() 提供了不同形式的原子更新。计算函数应该简短,不要在里面做慢网络调用,也不要递归修改映射,让其他更新长时间等待。

数组、链表和树

Java 8 用 Node 数组存放桶。同一个桶发生哈希冲突时,节点可以形成链表;节点较多且数组容量达到要求时,会尝试转换成树结构。

树化阈值是 8,最小树化容量是 64,但不能把它理解成“第八个元素插入后一定变成树”。具体触发取决于插入时的计数分支,数组较小时会优先扩容,减少碰撞。

put 的主要步骤

插入时先计算哈希并找到桶位置:

  1. 表还没有初始化,先协调初始化。
  2. 桶为空,尝试通过 CAS 安装新节点。
  3. 桶正在迁移,协助扩容后重试。
  4. 普通非空桶,对桶头节点加 synchronized,再次确认桶头没有变化,然后更新或追加节点。
  5. 调整元素计数,必要时触发扩容或树化。

锁主要落在发生冲突的桶上,不是每次写入都锁住整张表。空桶的 CAS 失败也不是报错,而是说明有其他线程抢先修改,需要重新观察。

get 与扩容

普通读取主要利用 volatile 字段和相应的读操作沿着节点查找,不需要像写入那样获取桶头的锁。遇到树节点或转发节点时,会走各自的查找逻辑。

扩容把旧表中的节点迁移到新表。迁移完成的桶会留下转发节点,其他线程可以据此寻找新表,也可以参与迁移。这是并发协作的过程,不是一个线程把全表锁住搬完后再放行。

使用边界

它不允许 null 键和值,因此 get() 返回 null 可以表示当前没有映射。遍历和 size() 在并发修改时不能当作整张表的稳定快照。

映射操作安全,也不意味着 value 内部可变字段安全。如果 value 是 ArrayList,多个线程取得同一个列表后直接添加,仍然需要保护这个列表。

此外,两个不同键的转账不是单键原子更新能解决的。涉及跨键约束时,应在更高一层明确一致性规则。

最后修改:2018 年 03 月 10 日
如果觉得我的文章对你有用,请随意赞赏