一、引言: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(); // 初始化 tableinitTable()通过 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的成功,源于它对并发性能和线程安全的精妙平衡。无论是面试还是日常开发,深入理解它的设计思想,都能让你写出更高效、更安全的并发代码。