前言
锁优化有一个很重要的思路,就是拆分锁的粒度,类似于分布式锁优化的实践,把一份数据拆分为多份数据,对每一份数据片段来加锁,这样就可以提升多线程并发的效率
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的性能,降低了锁的冲突。