1. 概述
ConcurrentHashMap
是 Java 并发编程中用于处理高并发访问的线程安全的 HashMap
实现。它在多线程环境下提供了高性能的并发读写操作,同时保证数据的完整性和一致性。
2. 用途
- 多线程共享数据:在需要多个线程同时读写同一份数据,且要求数据保持一致性时,
ConcurrentHashMap
是理想的选择。 - 高性能并发处理:通过内部精细的并发控制和优化,
ConcurrentHashMap
能够在高并发场景下提供出色的性能。
3. 数据结构
ConcurrentHashMap
的数据结构基于分段锁(JDK 1.7 及之前)或 CAS(JDK 1.8 及之后)和哈希表。在 JDK 1.8 中,它使用了一种称为“Node”的节点数组和链表/红黑树的结构。
4. 底层实现原理
4.1 JDK1.7
ConcurrentHashMap
在 JDK 1.7 中的实现是基于分段锁(Segment)的,它将整个哈希表划分为若干个段(Segment),每个段都包含一个锁以及一个哈希表。在并发环境下,当需要更新数据时,只需要锁定对应的段,而不是整个哈希表,从而减少了锁的粒度,提高了并发性能。
实现原理
- 分段锁(Segment):
ConcurrentHashMap
内部维护了一个 Segment 数组,每个 Segment 都包含一个锁(ReentrantLock
)和一个HashEntry
数组。锁用于保证对HashEntry
数组进行并发操作的线程安全性。 - HashEntry:
HashEntry
是ConcurrentHashMap
的内部类,用于存储键值对。它包含了键(key)、值(value)、哈希码(hash)以及指向下一个节点的指针(next)。 - 哈希表:每个 Segment 包含一个
HashEntry
数组,用于存储键值对。数组中的每个位置称为一个桶(bucket),通过哈希函数将键映射到桶上。 - 并发操作:当多个线程同时访问
ConcurrentHashMap
时,它们会访问不同的 Segment(即不同的哈希表段),从而减少了锁的竞争。当需要更新某个 Segment 中的数据时,只需要锁定该 Segment 的锁,而不会影响到其他 Segment 的操作。
源码分析
以下是对 ConcurrentHashMap 中一些关键类和方法的简要源码分析。
- Segment 类
- Segment 类继承自
ReentrantLock
,表示它是一个可重入的锁。 - Segment 内部维护了一个
HashEntry
数组(哈希表)和相关的计数器等。
- Segment 类继承自
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// ...
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
// ...
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 = 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;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node == null)
node = new HashEntry<K,V>(hash, key, value, null);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
tab[index] = node;
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
// ... 其他方法 ...
}
- 在 Segment 类中,put 方法首先尝试获取锁,如果获取成功则直接进行操作;如果获取失败,则通过
scanAndLockForPut
方法进行自旋锁操作。在获取到锁后,会遍历对应哈希表段中的HashEntry
数组,根据键的哈希码和键本身找到对应的节点,进行值的更新或插入操作。
- HashEntry 类
HashEntry
是 Segment 内部用于存储键值对的节点类。- 它包含键(key)、值(value)、哈希码(hash)以及指向下一个节点的指针(next)。
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
// ...
}
- put 方法
- put 方法用于向
ConcurrentHashMap
中插入一个键值对。 - 它首先根据键的哈希码确定应该访问哪个 Segment。
- 然后获取该 Segment 的锁,并在该 Segment 的哈希表中插入或更新键值对。
- put 方法用于向
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);
}
在 Segment 的 put 方法中,会进一步处理插入或更新操作。
- get 方法
get
方法用于从ConcurrentHashMap
中获取一个键对应的值。- 它首先根据键的哈希码确定应该访问哪个 Segment。
- 然后直接访问该 Segment 的哈希表,无需获取锁。
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 中的元素数量达到其容量的一定比例时,该 Segment 会进行扩容。扩容操作仅影响该 Segment,不会阻塞其他 Segment 的操作。
-
总结
- JDK 1.7 中的
ConcurrentHashMap
通过使用分段锁机制,允许对哈希表的不同部分进行并发访问,从而提高了并发性能。同时,它还使用了HashEntry
来存储键值对,并通过链表解决哈希冲突。在扩容时,只会影响单个 Segment,从而减少了扩容操作对整体性能的影响。
- JDK 1.7 中的
4.2 JDK1.8
在JDK 1.8中,ConcurrentHashMap
的实现有了重大的改变。它不再使用分段锁(Segment)的方式,而是采用了更加细粒度的同步控制,称为CAS(Compare-And-Swap)操作配合Node
数组和TreeNode
(红黑树节点)来实现高效的并发性能。
实现原理
- Node数组:
ConcurrentHashMap
内部维护一个Node<K,V>[]
数组,用于存储键值对。数组的每一个元素要么是一个链表的头节点,要么是一个红黑树的根节点。 - CAS操作:CAS是一种基于硬件支持的原子操作,用于实现无锁的数据结构。CAS包含三个操作数——内存位置(V)、预期的原值(A)和新值(B)。当且仅当该位置的值等于预期原值(A)时,才将该位置的值更新为新值(B)。否则,处理失败,重新尝试或者采取其他措施。
- 链表和红黑树:当某个哈希槽的链表长度超过一定阈值(TREEIFY_THRESHOLD,默认为8)时,链表会转换为红黑树,以提高查询效率。当红黑树的节点数量小于一定阈值(UNTREEIFY_THRESHOLD,默认为6)时,又会退化为链表。
- 同步控制:在插入、删除和更新操作时,
ConcurrentHashMap
使用CAS操作来确保线程安全。如果CAS操作失败,则使用自旋(spin-wait)的方式重试,直到成功为止。
源码分析
以下是ConcurrentHashMap
中的一些关键部分和源码片段的简化分析
- 主要成员变量
// 存储元素的数组,每个数组元素可能是一个链表或红黑树的头节点
transient volatile Node<K,V>[] table;
// 控制变量,用于触发初始化和扩容
private transient volatile int sizeCtl;
// 基础计数,表示在扩容之前已存在的键值对数量
private transient int baseCount;
// 实际键值对数量,包括在扩容过程中新添加的键值对
private transient volatile long count;
// 阈值,当实际键值对数量超过此值时触发扩容
private static final int DEFAULT_CAPACITY = 16;
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
private static final int TREEIFY_THRESHOLD = 8;
private static final int UNTREEIFY_THRESHOLD = 6;
private static final int MIN_TREEIFY_CAPACITY = 64;
// ... 其他成员变量 ...
- Node 类
- Node 是存储键值对的基本单元,它可以是链表的节点,也可以是红黑树的节点(通过 TreeNode 继承自 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;
// ... 构造器和其他方法 ...
}
- put 方法
put
方法用于向ConcurrentHashMap
中插入键值对。由于ConcurrentHashMap
支持并发插入,因此put
方法的实现相对复杂。
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ... 省略了部分代码,包括检查键是否为空、扩容判断等 ...
int h = 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) & h)) == null) {
// 如果桶为空,则尝试插入新节点
if (casTabAt(tab, i, null,
new Node<K,V>(h, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 如果遇到ForwardingNode,帮助迁移
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 == h &&
((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>(h, key, value, null);
break;
}
}
}
else if (f instanceof TreeBin) { // 桶是红黑树
TreeBin<K,V> t = (TreeBin<K,V>)f;
binCount = t.putTreeVal(h, key, value);
}
}
}
if (binCount >= TREEIFY_THRESHOLD - 1) // 如果链表长度达到阈值,转换为红黑树
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
addCount(1L, binCount); // 更新元素数量
return null;
}
在 put 方法中,首先会计算键的哈希值,并定位到对应的桶。然后,根据桶的状态执行不同的操作:
- 如果桶为空,则尝试直接插入新节点,这时不需要加锁。
- 如果遇到 ForwardingNode,表示该桶正在迁移中,会帮助进行迁移操作。
- 如果桶是普通链表,则通过锁定桶的第一个节点来确保线程安全,然后遍历链表查找是否已存在相同的键。如果存在,则更新值;如果不存在,则在链表末尾添加新节点。
- 链表长度达到某个阈值(TREEIFY_THRESHOLD)时,会将链表转换为红黑树。这是因为链表在插入和查找时的时间复杂度是 O(n),而红黑树在插入和查找时的时间复杂度是 O(log n),在链表过长时转换为红黑树可以优化性能。
- initTable 方法
- 初始化数组
table
。
- 初始化数组
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(); // spin;
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2); // 计算阈值
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
- treeifyBin 方法
- 当链表长度超过
TREEIFY_THRESHOLD
时,将链表转换为红黑树。
- 当链表长度超过
final void treeifyBin(Node<K,V>[] tab, int index) {
TreeNode<K,V> root = null;
// ... 省略了链表转红黑树的逻辑 ...
tab[index] = root;
}
- transfer 方法
transfer
方法是ConcurrentHashMap
扩容的核心逻辑。在扩容时,会将旧数组table
中的元素重新分配到新的数组nextTable
中。该方法可以被多个线程并发执行以加速扩容过程。
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; // 分段的大小
if (nextTab == null) { // initiating
try {
// ... 省略了初始化nextTab的逻辑 ...
} finally {
nextTable = nextTab;
transferIndex = n;
}
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
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.compareAndSetInt(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); // 计算新的阈值
return;
}
if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) < 0)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
// ... 省略了具体的迁移元素的逻辑 ...
// 如果当前桶为空,则将其置为ForwardingNode
if (tabAt(tab, i) == null &&
U.compareAndSet(tab, i, null, fwd)) {
advance = true;
}
}
}
- 上面的代码省略了具体的迁移元素的逻辑,但在实际的
transfer
方法中,会遍历旧数组tab
中的每个元素,并根据哈希值重新计算它们在新数组nextTab
中的位置,然后将元素迁移到新的位置。如果某个桶在迁移过程中为空,则将其置为一个特殊的ForwardingNode
节点,表示该桶正在迁移中。
- get 方法
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;
}
- 在
get
方法中,一旦定位到对应的桶,它会首先检查桶中的第一个节点(e
)是否就是要找的元素。这包括比较哈希值和键的相等性。如果第一个节点就是要找的元素,那么就直接返回该节点的值。 - 如果第一个节点的哈希值不匹配(
eh != h
),那么会检查该节点是否是树节点(eh < 0
表示是树节点)。如果是树节点,就会调用树节点的 find 方法来在树中查找键对应的值。 - 如果第一个节点既不是要找的元素也不是树节点,那么就会遍历桶中的链表(如果存在的话),逐个比较节点的哈希值和键的相等性,直到找到匹配的元素或者链表遍历结束。
- 如果在遍历过程中找到了匹配的元素,就返回该元素的值;如果遍历完整个链表都没有找到匹配的元素,就返回
null
,表示该键在ConcurrentHashMap
中没有对应的值。
- 链表转换为红黑树
- 当链表的长度达到
TREEIFY_THRESHOLD - 1
(默认是 7)时,treeifyBin
方法会被调用以尝试将链表转换为红黑树。但是,在转换之前,put
方法还会检查整个表的大小(size)是否达到了一个最小值(MIN_TREEIFY_CAPACITY
,默认是 64)。如果表的大小还没有达到这个最小值,那么会触发一次扩容操作(resize),而不是立即转换为红黑树。这是因为如果表的大小还很小,并且某个桶的链表就达到了转换阈值,这可能是因为哈希分布不均匀导致的,直接转换为红黑树可能会浪费空间。通过扩容,可以重新分配哈希值,使得分布更加均匀。
- 当链表的长度达到
- 扩容
ConcurrentHashMap
的扩容操作与HashMap
类似,但更加复杂,因为它需要支持并发操作。在扩容过程中,ConcurrentHashMap
会创建新的数组,并重新计算所有键的哈希值和新的索引位置。但是,由于存在并发插入和删除操作,ConcurrentHashMap
需要确保在扩容过程中数据的一致性。因此,ConcurrentHashMap
使用了多个辅助数组(nextTable
)和节点转发机制(ForwardingNode
)来支持并发扩容。
- 节点转发机制
- 在扩容过程中,如果一个桶正在被迁移,那么该桶的节点会被一个特殊的
ForwardingNode
节点替换。ForwardingNode
节点包含了新表中该桶的索引位置信息。当一个线程尝试访问或修改一个正在迁移的桶时,它会检查该桶是否是ForwardingNode
节点。如果是,那么它会根据ForwardingNode
节点提供的信息去新表中查找或修改数据。
- 在扩容过程中,如果一个桶正在被迁移,那么该桶的节点会被一个特殊的
- 总结
ConcurrentHashMap
的put
方法实现了并发插入操作,它通过哈希表、链表、红黑树等数据结构以及一系列复杂的并发控制机制来支持高并发的数据访问和修改。在插入过程中,如果链表长度过长,会尝试将其转换为红黑树以优化性能。同时,当表的大小达到某个阈值时,会触发扩容操作以重新分配哈希值并增加容量。在扩容过程中,ConcurrentHashMap
使用了节点转发机制来确保数据的一致性。
5. 优缺点
优点
- 高性能的并发性:
- ·ConcurrentHashMap· 使用了分段锁(在 Java 7 和之前)或 CAS(Compare-and-Swap)操作(在 Java 8 及以后)来减少锁竞争,从而提高并发性能。
- 在 Java 8 中,·ConcurrentHashMap· 使用了红黑树来优化链表过长时的性能,使得在大量数据和高并发场景下也能保持较好的性能。
- 良好的可扩展性:
- ·ConcurrentHashMap· 允许动态地扩展容量,通过重新哈希和迁移数据到新的桶中,从而支持更多的元素。
- 在扩容过程中,·ConcurrentHashMap· 使用了节点转发机制来确保并发访问的一致性。
- 无阻塞的读取:
- 在读取数据时,·ConcurrentHashMap· 通常不需要加锁,因此读取操作通常不会被阻塞,这使得它非常适合读多写少的场景。
- API 兼容性:
- ·ConcurrentHashMap· 提供了与 ·HashMap· 类似的 API,使得开发者可以轻松地将其替换为线程安全的版本,而无需修改代码。
- 灵活的配置:
- 可以通过构造函数或 ·concurrencyLevel· 参数来配置 ·ConcurrentHashMap· 的并发级别,以适应不同的并发场景。
缺点
- 内存占用:
- 由于
ConcurrentHashMap
使用了复杂的数据结构和并发控制机制,其内存占用通常比HashMap
要高。
- 由于
- 编程复杂性:
- 虽然
ConcurrentHashMap
提供了线程安全的Map
实现,但在使用它时仍然需要注意一些并发编程的陷阱,比如迭代器的弱一致性(weakly consistent iteration)。
- 虽然
- 不适合小数据量:
- 对于小数据量的场景,
ConcurrentHashMap
的并发优势可能并不明显,而且由于其较高的内存占用和复杂性,可能会带来不必要的开销。
- 对于小数据量的场景,
- 迭代器的弱一致性:
ConcurrentHashMap
的迭代器是弱一致的,这意味着在迭代过程中,如果其他线程修改了映射,那么迭代器可能反映这些修改,也可能不反映。这可能会导致一些难以预料的行为。
- 不支持 null 键和值:
- 与
HashMap
不同,ConcurrentHashMap
不允许使用null
作为键或值。虽然这可以减少一些潜在的错误,但也限制了其使用场景。
- 与
- 复杂性可能导致难以调试:
- 由于
ConcurrentHashMap
使用了复杂的并发控制机制和数据结构,因此在出现问题时可能难以调试。这要求开发者对并发编程和 Java 内存模型有深入的理解。
- 由于
缺点如何优化
- 优化哈希函数:使用更高效的哈希函数来减少哈希冲突,提高性能。
- 使用定制化的并发控制:根据具体应用场景,调整
ConcurrentHashMap
的并发控制策略,以适应不同的并发需求。
6. 注意事项
- 并发修改异常
- 尽管
ConcurrentHashMap
本身是线程安全的,但如果你在迭代过程中使用迭代器的同时修改了映射(比如通过put
、remove
等方法),那么迭代器可能会抛出ConcurrentModificationException
。这是因为迭代器的弱一致性并不能保证在迭代过程中映射的完整性。如果你需要在迭代过程中修改映射,应该使用其他并发控制机制,比如Collections.synchronizedMap
结合同步块。
- 尽管
- 并发级别(concurrencyLevel)
- 在创建
ConcurrentHashMap
时,可以通过构造函数指定并发级别(concurrencyLevel
)。这个参数影响内部段(Segment)的数量,从而影响并发性能。然而,在Java 8及以后的版本中,由于内部实现的变化(从分段锁到CAS和Synchronized),concurrencyLevel
参数的影响已经大大减弱。在大多数情况下,你可以使用默认的并发级别或者根据具体的并发需求进行调整。
- 在创建
- 扩容和迁移
- 当
ConcurrentHashMap
中的元素数量超过容量阈值时,它会自动进行扩容并重新哈希元素。这个过程可能会导致短暂的延迟和性能下降。在高并发的场景下,这种影响可能更加明显。此外,如果在扩容过程中发生异常或中断,可能会导致数据不一致或其他问题。因此,在设计系统时需要考虑到这一点,并采取相应的措施来避免潜在的问题。
- 当
- 线程安全但不等于无锁
- 尽管
ConcurrentHashMap
是线程安全的,但它并不完全是无锁的。在某些操作(比如扩容)中,它可能需要使用锁来确保数据的一致性。因此,在高并发的场景下,仍然需要谨慎地处理竞态条件和死锁等问题。
点,并采取相应的措施来避免潜在的问题。
- 尽管
- 理解其内部实现
- 为了更好地使用
ConcurrentHashMap
并避免潜在的问题,建议深入了解其内部实现和工作原理。这包括了解它的数据结构、并发控制机制、扩容策略等。这将有助于你更好地理解其性能特点和行为模式,并更好地满足你的并发需求。
- 为了更好地使用
7. 总结
ConcurrentHashMap
是 Java 中一个强大的并发哈希表实现,它通过精细的并发控制和优化,提供了高性能的并发读写操作。了解 ConcurrentHashMap
的底层实现原理和使用方法,对于提升并发编程的性能和稳定性具有重要意义。同时,我们也可以从 ConcurrentHashMap
的设计中汲取灵感,为未来的并发编程技术探索新的可能性。