一、HashMap初始化数组时的线程安全问题——引出ConcurrentHashMap
首先需要告诉大家的是,这篇文章需要一些基础知识。对于HashMap的相关操作还不是太了解的同学可以去看我的另一篇博客——面试官想问的HashMap,都在这一篇里面了!
1.1 产生问题
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
在定义数组时,在单个线程中执行,因为是按顺序执行,所以无论怎样折腾都不会产生问题。但在现实中往往会出现并发的情况,而这时多线程的情况下操作可能会和单线程操作的情况产生的数据不一致,这就是所谓的线程安全问题。
1.2 举例说明
可以用几个简单的例子来更好地理解:
假设有两个线程T1和T2,T1的任务是初始化容量为16的数组,T2的任务是初始化容量为32的数组;或者当T1还在初始化数组时T2已经开始put元素了,这样的情况产生的结果就不言而喻了。
1.3 解决办法
方法一:
那么有什么样的方法可以避免这样的线程安全问题呢?我们首先可能会想到给线程加锁。
synchronized(lock) {
Node<K,V> newTab = (Node<K,V>[])new Node[newCap];
}
我们使用synchronized(同步)关键字来给线程加锁,这样就可以使得线程安全。
那么这样做到底可不可以解决线程安全问题呢? 我觉得是可以的,因为有一个非常明显的例子来完成了证明,那就是Hashtable。
//Hashtable部分源码
public synchronized V put(K key, V value) {
...
}
但是又有人站出来了。“小老弟,你这样搞怕不是在跟钱过不去!”
没错,这样的解决办法确实会浪费大量的资源,因为如果给线程这样加锁,CPU就只能在一个时间段内处理一个线程,而我们希望的是在同一时间段内处理多个线程。
方法二:
如果采用加锁的方式会降低程序执行的效率,那么我们是否可以找到一种无锁化的机制来保证数组初始化的线程安全呢?
答案当然是有的:在并发编程中有一种机制叫 Compare And Swap,也就是我们经常听到的CAS,这样可以保证某个操作线程是安全的。那么如何保证线程安全呢?
我会去拿到对某个数据操作的内存当中的最新值以及需要去修改的值,两者去进行比较,如果相等,那么就认为是线程安全的,没有被改动过;如果不相等,那么就认为已经被其他线程改动过,这时我们就不能去对此线程进行改动。
这就是CAS的原理,这里只是简单介绍一下,在后续博客会更新具体内容。
方法三:
CAS确实是一种效率很高的解决线程安全问题的办法,但是难道JDK官方会没有注意到这一点吗? 哈哈,如果这么想你就天真了,Java身为全世界最流行的变成语言之一,JDK怎么会犯这种错误?
接下来就是最重要的,在ConcurrentHashMap中,对数组的初始化使用了CAS无锁化的方式保证了线程安全。
ConcurrentHashMap依旧是向内存中put值
public V put(K key,V value) {
return putVal(key,value,false);
}
而与HashMap不同的是:这里的数组初始化有所不同。
if (tab ==null || n = tab.length) == 0)
tab = initTable();
//同样是初始化,HashMap中使用的是resize()方法,而这里则是用了initTable()方法
那么initTable()方法是怎样执行的呢?它为什么能实现线程安全呢?来,让我们感受一下源码的威力。
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{};
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
//初始化的代码还是在这里,只不过前面和后面用CAS进行无锁化的操作来保证线程安全
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
假设当T1线程进来,执行while语句,判断数组是否为空。如果为空就之后的内容,但是这里有一个判断语句,需要我们来判断需要执行哪一块的代码。在 if((sc = sizeCtl) < 0) 中,我们肯定会疑惑这里的sizeCtl
是什么鬼东西,这里附上源码:
private transient volatile int sizeCtl;
//初始化的成员变量为0
在知道sizeCtl
为0时我们就可以判断接下来应该执行 else if 中的语句,这也是整段代码中最核心的内容。在数组的初始化之前通过CAS机制将SIZECTL
和sc
进行比较,而sc
在之前已经被sizeCtl
赋值成0,所以sc
和sizeCtl
都是相等为0,那么就会将sizeCtl
的值改为-1,这就是源码中compareAndSwapInt()
方法的作用。(比较调换)
这一段内容可能会有点不好理解,大家可以多看几遍
在将sizeCtl
的值改为-1后,完成了数组的初始化。那么当线程T2进来的时候,sizeCtl
的值已经小于0了,就不会执行数组初始化的内容,会执行的是线程的让步(Thread.yield
)。
二、ConcurrentHashMap解决put元素时的线程安全问题
2.1 引出问题
在HashMap中,put元素的具体过程可以看我的另一篇博客,链接已经放到文章顶端。这里附上部分源码只是为了引出问题。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = new Node(hash, key, value, null);
在HashMap中,put的过程中也会产生线程安全问题。为了解决这一问题,在ConcurrentHashMap中做了那些事情呢?
2.2 解决问题
2.2.1 put过程中数组没有元素
我们先来看一段代码
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
从这段代码中我看可以看出,将tab
和i
这两个值都赋值为null
。在线程T1执行的时候,因为i
与null
相等,那么就可以put元素。而在线程T2执行时,i
这个位置已经存放了数据,相当于已经被线程修改,所以这时就不能再存放元素,T2线程就无法执行,这与数组初始化的过程有异曲同工之妙。
2.2.2 put过程中有数组有元素
在HashMap中,put元素时分为三种情况(具体过程链接中的文章会有分析):
- 新数据与数组中原来数据相同
- 新数据与原来数组中数据不同,并用链表表示
- 新数据与原来数组中数据不同,并用红黑树表示
在这三种不同的情况下进行put元素,都会产生线程安全问题,所以我们来看一下ConcurrentHashMap是如何来解决的。
else {
V oldVal = null;
//给put元素时数组中有数据的情况使用的方法加锁
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;
}
}
}
}
从代码中欧给我们可以看出,这里使用了synchronized(lock)
关键字加锁来解决问题,那么为什么这里不再使用CAS机制了呢? 原因是如果使用CAS机制会逐个比较结点中的数据,这样的效率是非常低的。那么为什么又使用了加锁这种同样效率低下的方法呢?我们首先要认识一下synchronized
中的f
是什么东西。
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null)
从这里我们就知道了,f
实际上就是数组的索引的头结点,我们如果对其加锁,实际上只是锁住了此头结点以下的所有操作。而之前Hashtable中提到的加锁是给整个put方法加锁,其中所有的操作都会去争夺这把锁,因此才会使效率大大降低。
总结:实际上就是将锁的力度降低,使得在不同的情况下都能找打最合适的方法来保证线程安全。
三、数组的扩容问题
3.1 问题提出
在结点越来越多的情况下,随着链表的长度增加会转为红黑树,但是红黑树的结构也会越来越复杂,这种情况下查找的效率同样会降低,这时就需要用另外的方法来提高效率。
我们应该知道链表和红黑树都是在数组的基础上建立的,当他们的结构越来越复杂时,我们就可以通过给数组增大容量来降低复杂度,而这就是数组的扩容。
具体的扩容方法链接中文章也又提到,这里就不再赘述。而这里要讨论的问题就是在扩容的过程中,HashMap会不会存在线程安全问题?
答案当然是有的,在T1线程进行扩容时,T2线程想要进行put元素,这样不就产生了线程安全问题。但是又如何解决呢?难道是将线程T2暂停,这样做的话不就又会使效率下降吗?所以ConcurrentHashMap中又提出了一种机制,在一个线程进行扩容时,其他线程可以去帮助此线程进行扩容。
3.2 问题解决
源码又来了!
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
我们来判断头结点f
的hash值MOVED
,如果等于-1就会停止之后的put操作,会执行helpTransfer
方法,也就是帮助线程扩容的操作。
那么一个线程是如何帮助其他线程进行扩容操作的呢? 当线程T1在进行数组扩容时,如果线程T2想要put元素时,首先会判断是否有其他线程正在进行扩容,如果有则会帮助T1进行扩容。假设数组扩容需要扩到64个地址,那么线程T2就会领取最后16个地址进行帮助扩容,并且会留下标志。T3会根据标志领取32~48之间的地址进行扩容,以此类推。当每一个帮助线程领取任务时,为了防止线程不安全,也会进行加锁的处理。除此之外,具体的扩容内容与HashMap相同。
注意:在源码中,这里的16表示最小的步伐。如果只有16的地址,一个线程就能完成。加入有32个地址,就需要一个另外的线程来帮助扩容。