ConcurrentHashMap 是 Java 中用于实现线程安全且高性能的哈希表。它在多线程环境中可以比传统的 HashMap 提供更好的并发性能,同时避免同步的复杂性和开销。下面介绍 ConcurrentHashMap 的设计与实现。
设计思想
1. 分段锁机制(Java 7 及之前)
在 Java 7 及之前版本中,ConcurrentHashMap 采用了分段锁(Segment Locking)的技术,以解决多线程环境下的并发问题。
具体实现:
-
Segment:ConcurrentHashMap 将整个哈希表划分为多个 Segment,每个 Segment 拥有自己的锁和哈希桶。
- 每个 Segment 就像是一个小型的独立哈希表,并且可以并行操作。
- 通过限制每个 Segment 的锁范围,多个线程可以同时访问不同的 Segment,从而提高并发性能。
-
锁的粒度:传统的同步容器(如 Hashtable)使用的是全表锁,这样虽然简单,但并发性能较差,因为任何对表的写操作都会阻塞其它操作。而 ConcurrentHashMap 通过分段锁将锁的粒度降低到 Segment 级别,减少了锁争用,提高了吞吐量。
2. CAS(Compare-And-Swap)操作(Java 8 及之后)
在 Java 8 中,ConcurrentHashMap 摒弃了 Segment 的设计,转而采用更细粒度的锁和 CAS 操作来保证并发安全。
具体实现:
-
CAS 操作:这是无锁算法的核心,通过乐观并发控制来实现原子性操作。
- CAS 使用硬件指令(如
compareAndSwap),通过比较期望值和当前值,如果一致则更新为新值,否则重试,直到成功。 - 这种方式避免了线程阻塞,不需要上下文切换,因此性能非常高。
- CAS 使用硬件指令(如
-
链表和红黑树:为了进一步优化,在处理哈希冲突时,ConcurrentHashMap 先使用链表存储节点,当链表长度超过一定阈值时(默认是8),链表会转换为更高效的红黑树,以优化查找性能。
- 红黑树是一种自平衡二叉查找树,其查找、插入、删除操作的时间复杂度为 (O(\log n)),相比于链表的 (O(n)) 更加高效。
3. 更细粒度的并发控制
Java 8 的 ConcurrentHashMap 中,通过更细粒度的并发控制实现高效并发,包括以下几个方面:
- Node:基本的链表节点,用于存储键值对。
- TreeNode:当链表长度超过阈值时,链表节点会转换为 TreeNode,组成红黑树,以提高查询效率。
- TreeBin:封装和管理红黑树的结构,负责 TreeNode 的插入、删除和查找等操作。
主要方法
-
get(Object key) :
- 根据 key 的 hash 值找到对应的哈希桶,然后遍历桶中的链表或树结构来查找匹配的节点。
-
put(K key, V value) :
- 计算 key 的 hash 值,定位到对应的哈希桶。
- 使用 CAS 操作尝试插入新节点,如果失败则使用 synchronized 块进行插入。
- 如果链表长度超过阈值,则转换为红黑树。
-
remove(Object key) :
- 找到对应的哈希桶,使用 synchronized 块进行删除操作。
-
resize() :
- 当哈希桶的负载因子超过设定值时,触发扩容操作。
- 新建一个更大的数组,将旧数组中的数据重新散列到新数组中。
优点
- 高并发性能:通过细粒度的锁和 CAS 操作,实现高效的并发访问。
- 避免全局锁:相比于 Hashtable 的全局锁,ConcurrentHashMap 减少了锁的竞争。
- 动态扩容:支持动态扩容,且扩容过程是渐进式的,不会导致长时间的停顿。
核心方法解析
ConcurrentHashMap 的主要方法包括 get, put, remove 和 resize。下面详细解释这些方法的实现及其在并发环境下的处理策略。
Node
Node 类是 ConcurrentHashMap 的基本存储单元,每个桶(bucket)里存储的是一个链表或树形结构(在 Java 8 及以后)。Node 包含键值对对象,以及指向下一个 Node 的引用。
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
volatile V val;
volatile Node<K, V> next;
Node(int hash, K key, V val, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
// Getter methods and other relevant methods...
}
TreeNode 和 TreeBin
当链表长度超过阈值时,链表会转换为红黑树,以优化查找性能。TreeNode 是红黑树的节点,而 TreeBin 则是用于管理这棵树。
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;
TreeNode(int hash, K key, V val, Node<K,V> next, TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
}
static final class TreeBin<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// Methods to manipulate the tree...
}
1. get(Object key)
获取指定键对应的值,主要步骤如下:
- 计算哈希:计算键的哈希值,定位到具体的桶位置。
- 检查桶:如果该桶不为空,则遍历该桶的链表(或红黑树)查找对应的节点。
- 返回值:找到则返回该节点的值,否则返回
null。
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) { // 检查第一个节点
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
} else if (eh < 0) // 如果是红黑树节点
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) { // 遍历链表
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
2. put(K key, V value)
插入或更新键值对,主要步骤如下:
-
参数校验:检查键值是否为
null。 -
计算哈希:计算键的哈希值。
-
初始化表:如果哈希表还未初始化,则进行初始化。
-
插入节点:
- 定位到具体的桶位置,通过
CAS尝试插入新节点。 - 如果桶中已有节点,使用
synchronized锁进行插入。 - 如果链表长度超过阈值,将链表转化成红黑树以提高性能。
- 定位到具体的桶位置,通过
-
返回结果:返回旧值(如果存在)。
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();
// 尝试通过 CAS 插入新节点到空桶中
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K, V>(hash, key, value, null)))
break; // 成功插入新节点
// 如果该位置正在扩容,则帮助扩容
} else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 处理非空桶中的冲突情况
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)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
3. remove(Object key)
删除指定键对应的键值对,主要步骤如下:
- 计算哈希:计算键的哈希值,定位到具体的桶位置。
- 遍历节点:使用
synchronized锁定桶,从链表或红黑树中查找并删除节点。 - 返回结果:如果找到该节点,则删除并返回旧值,否则返回
null。
public V remove(Object key) {
return replaceNode(key, null, null);
}
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode()); // 计算键的哈希值,以确保哈希值分布均匀。
for (Node<K, V>[] tab = table;;) { // 循环查找正确的桶。
Node<K, V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0 || // 如果哈希表为空或尚未初始化。
(f = tabAt(tab, i = (n - 1) & hash)) == null) // 定位到具体的桶位置,如果桶为空。
return null; // 没有找到对应的节点,返回 null。
else if ((fh = f.hash) == MOVED) // 检查桶是否正在进行扩容操作。
tab = helpTransfer(tab, f); // 帮助完成扩容操作。
else {
V oldVal = null;
boolean validated = false;
synchronized (f) { // 锁定桶进行后续操作。
if (tabAt(tab, i) == f) { // 确认第一个节点没有发生变化。
if (fh >= 0) { // 链表节点处理。
validated = true; // 验证成功。
for (Node<K, V> e = f, pred = null;;) { // 遍历链表节点。
K ek;
if (e.hash == hash &&
((ek = e.key) == key || (ek != null && key.equals(ek)))) { // 找到匹配的节点。
V ev = e.val;
if (cv == null || cv == ev || (ev != null && cv.equals(ev))) { // 比较当前值是否匹配给定的比较值。
oldVal = ev; // 保存旧值。
if (value != null) // 如果提供了新的值。
e.val = value; // 更新节点值。
else if (pred != null) // 删除节点。
pred.next = e.next; // 修改前驱节点的 next 指针。
else
setTabAt(tab, i, e.next); // 修改桶头节点。
}
break; // 结束循环。
}
pred = e; // 前驱节点向前移动。
if ((e = e.next) == null) // 移动到下一个节点。
break;
}
} else if (f instanceof TreeBin) { // 红黑树节点处理。
validated = true; // 验证成功。
TreeBin<K, V> t = (TreeBin<K, V>) f;
TreeNode<K, V> r, p;
if ((r = t.root) != null && // 查找红黑树中的匹配节点。
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv || (pv != null && cv.equals(pv))) { // 比较当前值是否匹配给定的比较值。
oldVal = pv; // 保存旧值。
if (value != null) // 如果提供了新的值。
p.val = value; // 更新节点值。
else
t.removeTreeNode(p); // 从红黑树中删除节点。
}
}
}
}
}
if (validated) { // 如果验证成功。
if (oldVal != null) { // 如果找到并处理了节点。
if (value == null) // 如果是删除操作。
addCount(-1L, -1); // 更新计数器。
return oldVal; // 返回旧值。
}
break; // 结束外层 for 循环。
}
}
}
return null; // 如果没有找到匹配的节点,返回 null。
}
扩容机制
扩容触发条件
当哈希表中的元素数量达到当前容量上限的某个阈值时(通常为容量的 0.75 倍),会触发扩容操作。这个阈值称为 resizeStamp。在 ConcurrentHashMap 中,扩容的触发由以下条件决定:
- 当前负载因子超过阈值
扩容流程
-
标记扩容开始:
- 通过 CAS 操作设置扩容标识位,防止多个线程同时执行扩容操作。
-
创建新数组:
- 新数组的大小通常是原数组的两倍。
-
转移数据:
- 数据从旧数组转移到新数组。转移过程是分段进行的,以减少单次锁定时间。每个线程负责处理一部分数据。
-
Forwarding Nodes:
- 在转移过程中,会用
Forwarding Node标记已经处理过的桶。当其他线程访问这些桶时,会被引导到新数组中去。
- 在转移过程中,会用
-
调整索引位置:
- 在扩容过程中,需要重新计算每个元素的新索引位置,这通常是通过与新的数组大小取模运算 (
hash % newCapacity) 来完成的。
- 在扩容过程中,需要重新计算每个元素的新索引位置,这通常是通过与新的数组大小取模运算 (
-
清理旧数组:
- 当所有桶都被处理完毕后,旧数组会被废弃,所有读写操作都会转向新数组。
扩容过程中的并发控制
- 在整个扩容过程中,通过 CAS 和自旋锁等机制确保线程安全。
- 通过分段的方式,使得扩容操作可以被多个线程并行执行,从而提升性能。
核心方法:transfer
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;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false;
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
} else if (U.compareAndSwapInt(this, TRANSFERINDEX,
nextIndex, nextBound =
(nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextTab.length) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n;
}
} else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true;
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
// 链表节点处理逻辑在此
// ...
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
} else if (f instanceof TreeBin) {
// 红黑树节点处理逻辑在此
// ...
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
详细解释
初始化部分
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
- 计算每个线程要处理的桶范围:根据 CPU 数量和当前数组长度
n计算每个线程要处理的槽位范围(stride),并确保该范围不小于MIN_TRANSFER_STRIDE。 ForwardingNode:创建一个特殊的节点fwd,用来标记已经迁移过的数据位置。
主循环和槽位分配
boolean advance = true;
boolean finishing = false;
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
} else if (U.compareAndSwapInt(this, TRANSFERINDEX,
nextIndex, nextBound =
(nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
-
槽位范围确定和更新:
advance用于控制是否进入下一步的分配逻辑。- 如果
i超出当前线程负责的范围 (i >= bound),或如果正在完成最后的检查阶段 (finishing),则停止推进。 - 使用
CAS操作更新全局transferIndex,确保多个线程不会重复处理相同的槽位范围。 - 确定当前线程需要处理的新的槽位范围,并更新
i以开始处理这些槽位。
边界检查和完成标志
if (i < 0 || i >= n || i + n >= nextTab.length) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // 重新检查所有
}
}
-
边界检查:
- 如果当前索引
i超出有效范围,则进行一系列检查和操作,以决定是否完成迁移。 - 如果
finishing标志为真,则表示所有槽位的迁移已经完成,可以将nextTable设为null并更新当前table。 - 否则,通过
CAS操作减少sizeCtl,并检查是否所有线程都完成了他们的任务。 - 如果是最后一个线程完成任务,将
finishing和advance设置为真,继续检查所有槽位。
- 如果当前索引
数据迁移(链表和红黑树)
链表节点处理
if (fh >= 0) {
// 链表节点处理逻辑在此
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
} else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash;
K pk = p.key;
V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
} else if (f instanceof TreeBin) {
-
链表节点处理:
- 如果当前桶中的节点是以链表形式存在的(
fh >= 0),它会被拆分成两个部分ln和hn。 - 使用
hash & n检查每个节点属于低位或高位,并将其添加到ln或hn中。 - 最后将处理后的
ln和hn分别存储到新数组的对应位置,并在旧桶位置放置ForwardingNode标记。
- 如果当前桶中的节点是以链表形式存在的(
红黑树节点处理
// 红黑树节点处理逻辑在此
TreeBin<K,V> t = (TreeBin<K,V>) f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
} else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<>(lo) : null;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<>(hi) : null;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
-
红黑树节点处理:
- 如果当前桶中的节点是红黑树形式的(
f instanceof TreeBin),则需要更复杂的处理逻辑。 - 红黑树中的节点也会根据
hash & n拆分成两部分lo和hi。 - 根据拆分后的节点数量,决定是否需要转回链表形式(当节点数少于或等于
UNTREEIFY_THRESHOLD)或者继续使用红黑树。 - 最后,将处理后的
lo和hi分别存储到新数组的相应位置,并在旧桶位置放置ForwardingNode标记。
- 如果当前桶中的节点是红黑树形式的(
总结
transfer 方法的核心逻辑可以总结如下:
-
初始化:计算每个线程要处理的槽位范围,并创建
ForwardingNode。 -
槽位分配与处理:
- 多线程通过 CAS 操作分配各自负责的槽位范围。
- 对每个槽位进行处理,如果槽位空闲则设置为
ForwardingNode。 - 如果槽位中有链表节点,则按照
hash & n拆分为低位和高位链表,并分别存储到新表中。 - 如果槽位中有红黑树节点,则根据
hash & n拆分为低位和高位子树,并分别存储到新表中。
-
边界检查与完成:
- 检查是否所有槽位都已经处理完毕,如果处理完毕则更新
table引用,释放临时表并返回。 - 通过 CAS 操作减少
sizeCtl,确保多个线程协同工作。
- 检查是否所有槽位都已经处理完毕,如果处理完毕则更新
-
同步与锁操作:
- 对每个非空槽位进行同步,防止多线程同时操作导致数据不一致。
这个方法保证了在多线程环境下,ConcurrentHashMap 的扩容操作能够安全且高效地进行。