云计算百科
云计算领域专业知识百科平台

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——基本存储节点

Node 是 ConcurrentHashMap 中最基础的存储单元:

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;
}
}

关键设计:val 和 next 都使用了 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——最核心的控制字段

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

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); // 当前线程协助扩容

如果桶位的头节点是 ForwardingNode(hash == 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 不需要加锁?

  • volatile 保证可见性:Node 的 val 和 next 都是 volatile 的,写入的结果对所有线程立即可见

  • table 是 volatile 的:数组引用本身保证可见性

  • Node 的 hash 和 key 是 final 的:一旦创建不可变

  • 扩容时的特殊处理:通过 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 扩容的核心设计要点

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

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

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

  • 链表拆分:原链表被拆分为两个链表,分别放入新表的 i 和 i + n 位置

  • CAS 控制并发:通过 CAS 修改 sizeCtl 和 transferIndex 协调多线程

  • 多线程扩容的优势:扩容时间随着参与线程数增加而缩短,充分利用多核 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 在并发编程领域的不断进步:

    核心要点回顾

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

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

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

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

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

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

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

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

  • 性能对比

    容器

    线程安全

    并发性能

    适用场景

    HashMap

    最高

    单线程环境

    Hashtable

    ✅(全表锁)

    极低

    遗留代码

    ConcurrentHashMap (JDK 7)

    ✅(分段锁)

    中等并发

    ConcurrentHashMap (JDK 8)

    ✅(CAS + 锁头节点)

    高并发首选

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

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » ConcurrentHashMap 深度解析:从分段锁到 CAS 的进化之路
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!