我们首先来看一下 Java 7 版本中的 ConcurrentHashMap
的结构示意图:
从图中我们可以看出,在 ConcurrentHashMap
内部进行了 Segment
分段, Segment
继承了ReentrantLock
,可以理解为一把锁,各个 Segment
之间都是相互独立上锁的,互不影响。相比于之前的Hashtable
每次操作都需要把整个对象锁住而言,大大提高了并发效率。因为它的锁与锁之间是独立的,而不是整个对象只有一把锁。
每个 Segment
的底层数据结构与 HashMap
类似,仍然是数组和链表组成的拉链法结构。默认有 0~15共 16
个 Segment
,所以最多可以同时支持 16
个线程并发操作(操作分别分布在不同的 Segment
上)。 16
这个默认值可以在初始化的时候设置为其他值,但是一旦确认初始化以后,是不可以扩容 的。
在 Java 8 中的示意图:
图中的节点有三种类型。
HashMap
非常类似的拉链法结构,在每一个槽中会首先填入第一个节点,但是后续 如果计算出相同的 Hash
值,就用链表的形式往后进行延伸。ConcurrentHashMap
中所没有的结构。当第二种情况的链表长度大于某一个阈值(默认为 8),且同时满足一定的容量要求的时候,ConcurrentHashMap
便会把这个链表从链表的形式转化为红黑树的形式,目的是进一步提高它的查找性能。
红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色,是一种平衡二叉查找树,查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生。由于自平衡的特点,即左右子树高度几乎一致,所以其查找性能近似于二分查找,时间复杂度是O(log(n))
级别;反观链表,它的时间复杂度就不一样了,如果发生了最坏的情况,可能需要遍历整个链表才能找到目标元素,时间复杂度为 O(n)
,远远大于红黑树的 O(log(n)),尤其是在节点越来越多的情况下,O(log(n))
体现出的优势会更加明显。
红黑树的一些其他特点:
正是由于这些规则和要求的限制,红黑树保证了较高的查找效率,好处就是避免在极端的情况下冲突链表变得很长,在查询的时 候,效率会非常慢。而红黑树具有自平衡的特点,所以,即便是极端情况下,也可以保证查询效率在O(log(n))
。
由于 Java 7 版本已经过时了,所以我们把重点放在 Java 8 版本的源码分析上。
我们看看最基础的内部存储结构 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
里面是key-value
的形式,并且把 value
用 volatile
修饰,以便保证可见性,同时内部还有一个指向下一个节点的 next
指针,方便产生链表结构。
下面我们看两个最重要、最核心的方法。
put 方法的核心是 putVal 方法,为了方便阅读,我把重要步骤的解读用注释的形式补充在下面的源码中。
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; // 添加到空桶时不需要锁定
} 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;
}
以下是代码的主要功能和步骤解释:
NullPointerException
。spread
方法对哈希码进行扩散,以减少哈希冲突的可能性。initTable
方法初始化哈希表。Node
节点到该桶中,如果成功则退出循环。helpTransfer
方法协助进行桶的迁移。fh >= 0
),则使用同步块在链表中查找键值对。如果找到了相同的键,更新其值,否则将新的Node
节点添加到链表中。f instanceof TreeBin
),则调用putTreeVal
方法在树中插入键值对。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; // 返回找到的值
}
}
// 未找到匹配的键,返回null
return null;
}
数据结构
Java 7 采用 Segment 分段锁来实现,而 Java 8 中的 ConcurrentHashMap 使用数组 + 链表 + 红黑树。
Java 7 中,每个 Segment
独立加锁,最大并发个数就是 Segment
的个数,默认是 16
。
Java 8 中,锁粒度更细,理想情况下 table
数组元素的个数(也就是数组长度)就是其支持并
发的最大个数,并发度比之前有提高。
Java 7 采用 Segment
分段锁来保证安全,而 Segment
是继承自 ReentrantLock
。
Java 8 中放弃了 Segment
的设计,采用 Node+CAS+synchronized
保证线程安全。
Java 7 在 Hash 冲突时,会使用拉链法,也就是链表的形式。
Java 8 先使用拉链法,在链表长度超过一定阈值时,将链表转换为红黑树,来提高查找效率。
Java 7 遍历链表的时间复杂度是O(n)
,n 为链表长度。
Java 8 如果变成遍历红黑树,那么时间复杂度降低为 O(log(n))
,n 为树的节点个数。