ConcurrentHashMap源码剖析

前言

锁优化有一个很重要的思路,就是拆分锁的粒度,类似于分布式锁优化的实践,把一份数据拆分为多份数据,对每一份数据片段来加锁,这样就可以提升多线程并发的效率

HashMap底层不就是一个数组的数据结构么?如果你要完全保证里的并发安全,如果你每次对数组做一些put、resize、get的操作的时候,你都是加锁,synchronized好了,此时就会导致并发性能非常的低下

所有的线程读写hashmap的过程都是串行化的,hashtable,就是采用的这种做法

读写锁,大量的读锁和写锁冲突的时候,也会导致多线程并发的效率大大的降低,也不行。

ConcurrentHashMap,分段加锁,把一份数据拆分为多个segment,对每个段设置一把小锁,put操作,仅仅只是锁掉你的那个数据一个segment而已,锁一部分的数据,其他的线程操作其他segmetn的数据,跟你是没有竞争的。大大减少了锁竞争,从而提高了性能。

put()剖析

int hash = spread(key.hashCode());

对key获取了hashCode,调用了spread算法,获取到了一个hash值

    static final int spread(int h) {

        return (h ^ (h >>> 16)) & HASH_BITS;

}

他相当于是把hash值的高低16位都考虑到后面的hash取模算法里,这样就可以把hash值的高低16位的特征都放到hash取模算法来运算,有助于尽可能打散各个key在不同的数组的位置,降低hash冲突的概率

刚开始,table是null的话,此时就要初始化这个table。

U.compareAndSwapInt(this, SIZECTL, sc, -1):CAS操作,sizeCtl = -1

初始化一个table数组,默认的大小就是16

tabAt(tab, i = (n - 1) & hash) --》return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);

i = (n - 1) & hash,这个就是hash取模的算法,定位的算法。定位出来的位置,传递给了tabAt()函数进行volatile读。

他在这里的意思,就是走一个线程安全的操作,Unsafe,如果此时数组那个位置的元素给返回出来。

当前这个数组这里没有元素的话,此时就直接把你的key-value对放在数组的这个地方了。

通过casTabAt()--》 U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);

通过上述的代码,使用底层的CAS操作,直接将你的key-value对放在了数组的hash定位到的那个位置。

为什么ConcurrentHashMap他说是线程安全的呢?

Unsafe,CAS的操作,依靠线程安全的CAS对数组里的位置进行赋值,只有一个线程可以在这里成功的将一个key-value对放在数组的一个地方里。

CAS初步体现了分段加锁的思想???

他并没有对整个一个大的数组进行synchronized加锁的机制,并没有,线程都会串行化的来执行,性能和效率是很低下的。

多个线程同时CAS操作数组同一个位置的元素时,在硬件层面实际上会对这个元素加了一个锁,此时只有一个线程可以加锁成功,其他线程则等待他释放锁后才可以进行CAS操作。数组中的每一个位置实际上都可以加一把CAS锁,没有对一个数组全部进行加锁,仅仅是说对数组的同一个位置的元素赋值的时候,多个线程会基于CAS(隐含式的加锁),仅仅是对数组的那个位置的元素进行加锁而已,隐式的加锁,硬件层面上的锁。

数组大小是16,有16个元素,同时可以允许最多是16个线程同时并发的来操作这个数组,如果16个线程操作的是数组的不同位置的元素的话,此时16个线程之间是没有任何锁的关系

数组大小是16,16个元素,CAS赋值的机制,实现了一个效果,这个数组有16把锁,每个元素是一把锁,只有竞争同一个位置的元素的多个线程,才会对一把锁进行争用的操作。

CAS成功和失败

如果CAS成功,key-value对就直接进到数组里去了。

如果CAS失败了以后,两个或者是多个线程同时来进行并发的put操作的时候,如果不巧的定位都到了一个数组的元素的位置,此时大家都尝试进行CAS,只有一个线程是可以执行成功的,而其他的线程此时CAS设置数组的元素就会失败。

万一有线程CAS失败了呢?此时会就什么都不会干,直接进入下一轮for循环,(f = tabAt(tab, i = (n - 1) & hash)) == null,此时会发现说数组的那个位置的元素已经不是null了,因为之前已经有人在数组的那个位置设置了一个Node。此时就应该对数组的那个位置的元素进行链表+红黑树的处理,把冲突的多个key-value对挂成一个链表或者是红黑树。

f就代表了数组当前位置的元素,Node节点

synchronized(f) {

。。。。

}

这个就是所谓的JDK 1.8里的ConcurrentHashMap分段加锁的思想,淋漓尽致的体现,他仅仅就是对数组这个位置的元素加了一把锁而已。同一时间只有一个线程可以进锁来进行这个位置的链表+红黑树的处理,这个就是分段加锁

数组的中的每一个元素,无论是null情况下来赋值(CAS,分段加锁思想),有值情况下来处理链表/红黑树(synchronized,对元素本身加锁,更加是分段加锁思想),都是对数组的一个元素加锁而已。你的数组有多大,有多少个元素,你就有多少把锁,大幅度的提升了整个HashMap加锁的效率。

如果有一个线程成功对数组的某个元素加锁了,synchronized.如何对这个位置的元素进行链表/红黑树的处理?

                                if (e.hash == hash &&

                                    ((ek = e.key) == key ||

                                     (ek != null && key.equals(ek)))) {

                                    oldVal = e.val;

                                    if (!onlyIfAbsent)

                                        e.val = value;

                                    break;

                                }

如果发现说我当前要put的key跟数组里这个位置的key是一样的,此时就对数组当前位置的元素的value值覆盖一下

如果两个key不同,就默认走链表的一个处理,此时就是把e.next = 新封装的一个节点,如下代码所示,e就是数组当前位置的元素

    Node<K,V> pred = e;

                                if ((e = e.next) == null) {

                                    pred.next = new Node<K,V>(hash, key,

                                                              value, null);

                                    break;

                                }

 

                if (binCount != 0) {

                    if (binCount >= TREEIFY_THRESHOLD)

                        treeifyBin(tab, i);

                    if (oldVal != null)

                        return oldVal;

                    break;

                }

上面这块代码的判断就是,如果一个链表的元素的数量超过了8,达到了一个阈值之后,就会将链表转换为红黑树。如果转换为红黑树以后,下次如果有hash冲突的问题,是直接把key-value对加入到红黑树里去   

   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;

                            }

                        }

出现hash冲突的时候,分段加锁成功了以后,就会做值覆盖/链表/红黑树处理,前提条件,都是你对数组当前位置的元素synchronized加锁成功了才可以的。

get()剖析

不加锁,但是他通过volatile读,尽可能给你保证说是读到了其他线程修改的一个最新的值,但是不需要加锁。

volatile底层硬件级别的原理,volatile读操作的话,会插入一个load屏障,绝对是在读取数据的时候,一定会嗅探一下,探查一下无效队列,这个数据是否被别人修改过了。

此时必须立马过期掉本地高速缓存里的缓存数据,invalid(I),然后再读的时候,就需要发送read消息到总线,从其他线程修改修改这个值的线程的高速缓存里(或者从主存读。具体从哪里读取最新的值,跟CPU有关系),必须这个加载到最新的值

size()剖析

size方法,是帮助你去读到当前最新的一份数据,通过volatile的读操作。但是因为读操作是不加锁,他不定根可以保证说,你读的时候就一定没人修改了,很可能是你刚刚读完一份数据,就有人来修改

这个数据结构,记录了数组的每个位置挂载了多少个元素,每个位置都可能挂的是链表或者是红黑树,此时可能一个位置有多个元素。注意:value值是volatile变量,保证可见性。

将每个位置对应的counterCell累加起来,再加上baseCount就是我们map的总大小。

JDK1.7和JDK1.8中ConcurrentHashMap的对比

JDK 1.7的ConcurrentHashMap,稍微是有一些差距的。Segment,16个Segment,16个分段,每个Segment对应Node[]数组,每个Segment有一把锁,也就是说对一个Segment里的Node[]数组的不同的元素如果要put操作的话,其实都是要竞争一个锁,串行化来处理的。锁的粒度是比较粗的,因为一个Node[]数组是一把锁,但是他有多个Node[]数组

JDK 1.8的ConcurrentHashMap,优化了,锁粒度细化,他是就一个Node[]数组,正常会扩容的,但是他的锁粒度是针对的数组里的每个元素,每个元素的处理会加一把锁,不同的元素就会有不同的锁。大幅度的提升了多线程并发写ConcurrentHashMap的性能,降低了锁的冲突。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章