Hashtable是一个线程安全的类,通过使用synchronized来锁住整张Hash表来实现线程安全的。由于对整个表加锁会导致操作效率低。为此,为了实现一个线程安全且效率高的数据结构,Concurrenthashmap通过使用分段加锁的机制来提高效率。
1.如何实现分段加锁的机制
Concurrenthashmap使用分段锁技术,将数据分为一段一段的存储,然后给每一段数据配一把锁。这样,每段数据在操作上是互斥的。即一个线程访问其中一段数据,那么其他线程可以访问其他数据段互不干扰。
由图可以看出数据结构为:数组+(数组+链表)
从源码级分析特性:
1.继承关系
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable
继承AbstractMap类,实现ConcurrentMap<K,V>, Serializable 接口,其中AbstractMap、Serializable是在Hashmap中已经使用过的,因此与Hashmap的区别在于实现接口不同,从而与ConcurrentHashMap方法也不同。
对于ConcurrentMap接口的研究来探索ConcurrentHashMap多了那些方法,其中方法的特点你是什么?
2.由于concurrenthashmap的数据结构为数组+数组+链表,其中链表的Node节点的属性如下:
class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
其中属性大体和hashmap相同,就是val,next变量使用volatile 修饰,由于volatile修饰的变量可以保证内存可见性以及禁止指令重排序。因此达到我们每次操作的值都是最新的。
下面就源码具体分析它的增删改查操作:
1.首先判断key,value值是否为空,如果为空,抛出空指针异常。
2.如果没有空指针异常问题,则需要通过spread函数计算哈希值
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
3.根据hash值计算存放在桶的位置index,判断index位置是否存在元素,如果没有元素,则采用无锁的CAS操作尝试插入新节点,若成功,则执行;失败,则重新执行3;若当前位置有元素,则执行4;
4.判断当前是否正在扩容,若正在扩容,则加入协助扩容,扩容完毕后则回去重新执行3;若不是正在扩容,则执行5.
5.锁住当前index位置的节点,遍历该位置的所有节点,判断是否存在相同的元素,存在则替换旧值;不存在则插入一个新的节点保存元素。然后执行6.
6.给容器的当前元素个数count加1,若超出负载因子,则进行扩容操作。
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
//检查key和value是否为null,为null则抛出异常
if (key == null || value == null) throw new NullPointerException();
//计算key的hash值,与hashmap计算方式相同
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();
//根据hash值计算存放的位置,判断当前位置是否存在元素
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果为空,使用CAS操作尝试更新插入节点,成功,则跳出循环,count+1,否则代表已经有其他线程在该 //位置插入元素,则需要重新进入循环,以便插入新元素的后面。
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//若当前位置元素的hash值为MOVED(这是一个常数),标志着正在进行扩容操作,则当前线程加入协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//锁住当前位置的头元素,这里使用的分段锁,它并没有锁住整个table,而是锁住了table中某一个位置的
//头元素因此对于可以并发对不同位置进行插入操作
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;
}
}
}
//执行count+1,这里可能会触发扩容操作
addCount(1L, binCount);
return null;
}
CAS插入头结点:
当table的index位置无元素时,则会尝试使用CAS操作插入新节点作为头元素。
CAS操作是JAVA提供的一种无锁更新操作,它有三个操作数,分别是被修改的变量地址,旧值,新值。若果当前地址的值与旧值相同,则认为它没有被修改过,则使用新值替换旧值,修改成功;否则代表已经有其他线程已经更改了变量,则修改失败。与传统的加锁不同,传统的加锁是一种悲观锁的实现,而CAS操作则是一种乐观锁的实现,他避免了加锁,从而使得线程不会因为等待锁而进入阻塞,在一定程度上提高了并发性能。
分段锁:
分段锁是concurrenthashmap的核心,在其内部结构中,hash表是用数组保存,数组中每个元素它相当于一个个桶,桶内装了发生冲突的(key,value)对。
当进行插入动作时,concurrenthashmap并不会锁住hash表,则是先通过key的hash计算应在的桶的位置,再锁住该桶,再执行插入操作。该方式允许并发地对不同的桶进行插入操作,但当插入的(key,value)存放于同一个桶中,还是会由于等待锁的获取而进入阻塞。所以从整体上看,当元素存取较分散时,性能还是会比hashtable要高得多。
扩容:
扩容机制与hashmap的实现类似,将table的容量扩展为原来的2倍,再对旧表的元素重哈希到新的位置,不过在这基础上,增加了并发扩容机制,允许多个线程同时参与扩容,使得扩容更快完成。
在看源代码前,先提几个重要变量
1. sizeCtl
- -1:表示 hash 表正在进行初始化或扩容操作
- -n:表示当前有 n-1 个线程执行扩容操作
- 0 or n:代表当前 hash 表未进行初始化或负载因子,当超过则需要扩容
2. transferIndex:表示要被重哈希的位置
3. stride:由于允许并发扩容,该数值记录一次扩容需重哈希多少长度的 hash 表,该值会根据你的 CPU 个数和表长而决定,最小为 16
工作流程:
1. 根据 CPU 个数和当前表的长度,计算一轮重哈希需要对多少长度的表进行重哈希;
2. 如果是第一个触发扩容的线程,创建新表,长度为原来的两倍;
3. 循环对旧表的元素进行重哈希,直到全部位置都重哈希完毕
3. 获取新一段需重哈希的初始 index,每一段的长度为 stride,获取后该线程就负责对 [index - stride , index] 的元素进行重哈希,由于存在并发扩容,因此获取并更新 transferIndex 需要循环使用 CAS 操作来获取
4. 对段中的每一个位置都进行重哈希,哈希过程中需要对头元素进行加锁,避免由于扩容和 put、delete 操作冲突,具体重哈希原理与 HashMap 类似。
来看两个问题,下面我们就这两个问题分析 ConcurrentHashMap 的具体实现:
1. 为什么扩容时要将 table 分成一段一段来处理?
2. 多个线程如何协调地一起进行扩容操作?
3. 其他线程如何可以知道当前正在执行扩容操作?
扩容时将 table 分成一段一段来处理
首先前面提及到 stride 用于保存重哈希时的段容量,它的数值由当前 CPU 个数和表长决定,计算公式如下:
int stride = NCPU > 1 ? (n >>> 3) / NCPU : n;
从公式可以看出,当系统的 CPU 个数为 1 个时,stride 的值为 n,即由单个线程完成整个 hash 表的重哈希过程。
有的人可能会想,为什么要这样做呢?多个线程一起扩容不是更快吗?注意,效率更高是要建立系统有多个 CPU 的情况下,在单个 CPU 的系统,一个时间点只能执行一个线程,如果让多个线程协助扩容,不仅不会提高效率,还增加了线程切换的开销,导致扩容更慢。因此只有当系统 CPU 个数多于一个时,才会将扩容过程分为多段来进行。注:段的长度最小规定为 16。
多个线程如何协调地一起进行扩容操作?
前面提过每个线程会各自负责一段的重哈希,当该段完成后再去负责下一段。
下面通过一个具体场景来分析运行过程:
当前有一个 concurrentHashMap 对象需要进行扩容,n 为 512,stride = 64,即一段为 64 长度,transferIndex 为 512。
线程 1 申请一段,申请成功,返回段的位置 transferIndex - 1 = 511,则此时线程 1 负责 [512-64,511] 段的重哈希,此时 transferIndex 为 448
线程 2 加入扩容,申请,此时线程 2 负责 [448-64, 448] 段的重哈希,此时 transferInex 为 384
线程 1 完成了 [448,511] 段的重哈希,想要继续再申请一段,当前 transferIndex 为 384;但此时线程 3 加入进来,它同时也申请一段,并且为它先分配了,transferIndex 变为 320。线程 1 通过 CAS 操作更新 transferIndex 失败,则重新获取 transferIndex 的最新值,再次申请。
3 个线程通过上述这样的协助操作,完成整个 hash 表的重哈希过程
其他线程如何可以知道当前正在执行扩容操作?
在扩容过程中,对每一个位置重哈希完毕后,会将旧表的当前位置元素使用一个 ForwardingNode 来代替该位置,其他线程当对 ConcurrentHashMap 执行操作时,探测到一个 ForwardingNode 时,则就可以知道当前集合正在进行扩容操作。是否加入扩容,则需要根据配置和当前的扩容线程数来决定,具体可参看 helpTransfer 方法。