本篇文章来讲解第二个非常重要的内容,那就是ConcurrentHashMap是怎样保证多线程安全的。
本篇文章的主要内容如下:
1:CAS在ConcurrentHashMap中的运用 2:ConcurrentHashMap在初始化容器时怎样保证安全的 3:ConcurrentHashMap在添加元素时怎样保证线程安全的 4:ConcurrentHashMap在获取元素时怎样保证线程安全的
一、CAS在ConcurrentHashMap中的运用
首先看一下如下代码:
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
上面的3个方法是保证不加锁的情况下,保证原子操作。
二、ConcurrentHashMap在初始化容器时怎样保证线程安全的:利用CAS机制保证线程安全的
我在讲解put方法时,已经说过了初始化容器方法initTab(),我总结如下:
1:所有的线程通过CAS机制竞争更改sizeCtrl=-1.更改成功者初始化容器 2:只能有一个线程进行容器的初始化工作 3:其他线程检测到sizeCtrl=-1了,说明已经有线程正在初始化了,当前线程需要让出CPU
所以从上面的总结可以看出,ConcurrentHashMap是利用CAS机制修改sizeCtrl来保证只有一个线程才能初始化容器,而sizeCtrl是用volatile关键字修饰的。我们回忆一下volatile的特点。
1:任何对volatile变量的写,都会立即从工作内存中刷新到主存中去。 2:任何对volatile变量的读,都会从主存中读取一份最新的数据。
大家目前只要理解上面的两段话就可以了,至于volatile的详细讲解,我会单独有一篇文章去分析,这牵涉到JMM(Java内存模型)的知识点,我们暂且放一放,你就理解成只要一个变量被volatile修饰,一个线程的修改,立刻会对其他线程可见,只是它不保证原子性特点。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
//sizeCtrl=-1:说明已经有线程正在初始化了,那么我就让出CPU,Thread.yield()的意思就是让出CPU,但是我还可能再次获取到CPU的执行权。
Thread.yield(); // lost initialization race; just spin
//利用CAS机制将sizeCtrl=-1
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
//走到这一步:说明CAS成功,当前线程就负责容器的初始化工作了。
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 {
//走到这一步:说明初始化完成,将sc赋值给sizeCtrl,此时sizeCtrl=0.75tab.length>0,代表下一次扩容的阀门
sizeCtl = sc;
}
break;
}
}
return tab;
}
可能同时有多个线程并发访问initTable()方法,但是只有一个线程进行初始化工作,其他线程需要让出CPU。所以总结初始化容器的线程安全。流程图如下:
initTab初始化底层容器的流程
总结一句话:初始化底层容器时利用CAS保证线程安全的
三、ConcurrentHashMap添加元素时怎样保证线程安全的:利用CAS+synchronized实现的
1:ConcurrentHashMap使用乐观锁,只有冲突时才加锁并发处理,如果没有冲突,则利用CAS机制原子操作. 2:ConcurrentHashMap使用锁分离的方法,每一次加锁不是锁住整个数组,而只是锁住一个下标的数据。 说的大白话一点就是: 1:如果计算出的下标还没有值,则直接把(key,value)封装的Node节点利用CAS机制存放在这个下标。 2:如果计算的下标已经有值了,并且不是在扩容期间,那说明就是向链表或者红黑树添加数据,此时利用synchronized进行加锁,而锁对象就是此下标第一个元素。
从上面的总结可以看出,ConcurrentHashMap并不是把底层的整个表都锁住,如果计算出的数组下标没有值,则利用CAS的机制保证线程的安全,如果此下标已经有值了,则利用synchronized锁进行保证线程的安全,而此时锁住的只是此下标,数组的其他下标并没有加锁,所以能够多个线程同时添加元素,只要多个线程计算的下标不同就可以并发添加,这大大的提高了元素添加的性能。
举一个例子:此时数组下标0没有值,下标3是一个链表,下标5只有一个值,此时线程1计算的下标为0,线程2和线程3计算的下标为3,线程4计算的下标为5,4个线程同时调用put方法。那么怎样保证线程安全的呢?
1:线程1计算的下标0,此下标没有值,则利用CAS直接添加 2:线程2和线程3计算的下标相同,都是3,锁对象就是tab[3],那么两个线程谁获取了锁,谁就添加元素,没有获取到锁的则阻塞,直到获取锁。 3:线程4计算的下标5,锁对象就是tab[5]。那么它获取的锁和线程2/3不是同一个锁,线程4不会因为线程2获取了锁而阻塞。
所以从上面可知,不同的下标有不同的锁对象,一个下标被锁住了,不影响其他下标插入元素。
所以put的流程图如下:
四、ConcurrentHashMap在获取元素时怎样保证线程安全的:无锁化,关键字volatile
大家回过头来首先看一下底层数组和节点Node是怎样定义的:
//被volatile修饰
transient volatile Node<K,V>[] table;
-------------------------------------------------------------------------------------------------
//val和next被volatile修饰
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
volatile修饰数组或者对象时,只是表示数组的每一个对象的地址具有可见性,一个线程修改,其他线程立刻可见,但是它并不保证对象中的成员变量具有可见性,所以在Node定义时,val和next都被volatile修饰,一个线程修改,其他线程立刻可见。所以在ConcurrentHashMap获取元素时并没有加锁,而是利用volatile修饰底层数组和Node的val和next保证线程安全的。
对ConcurrentHashMap线程安全总结如下:
1:在ConcurrentHashMap对元素修改时(增删改):利用CAS和锁分离原理保证线程安全的,只有在有冲突时才进行加锁。而加锁并不是锁住整个数组,而是只锁住指定下标的数据。 2:在获取元素时,并没有加锁,利用关键字volatile。
ConcurrentHashMap保证线程安全总结一句话:volatile+CAS+synchronized(锁分离技术)
从上面看出Doug Lea大神在高并发的功底令人膜拜,接下来一篇文章,我会介绍ConcurrentHashMap的最后一个知识点:计数,也就是size是怎样计算出来的呢?可能有人非常的疑惑,这不是非常的简单吗?其实在高并发下,size的计算并不是那么的容易,虽然有AtomicLong这种原子计数器,大家想一想在大并发下,CAS失败率非常的大,总是失败后在重试,效率不太高,所以ConcurrentHashMap并没有使用这个。而是使用和LongAdder机制完全一样的原理