关于后端:为什么ConcurrentHashMap是线程安全的

34次阅读

共计 2841 个字符,预计需要花费 8 分钟才能阅读完成。

ConcurrentHashMap 是 HashMap 的多线程版本,HashMap 在并发操作时会有各种问题,比方死循环问题、数据笼罩等问题。而这些问题,只有应用 ConcurrentHashMap 就能够完满解决了,那问题来了,ConcurrentHashMap 是如何保障线程平安的?它的底层又是如何实现的?接下来咱们一起来看。

JDK 1.7 底层实现

ConcurrentHashMap 在不同的 JDK 版本中实现是不同的, 在 JDK 1.7 中它应用的是数组加链表的模式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。 大数组 Segment 能够了解为 MySQL 中的数据库,而每个数据库(Segment)中又有很多张表 HashEntry,每个 HashEntry 中又有多条数据,这些数据是用链表连贯的,如下图所示:

JDK 1.7 线程平安实现

理解了 ConcurrentHashMap 的底层实现,再看它的线程平安实现就比较简单了。
接下来,咱们通过增加元素 put 办法,来看 JDK 1.7 中 ConcurrentHashMap 是如何保障线程平安的,具体实现源码如下:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 在往该 Segment 写入前,先确保获取到锁
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); 
    V oldValue;
    try {
        // Segment 外部数组
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {if (e != null) {
                K k;
                // 更新已有值...
            }
            else {
                // 搁置 HashEntry 到特定地位,如果超过阈值则进行 rehash
                // 疏忽其余代码...
            }
        }
    } finally {
        // 开释锁
        unlock();}
    return oldValue;
}

从上述源码咱们能够看出,Segment 自身是基于 ReentrantLock 实现的加锁和开释锁的操作,这样就能保障多个线程同时拜访 ConcurrentHashMap 时,同一时间只有一个线程能操作相应的节点,这样就保障了 ConcurrentHashMap 的线程平安了。
也就是说 ConcurrentHashMap 的线程平安是建设在 Segment 加锁的根底上的,所以咱们把它称之为分段锁或片段锁,如下图所示:

JDK 1.8 底层实现

在 JDK 1.7 中,ConcurrentHashMap 尽管是线程平安的,但因为它的底层实现是数组 + 链表的模式,所以在数据比拟多的状况下拜访是很慢的,因为要遍历整个链表,而 JDK 1.8 则应用了数组 + 链表 / 红黑树的形式优化了 ConcurrentHashMap 的实现,具体实现构造如下:

链表降级为红黑树的规定:当链表长度大于 8,并且数组的长度大于 64 时,链表就会降级为红黑树的构造。

PS:ConcurrentHashMap 在 JDK 1.8 尽管保留了 Segment 的定义,但这仅仅是为了保障序列化时的兼容性,不再有任何构造上的用途了。

JDK 1.8 线程平安实现

在 JDK 1.8 中 ConcurrentHashMap 应用的是 CAS + volatile 或 synchronized 的形式来保障线程平安的,它的外围实现源码如下:

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;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 节点为空
            // 利用 CAS 去进行无锁线程平安操作,如果 bin 是空的
            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;
        else {
            V oldVal = null;
            synchronized (f) {// 细粒度的同步批改操作...}
            }
            // 如果超过阈值,降级为红黑树
            if (binCount != 0) {if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

从上述源码能够看出,在 JDK 1.8 中,增加元素时首先会判断容器是否为空,如果为空则应用 volatile 加 CAS 来初始化。如果容器不为空则依据存储的元素计算该地位是否为空,如果为空则利用 CAS 设置该节点;如果不为空则应用 synchronize 加锁,遍历桶中的数据,替换或新增节点到桶中,最初再判断是否须要转为红黑树,这样就能保障并发拜访时的线程平安了。
咱们把上述流程简化一下,咱们能够简略的认为在 JDK 1.8 中,ConcurrentHashMap 是在头节点加锁来保障线程平安的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率升高了,并发操作的性能就进步了。而且 JDK 1.8 应用的是红黑树优化了之前的固定链表,那么当数据量比拟大的时候,查问性能也失去了很大的晋升,从之前的 O(n) 优化到了 O(logn) 的工夫复杂度,具体加锁示意图如下:

总结

ConcurrentHashMap 在 JDK 1.7 时应用的是数据加链表的模式实现的,其中数组分为两类:大数组 Segment 和小数组 HashEntry,而加锁是通过给 Segment 增加 ReentrantLock 锁来实现线程平安的。而 JDK 1.8 中 ConcurrentHashMap 应用的是数组 + 链表 / 红黑树的形式实现的,它是通过 CAS 或 synchronized 来实现线程平安的,并且它的锁粒度更小,查问性能也更高。

是非审之于己,毁誉听之于人,得失安之于数。

公众号:Java 面试真题解析

文章合集:https://gitee.com/mydb/interview

正文完
 0