线程安全的HashMap

542 阅读4分钟

这是我参与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覆盖掉。

image.png

由于HashMap的复杂操作,像队列/链表那样,基于HashMap在完善每个操作的原子性过于困难,所以需要重新实现。

ConcurrentHashMap

我们只需关注这么几个点如何实现并发安全:

  1. 初始化table
  2. put操作

并发安全的初始化table

hashmap的元素是使用一个Node数组表示,只有在第一次put操作才会对其赋值。那么如何实现初始化话全局变量table的线程安全呢?

image.png

由于table的初始化只会执行一次,所以可以使用一次CAS操作,只让一个线程初始化table,其它线程则等待其初始化完成。

image.png

并发安全的put操作

put操作,先对key计算hash,如果key的hash所在table的位置==null,则通过cas设置Node,假若某个线程失败,则通过循环逻辑,进入插入链表。

image.png

在链表的逻辑中,要先判断链表的每个元素的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确定在哪个对象中。