1. ConcurrentHashMap 的基本原理
1.1 ConcurrentHashMap 的结构
ConcurrentHashMap
是一种线程安全的哈希表。它通过将数据分成多个段(在 Java 8 之前)或桶(Java 8 之后),使得多个线程可以并发地访问不同的部分,从而减少了锁争用,提高了并发性能。
-
Java 7 及之前的实现:
ConcurrentHashMap
使用了一种称为“分段锁”(Segmented Lock)的技术。它将整个哈希表分成多个段(Segment
),每个段其实是一个小的哈希表,并且有自己的锁。这样,多个线程可以同时访问不同的段而不会发生竞争。 -
Java 8 及之后的实现:
ConcurrentHashMap
移除了分段锁的设计,转而采用一种更精细的锁机制,称为“锁分离”(Lock Striping)。每个桶(bucket)仍然是一个链表或树,但锁的粒度更加细化,锁的争用进一步减少。
1.2 分段锁的工作原理(Java 7)
在 Java 7 及之前的版本中,ConcurrentHashMap
的基本单位是 Segment
。每个 Segment
是一个哈希表,它们各自独立,并发访问时通过独立的锁保护:
- 分段锁定:当线程要访问
ConcurrentHashMap
中的数据时,它会根据哈希值计算出对应的段(Segment
),并且仅锁定该段的锁(ReentrantLock
)。 - 分段读取:如果只是读取操作,没有任何锁定操作,因为
ConcurrentHashMap
保证了弱一致性——即读取操作可能会读取到最新写入的值,也可能读取到旧值,但不会导致崩溃。
1.3 锁分离机制(Java 8 及之后)
Java 8 引入了更细粒度的锁管理,使得锁的争用进一步减少,具体方式如下:
- CAS 操作:使用
Compare-And-Swap
(CAS)操作来实现无锁的快速更新操作,如节点插入或更新。CAS 操作能够在多个线程竞争时依然保持高效性。 - Synchronized + 内置锁:在链表转换为红黑树的过程中,
ConcurrentHashMap
通过内置锁来保护单个桶的操作,而不是锁住整个Segment
。 - 减少锁粒度:桶级别的锁使得锁粒度更小,多个线程只要不访问同一个桶,便不会产生锁竞争。
2. 锁的加法与使用
2.1 如何加锁
在 ConcurrentHashMap
中,锁的加法取决于操作类型:
- 读取操作:通常不需要加锁,因为读取操作不会影响哈希表的结构,且
ConcurrentHashMap
使用了volatile
关键字确保变量的可见性。 - 写入操作:在写入时,
ConcurrentHashMap
需要确保哈希表的结构不被破坏,这时会对相关的桶或段加锁。在 Java 8 中,写操作会锁定具体的桶或节点。
示例(Java 8 实现):
final V putVal(K key, V value, boolean onlyIfAbsent) {
// Calculate the hash value of the key
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 an empty bucket
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) { // Lock the first node of the bin
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;
}
}
}
}
}
}
}
}
在这个例子中:
- CAS 操作:用于无锁地尝试更新或插入节点。
- 同步锁:在需要更新链表或树结构时,加锁保护。
2.2 分段越多越好?
并不是分段(或者说桶)的数量越多越好。分段过多或过少都会影响性能:
- 分段过少:会导致大量线程争用同一个锁,降低并发性能。
- 分段过多:每个段或桶的空间开销增大,同时桶的数量与内存占用呈线性关系。此外,哈希冲突的概率增加,导致更多的链表或树操作,反而降低性能。
理想的分段数量取决于系统的实际并发度和可用内存。默认情况下,ConcurrentHashMap
的初始容量和并发级别都设置为合理的默认值,能够满足大部分场景的需求。
3. 适当配置 ConcurrentHashMap
3.1 初始容量和并发级别
ConcurrentHashMap
的初始容量和并发级别应根据应用程序的实际情况来配置:
- 初始容量:需要根据预期的最大存储元素数量以及装载因子(默认 0.75)来设置,以减少动态扩容的开销。
- 并发级别(在 Java 7 中适用):设置合理的并发级别,使得多个线程可以同时访问不同的段,提高并发性能。
示例:
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(16, 0.75f, 16);
这个示例中:
- 初始容量为 16
- 装载因子为 0.75
- 并发级别为 16
3.2 扩容策略
ConcurrentHashMap
的扩容是渐进式的,不会一次性扩容整个哈希表,而是分成多个步骤,逐步扩容每个桶。这样避免了在高并发场景下扩容造成的性能瓶颈。
4. 总结
ConcurrentHashMap
是一种高效的并发集合,通过分段锁或细粒度锁机制保证了线程安全。它通过合理的锁设计和无锁操作(如 CAS),在高并发环境下提供了优秀的性能。
在选择分段数量时,需要平衡内存开销和并发性能。分段并不是越多越好,合理配置并发级别和初始容量,才能充分发挥 ConcurrentHashMap
的性能优势。Java 8 之后的实现通过进一步细化锁的粒度,使得 ConcurrentHashMap
在处理大量并发读写操作时,性能更加优越,适用于大多数并发场景。