ConcurrentHashMap 深度解析:从分段锁到 CAS 的进化之路

一、引言:HashMap 的线程安全困境

HashMap 是 Java 中最常用的容器之一,但它有一个致命缺陷——线程不安全。在多线程环境下,HashMap 在扩容时可能形成环形链表,导致 get() 操作陷入死循环,CPU 飙升到 100%。

那用 Hashtable 呢?它所有方法都加了 synchronized,相当于给整张表上了一把大锁,同一时刻只允许一个线程操作,并发性能极差。

于是,ConcurrentHashMap 应运而生。它既保证了线程安全,又追求极高的并发性能,是 Java 并发容器中最闪耀的明星。

核心定位ConcurrentHashMap 是一个线程安全的哈希表,设计目标是在最小化更新操作对哈希表占用的同时,保持与 HashMap 相当的空间消耗,并支持多线程高效并发访问。

从 Java 7 到 Java 8,ConcurrentHashMap 经历了一次彻底的重写,代码量从 1000 多行暴涨到 6000 多行。下面我们从 JDK 8 的视角,深入源码一探究竟。

二、JDK 7 vs JDK 8:架构的全面进化

2.1 JDK 7:分段锁(Segment Lock)

Java 7 中的 ConcurrentHashMap 采用了分段锁技术:将数据分成多个 Segment,每个 Segment 独立加锁。

  • Segment 继承自 ReentrantLock,每个 Segment 是一把独立的锁

  • 默认 16 个 Segment,理论上支持 16 个线程并发写入

  • 锁的粒度是整个 Segment,一个 Segment 内的所有操作互斥

2.2 JDK 8:CAS + synchronized

Java 8 彻底摒弃了 Segment 的设计,采用了与 HashMap 相同的数据结构——数组 + 链表 + 红黑树,并使用 CAS + synchronized 保证线程安全。

对比维度

JDK 7

JDK 8

数据结构

Segment数组 + HashEntry数组 + 链表

Node数组 + 链表 + 红黑树

锁机制

ReentrantLock(分段锁)

CAS + synchronized(锁头节点)

锁粒度

整个 Segment

单个桶(链表/树的首节点)

并发度

固定(默认16)

动态(数组长度)

扩容参与

单线程扩容

多线程协助扩容

JDK 8 的核心优势:锁粒度从 Segment 缩小到单个桶节点,只要 hash 不冲突,不同桶的操作可以完全并行。

三、核心数据结构与成员变量

3.1 Node——基本存储节点

NodeConcurrentHashMap 中最基础的存储单元:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;          // volatile 保证可见性
    volatile Node<K,V> next; // volatile 保证可见性

    Node(int hash, K key, V val, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.val = val;
        this.next = next;
    }
}

关键设计valnext 都使用了 volatile 修饰,确保一个线程对节点的修改对其他线程立即可见。

3.2 TreeNode——红黑树节点

当链表长度超过阈值时,链表会转换为红黑树。TreeNode 继承自 Node,增加了红黑树所需的指针:

static final class TreeNode<K,V> extends Node<K,V> {
    TreeNode<K,V> parent;  // 父节点
    TreeNode<K,V> left;    // 左子节点
    TreeNode<K,V> right;   // 右子节点
    TreeNode<K,V> prev;    // 前驱节点(用于维持双向链表)
    boolean red;           // 红/黑颜色标记
}

3.3 TreeBin——红黑树代理

注意:桶位中存储的不是 TreeNode 对象,而是 TreeBin 对象。TreeBin 是红黑树的代理容器,内部维护着红黑树的根节点 root 和双向链表的头节点 first

3.4 ForwardingNode——扩容标记节点

当某个桶的数据迁移完成后,该桶位会被设置为 ForwardingNode(简称 FWD 节点),其 hash 值为 MOVED(-1)。后续其他线程看到这个标记,就知道该桶正在扩容或已迁移完成。

3.5 核心成员变量

// 存储数据的数组,volatile 保证可见性
transient volatile Node<K,V>[] table;

// 扩容时使用的新数组(仅在扩容期间非空)
private transient volatile Node<K,V>[] nextTable;

// 核心控制字段:控制初始化和扩容
private transient volatile int sizeCtl;

// 默认初始容量 16
private static final int DEFAULT_CAPACITY = 16;

// 最大容量 2^30
private static final int MAXIMUM_CAPACITY = 1 << 30;

// 负载因子 0.75(固定,不可修改)
private static final float LOAD_FACTOR = 0.75f;

// 链表转红黑树阈值:8
static final int TREEIFY_THRESHOLD = 8;

// 红黑树转链表阈值:6
static final int UNTREEIFY_THRESHOLD = 6;

// 转红黑树的最小数组容量:64(小于此值优先扩容)
static final int MIN_TREEIFY_CAPACITY = 64;

3.6 sizeCtl——最核心的控制字段

sizeCtlConcurrentHashMap出镜率最高的字段,它的值在不同阶段代表不同含义:

sizeCtl 值

含义

0

默认值,尚未初始化

-1

正在初始化 table

< -1

正在扩容,低 16 位表示参与扩容的线程数(如 -N 表示有 N-1 个线程参与)

> 0

初始化完成后的扩容阈值(容量 × 0.75)

四、构造方法:延迟初始化

ConcurrentHashMap 采用了延迟初始化策略——构造方法只计算容量,并不真正创建 table 数组。

// 无参构造:什么也不做
public ConcurrentHashMap() { }

// 指定初始容量的构造方法
public ConcurrentHashMap(int initialCapacity) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
    // 计算大于 1.5 * initialCapacity + 1 的最小 2 的幂
    int cap = tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1);
    this.sizeCtl = cap;  // 只设置 sizeCtl,不创建 table
}

延迟初始化的好处:只有在第一次 put 时才真正创建数组,避免了不必要的内存占用。

tableSizeFor 方法确保容量始终是 2 的幂,这是为了后续使用位运算((n - 1) & hash)替代取模运算,提升性能。

注意loadFactor 虽然在构造方法中作为参数传入,但计算完 size 后并未被保存为成员变量,后续扩容阈值计算固定使用 0.75。

五、put 方法:线程安全的核心

put 方法是 ConcurrentHashMap 最复杂的部分,涉及初始化、CAS 插入、锁扩容、链表/树操作等多个环节。

5.1 入口与 hash 计算

public V put(K key, V value) {
    return putVal(key, value, false);
}

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // key 和 value 都不能为 null
    if (key == null || value == null) throw new NullPointerException();
    
    // spread:让高位参与寻址,使 hash 更分散
    int hash = spread(key.hashCode());
    int binCount = 0;  // 记录桶中元素个数,用于判断是否树化
    
    // 自旋(无限循环),直到操作成功
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        // ... 四种情况处理
    }
}

spread 方法将 key.hashCode() 的高位与低位混合,减少 hash 冲突:

static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}

5.2 四种情况处理

putVal 的核心是一个自旋循环,处理四种不同的情况:

Case 1:table 未初始化
if (tab == null || (n = tab.length) == 0)
    tab = initTable();  // 初始化 table

initTable() 通过 CAS 控制 sizeCtl,确保只有一个线程执行初始化:

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)  // 其他线程正在初始化
            Thread.yield();
        else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
            // CAS 将 sizeCtl 设为 -1,当前线程获得初始化权
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);  // 0.75 * n
                }
            } finally {
                sizeCtl = sc;  // 设置扩容阈值
            }
            break;
        }
    }
    return tab;
}
Case 2:桶位为空(无 hash 冲突)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    // 使用 CAS 将新节点放入空桶位
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break;  // CAS 成功,跳出循环
}

这里使用 CAS 无锁操作,不需要加锁,是最高效的插入场景。

Case 3:桶位正在扩容(FWD 节点)
else if ((fh = f.hash) == MOVED)
    tab = helpTransfer(tab, f);  // 当前线程协助扩容

如果桶位的头节点是 ForwardingNodehash == MOVED),说明该桶正在扩容迁移,当前线程会协助扩容

Case 4:正常插入(链表或红黑树)
else {
    V oldVal = null;
    synchronized (f) {  // 锁住头节点
        if (tabAt(tab, i) == f) {  // 双重检查
            if (fh >= 0) {  // 普通链表节点
                binCount = 1;
                for (Node<K,V> e = f;; ++binCount) {
                    K ek;
                    if (e.hash == hash && 
                        ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                        oldVal = e.val;
                        if (!onlyIfAbsent) e.val = value;
                        break;
                    }
                    Node<K,V> pred = e;
                    if ((e = e.next) == null) {
                        pred.next = new Node<K,V>(hash, key, value, null);
                        break;
                    }
                }
            } else if (f instanceof TreeBin) {  // 红黑树节点
                Node<K,V> p;
                binCount = 2;
                if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
                    oldVal = p.val;
                    if (!onlyIfAbsent) p.val = value;
                }
            }
        }
    }
    // 判断是否需要树化
    if (binCount != 0) {
        if (binCount >= TREEIFY_THRESHOLD)  // >= 8
            treeifyBin(tab, i);
        if (oldVal != null) return oldVal;
        break;
    }
}

关键设计:使用 synchronized 锁住桶的头节点f),而不是锁整张表。这意味着只有操作同一个桶的线程才会竞争锁,不同桶的操作完全并行。

5.3 addCount:元素计数与扩容触发

插入完成后,调用 addCount 增加元素数量,并检查是否需要扩容:

addCount(1L, binCount);

addCount 内部会判断当前元素数量是否超过 sizeCtl(扩容阈值),如果超过则触发 transfer 扩容。

六、get 方法:无锁读取的秘密

get 方法是 ConcurrentHashMap 性能的又一体现——全程不加锁

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) {
        
        if ((eh = e.hash) == h) {
            // 情况1:首节点就是目标节点
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0) {
            // 情况2:hash < 0,可能是 TreeBin 或 ForwardingNode
            // - 如果是 ForwardingNode(扩容中),去 nextTable 中查找
            // - 如果是 TreeBin,遍历红黑树
            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;
}

为什么 get 不需要加锁?

  1. volatile 保证可见性Nodevalnext 都是 volatile 的,写入的结果对所有线程立即可见

  2. tablevolatile:数组引用本身保证可见性

  3. Nodehashkeyfinal:一旦创建不可变

  4. 扩容时的特殊处理:通过 ForwardingNode.find() 到新表查找

这种设计使得 get 操作几乎不受锁竞争影响,性能极高。

七、扩容机制:多线程协同作战

扩容是 ConcurrentHashMap 最复杂的部分,也是它区别于普通 HashMap 的核心优势——支持多线程协同扩容

7.1 扩容触发条件

扩容在 addCount 方法中被触发:

if (check >= 0) {
    Node<K,V>[] tab, nt; int n, sc;
    while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
           (n = tab.length) < MAXIMUM_CAPACITY) {
        int rs = resizeStamp(n);
        if (sc < 0) {  // 已有线程在扩容
            if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                transferIndex <= 0)
                break;
            if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
                transfer(tab, nt);  // 当前线程协助扩容
        }
        else if (U.compareAndSetInt(this, SIZECTL, sc,
                                     (rs << RESIZE_STAMP_SHIFT) + 2))
            transfer(tab, null);  // 当前线程是第一个发起扩容的
        s = sumCount();
    }
}

7.2 transfer:数据迁移

transfer 方法负责将旧 table 的数据迁移到新 table(容量翻倍):

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // 计算每个线程负责的桶位数(步长)
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE;  // 最小 16

    if (nextTab == null) {  // 第一个发起扩容的线程创建新数组
        try {
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];  // 容量翻倍
            nextTab = nt;
        } catch (Throwable ex) {
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;  // 从最后一个桶开始分配任务
    }

    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false;

    // 自旋迁移数据
    for (int i = 0, bound = 0;;) {
        // ... 分配任务、迁移数据 ...
    }
}

7.3 扩容的核心设计要点

  1. 任务分片:每个线程每次负责 stride 个桶的迁移(默认 16)

  2. 从后往前迁移transferIndex 记录全局迁移进度,从高位向低位推进

  3. FWD 标记:迁移完成的桶位设置为 ForwardingNode,hash 值为 MOVED

  4. 链表拆分:原链表被拆分为两个链表,分别放入新表的 ii + n 位置

  5. CAS 控制并发:通过 CAS 修改 sizeCtltransferIndex 协调多线程

多线程扩容的优势:扩容时间随着参与线程数增加而缩短,充分利用多核 CPU 能力。

八、链表 ↔ 红黑树转换

8.1 链表转红黑树

当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转换为红黑树:

private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)  // < 64
            tryPresize(n << 1);  // 优先扩容
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) {
                if (tabAt(tab, index) == b) {
                    // 将链表节点转为 TreeNode,然后构建红黑树
                    TreeNode<K,V> hd = null, tl = null;
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val, null, null);
                        if ((p.prev = tl) == null) hd = p;
                        else tl.next = p;
                        tl = p;
                    }
                    // 用 TreeBin 替代原链表头节点
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

为什么阈值是 8? 源码注释指出,在理想情况下,桶中节点数服从泊松分布,一个桶中出现 8 个节点的概率仅为 0.00000006。因此 8 是一个足够保守的阈值。

8.2 红黑树转链表

当红黑树节点数减少到 ≤ 6 时,树退化为链表。

九、总结

ConcurrentHashMap 是 Java 并发容器中最闪耀的明星,它的设计体现了 Java 在并发编程领域的不断进步:

核心要点回顾

  1. 数据结构:数组 + 链表 + 红黑树,与 HashMap 1.8 保持一致

  2. 线程安全机制CAS(无竞争场景)+ synchronized(锁头节点),摒弃了 Segment 分段锁

  3. 锁粒度:从 Segment 级别细化到单个桶节点,并发度大幅提升

  4. 无锁读取get 方法全程不加锁,依赖 volatile 保证可见性

  5. 多线程扩容:支持多线程协同完成数据迁移,充分利用多核 CPU

  6. 延迟初始化:table 在第一次 put 时才真正创建

  7. put操作流程:首先先判断key和value是否为空,如果为空,则抛出异常,然后计算哈希值,进入自旋操作(CAS),如果table为空,则CAS初始化table,如果是正在扩容,则协助扩容,如果桶为空,则直接CAS插入,如果桶位有节点,则synchronized锁住头节点,遍历链表,有相同的则替换,没有则插入,然后判断是否需要树化。

  8. get操作流程:首先计算哈希值,定位桶,检查首节点,相同则直接返回,如果是fwd扩容中 则去nextable中寻找,如果是treebin,则去树中遍历,否则去链表中遍历。

性能对比

容器

线程安全

并发性能

适用场景

HashMap

最高

单线程环境

Hashtable

✅(全表锁)

极低

遗留代码

ConcurrentHashMap (JDK 7)

✅(分段锁)

中等并发

ConcurrentHashMap (JDK 8)

✅(CAS + 锁头节点)

高并发首选

ConcurrentHashMap 的成功,源于它对并发性能线程安全的精妙平衡。无论是面试还是日常开发,深入理解它的设计思想,都能让你写出更高效、更安全的并发代码。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值