首页 > 试题广场 >

说一说ConcurrentHashMap的实现原理

[问答题]

说一说ConcurrentHashMap的实现原理

推荐

得分点

​ 数组+链表+红黑树、锁头节点

参考答案

标准回答

​ 在JDK8中,ConcurrentHashMap的底层数据结构与HashMap一样,也是采用“数组+链表+红黑树”的形式。同时,它又采用锁定头节点的方式降低了锁粒度,以较低的性能代价实现了线程安全。底层数据结构的逻辑可以参考HashMap的实现,下面我重点介绍它的线程安全的实现机制。

  1. 初始化数组或头节点时,ConcurrentHashMap并没有加锁,而是CAS的方式进行原子替换(原子操作,基于Unsafe类的原子操作API)。
  2. 插入数据时会进行加锁处理,但锁定的不是整个数组,而是槽中的头节点。所以,ConcurrentHashMap中锁的粒度是槽,而不是整个数组,并发的性能很好。
  3. 扩容时会进行加锁处理,锁定的仍然是头节点。并且,支持多个线程同时对数组扩容,提高并发能力。每个线程需先以CAS操作抢任务,争抢一段连续槽位的数据转移权。抢到任务后,该线程会锁定槽内的头节点,然后将链表或树中的数据迁移到新的数组里。
  4. 查找数据时并不会加锁,所以性能很好。另外,在扩容的过程中,依然可以支持查找操作。如果某个槽还未进行迁移,则直接可以从旧数组里找到数据。如果某个槽已经迁移完毕,但是整个扩容还没结束,则扩容线程会创建一个转发节点存入旧数组,届时查找线程根据转发节点的提示,从新数组中找到目标数据。

加分回答

​ ConcurrentHashMap实现线程安全的难点在于多线程并发扩容,即当一个线程在插入数据时,若发现数组正在扩容,那么它就会立即参与扩容操作,完成扩容后再插入数据到新数组。在扩容的时候,多个线程共同分担数据迁移任务,每个线程负责的迁移数量是 (数组长度 >>> 3) / CPU核心数

​ 也就是说,为线程分配的迁移任务,是充分考虑了硬件的处理能力的。多个线程依据硬件的处理能力,平均分摊一部分槽的迁移工作。另外,如果计算出来的迁移数量小于16,则强制将其改为16,这是考虑到目前服务器领域主流的CPU运行速度,每次处理的任务过少,对于CPU的算力也是一种浪费。

延伸阅读

​ 插入数据的关键代码如下:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    ...
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        // 1.若数组还未初始化,则初始化数组。
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        // 2.若该位置头节点为空,则初始化头节点。
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;
        }
        // 3.若头节点哈希值为MOVED,则表示数组正在扩容,则当前线程也去参与扩容。
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // 4.若该位置头节点非空,则追加一个新节点。
        else {
            V oldVal = null;
            // 加锁,锁的不是整个数组,而是头节点。
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    // 头节点为链表节点(参考状态常量)
                    if (fh >= 0) {
                        ...
                    }
                    // 头节点为红黑树节点
                    else if (f instanceof TreeBin) {
                        ...
                    }
                }
            }
            // 当链表中节点数达到8时,则扩容数组或者转为红黑树。
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                ...
            }
        }
    }
    // 计数,若发现数组过小则进行扩容。
    addCount(1L, binCount);
    return null;
}

​ 数组扩容的关键代码如下:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    ...
    // 每个CPU处理的节点数(步长)
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        // 最少设定为16
        stride = MIN_TRANSFER_STRIDE;
    if (nextTab == null) {
        ...
        // 扩容至2倍
         Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
        ...
    }

    // 特殊节点,标识该位置的元素已被转移,并指向新的数组。
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);

    // bound为遍历的边界,因为每个线程负责转移一部分节点,不是全部。
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        // advance表示从transferIndex-1遍历到bound位置的过程中,是否需要继续。
        // 自旋
        while (advance) {
            ...
            // 抢任务(CAS修改TRANSFERINDEX)
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                ...
            }
        }
        // i越界,整个集合遍历完成。
        if (i < 0 || i >= n || i + n >= nextn) {
            // 转移完成
            if (finishing) {
                // 替换旧数组
                nextTable = null;
                table = nextTab;
                // 将sizeCtl设置为扩容后的1.5倍
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            ...
        }
        // tab[i]迁移完毕
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        // tab[i]正在迁移
        else if ((fh = f.hash) == MOVED)
            advance = true;
        else {
            synchronized (f) {
                ...
            }
        }
    }
}
编辑于 2021-09-15 10:32:46 回复(0)

为什么要用ConcurrentHashMap?

HashMap线程不安全,而Hashtable是线程安全,但是它使用了synchronized进行方法同步,插入、读取数据都使用了synchronized,当插入数据的时候不能进行读取(相当于把整个Hashtable都锁住了,全表锁),当多线程并发的情况下,都要竞争同一把锁,导致效率极其低下。而在JDK1.5后为了改进Hashtable的痛点,ConcurrentHashMap应运而生。

ConcurrentHashMap为什么高效?

JDK1.5中的实现

ConcurrentHashMap使用的是分段锁技术,将ConcurrentHashMap锁一段一段的存储,然后给每一段数据配一把锁(segment),当一个线程占用一把锁(segment)访问其中一段数据的时候,其他段的数据也能被其它的线程访问,默认分配16个segment。默认比Hashtable效率提高16倍。

JDK1.8中的实现

ConcurrentHashMap取消了segment分段锁,而采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

JDK7的ConcurrentHashMap扩容

HashMap是线程不安全的,我们来看下线程安全的ConcurrentHashMap,在JDK7的时候,这种安全策略采用的是分段锁的机制,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。

JDK8的ConcurrentHashMap扩容

在JDK8中彻底抛弃了JDK7的分段锁的机制,新的版本主要使用了Unsafe类的CAS自旋赋值+synchronized同步+LockSupport阻塞等手段实现的高效并发。
在JDK8里面,去掉了分段锁,将锁的级别控制在了更细粒度的table元素级别,也就是说只需要锁住这个链表的head节点,并不会影响其他的table元素的读写,好处在于并发的粒度更细,影响更小,从而并发效率更好,但不足之处在于并发扩容的时候,由于操作的table都是同一个,不像JDK7中分段控制,所以这里需要等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈。好在Doug lea大神对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞,但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类,在一个线程发起扩容的时候,就会改变sizeCtl这个值。

转载自:https://blog.csdn.net/qq_36174081/article/details/90606392

发表于 2021-10-27 13:01:56 回复(0)
hashMap是非线程安全的,hashTable是线程安全的,但是它使用synchronized进行方法同步,读写都使用了synchronized,写的时候不能读数据,因为是把整个hashtable都锁了。多线程并发的情况下,都要竞争同一把锁,效率比较低。在jdk1.5以后,引入了ConcurrentHashMap。
在jdk1.7中,ConcurrentHashMap是采用了分段锁技术。将ConcurrentHashMap分成不同的segment,默认是16个segment,这样每次锁的时候只锁对应的segment,而其他segment的读写并不受影响,从而提高了性能。
在jdk1.8以后,采用了更细粒度的锁,采用cas+synchronized的技术保证线程安全。ConcurrentHashMap的结构同hashMap都是数组+链表/红黑二叉树,而jdk1.8以后,synchronized只锁当前链表或者红黑二叉树的首节点,这样只要不发生hash冲突,就不会发生并发,效率又进一步提升。
发表于 2022-02-14 19:14:50 回复(0)
首先它的底层数据结构是一个长度为16的数组
每个数组里面存放着一个链表
当链表长度超过8时,还会将链表转换为红黑树
首先会根据键的hashcode使用除留余数法得到索引下标位置。
同样的下标位置会使用头插法插入链表
然后它有一个分段锁segment来实现并发访问
发表于 2022-09-14 16:16:28 回复(0)
HashMap结构默认是线程不安全的,线程安全的结构可以使用HashTable,但是HashTable底层的读和写都使用的是synchronized这种重量级锁,锁的是全表,这也就导致了在并发环境之下,HashTable的效率将会及其低下。
因此在JDK1.5中引入了ConcurrentHashMap,该数据结构底层维护了一个Segment数组,在并发环境下进行分段加锁,相比于原来的HashTable效率提升了16倍。
在JDK1.8之后,取消了分段锁的机制,使用CAS乐观锁 + synchronized控制并发环境下的数据操作,锁住的仅仅是一个红黑树的Node节点,只要不发生Hash冲突,其他Node节点基本就不会存在并发冲突的情况,效率大大提升
发表于 2024-08-24 20:28:08 回复(0)
JDK1.7
    采用分段锁机制,ConcurrentHashMap维护了一个Segment数组,Segment继承了ReenTrantLock类,扩容的时候是对Segment加锁,当线程访问不同的数据段时,不会出现锁竞争,因此提高了并发能力。
JDK1.8
    不再使用分段锁机制,采用CAS+sychronized保证线程安全。如果并发度比较低,没有出现哈希冲突,则采用CAS的无锁方式,减少开销;如果出现了哈希冲突,则使用sychronized锁定产生冲突的链表或红黑树的首节点,锁定粒度更小。底层的数据结构是数组+链表+红黑树,当链表长度超过8时,会转成红黑树,并且使用头插法将元素插入到链表中。
发表于 2024-08-15 11:56:03 回复(0)
因为HashMap本身是线程不安全的,HashTable因为put和get方法都是synchronized所以并发度很低不能同时读写,ConcurrentHashMap1.7是根据segement加锁,再1.8后使用cas+synchornized保证并发度使用forwardingNode再扩容时候标记node
发表于 2023-10-02 16:32:45 回复(0)

为什么要用ConcurrentHashMap?

HashMap线程不安全,而Hashtable是线程安全,但是它使用了synchronized进行方法同步,插入、读取数据都使用了synchronized,当插入数据的时候不能进行读取(相当于把整个Hashtable都锁住了,全表锁),当多线程并发的情况下,都要竞争同一把锁,导致效率极其低下。而在JDK1.5后为了改进Hashtable的痛点,ConcurrentHashMap应运而生。

ConcurrentHashMap为什么高效?

JDK1.5中的实现

ConcurrentHashMap使用的是分段锁技术,将ConcurrentHashMap锁一段一段的存储,然后给每一段数据配一把锁(segment),当一个线程占用一把锁(segment)访问其中一段数据的时候,其他段的数据也能被其它的线程访问,默认分配16个segment。默认比Hashtable效率提高16倍。

JDK1.8中的实现

ConcurrentHashMap取消了segment分段锁,而采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

JDK7的ConcurrentHashMap扩容

HashMap是线程不安全的,我们来看下线程安全的ConcurrentHashMap,在JDK7的时候,这种安全策略采用的是分段锁的机制,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。

JDK8的ConcurrentHashMap扩容

在JDK8中彻底抛弃了JDK7的分段锁的机制,新的版本主要使用了Unsafe类的CAS自旋赋值+synchronized同步+LockSupport阻塞等手段实现的高效并发。
在JDK8里面,去掉了分段锁,将锁的级别控制在了更细粒度的table元素级别,也就是说只需要锁住这个链表的head节点,并不会影响其他的table元素的读写,好处在于并发的粒度更细,影响更小,从而并发效率更好,但不足之处在于并发扩容的时候,由于操作的table都是同一个,不像JDK7中分段控制,所以这里需要等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈。好在Doug lea大神对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞,但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类,在一个线程发起扩容的时候,就会改变sizeCtl这个值。

发表于 2022-09-04 21:42:43 回复(0)