ConcurrentHashMap 设计原理笔记

在 Java 并发编程中,ConcurrentHashMap 是最常用的并发容器之一。它既要保证线程安全,又要追求接近 HashMap 的高性能。本文将深入剖析 ConcurrentHashMap 的整体设计思路,从数据结构、锁粒度、无锁读、CAS 无锁更新到多线程协助扩容,带你领略并发容器设计的精妙之处。

一、从 HashTable 到 ConcurrentHashMap

ConcurrentHashMap 出现之前,Hashtable 是 Java 中唯一的线程安全哈希表。它的实现非常粗暴:对所有方法(如 putget)都加上 synchronized 关键字,整个表被一把大锁保护。在高并发场景下,这把锁成为严重的性能瓶颈,因为任何时刻只有一个线程能够访问哈希表。

HashMap 虽然性能高,但非线程安全。于是,Java 并发包推出了 ConcurrentHashMap,旨在解决以下问题:

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

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

  • 无锁更新:对于简单的更新操作,使用 CAS(Compare And Swap)无锁算法替代加锁。

  • 多线程协助扩容:将耗时的扩容操作分摊到多个线程,降低单次扩容的停顿时间。

二、核心设计理念

ConcurrentHashMap 的整体设计可以概括为:

降低锁粒度 + 无锁读 + CAS 无锁更新 + 局部加锁

1. 为什么读操作可以不加锁?

在实际应用中,对哈希表的读操作远多于写操作。如果读操作也加锁,性能会大幅下降,甚至不如 HashtableConcurrentHashMap 的读操作完全不加锁,依靠 volatile 保证可见性。

volatile 的作用是:

  • 可见性 :一个线程修改了 volatile 变量,会立即刷新到主内存,其他线程读取时会直接从主内存读取,而不是自己的 CPU 缓存,从而总是读到最新值。

  • 禁止指令重排序:保证对象的构造过程不会提前将引用暴露出去,避免读到半初始化对象。

ConcurrentHashMap 中,数组 tablevolatile 的,每个桶的节点(Node)的 valnext 也是 volatile 的。这样,当读线程访问某个桶时,它能看到最新的节点引用和值,无需加锁。

2. 如何降低锁粒度?

Hashtable 的锁是整个对象,而 ConcurrentHashMap 只锁住当前操作的桶(bucket)的头节点。每个桶对应一个 Node 链表的头节点(或红黑树的根节点)。当多个线程同时操作不同的桶时,它们可以完全并发,互不干扰。

这种设计称为分段锁(Segmented Locking) ,在 Java 8 中进一步演进为锁桶(Lock Striping),锁的粒度更细,并发度更高。

3. 什么时候使用 CAS?

对于简单的、无需遍历链表的操作,如:

  • 初始化空桶

  • 更新计数器(size

  • 修改 sizeCtl(扩容状态)

使用 CAS 可以避免加锁,实现无锁更新,进一步提升性能。

4. 为什么采用多线程协助扩容?

扩容需要将旧数组中的元素重新哈希分配到新数组,是一个耗时操作。如果单线程完成,可能会造成长时间停顿。ConcurrentHashMap 将扩容任务拆分成多个小任务,允许其他线程在插入数据时"顺带"帮忙完成一部分迁移工作,从而大幅减少整体扩容时间,避免性能毛刺。

三、数据结构演进:Java 8 的优化

Java 7 及之前的 ConcurrentHashMap 采用 分段锁 设计,内部由多个 Segment 组成,每个 Segment 独立加锁,锁粒度较粗。Java 8 彻底重构,采用 数组 + 链表 + 红黑树 的结构,锁的粒度细化到每个桶的头节点。

Java 8 的核心数据结构:

  • Node<K,V>[] table:哈希桶数组,volatile 修饰。

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

  • 当链表长度超过阈值(TREEIFY_THRESHOLD = 8)且数组长度大于 MIN_TREEIFY_CAPACITY = 64 时,链表转为红黑树(TreeNode),以优化极端情况下的查找性能。

  • ForwardingNode:在扩容过程中,用于标记已经被迁移的桶,指向新数组。

四、读操作(get)的实现

get 方法的流程如下:

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;
}
  • 无锁 :整个读过程没有任何 synchronizedLock

  • volatile 保证tablevolatile 的,Nodevalnext 也是 volatile 的,所以即使没有锁,也能读到最新值。

  • 特殊节点 :如果头节点的 hash 小于 0,说明是红黑树节点(TreeBin)或扩容节点(ForwardingNode),会调用其 find 方法进行查找,同样不加锁。

由于读操作不加锁,ConcurrentHashMap 的并发读性能几乎与 HashMap 持平,这是其设计的核心优势。

五、写操作(put)的实现

put 方法的关键步骤(简化):

java 复制代码
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();                    // 1. 初始化数组(CAS)
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 2. 空桶:CAS 直接插入新节点
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;
        }
        else if ((fh = f.hash) == MOVED)
            // 3. 正在扩容:协助扩容
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) {                   // 4. 锁住头节点
                // 双重检查,确保头节点未被修改
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {              // 普通链表
                        // 遍历链表,找到插入位置或更新
                        // ...
                    } else if (f instanceof TreeBin) {
                        // 红黑树插入
                        // ...
                    }
                }
            }
            // 5. 链表长度超过阈值,转为红黑树
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                break;
            }
        }
    }
    addCount(1L, binCount);   // 6. 计数更新(CAS)
    return null;
}
  • 初始化数组 :使用 CAS 保证只有一个线程能成功初始化 table

  • 空桶插入 :通过 casTabAt 原子地插入新节点,无需加锁。

  • 非空桶 :对桶的头节点加锁(synchronized (f)),这样其他线程访问同一桶会被阻塞,但不同桶的线程完全并发。

  • 红黑树:同样加锁,树节点的操作也在锁保护下进行。

  • 扩容协助:如果检测到正在扩容,当前线程会帮助迁移数据,降低扩容总时间。

这种设计使得写操作只在必要的时候加锁,且锁的粒度非常小,极大提升了并发写入能力。

六、CAS 无锁更新的应用

CAS(Compare And Swap)是一种乐观锁技术,它通过比较并交换的方式原子地更新变量,避免加锁开销。在 ConcurrentHashMap 中,CAS 被广泛应用于:

  • 初始化数组sizeCtl 通过 CAS 控制初始化状态。

  • 空桶插入casTabAt 确保只有成功插入的线程才能创建节点。

  • 计数器更新 :使用 LongAdder 思想的 CounterCell 数组,通过 CAS 更新计数值,高并发下避免了竞争。

  • 修改 sizeCtl :在扩容过程中,sizeCtl 用于控制扩容进度,通过 CAS 修改其值。

七、多线程协助扩容机制

扩容是 ConcurrentHashMap 中最复杂的部分。Java 8 实现了并发扩容,允许多个线程共同参与迁移,大幅缩短扩容时间。

1. 触发扩容的条件

  • 插入元素后,链表长度超过阈值(TREEIFY_THRESHOLD),且数组长度小于 MIN_TREEIFY_CAPACITY

  • 元素个数超过 sizeCtl(即扩容阈值)。

2. 扩容的核心思想

  • 当需要扩容时,首先计算新数组容量(原数组的 2 倍)。

  • 设置 sizeCtl 为一个负数,表示正在扩容,并记录参与扩容的线程数。

  • 创建 ForwardingNode 作为占位节点,指向新数组。

  • 将旧数组的桶分配给不同的线程去迁移。每个线程负责一个"步长"(stride)的桶,迁移完成后通过 CAS 修改 transferIndex 来获取下一个任务范围。

  • 线程在迁移某个桶时,先对该桶的头节点加锁,然后将其中的元素重新哈希分配到新数组的对应桶中。

  • 迁移完成后,将桶设置为 ForwardingNode,后续的读写操作会通过该节点转发到新数组。

3. 协助扩容

当线程执行 put 时,如果发现当前桶的头节点是 ForwardingNode,它会调用 helpTransfer 方法加入扩容队伍,帮助迁移数据,而不是傻傻等待。

这种设计使得扩容不再是单线程的"stop-the-world"操作,而是变成多线程并行执行,整体扩容时间大幅缩短,尤其在高并发场景下效果显著。

八、计数机制:LongAdder 思想

ConcurrentHashMap 需要知道当前元素个数(size()),且在高并发下计数必须准确。如果使用 AtomicLong,高并发 CAS 竞争会很激烈,影响性能。

Java 8 引入了 LongAdder 的思想,将计数分散到多个 CounterCell 中,每个线程通过 CAS 更新自己的计数器,最后求和。这样减少了 CAS 冲突,提高了计数性能。

关键类:

  • baseCount:基础的计数器。

  • CounterCell[] counterCells:辅助计数数组。

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

当 CAS 更新 baseCount 失败时,会尝试更新 CounterCell,若仍失败,则扩容 counterCells 数组。最终 size() 返回 baseCount 加上所有 CounterCell 的值之和。

九、总结

ConcurrentHashMap 通过一系列精妙的设计,在保证线程安全的前提下,提供了接近 HashMap 的读写性能。其设计精髓可以总结为:

  • 无锁读 :借助 volatile 保证可见性,读操作完全不加锁,实现高并发读。

  • 细粒度锁:锁的粒度从整个表缩小到每个桶的头节点,并发写能力大幅提升。

  • CAS 无锁更新:对于简单的、无需遍历的操作,使用 CAS 原子更新,避免锁开销。

  • 多线程协助扩容:将耗时的扩容任务拆分为多个子任务,允许写线程帮忙迁移,降低扩容停顿。

  • 高效计数 :采用 LongAdder 思想,分散计数压力,提升高并发下的计数性能。

相关推荐
keyborad pianist2 小时前
包装类、泛型、集合
java
华科易迅2 小时前
Spring装配对象方法-构造方法
java·后端·spring
AI视觉网奇2 小时前
语音播报 F5-TTS 部署笔记
笔记
是小蟹呀^2 小时前
Java 内部类详解:成员内部类、静态内部类、局部内部类与匿名内部类
java·内部类
于先生吖2 小时前
国际语言适配拼车系统 JAVA 后端源码 + 同城顺风车功能全解析
java·开发语言
ID_180079054732 小时前
超详细:Python 调用淘宝商品详情 API 完整教程
开发语言·python
czlczl200209252 小时前
KRaft原理
java·zookeeper
小恶魔巴巴塔2 小时前
C语言避免头文件循环
c语言·开发语言
西西学代码3 小时前
Flutter---构造函数
开发语言·javascript·flutter