ConcurrentHashMap是Java并发包中的一个线程安全的哈希表实现,它提供了高效的并发访问性能。本文将对ConcurrentHashMap的源码进行分析,以帮助大家更好地理解其内部实现原理。
整体结构
ConcurrentHashMap的主要组成部分有以下几个:
-
Node数组: 在 Java 1.8 版本中,ConcurrentHashMap 放弃了分段锁的设计,改为使用 CAS(Compare and Swap)操作和一系列辅助数据结构来保证并发操作的安全性。它不再有 Segment 结构,而是直接使用类似 HashMap 中的 Node 数组(也称为桶(bucket)数组),Node 类同样用于存储键值对。
-
链表/红黑树: 当桶中的元素数量达到一定阈值时,链表会转换为红黑树以优化查找性能。
-
CAS操作和相关原子类: JDK 1.8 的 ConcurrentHashMap 使用了 AtomicInteger 等原子类以及 Unsafe 提供的 CAS 操作,来实现在无锁或更细粒度的锁控制下完成插入、删除和查询等操作。
初始化
ConcurrentHashMap的初始化过程主要包括以下几个步骤:
- 构造函数调用: 当创建一个新的 ConcurrentHashMap 实例时,可以通过构造函数指定初始容量(initial capacity)、负载因子(load factor)和并发级别(concurrency level)。但在 Java 1.8 中,实际并未使用并发级别参数,因为取消了分段锁的设计,转而采用更加精细的粒度控制。
ConcurrentHashMap<K, V> map = new ConcurrentHashMap<>(initialCapacity, loadFactor);
-
容量调整: 初始化过程中,会将传入的初始容量向上调整到最接近的2的幂次方,因为它的内部数组长度必须是2的倍数,这样可以利用位运算快速定位槽位。
-
初始化table数组: 初始化时,首先检查 table 是否已存在(即是否已被其他线程初始化)。如果不存在,则会尝试初始化 table 数组。这个数组就是用来存放键值对节点(Node)的桶(bucket)。
-
同步控制: 使用一个名为 sizeCtl 的变量来协调初始化过程的并发控制。sizeCtl 的初始值代表不同的状态含义,例如负值表示正在进行初始化或扩容。
// 非静态内部类 Node<K, V>
transient volatile Node<K, V>[] table;
-
初始化操作: 线程尝试进行初始化时,会先CAS更新 sizeCtl 值,确保只有一个线程能够成功执行初始化。成功获得初始化权限的线程会根据给定的初始容量创建 table 数组,并将其长度设置为合适的值。
-
填充table: 初始化完成后,table 数组会被填充为空节点,以准备接收后续的插入操作。
put操作
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();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
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;
}
ConcurrentHashMap的put操作主要包括以下几个步骤:
-
校验输入:
- 检查传入的key和value是否为null,两者都不能为null,否则抛出NullPointerException。
-
计算hash值:
- 调用hash()函数计算key的哈希值。
-
定位槽位:
- 根据计算出的哈希值找到table数组中的索引位置。
-
初始化或扩容:
- 如果table尚未初始化或者发现容量不足(负载因子超过阈值),则可能触发一次扩容操作(resize)。扩容时会创建新的table数组,然后重新分配所有的键值对到新的数组中,同时调整相应的索引位置。
-
插入元素:
- 在目标槽位处,通过CAS(Compare And Swap)操作尝试将新节点插入到链表头部(或红黑树的根节点位置,如果已经转化为树结构)。
- 如果槽位为空,则直接尝试CAS插入新节点。
- 如果已经有节点存在,进入链表或树的插入逻辑:
- 遍历链表或树,寻找合适的位置(相同hash值和equals相等的key存在的位置)。
- 若找到已有键值对,替换原有值(根据onlyIfAbsent参数决定是否替换)。
- 若未找到相同键值对,则使用CAS或synchronized添加新节点至链表或树中。
- 在目标槽位处,通过CAS(Compare And Swap)操作尝试将新节点插入到链表头部(或红黑树的根节点位置,如果已经转化为树结构)。
-
更新大小:
- 插入成功后,如果之前桶内没有该键值对,则更新整体映射的大小。
get操作
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;
}
ConcurrentHashMap的get操作主要包括以下几个步骤:
-
计算hash值:
- 调用散列函数(通常通过 spread() 方法)计算 key 的哈希值。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
-
校验输入:
- 检查传入的 key 是否为 null,若为 null,则返回 null,因为 ConcurrentHashMap 不允许 null 键。
-
定位槽位:
- 将计算出的哈希值与 table 数组的长度进行按位与操作,确定要访问的数组槽位(桶)索引。
-
遍历槽位中的元素:
- 访问对应索引位置的桶,此时可能是单个节点、链表节点或红黑树节点:
- 如果桶为空,则直接返回 null,表示键不存在于映射中。
- 如果桶中只有一个节点(或链表长度为1),直接检查该节点的键是否与传入的 key 相等,如果相等则返回对应的值。
- 如果桶中包含链表结构,从头开始遍历链表,直到找到键值相等的节点,返回其值;若遍历完链表未找到匹配项,则返回 null。
- 如果桶中存储的是红黑树结构,则按照红黑树的查找算法遍历树节点,找到与 key 相匹配的节点并返回其值。
- 访问对应索引位置的桶,此时可能是单个节点、链表节点或红黑树节点:
-
内存可见性保证:
- 在 Java 1.8 中,虽然 get 操作不需要加锁,但由于 ConcurrentHashMap 中的节点(如链表或树节点的 value 字段)被声明为 volatile,因此可以保证当其他线程对映射进行修改时,读取操作能够看到最新的值,实现了线程间的内存可见性。
扩容操作
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
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.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
当ConcurrentHashMap中的元素数量超过阈值时,会触发扩容操作。扩容操作主要包括以下几个步骤:
-
初始化或更新扩容标志: 设置一个内部标志(如sizeCtl字段)来指示正在进行扩容,或者增加正在参与扩容的线程计数器。
-
创建新的table: 创建一个新的更大的Node数组,即新的table。新数组的大小通常是原数组的两倍。
-
迁移数据:
- 转移元素:遍历旧table的所有槽位,对于每个槽位内的链表或红黑树结构,将其中的元素重新计算哈希值,并根据新的容量重新定位到新的table中。
- 并发迁移:多个线程可以参与到扩容过程中,每个线程负责一部分槽位的数据迁移工作。迁移过程中,使用CAS操作和其他并发控制手段确保数据迁移的正确性和线程安全性。
-
清理旧table: 当所有元素都迁移完毕后,将旧的table引用替换为新的table。这个替换操作需要保证原子性。
-
同步控制: 在迁移过程中,对于正在进行扩容的槽位,其他线程试图对该槽位进行写操作时会被阻塞,等待扩容完成。同时,扩容过程结束后,释放相关的锁,让其他线程可以继续进行读写操作。
-
更新阈值: 根据新的容量计算新的扩容阈值,确保未来在元素数量再次增长到一定程度时才会触发下一轮扩容。
负载因子
ConcurrentHashMap 在 Java 1.8 及更高版本中的负载因子(load factor)之所以默认设置为 0.75,主要是基于以下原因:
-
空间和时间成本平衡: 负载因子决定了哈希表何时应该扩容。设置为 0.75 表示当哈希表中的元素数量达到了容量的 75% 时,将会触发扩容操作。这样设计是因为如果负载因子过大,哈希表会趋向于更长时间才扩容,这意味着更多的元素会在哈希表填满的状态下进行操作,增加哈希冲突的概率,进而影响 get、put 等操作的性能。相反,如果负载因子较小,虽然哈希冲突减少,但扩容操作会更加频繁,浪费更多内存空间。0.75 是一个经过实践检验的折衷点,能够在空间消耗和哈希冲突之间取得较好的平衡。
-
经验值: 在众多哈希表实现中,0.75 已经成为一个广泛接受的经验值。它在多种负载情况下表现良好,既能防止过早扩容造成的空间浪费,又能及时扩容避免因负载过高导致的性能问题。
-
二进制关系: 由于 ConcurrentHashMap 的容量总是2的幂,选择0.75作为一个负载因子,使得扩容时的新容量恰好是原容量的两倍,这样可以方便地通过位运算来重新定位元素,同时也简化了扩容操作的逻辑。
总结
ConcurrentHashMap是Java中一种线程安全的哈希表实现,尤其适用于多线程环境。在Java 1.7版本中采用了分段锁(Segment)技术来实现并发控制,而在1.8及更高版本中改用CAS(Compare and Swap)操作和原子类以减少锁竞争,提高并发效率。内部结构主要包括一个可动态扩容的数组,数组中的每个元素指向链表或红黑树(在适当条件下转换),存储键值对。put操作通过计算键的哈希值定位数组桶,借助CAS或轻量级锁插入新节点,而get操作则快速定位桶并通过无锁机制检索值。ConcurrentHashMap充分利用并发处理的优势,兼顾了高并发读写性能与数据一致性,是多线程编程中实现共享数据存储的理想选择。