ConcurrentHashMap 加锁机制深度剖析:JDK 1.7 与 JDK 1.8 对比
文章目录
一、引言
在多线程编程领域,ConcurrentHashMap
是一个被广泛使用的线程安全的哈希表实现。它提供了高效的并发访问性能,满足了众多高并发场景下的数据存储和检索需求。然而,理解其内部的加锁机制对于开发者充分发挥其性能优势以及编写正确的并发代码至关重要。本文将深入探讨ConcurrentHashMap
在 JDK 1.7 和 JDK 1.8 版本中插入数据和读数据时的加锁机制,并通过详细的源码分析进行对比。
二、JDK 1.7 中 ConcurrentHashMap 的加锁机制
2.1 数据结构概述
在 JDK 1.7 中,ConcurrentHashMap
采用了分段锁(Segment)的设计。整个ConcurrentHashMap
由多个 Segment 组成,每个 Segment 类似于一个独立的HashMap
,并且每个 Segment 都拥有自己独立的锁。这种结构使得在并发操作时,不同 Segment 之间的操作可以并行执行,只有当多个线程同时访问同一个 Segment 时才会发生锁竞争,从而在一定程度上提高了并发性能。
2.2 插入数据时的加锁流程
- 定位目标 Segment
首先,通过对键的哈希值进行特定的位运算,计算出该键应该存储在哪个 Segment 中。这里使用了public V put(K key, V value) { Segment<K,V> s; if (value == null) throw new NullPointerException(); int hash = hash(key); int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment s = ensureSegment(j); return s.put(key, hash, value, false); }
segmentShift
和segmentMask
这两个常量来进行精确的定位。如果定位到的 Segment 为空,则调用ensureSegment
方法来创建该 Segment。 - Segment 的 put 操作
在获取到目标 Segment 后,首先尝试使用final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntry<K,V> node = tryLock()? null : scanAndLockForPut(key, hash, value); V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash; HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { if (e!= null) { // 处理键已存在的情况,遍历链表更新值 K k; if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; } break; } e = e.next; } else { if (node!= null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; }
tryLock()
方法获取该 Segment 的锁。tryLock()
方法会立即返回,如果获取锁成功,则继续后续操作;如果获取锁失败,则调用scanAndLockForPut
方法进行自旋等待锁。
在获取到锁后,会在该 Segment 的哈希表中查找键对应的节点。如果键已存在,则更新节点的值;如果键不存在,则创建一个新的节点并插入到链表头部。同时,会检查当前 Segment 的元素数量是否超过阈值,如果超过且哈希表的容量未达到最大值,则进行 rehash 操作以扩大哈希表的容量。private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) { HashEntry<K,V> first = entryForHash(this, hash); HashEntry<K,V> e = first; HashEntry<K,V> node = null; int retries = -1; // negative while locating node while (!tryLock()) { HashEntry<K,V> f; // to recheck first below if (retries < 0) { if (e == null) { if (node == null) // speculatively create node node = new HashEntry<K,V>(hash, key, value, null); retries = 0; } else if (key.equals(e.key)) retries = 0; else e = e.next; } else if (++retries > MAX_SCAN_RETRIES) { lock(); break; } else if ((retries & 1) == 0 && (f = entryForHash(this, hash))!= first) { e = first = f; // re - traverse if entry changed retries = -1; } } return node; }
scanAndLockForPut
方法会不断尝试获取锁,在自旋过程中,如果发现当前遍历的链表节点发生了变化(例如其他线程在插入或删除节点),则重新遍历链表。如果自旋次数超过MAX_SCAN_RETRIES
,则调用lock()
方法阻塞等待锁,直到成功获取锁为止。
2.3 读数据时的加锁情况
在 JDK 1.7 中,读操作一般不需要加锁。ConcurrentHashMap
通过使用volatile
关键字来保证数据的可见性。
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u))!= null &&
(tab = s.table)!= null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e!= null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
读操作首先通过位运算定位到目标 Segment,然后使用UNSAFE.getObjectVolatile
方法获取该 Segment 的哈希表以及表中的节点。由于HashEntry
中的next
和value
字段都被声明为volatile
,所以在读取链表时,可以保证读取到的数据是最新的,即使在其他线程进行插入或删除操作的情况下,也不会出现数据不一致的情况。
三、JDK 1.8 中 ConcurrentHashMap 的加锁机制
3.1 数据结构变革
在 JDK 1.8 中,ConcurrentHashMap
对数据结构进行了重大改进,放弃了分段锁机制,而是采用了CAS
(Compare and Swap)操作和synchronized
关键字相结合的方式。同时,当链表的长度超过一定阈值(8)时,会将链表转换为红黑树,以提高查询和插入的性能。
3.2 插入数据时的加锁流程
- 初始化或扩容操作
首先,通过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; // 这里使用CAS操作添加新节点 } 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.value; if (!onlyIfAbsent) e.value = 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; }
spread
方法对键的哈希值进行处理,以减少哈希冲突。如果当前哈希表为空(tab == null
或tab.length == 0
),则调用initTable
方法进行初始化。
如果定位到的数组位置为空,则使用casTabAt
方法(基于CAS
操作)尝试将新节点直接插入到该位置。如果插入成功,则结束操作。
如果定位到的数组位置正在进行扩容(fh == MOVED
),则调用helpTransfer
方法帮助进行扩容操作。
如果定位到的数组位置不为空且不是正在扩容,则对该位置的头节点使用synchronized
关键字加锁。在加锁后,判断该节点是链表节点(fh >= 0
)还是红黑树节点(f instanceof TreeBin
)。如果是链表节点,则遍历链表查找键是否存在,若存在则更新值,若不存在则在链表尾部插入新节点;如果是红黑树节点,则调用红黑树的插入方法putTreeVal
进行插入操作。
最后,检查链表的长度是否达到转换为红黑树的阈值(TREEIFY_THRESHOLD
),如果达到则调用treeifyBin
方法将链表转换为红黑树。 - CAS 操作的原理
CAS
操作是一种无锁的原子操作,它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。当且仅当内存位置 V 的值等于预期原值 A 时,CAS
操作才会将内存位置 V 的值更新为新值 B,否则不执行任何操作。在ConcurrentHashMap
的插入操作中,casTabAt
方法就是利用CAS
操作来尝试将新节点插入到数组的指定位置,只有在该位置为空时才能插入成功,从而避免了使用锁带来的性能开销。
3.3 读数据时的加锁情况
在 JDK 1.8 中,读操作同样不需要加锁。
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;
}
读操作首先通过spread
方法计算哈希值,然后定位到数组的相应位置。使用tabAt
方法通过Unsafe
类的getObjectVolatile
操作获取该位置的节点。由于Node
类中的val
和next
字段都被声明为volatile
,所以在读取链表或红黑树时,可以保证读取到的数据是最新的。对于红黑树节点,通过调用find
方法在红黑树中查找键对应的值。
四、JDK 1.7 与 JDK 1.8 加锁机制对比
- 锁的粒度
- JDK 1.7:采用分段锁机制,锁的粒度相对较粗,每个 Segment 是一个较大的锁单元,同一时间只有一个线程可以访问同一个 Segment 中的数据。虽然不同 Segment 之间的操作可以并发执行,但在高并发场景下,如果多个线程频繁访问同一个 Segment,仍然可能会出现锁竞争的情况,从而影响性能。
- JDK 1.8:放弃了分段锁,使用
CAS
操作和synchronized
关键字相结合的方式。CAS
操作可以在无锁的情况下进行一些原子性的更新操作,而synchronized
关键字只在对链表或红黑树的头节点进行操作时才使用,锁的粒度更细,大大减少了锁竞争的可能性,提高了并发性能。
- 数据结构与性能
- JDK 1.7:在数据结构上,每个 Segment 内部是一个链表结构,在处理大量数据时,链表的查询和插入性能会随着链表长度的增加而下降,容易出现哈希冲突。
- JDK 1.8:引入了红黑树结构,当链表长度超过一定阈值时,会将链表转换为红黑树。红黑树的查询和插入性能在平均情况下为 O(log n),相比链表的 O(n)性能有了显著提升,尤其是在数据量较大且哈希冲突较多的情况下,性能优势更加明显。
- 读操作的实现
- JDK 1.7:读操作利用
volatile
关键字保证数据的可见性,通过UNSAFE.getObjectVolatile
方法获取数据,在读取链表时,由于链表节点的next
和value
字段是volatile
的,所以可以保证读取到的数据是最新的。 - JDK 1.8:读操作同样利用
volatile
关键字和Unsafe
类的getObjectVolatile
操作保证可见性,在读取链表或红黑树时,也能保证数据的一致性。并且,在红黑树的实现中,通过find
方法进行查找,进一步优化了查询性能。
- JDK 1.7:读操作利用
五、总结
ConcurrentHashMap
在 JDK 1.7 和 JDK 1.8 中通过不同的加锁机制和数据结构设计,实现了高效的并发访问性能。JDK 1.7 的分段锁机制在一定程度上提高了并发性能,但锁的粒度较粗,在高并发场景下可能存在性能瓶颈。而 JDK 1.8 采用的CAS
操作和synchronized
关键字相结合的方式,以及引入红黑树的数据结构,使得锁的粒度更细,性能得到了显著提升。深入理解这些加锁机制和数据结构的变化,有助于开发者在实际应用中更好地使用ConcurrentHashMap
,编写高效、正确的并发代码。
无论是在高并发的网络服务器应用中,还是在大数据处理等领域,ConcurrentHashMap
都发挥着重要的作用。通过不断优化和改进,ConcurrentHashMap
也在持续适应着日益增长的并发编程需求。希望本文的分析能够帮助读者更好地掌握ConcurrentHashMap
的内部实现原理,从而在实际工作中能够灵活运用,解决各种并发编程的问题。