ConcurrentHashMap ——put + get

在 Java 并发编程中,ConcurrentHashMap 是当之无愧的明星容器。它既要在多线程环境下保证线程安全,又要追求接近 HashMap 的高性能。本文将带你深入剖析 ConcurrentHashMap 的 put 和 get 流程,从源码视角解读其精妙设计,理解它如何通过无锁读、CAS 原子操作、细粒度锁以及多线程协助扩容,实现了并发与性能的完美平衡。

一、从 Hashtable 到 ConcurrentHashMap

在 Java 早期,Hashtable 是唯一的线程安全哈希表,但它对所有方法都加 synchronized,整个表被一把大锁保护。高并发下,这把锁成为严重的性能瓶颈------任何时刻只有一个线程能访问。而 HashMap 虽然性能高,却非线程安全。于是,Java 并发包推出了 ConcurrentHashMap,目标是:

  • 细粒度锁:将锁的粒度从整个表缩小到每个桶(bucket)。

  • 无锁读 :读操作不加锁,依靠 volatile 保证可见性。

  • CAS 无锁更新:对于简单操作(如空桶插入、计数器更新),使用乐观锁 CAS 替代重量级锁。

  • 多线程协助扩容:将耗时的扩容任务拆解,让多个线程共同完成,减少单次扩容的停顿时间。

二、核心数据结构(Java 8)

Java 8 的 ConcurrentHashMap 彻底重构,放弃了 Java 7 的分段锁(Segment),采用了更精妙的数组 + 链表 + 红黑树结构:

  • Node<K,V>[] table:哈希桶数组,用 volatile 修饰,保证数组引用对所有线程可见。

  • Node 节点:包含 hashkeyvalvolatile)和 nextvolatile)。

  • 当链表长度超过 8 且数组长度 ≥ 64 时,链表会转换为红黑树(TreeNode),查询复杂度从 O(n) 降到 O(log n)。

  • ForwardingNode:扩容时的特殊节点,用于指向新数组,标记该桶已被迁移。

三、put 流程详解:并发写入的精妙设计

ConcurrentHashMapput 方法既要处理普通插入,又要应对并发竞争、扩容等复杂场景。下面我们逐步拆解其核心流程。

1. 参数校验与哈希扰动

java 复制代码
public V put(K key, V value) {
    return putVal(key, value, false);
}
  • 首先,ConcurrentHashMap 不允许 key 或 value 为 null ,一旦检测到 null,直接抛出 NullPointerException

  • 对 key 的 hashCode() 进行二次扰动(spread() 方法),目的是让高位也参与运算,减少哈希冲突,使数据分布更均匀。

java 复制代码
static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}

2. 计算桶下标

通过 (n - 1) & hash 计算桶下标,其中 n 是数组长度(总是 2 的幂次)。这个操作等价于取模,但效率更高。

3. 空桶插入:CAS 无锁操作

如果定位到的桶为空,则使用 CAS 原子地插入新节点。这是无锁操作,避免了加锁开销:

java 复制代码
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break; // 插入成功,跳出循环
}

如果 CAS 失败(说明有其他线程抢先插入了),则进入下一次自旋重试。

4. 正在扩容:协助迁移

如果桶的头节点是 ForwardingNode(其 hash 值为 MOVED),说明当前数组正在扩容。此时当前线程不会阻塞等待,而是调用 helpTransfer() 方法主动加入扩容任务,帮助迁移其他桶中的数据,再继续自己的 put 操作。这种设计将扩容的压力分散到多个写线程,大大缩短了整体扩容时间。

5. 正常插入:细粒度锁

如果桶不为空且不是 ForwardingNode,就对桶的头节点加 synchronized 锁(锁对象就是这个节点)。注意:这里只锁住了当前桶,其他桶的操作完全不受影响,实现了细粒度并发控制。

加锁后,再次检查头节点是否被修改(双重检查),然后根据节点类型进行插入:

  • 链表遍历 :遍历链表,如果找到相同 key,则覆盖 value(根据 onlyIfAbsent 参数决定);否则在链表尾部插入新节点(Java 8 采用尾插法,避免并发下链表环的问题)。

  • 红黑树 :如果是 TreeBin 节点,则调用红黑树的插入方法,同样在锁保护下进行。

6. 树化判断

插入完成后,如果当前桶的链表长度 ≥ 8,会触发树化判断:

  • 如果数组长度 < 64,优先扩容数组(tryPresize),因为较短的数组容易导致哈希冲突,扩容可以更有效地解决冲突。

  • 如果数组长度 ≥ 64,则将链表转换为红黑树,将查询复杂度从 O(n) 降到 O(log n)。

7. 更新计数与扩容触发

最后,调用 addCount() 更新元素总数。该方法使用 LongAdder 思想 ,将计数分散到 baseCountCounterCell 数组中,通过 CAS 原子更新,减少高并发下的竞争。同时,addCount() 会检查当前元素数量是否超过 sizeCtl(扩容阈值),若达到则触发扩容流程。

四、get 流程:全程无锁,性能接近 HashMap

ConcurrentHashMapget 操作是 完全无锁 的,这是其高并发读性能的核心。整个查询过程仅依赖 volatile 保证可见性。

java 复制代码
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 1. 检查头节点
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // 2. 特殊节点(红黑树或扩容转发节点)
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        // 3. 普通链表遍历
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

步骤解析:

  1. 计算桶下标 ,通过 tabAt 获取桶头节点(volatile 读,保证可见性)。

  2. 检查头节点:如果头节点的 key 匹配,直接返回 value。

  3. 特殊节点 :如果头节点的 hash 小于 0(表示是红黑树节点或扩容转发节点),调用其 find 方法查找。红黑树的查找遵循二叉搜索树规则:从根开始比较哈希值,小于走左子树,大于走右子树,哈希相等再用 equals 判断;扩容转发节点则会去新数组查找。

  4. 链表遍历:否则,按链表顺序遍历,找到匹配的 key 则返回 value,否则返回 null。

整个过程没有加任何锁,只依赖 volatile 保证读到的值是最新的。即使有并发写,volatile 也能保证修改对读线程可见,因此读性能几乎与 HashMap 持平。

五、其他关键机制

1. 计数机制:LongAdder 思想

ConcurrentHashMapsize() 方法需要实时返回元素个数。高并发下,使用 AtomicLong 会导致大量 CAS 失败,影响性能。因此,它采用了类似 LongAdder 的设计:

  • baseCount:基础计数器。

  • CounterCell[] counterCells:辅助计数数组,每个线程可以独立更新自己的 CounterCell。

  • cellsBusy:自旋锁,用于控制 counterCells 的初始化或扩容。

当 CAS 更新 baseCount 失败时,会尝试更新 CounterCell,如果仍失败,则尝试扩容 counterCells 数组。最终 size() 返回 baseCount + sum(CounterCell)

2. 多线程协助扩容

ConcurrentHashMap 的扩容不是单线程完成,而是允许多个线程共同参与。当某个线程发现需要扩容时,它会设置 sizeCtl 为负数,并创建 ForwardingNode。其他线程在插入时如果遇到 ForwardingNode,会调用 helpTransfer 协助迁移数据。每个线程负责一个"步长"(stride)的桶,通过 CAS 修改 transferIndex 来分配任务。迁移完成后,将桶设为 ForwardingNode,后续操作自动转发到新数组。这种并发扩容机制,使得扩容时间随参与线程数增加而缩短,避免了单线程扩容时的长时间停顿。

六、总结

ConcurrentHashMap 通过一系列精妙的设计,在并发场景下实现了高性能的哈希表操作:

  • 无锁读volatile 保证可见性,读操作不加锁,性能接近 HashMap

  • 细粒度锁:写操作只锁当前桶的头节点,并发写入互不干扰。

  • CAS 无锁更新:空桶插入、计数更新等简单操作使用 CAS,避免锁开销。

  • 多线程协助扩容:将扩容任务分摊给多个写线程,大幅减少扩容停顿时间。

  • 高效计数LongAdder 思想分散计数压力,高并发下依然准确高效。

相关推荐
今夕资源网2 小时前
零基础 Python 环境搭建工具 一键安装 Python 环境自动配置 升级 pip、setuptools、wheel
开发语言·python·pip·环境变量·python环境变量·python自动安装
啥咕啦呛2 小时前
java打卡学习4:HashMap底层结构、扩容机制
java·学习·哈希算法
qq_297574672 小时前
K8s系列第十四篇:K8s 故障排查实战:常见故障定位与解决方法
java·docker·kubernetes
Flittly2 小时前
【SpringAIAlibaba新手村系列】(3)ChatModel 与 ChatClient 的深度对比
java·人工智能·spring boot·spring
朱一头zcy2 小时前
设计模式入门:最简单的模板方法模式
笔记·设计模式·模板方法模式
小CC吃豆子2 小时前
C++ 继承
开发语言·c++
Derrick__12 小时前
Scrapling 爬取豆瓣电影Top250
开发语言·python·网络爬虫·豆瓣·scrapling
serve the people2 小时前
ACME 协议流程与AllinSSL 的关系(一)
开发语言
2401_835792542 小时前
Java复习上
java·开发语言·python