ConcurrentHashMap
HashMap不是线程安全的,hashTable是线程安全的,但是它对整个hash表加锁,get/put操作都加锁了,在高并发的情况下性能不好,于是就有了ConcurrentHashMap
ConcurrentHashMap的读操作是不用加锁的。
为什么1.8不再采用reentrantlock,主要因为至于为什么不用ReentrantLock而是Synchronzied呢?实际上,synchronzied做了很多的优化,包括偏向锁,轻量级锁,重量级锁,可以依次向上升级锁状态,但不能降级(关于synchronized,看文末扩展文章),因此,使用synchronized相较于ReentrantLock的性能会持平甚至在某些情况更优。
jdk1.8之前的ConcurrentHashMap
是使用分段锁的策略,一个hash表维持一个segment数组,一个segment维持一个hashentry数组。每次put对同一个segment加锁,get不用加锁。
分段锁的寻址方式:在读写某个Key时,先取该Key的哈希值。并将哈希值的高N位对Segment个数取模从而得到该Key应该属于哪个Segment,接着如同操作HashMap一样操作这个Segment。
对于读操作,获取Key所在的Segment时,需要保证可见性。具体实现上可以使用volatile关键字,也可使用锁。但使用锁开销太大,而使用volatile时每次写操作都会让所有CPU内缓存无效,也有一定开销。
理论上可支持concurrencyLevel(等于Segment的个数)个线程安全的并发读写。在获取segment锁的时候都是都是采用的自旋锁,并不是通过lock来获取的。
Segment<K,V> s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)//也就是强制从主存中获取属性值。类似的方法有getIntVolatile、getDoubleVolatile等等。这个方法要求被使用的属性被volatile修饰 HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE)
对于size的操作:
ConcurrentHashMap会在不上锁的前提逐个计算Segment最多3次,如果某相邻两次计算获取的所有Segment的更新次数(每个Segment都与HashMap一样通过modCount跟踪自己的修改次数,Segment每修改一次其modCount加一)相等,
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
如果相邻两次计算过程中无更新操作,则这两次计算出的总size相等,可直接作为最终结果返回。如果这三次计算过程中Map有更新,则对所有Segment加锁重新计算Size。
jdk1.8中的concurrenthashMap
基本上和HashMap的结构一样了,都是数组加链表或者红黑树的结构。内部大量采用CAS操作,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
jdk1.8中的:
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();//key,value都不能为空 int hash = spread(key.hashCode());过程1 int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; // 如果table为空,初始化;否则,根据hash值计算得到数组索引i,如果tab[i]为空,直接新建节点Node即可。注:tab[i]实质为链表或者红黑树的首节点。 if (tab == null || (n = tab.length) == 0)过程2 tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {//当table[i]后面没有节点时,直接创建Node节点(无锁操作,CAS操作)过程3 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; } // 如果tab[i]不为空并且hash值为MOVED,说明该链表正在进行transfer操作,返回扩容完成后的table。 else if ((fh = f.hash) == MOVED)过程4 tab = helpTransfer(tab, f); else { V oldVal = null; // 针对首个节点进行加锁操作,而不是segment,进一步减少线程冲突 synchronized (f) {过程5 if (tabAt(tab, i) == f) { if (fh >= 0) { //代表是单链表节点 binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; // 如果在链表中找到值为key的节点e,直接设置e.val = value即可。 if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } // 如果没有找到值为key的节点,直接新建Node并加入链表即可。 Node<K,V> pred = e; if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); break; } } } // 如果首节点为TreeBin类型,说明为红黑树结构,执行putTreeVal操作。 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; } } } } if (binCount != 0) { // 如果节点数>=8,那么转换链表结构为红黑树结构。 if (binCount >= TREEIFY_THRESHOLD)过程6 treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } // 计数增加1,有可能触发transfer操作(扩容)。 addCount(1L, binCount); return null; }
put分为6个过程:
1.根据key计算出hashcode值。
2.判断原始table是否需要初始化。
3.根据hash值定位到对应的Node槽位,如果为空,表示当前位置可以直接写入,利用CAS尝试写入,失败则自旋来保证成功。
4.判断如果当前位置hashcode==moved==-1,表示正在进行扩容,则辅助扩容。
5.如果都不满足。就利用synchronized锁写入数据。
6.如果数量大于REEIFY_THRESHOLD 则要转换为红黑树。
关于ConcurrentHashMap其他方面的改进:求size的改进
比如新增字段 transient volatile CounterCell[] counterCells; 可方便的计算hashmap中所有元素的个数,性能大大优于jdk1.7中的size()方法。
private transient volatile long baseCount; private transient volatile CounterCell[] counterCells; @sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } } final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; }
在concurrenthashmap里面计算size,在单线程的情况下就可以直接通过baseCount得到。
下面是addCount方法,在putVal函数的最后都会使用这个函数
private final void addCount(long x, int check) { CounterCell[] as; long b, s; if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { //第一步,CAS原子操作更新basecount,当有多线程的时候就需要借助counterCells这个数组了,counterCells不为空,优先处理这个数组。 CounterCell a; long v; int m; boolean uncontended = true; if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null || !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) { //第二步当basecount更新失败(通常在多线程的情况下),就会去具体第几个位置更新countCells[i]这个里面的内容,仍然更新失败的话会尝试自旋。 fullAddCount(x, uncontended); return; } if (check <= 1) return; s = sumCount(); }
//下面是检查是否需要扩容,默认是需要扩容的 if (check >= 0) { Node<K,V>[] tab, nt; int n, sc;
//s大于sizeCtl阈值,nextTable不为空,需要扩容 while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { int rs = resizeStamp(n); if (sc < 0) { if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null); s = sumCount(); } } }
过程:
- 判断计数盒子属性是否是空,如果是空,就尝试修改 baseCount 变量,对该变量进行加 X。
- 如果计数盒子不是空,或者修改 baseCount 变量失败了,则放弃对 baseCount 进行操作。
- 如果计数盒子是 null 或者计数盒子的 length 是 0,或者随机取一个位置取于数组长度是 null,那么就对刚刚的元素进行 CAS 赋值。
- 如果赋值失败,或者满足上面的条件,则调用 fullAddCount 方法重新死循环插入。
- 这里如果操作 baseCount 失败了(或者计数盒子不是 Null),且对计数盒子赋值成功,那么就检查 check 变量,如果该变量小于等于 1. 直接结束。否则,计算一下 count 变量。
- 如果 check 大于等于 0 ,说明需要对是否扩容进行检查。
- 如果 map 的 size 大于 sizeCtl(扩容阈值),且 table 的长度小于 1 << 30,那么就进行扩容。
- 根据 length 得到一个标识符,然后,判断 sizeCtl 状态,如果小于 0 ,说明要么在初始化,要么在扩容。
- 如果正在扩容,那么就校验一下数据是否变化了(具体可以看上面代码的注释)。如果检验数据不通过,break。
- 如果校验数据通过了,那么将 sizeCtl 加一,表示多了一个线程帮助扩容。然后进行扩容。
- 如果没有在扩容,但是需要扩容。那么就将 sizeCtl 更新,赋值为标识符左移 16 位 —— 一个负数。然后加 2。 表示,已经有一个线程开始扩容了。然后进行扩容。然后再次更新 count,看看是否还需要扩容。
然后就是可以帮助扩容:如果这个槽位的hash值是-1的话
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
关于helpTransfer方法:
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) { Node<K,V>[] nextTab; int sc; // 如果 table 不是空 且 node 节点是转移类型,数据检验 // 且 node 节点的 nextTable(新 table) 不是空,同样也是数据校验 // 尝试帮助扩容 if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) { // 根据 length 得到一个标识符号 int rs = resizeStamp(tab.length); // 如果 nextTab 没有被并发修改 且 tab 也没有被并发修改 // 且 sizeCtl < 0 (说明还在扩容) while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) { // 如果 sizeCtl 无符号右移 16 不等于 rs ( sc前 16 位如果不等于标识符,则标识符变化了) // 或者 sizeCtl == rs + 1 (扩容结束了,不再有线程进行扩容)(默认第一个线程设置 sc ==rs 左移 16 位 + 2,当第一个线程结束扩容了,就会将 sc 减一。这个时候,sc 就等于 rs + 1) // 或者 sizeCtl == rs + 65535 (如果达到最大帮助线程的数量,即 65535) // 或者转移下标正在调整 (扩容结束) // 结束循环,返回 table if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || transferIndex <= 0) break; // 如果以上都不是, 将 sizeCtl + 1, (表示增加了一个线程帮助其扩容) if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { // 进行转移 transfer(tab, nextTab); // 结束循环 break; } } return nextTab; } return table; }
多线程无锁扩容的关键就是通过CAS设置sizeCtl与transferIndex变量,协调多个线程对table数组中的node进行迁移。
扩容transfer方法的理解:
https://juejin.im/post/5b00160151882565bd2582e0
https://blog.csdn.net/varyall/article/details/81283231
本文来自博客园,作者:LeeJuly,转载请注明原文链接:https://www.cnblogs.com/peterleee/p/10550178.html

浙公网安备 33010602011771号