这是我参与11月更文挑战的第17天,活动详情查看:2021最后一次更文挑战
HashMap为什么非线程安全
当hashmap在拉链法时,多线程同时进行resize,同时进行rehash转移数据操作:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];// 步骤1
newTable[i] = e;// 步骤2
e = next;
}
}
}
以上代码当两个线程同时resize可能出发以下情况,线程A、B同时到达步骤1,线程A执行完成步骤1、2后,线程B执行步骤1,会使得e.next=e。从而导致链表产生死循环。导致get元素时,如果产生hash碰撞正好道这个位置,扫描链表会导致CPU打到100%。
当然不仅仅这一个并发问题,还有table的初始化,并发执行可能导致数据丢失,下图是resize的一段代码,如果两个线程先后或者同时执行table = newTable,会导致你put的数据被后面线程的newTable覆盖掉。
由于HashMap的复杂操作,像队列/链表那样,基于HashMap在完善每个操作的原子性过于困难,所以需要重新实现。
ConcurrentHashMap
我们只需关注这么几个点如何实现并发安全:
- 初始化table
- put操作
并发安全的初始化table
hashmap的元素是使用一个Node数组表示,只有在第一次put操作才会对其赋值。那么如何实现初始化话全局变量table的线程安全呢?
由于table的初始化只会执行一次,所以可以使用一次CAS操作,只让一个线程初始化table,其它线程则等待其初始化完成。
并发安全的put操作
put操作,先对key计算hash,如果key的hash所在table的位置==null,则通过cas设置Node,假若某个线程失败,则通过循环逻辑,进入插入链表。
在链表的逻辑中,要先判断链表的每个元素的hash是否与put参数key的hash相等,在判断equals方法是否相等,即这也是为什么我们需要重写hashCode与equals方法。
如果上面两个相等,则需要修改改节点的value=put参数的val。
如果不等,则需要新建Node插入道链表。
以上两个逻辑能否并发执行?NO,因为假若前一个线程put操作插入链表的Node,正好是下一个线程进行put操作的修改val。并发执行会使得两个线程都去插入链表。所以我们必须去保证顺序,由于操作太多,juc大师也做不到循环+CAS的操作,所以加锁不可避免。下一个问题便是加锁需要锁整个ConcurrentHashMap对象吗?没有必要。
只有当两个线程put操作的key的hash值命中table的同个节点才会出现非线程安全问题。所以加锁只需要加在产生hash碰撞的Node节点即可。
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; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {// 对节点加锁
if (tabAt(tab, i) == f) {// 这一步很重要,如果某个线程CAS失败,则f==null,需要循环重新获取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)))) {// 判断hash和equals
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;
}
}
}
}
以上便是concurrentHashMap put操作保证线程安全的逻辑,可以看书hashmap的逻辑是非常复杂,难以再使用循环+CAS实现,所以锁必不可少,但锁的粒度需要仔细考虑。
分段再减少锁粒度
一直听说concurrentHashMap使用分段segment,但是从put的源码并未开到segment的身影。在进行分段也是一种减少锁粒度的方法。比如分三个segment相当于创建三个ConcurrentHashMap,在put的时候,再计算hash确定选择三个中的哪个对象。get时候也通过hash确定在哪个对象中。