常见面试题:HashMap线程不安全怎么办?

常见面试题:HashMap线程不安全怎么办?

有关 HashMap 的具体分析在前一篇随笔中有,如不了解可自行查看

HashMap 线程不安全其实并不能说是它的缺点,毕竟它本来就不是为了线程安全而设计的,因此存在线程不安全的问题是很正常的

在 JDK7 中,HashMap 的线程不安全主要体现在扩容时可能会导致链表中存在环,因为在 transfer 方法中,转移 node 时会把 node 以头插法的方式一个个插入到新的位置中,而在 JDK8 中,就不再使用头插法了,因此不会再出现这个问题,不过除了这个问题之外其实还有其它问题,就算是 JDK8 中的 HashMap 也只是解决了其中部分

个人认为,其实没什么必要纠结于 HashMap 哪里线程不安全的问题,因为不管再怎么优化它,总归都是存在问题的,因此,如果有线程安全的需求,那么直接使用 HashTable 或者 ConcurrentHashMap 就好了

modCount

在深入这两个类前,我们了解一下 fail fast 这个安全机制,也就是 HashMap 的 modCount 属性的意义,因为 HashMap 线程不安全,所以我们需要一个机制,使得虚拟机可以快速判别同时有两个线程在操作这个 HashMap,最典型的场景就是,当一个线程正在遍历 HashMap,此时又有另一个线程修改了 HashMap 中的某个元素,看看遍历的源码就能明白了:

final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    //检查modCount是否符合预期
    if (modCount != expectedModCount)
        //不符合预期,抛出异常
        new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
    if ((next = (current = e).next) == null && (t = table) != null) {
        do {} while (index < t.length && (next = t[index++]) == null)
    }
    return e;
}

HashMap 的迭代器内部的逻辑就是在不断调用这个 nextNode 方法,可以发现,每次迭代都会检查一次当前的 modCount 是否和最开始相同,如果不同,说明在遍历的过程中有其它线程修改了这个 HashMap 的结构,因此报出 ConcurrentModificationException 这个异常

不过这个机制其实并不可靠,有两个需要注意的点:

  • 假如只有一个线程,但这个线程边遍历这个 HashMap 边修改其中的元素,其实也会报出这个异常
  • 这个机制并不能发现所有的并发操作,据我观察,似乎只有在遍历时会使用这个机制来保证没有并发的修改操作(注意这里的修改指的是新增元素或者删除元素,如果是覆盖元素是不会让 modCount 加一的)

HashTable

HashTable 是 HashMap 的一个线程安全的版本,但它的性能是比较差的,因为看一下源码就会发现,它几乎在所有方法上都加上了 synchronized 关键字,这种加锁的粒度实在太大了,势必会影响到性能

HashTable 和 HashMap 还有一些地方有差别:

  • 前者不允许 null 作为 key 或者 value,但后者允许
  • 前者默认的初始容量为 11,后者默认的初始容量为 16

ConcurrentHashMap

JDK7 和 JDK8 中的 ConcurrentHashMap 其实是有很大差别的

我们先来看一下 JDK7 的版本,它引入了一个很重要的概念叫做 Segment,这个类继承了 ReentrantLock,我们直接看一下整个 ConcurrentHashMap 的结构:

常见面试题:HashMap线程不安全怎么办?

在 HashMap 中,底层就是一个 Node 数组,而在 ConcurrentHashMap 中,底层先是一个 Segment 数组,而在每个 Segment 中,再包含一个 Node 数组,这样一来,当某个线程在操作 segment1 里的数据时,其实只需要把 segment1 上锁就足够了,这就是所谓的分段锁,相比 HashTable 直接把整个表都给锁了,性能是有显著提升的

但这种实现方式也有不好的地方,就是在定位一个 node 的位置时需要定位两次,首先定位这个 node 在哪个 Segment 中,然后再定位这个 node 在对应 table 的哪个位置中

再来看看 JDK8 中的 ConcurrentHashMap,在 JDK8 中,ConcurrentHashMap 完全舍弃了之前的分段锁的设计,单纯从数据结构上来看和 HashMap 是一样的,但通过细粒度的 CAS 和 synchronized 来保证线程安全,我们简单看一下 putVal方法:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f;int n, i, fh;K fk;V fv;
        //如果table未初始化,进行初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        //定位要把node放到哪个位置,如果发现这个位置为空
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //****通过CAS+自旋来把node写入这个位置****
            //这里不倾向于上锁,是因为,写入一个node是一件很简单的事情,可能只需要5ms就完成了
            //首先,5ms很短,这段时间里正好有线程并发修改这里的可能性很低,就算真的有,5ms就算自旋重试也没有什么关系
            //但上锁解锁是一件开销很大的事情,本身5ms就能完成,结果上锁解锁就花了50ms,还阻塞了其它线程,明显划不来
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;                   
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else if (onlyIfAbsent
                 && fh == hash
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)
                                             && (fv = f.val) != null)
            return fv;
        //前面也不用管了,反正到这里就说明,真的得往链表或者红黑树里插入node了             
        else {
            V oldVal = null;
           	//****通过synchronized来保证线程安全****
            //这里不CAS了,是因为这里的代码比较复杂,可能要花200ms才能执行完毕
            //200ms算是比较长的时间了,期间很有可能出现其它线程进行并发的操作,而一旦自旋重试,就又是200ms,很恐怖
            //这种情况下,加锁解锁的50ms的接受度就比较大了,并且可以保证一次性成功,显然比CAS要好一些
            synchronized (f) {
            	...        
            }
            //对链表树化的判断
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

总之,可以发现 JDK7 的 ConcurrentHashMap 是改动了 HashMap 的数据结构的,主要就是引入了 Segment,然后通过分段锁来尽量减小锁的粒度,相比 HashTable 肯定是强了不少的,但 JDK7 中的 HashMap 还没有引入红黑树,这意味着长链表的遍历是不可避免的,因此,在 JDK8 中 HashMap 全面升级后,java 团队对 ConcurrentHashMap 也做了升级,抛弃了 Segment 和分段锁的设计,完全采用升级后的 HashMap 的数据结构,但相比 HashTable,它只会在有需求的地方上锁(粒度较小),并且有些地方会通过 CAS+自旋 来进一步减小锁带来的开销,因此性能也是较好的

学习过程中,部分内容参考了网上的其它文章,如有侵权必删

常见面试题:HashMap线程不安全怎么办?

上一篇:python_java_go_c++_295. 数据流的中位数


下一篇:数据结构与算法——栈(三)有关栈的三种表达式 —— 前缀、中缀、后缀表达式