ConcurrentHashMap源码分析
目录 |
|---|
| # 源码 |
| ## put()阶段 |
| ```java |
| //ConcurrentHashMap.putVal() |
| final V putVal(K key, V value, boolean onlyIfAbsent) { |
| if (key == null |
| int hash = spread(key.hashCode()); |
| int binCount = 0; |
| for (Node<K,V>[] tab = table;😉 { |
| Node<K,V> f; int n, i, fh; |
| //当tab为空时,先初始化 |
| if (tab == null |
| tab = initTable(); |
| //当命中的数组下标没有元素时,直接设置值到该位置 |
| else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { |
| if (casTabAt(tab, i, null, |
| new Node<K,V>(hash, key, value, null))) |
| break; // no lock when adding to empty bin |
| } |
| else if ((fh = f.hash) == MOVED) |
| tab = helpTransfer(tab, f); |
| else { |
| //...略 |
| } |
| } |
| //进行size+1的操作 同时判断是否需要扩容 |
| addCount(1L, binCount); |
| return null; |
| } |
| ``` |
| ### 数组初始化 |
当put(object key)时,若数组未初始化,则先进行数组初始化,这个方法比较简单,就是初始化一个合适大小的数组。 |
| ```java |
| private final Node<K,V>[] initTable() { |
| Node<K,V>[] tab; int sc; |
| while ((tab = table) == null |
| //sizeCtl<0 表示有其他线程正在进行初始化 |
| if ((sc = sizeCtl) < 0) |
| Thread.yield(); // 出让CPU时间片 |
| //CAS操作当前线程是否能够成功设置sizeCtl为-1 抢占 |
| else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { |
| try { |
| //当CAS操作成功后,需要再次判断tab是否为空防止并发设值 |
| if ((tab = table) == null |
| int n = (sc > 0) ? sc : DEFAULT_CAPACITY; |
| @SuppressWarnings("unchecked") |
| Node<K,V>[] nt = (Node<K,V>[])new Node[n]; |
| table = tab = nt; |
| sc = n - (n >>> 2);//sc为扩容阈值 |
| } |
| } finally { |
| sizeCtl = sc; |
| } |
| break; |
| } |
| } |
| return tab; |
| } |
| ``` |
| sizeCtl是一个标记位 |
| ```java |
| private transient volatile int sizeCtl; |
| ``` |
| * 当为0时,表示Node 数组还没有被初始化; |
| * 大于0时,表示已初始化,下次扩容的阈值大小; |
| * 当为-1时,表示正在被某一线程进行初始化; |
* 当为-N N!=1 表示有N-1个线程正在进行扩容操作,这里不是简单的理解成 n 个线程。 |
| ### tabAt() |
同HashMap中一样,也是通过(n-1) & h方式来计算得到数组下标的位置。 |
| ```java |
| else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { |
| //...略 |
| } |
| ``` |
那么按照我们正常的逻辑得到数组下标后,直接tab[i]不就行了么,为什么还需要通过一个方法来获取值呢? |
| ```java |
| static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) { |
| return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE); |
| } |
| ``` |
getObjectVolatile()看到volatile关键字,我们就会想到可见性;因为对于volatile的写操作happens-before于volatile的读操作,因此其他线程对于table的修改对于get()读取操作是可见的。 |
虽然table数组本身有volatile修饰,但是“volatile 的数组只针对数组的引用具有volatile 的语义,而不是它的元素”。 |
| 所以如果有其他线程对这个数组的元素进行写操作,那么当前线程来读的时候不一定能读到最新的值。出于性能考虑,直接通过unsafe来操作。 |
| ### addCount() |
当put(Object key)或者remove(Object key)后,需要对Map的容量大小进行调整,同时判断是否需要扩容。 |
那么高并发下size()的是怎么设计的呢? |
| ```java |
| // 从 putVal 传入的参数是 1, binCount,binCount 默认是0,只有 hash 冲突了才会大于 1.且他的大小是链表的长度(如果不是红黑数结构的话)。 |
| private final void addCount(long x, int check) { |
| CounterCell[] as; long b, s; |
| // 如果counterCells不是空 或者 修改 baseCount 失败(表示存在并发冲突) |
| if ((as = counterCells) != null |
| !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { |
| CounterCell a; long v; int m; |
| boolean uncontended = true; |
| // 如果counterCells是空(尚未出现并发)或如果随机取余一个数组位置为空 修改这个槽位的变量失败(出现并发了) |
| if (as == null |
| (a = as[ThreadLocalRandom.getProbe() & m]) == null |
| !(uncontended = |
| U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) { |
| //fullAddCount 主要是用来初始化 CounterCell,来记录元素个数,里面包含扩容,初始化等操作 |
| fullAddCount(x, uncontended); |
| return; |
| } |
| if (check <= 1) |
| return; |
| s = sumCount(); |
| } |
| // 如果需要检查,检查是否需要扩容,在 putVal 方法调用时,默认就是要检查的。 |
| if (check >= 0) { |
| Node<K,V>[] tab, nt; int n, sc; |
| // 如果map.size() 大于 sizeCtl(达到扩容阈值需要扩容) 且 |
| // table 不是空;且 table 的长度小于 1 << 30。(可以扩容) |
| while (s >= (long)(sc = sizeCtl) && (tab = table) != null && |
| (n = tab.length) < MAXIMUM_CAPACITY) { |
| // 根据 length 得到一个标识 |
| int rs = resizeStamp(n); |
| // 如果正在扩容 |
| if (sc < 0) { |
| // 如果 sc 的低 16 位不等于 标识符(校验异常 sizeCtl 变化了) |
| // 如果 sc == 标识符 + 1 (扩容结束了,不再有线程进行扩容)(默认第一个线程设置 sc ==rs 左移 16 位 + 2,当第一个线程结束扩容了,就会将 sc 减一。这个时候,sc 就等于 rs + 1) |
| // 如果 sc == 标识符 + 65535(帮助线程数已经达到最大) |
| // 如果 nextTable == null(结束扩容了) |
| // 如果 transferIndex <= 0 (转移状态变化了) |
| // 结束循环 |
| if ((sc >>> RESIZE_STAMP_SHIFT) != rs |
| sc == rs + MAX_RESIZERS |
| transferIndex <= 0) |
| break; |
| // 如果可以帮助扩容,那么将 sc 加 1. 表示多了一个线程在帮助扩容 |
| if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) |
| // 扩容 |
| transfer(tab, nt); |
| } |
| // 如果不在扩容,将 sc 更新:标识符左移 16 位 然后 + 2. 也就是变成一个负数。高 16 位是标识符,低 16 位初始是 2. |
| else if (U.compareAndSwapInt(this, SIZECTL, sc, |
| (rs << RESIZE_STAMP_SHIFT) + 2)) |
| // 更新 sizeCtl 为负数后,开始扩容。 |
| transfer(tab, null); |
| s = sumCount(); |
| } |
| } |
| } |
| ``` |
* 当counterCells为空时,尝试直接通过baseCount+1方式来累加size;若counterCells不为空时,直接采用counterCells来计数。 |
然就是判断是否需要调用fullAddCount()方法,调用完这个方法后,直接return。因为在fullAddCount()方法中,会对counterCells进行初始化,此时自然就能够进行size大小的维护动作。 |
| ```java |
| //1.计数表为空则直接调用 fullAddCount |
| //2.从计数表中随机取出一个数组的位置为空,直接调用 fullAddCount |
| //3.通过CAS 修改 CounterCell 随机位置的值,如果修改失败说明出现并发情况 调用 fullAndCoun。 |
| if (as == null |
| (a = as[ThreadLocalRandom.getProbe() & m]) == null |
| !(uncontended = |
| U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) { |
| //fullAddCount 主要是用来初始化 CounterCell,来记录元素个数,里面包含扩容,初始化等操作 |
| fullAddCount(x, uncontended); |
| return; |
| } |
| ``` |
| 前面已经判断了,进入这里的条件是 |
* counterCells为空,但是通过CAS操作更新baseCount失败,意味着存在并发 |
* counterCells不为空 |
counterCells说明 |
| ```properties |
| 一般的集合记录size大小,直接定义一个size变量,每次进行加减操作即可;为什么ConcurrentHashMap要用采用counterCells数组来记录元素的个数呢? |
| 问题还是出在并发上,ConcurrentHashMap 是并发集合,如果用一个成员变量来统计元素个数,为了保证并发情况下共享变量的的安全性,势必会需要通过加锁或者自旋来实现,如果竞争比较激烈的情况下,size的设置上会出现比较大的冲突反而影响了性能,所以在ConcurrentHashMap采用分片来记录元素个数。 |
| ``` |
| ```java |
| private transient volatile CounterCell[] counterCells;//CounterCell数组作为成员变量 |
| @sun.misc.Contended static final class CounterCell { |
| volatile long value;//CounterCell对象只有一个成员变量就是整型的数值记录每片的大小 |
| CounterCell(long x) |
| } |
| //当需要累计时,则对整个counterCells数组进行累加求和 |
| 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; |
| } |
| ``` |
| ### fullAddCount() |
fullAddCount() 主要是用来初始化 CounterCell来记录元素个数,里面包含CounterCell的扩容,初始化等操作。 |
| ```java |
| private final void fullAddCount(long x, boolean wasUncontended) { |
| int h; |
| if ((h = ThreadLocalRandom.getProbe()) == 0) { |
| ThreadLocalRandom.localInit(); // force initialization |
| h = ThreadLocalRandom.getProbe(); |
| wasUncontended = true;//刚开始初始化当前线程的随机值,所以将未冲突标记位为true |
| } |
| boolean collide = false; // True if last slot nonempty |
| for (;😉 {//自旋等待 |
| CounterCell[] as; CounterCell a; int n; long v; |
| //说明counterCells已经被初始化了 |
| if ((as = counterCells) != null && (n = as.length) > 0) { |
| if ((a = as[(n - 1) & h]) == null) {//获得当线程的在counterCells数组的下标值 |
| if (cellsBusy == 0) { //cellsBusy=0 表示 counterCells 不在初始化或者扩容状态 |
| CounterCell r = new CounterCell(x); // 构造一个CounterCell对象r |
| if (cellsBusy == 0 &&//CAS设置cellsBusy的值,防止其他线程进来 |
| U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { |
| boolean created = false; |
| try { //将初始化的 r 对象的元素个数放在对应下标的位置 |
| CounterCell[] rs; int m, j; |
| if ((rs = counterCells) != null && |
| (m = rs.length) > 0 && |
| rs[j = (m - 1) & h] == null) { |
| rs[j] = r; |
| created = true; |
| } |
| } finally {//恢复标志位 |
| cellsBusy = 0; |
| } |
| if (created) |
| break; |
| continue; // 说明counterCells数组中计算出来的下标位置不为空,进行下一次循环 |
| } |
| } |
| collide = false; |
| } |
| else if (!wasUncontended) // 入参 说明在addCount中通过CAS操作将增加的size值加到随机的下标中失败 且当前线程的probe值不为空 |
| wasUncontended = true; // 直接重新设置为true 同时在下面会更新hash值 来执行下一次循环 |
| //由于指定下标位置的 cell 值不为空,则直接通过 cas 进行原子累加,如果成功,则直接退出 |
| else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)) |
| break; |
| //如果已经有其他线程建立了新的 counterCells 或者 CounterCells 大于 CPU 核心数 (很巧妙,线程的并发数不会超过 cpu 核心数) |
| else if (counterCells != as |
| collide = false; //设置当前线程的循环失败不进行扩容 |
| else if (!collide) |
| collide = true; |
| else if (cellsBusy == 0 &&//进入到这里,说明竞争比较大,所以可以加大counterCells数组的大小 |
| U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { |
| try { |
| if (counterCells == as) {//再次确认没有其他线程扩容成功 |
| CounterCell[] rs = new CounterCell[n << 1];//扩容为原来的2倍 |
| for (int i = 0; i < n; ++i) |
| rs[i] = as[i]; |
| counterCells = rs; |
| } |
| } finally { |
| cellsBusy = 0; |
| } |
| collide = false; |
| continue; // Retry with expanded table |
| } |
| h = ThreadLocalRandom.advanceProbe(h); |
| } |
| //再次确认标记位cellsBusy为0 且成功将cellsBusy设置为1 表示正在初始化counterCells |
| else if (cellsBusy == 0 && counterCells == as && |
| U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { |
| boolean init = false; |
| try { |
| if (counterCells == as) { |
| CounterCell[] rs = new CounterCell[2];//初始化容量为2 |
| rs[h & 1] = new CounterCell(x); |
| counterCells = rs; |
| init = true; |
| } |
| } finally { |
| cellsBusy = 0;//初始化完毕,将cellsBusy重新置为0 |
| } |
| if (init) |
| break; |
| } |
| //竞争激烈 其他线程占据着counterCells数组,尝试将值累加至baseCount 若不成功,则进入下一次循环 |
| else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x)) |
| break; // Fall back on using base |
| } |
| } |
| ``` |
上述中,使用了ThreadLocalRandom.getProbe()来进行哈希线程,从而得到在counterCells数组中的下标位置;在ConcurrentHashMap中,放置key的元素到哪个数组中也是通过哈希值计算的,那么他们有什么不同呢? |
| ``` |
| map中计算key在数组中的下标位置,是一次的程序运行期间是固定的,每次放置相同的元素要保证命中到同一个下标,不然就有问题了。 |
| counterCells计数器中,则只为了避免多个线程写入同一个下标,因此尽量保证每次线程得到的哈希值都不一样,避免冲突。 |
| ``` |
| * ThreadLocalRandom.getProbe() |
| ```java |
| ThreadLocalRandom.getProbe()的作用是产生一个随机数,为什么不使用Random呢? |
| 其实ThreadLocalRandom也是继承自Random,主要是因为Random的关键是随机种子,如果多线程并发情况下,对随机种子进行CAS竞争操作,对效率是一个影响,所以这里为了避免竞争,通过ThreadLocal方式来隔离每个线程的Random。 |
| ``` |
最后,我们来总结下addCount()统计元素个数的过程: |
![]() |
| ## transfer扩容阶段 |
在addCount()方法中,除了需要增加size大小以外,还需要判断当前Map是否需要进行扩容以提高查询的效率。 |
| ```java |
| private final void addCount(long x, int check) { |
| CounterCell[] as; long b, s; |
| //...略 该部分为统计size的大小 初始化以及对counterCells的扩容 |
| if (check >= 0) {//检查是否需要扩容 默认都需要 |
| Node<K,V>[] tab, nt; int n, sc; |
| //当size容量大于阈值且table不为空时且容量小于最大容量时 |
| while (s >= (long)(sc = sizeCtl) && (tab = table) != null && |
| (n = tab.length) < MAXIMUM_CAPACITY) { |
| int rs = resizeStamp(n);//生成一个扩容戳 |
| if (sc < 0) {//小于0 说明此时已经有别的线程在扩容 |
| if ((sc >>> RESIZE_STAMP_SHIFT) != rs |
| sc == rs + MAX_RESIZERS |
| 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(); |
| } |
| } |
| } |
| ``` |
| 当size容量大于阈值且table不为空时且容量小于最大容量时,会进行扩容操作: |
| > 1.如果当前正在处于扩容阶段,则当前线程会加入并且协助扩容 |
| > |
| > 2.如果当前没有在扩容,则直接触发扩容操作 |
| 当满足如下判断时,会跳出扩容,说明当前线程不符合协助扩容,直接跳出循环 |
| ```java |
| if ((sc >>> RESIZE_STAMP_SHIFT) != rs |
| sc == rs + MAX_RESIZERS |
| transferIndex <= 0) |
| break; |
| //sc>>>RESIZE_STAMP_SHIFT!=rs表示比较高位生成戳和rs是否相等 |
| //sc=rs+1表示扩容结束 |
| //sc=s+ MAX_RESIZERS 表示帮助线程线程已经达到最大值了 |
| //nt= nexttable->表示扩容已经结束 |
| // transferindex<=0表示所有的 transfer任务都被领取完了,没有剩余的hash桶来给自己这个线程来做 transfer |
| ``` |
| ### reslzestamp() |
resizestamp用来生成一个和扩容有关的扩容戳,具体有什么作用呢?我们基于它的实现来做一个分析。 |
| ```java |
| static final int resizeStamp(int n) { |
| return Integer.numberOfLeadingZeros(n) |
| } |
| ``` |
Integer.numberOfLeadingZeros(n)这个方法是返回无符号整数n前面0的个数。 |
比如如 16 的二进制是 0000 0000 0000 0000 0000 0000 0001 0000,那么这个方法返回的值就是 27。 |
转成二进制就是 0000 0000 0000 0000 1000 0000 0001 1011 |
而1 << (RESIZE_STAMP_BITS - 1)即1 << 15,表示二进制即是高16位为0,第16位为1: |
| ```java |
| 0000 0000 0000 0000 1000 0000 0000 0000 |
| ``` |
所以根据 resizestamp()的运算逻辑,我们来推演一下,假如n=16,那么 resizestamp(16)=32795, 转化为二进制就是是如下: |
| ``` |
| 0000 0000 0000 0000 1000 0000 0001 1011 |
| ``` |
那么通过resizeStamp()方法主要是起到了什么作用呢? |
| >首先因为CHM的元素的个数肯定是2的次幂,所以每个数组容量的前面0的个数肯定是不同的,这样可以保证是在原容量为n的情况下进行扩容。而不会出现因为多线程问题导致的多次扩容问题。 |
| 接着再来看,当第一个线程尝试进行扩容的时候,会先判断执行下面这段代码能否成功。 |
| ```java |
| U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2) |
| ``` |
当成功CAS操作后, rs左移16位,相当于原本的二进制低位変成了高位1000 0000 0001 1011 0000 0000 0000 0000,然后再+2 |
| ```java |
| 1000 0000 0001 1011 0000 0000 0000 0000 + 0010 = 1000 0000 0001 1011 0000 0000 0000 0010 |
| ``` |
| 高16位代表扩容的标记、低16位代表并行扩容的线程数 |
| 这样来存储有什么好处呢? |
| > 1.首先在CHM中是支持并发扩容的,也就是说如果当前的数组需要进行扩容操作,可以由多个线程来共同负责。 |
| > 2.可以保证每次扩容都生成唯一的生成戳,每次新的扩容,都有一个不同的n,这个生成戳就是根据n来计算出来的一个数字,n不同,这个数字也不同 |
| 第一个线程尝试扩容的时候,为什么是+2? |
| > 因为ziseCtrol 为-1表示初始化,-2表示一个线程在执行扩容,而且对 sizectl的操作都是基于位运算的, 所以不会关心它本身的数值是多少,只关心它在二进制上的数值,而SC+1会在低16位上加1。 |
| ### transfer() |
transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) |
| 正常来说,当我们需要对Map进行扩容时,直接将旧数组中的数据重新散列到新的下标,然后进行迁移到新的数组中即可;但是在高并发时,可能会有多个线程进行扩容,同时也可能存在扩容的同时存在添加元素,这就需要另外的处理机制。 |
| 如通过加锁的过程,将扩容的过程的上锁,完成后释放,这样的方式在效率上比较低;CHM中采用了CAS的无锁并发同步机制;同时当发现已经有线程在进行扩容时,当前线程会加入其中,协助其扩容。 |
| ```java |
| //ConcurrentHashMap.transfer() |
| private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { |
| int n = tab.length, stride; |
| //将原table的桶进行划分范围最小为16 当桶较小的时候,只会有一个CPU(线程)进行扩容 减少资源竞争 |
| if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) |
| stride = MIN_TRANSFER_STRIDE; // subdivide range |
| if (nextTab == null) { // initiating |
| try { |
| //申请2倍的数组空间 |
| Node<K,V>[] nt = (Node<K,V>[])new Node[n << 1]; |
| nextTab = nt; |
| } catch (Throwable ex) { // try to cope with OOME |
| sizeCtl = Integer.MAX_VALUE; //扩容失败 将sizeCtl标记为Integer的最大值 |
| return; |
| } |
| nextTable = nextTab; |
| transferIndex = n;//本次扩容时要处理的开始下标 |
| } |
| int nextn = nextTab.length; |
| ////创建一个fwd节点,表示一个正在被迁移的Node,并且它的hash值为-1( MOVED),它的作用是用来占位,表示 |
| //原数组中位置i处的节点完成迁移以后,就会在i位置设置一个fwd来告诉其他线程这个位置已经处理过了 |
| ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); |
| boolean advance = true;//标识是否能够往前推进一个槽位 否则要等其处理完毕 |
| boolean finishing = false; // 判断是否已扩容完成 |
| for (int i = 0, bound = 0;😉 {//i 指当前处理的槽位序号,bound 指需要处理的槽位边界,先处理槽位 15 的节点; |
| Node<K,V> f; int fh; |
| while (advance) { |
| int nextIndex, nextBound; |
| if (--i >= bound |
| advance = false; |
| else if ((nextIndex = transferIndex) <= 0) {//表示所有bucket都已经分配完毕 |
| i = -1; |
| advance = false; |
| } |
| ///这里只有分配完当前处理的任务才会进来 通过cas来修改 RAINSHERINEX,为当前线程分配任务,处理的节点区间为(nextbound, nextindex)->(16, 31) |
| //当只有一个线程时 第二次领取任务时 [bound,nextIndex) =[0,16) |
| else if (U.compareAndSwapInt |
| (this, TRANSFERINDEX, nextIndex, |
| nextBound = (nextIndex > stride ? |
| nextIndex - stride : 0))) { |
| bound = nextBound;// 初始容量为32的map nextBound为16 i=31 |
| i = nextIndex - 1; |
| advance = false; |
| } |
| } |
| //i<0 说明已经遍历完旧的数组,也就是当前线程已经处理完所有负责的 bucket |
| if (i < 0 |
| int sc; |
| if (finishing) { |
| nextTable = null; |
| table = nextTab; |
| sizeCtl = (n << 1) - (n >>> 1); |
| return; |
| } |
| // sized1在迁移前会设置为(ェs<< RESIZE STAMP SHIFT)+2 表示正在参与扩容的线程数 |
| //这里减1 表示当前线程已完成自己的任务 退出扩容 |
| if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { |
| if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)//此处说明见下文: |
| return; |
| finishing = advance = true;//表示扩容结束了 |
| i = n; // 再次检查整张表 |
| } |
| } |
| else if ((f = tabAt(tab, i)) == null) |
| advance = casTabAt(tab, i, null, fwd); |
| else if ((fh = f.hash) == MOVED) |
| advance = true; // already processed |
| else { |
| //...略 具体的转移过程 |
| } |
| } |
| } |
| ``` |
| 分配区间 |
| ```java |
| //这个循环使用CAS不断尝试为当前线程分配任务 |
| //直到分配成功或任务队列已经被全部分配完毕 |
| //如果当前线程已经被分配过 bucket区域 |
| //那么会通过--i指向下一个待处理 bucket然后退出该循环 |
| while (advance) { |
| int nextIndex, nextBound; |
| if (--i >= bound |
| advance = false; |
| else if ((nextIndex = transferIndex) <= 0) {//表示所有bucket都已经分配完毕 |
| i = -1; |
| advance = false; |
| } |
| ///这里只有分配完当前处理的任务才会进来 通过cas来修改 RAINSHERINEX,为当前线程分配任务,处理的节点区间为(nextbound, nextindex)->(16, 31) |
| //当只有一个线程时 第二次领取任务时 [bound,nextIndex) =[0,16) |
| else if (U.compareAndSwapInt |
| (this, TRANSFERINDEX, nextIndex, |
| nextBound = (nextIndex > stride ? |
| nextIndex - stride : 0))) { |
| bound = nextBound;// 初始容量为32的map nextBound为16 i=31 |
| i = nextIndex - 1; |
| advance = false; |
| } |
| } |
| ``` |
| 假设CHM的容量为32,且需要进行扩容则其示意图如下: |
![]() |
| 结束扩容 |
| ```java |
| // sized1在迁移前会设置为(sc << RESIZE STAMP SHIFT)+2 表示正在参与扩容的线程数 |
| //这里减1 表示当前线程已完成自己的任务 退出扩容 |
| if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { |
| if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) |
| return; |
| finishing = advance = true;//表示扩容结束了 |
| i = n; // 再次检查整张表 |
| } |
| ``` |
当无可分配任务时,将sizeCtrl -1,表示正在参与扩容的线程数减少了一个。 |
在开始transfer()时 ,(rs << RESIZE_STAMP_SHIFT) + 2会有这个操作,此处通过(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)判断是否结束。 |
| 如果相等,表示当前线程为整个扩容操作的最后一个线程; |
| 否则说明没有结束扩容,直接return。但是前面已经判断了没有正在参与扩容的线程数了?为什么这里还会不相等呢? |
| ```j |
| 这么做的目的,一方面是防止不同扩容之间出现相同的 sizectl,另外一方面,还可以避免 sizectl 的ABA问题导致的扩容重的情况 |
| ``` |
| ## 高低位扩容转移 |
| 从前面可知,参与扩容的线程倒序遍历数组槽位上的单链表,那么他们是具体是怎么转移到一个新的table数组的呢? |
| 在JDK1.8的HashMap中,是通过区分待转移元素是在原位置还是需要变动位置区分成两组,遍历转移过程中,形成两个单链表,结束遍历当前链表时,将新生成的链表的头结点指向新的Table的下标即结束该槽位的转移。 |
| CHM中也是采用这种思想,将需要转移的链表分成高低位链表,低位链表表示扩容后再新数组的下标位置不变,高位链表表示在新数组的下标位置变为 i+n(i为当前所属的槽位号,n为旧数组的容量) |
| ```java |
| //ConcurrentHashMap.transfer() |
| synchronized (f) { |
| if (tabAt(tab, i) == f) { |
| Node<K,V> ln, hn; |
| if (fh >= 0) {//链表头节点的 hash值大于0 转移完成的是-1 |
| int runBit = fh & n; |
| Node<K,V> lastRun = f; |
| for (Node<K,V> p = f.next; p != null; p = p.next) {//遍历当前槽位号链表 达到重用尾部链表的目的 |
| int b = p.hash & n; |
| if (b != runBit) {//只要跟前面的位结果不一致(就是区分0 和1),那么将runBit替换成当前的 同时lastRun也变更成最新的 |
| runBit = b; |
| lastRun = p; |
| } |
| } |
| if (runBit == 0) {//如果最后一截相同的链表为低位 那么将lastRun赋值给ln |
| ln = lastRun; |
| hn = null; |
| } |
| else {//如果最后一截相同的链表为高位 那么将lastRun赋值给hn |
| hn = lastRun; |
| ln = null; |
| } |
| for (Node<K,V> p = f; p != lastRun; p = p.next) {//再次遍历当链表 但是只到lastRun前一位置即停止 |
| int ph = p.hash; K pk = p.key; V pv = p.val; |
| if ((ph & n) == 0) |
| ln = new Node<K,V>(ph, pk, pv, ln); |
| else |
| hn = new Node<K,V>(ph, pk, pv, hn); |
| } |
| setTabAt(nextTab, i, ln);//低位链表放在i槽位位置 |
| setTabAt(nextTab, i + n, hn);//高位链表放在i+n 槽位位置 |
| setTabAt(tab, i, fwd);//把旧 tabble的hash桶中放置转发节点,表明此hash桶已经被处理 |
| advance = true; |
| } |
| //...红黑树部分略 |
| } |
| ``` |
| 以下如高低位扩容转移元素的示例图: |
![]() |
runbit ==0这是非常重要的一点 |
| > runbit实际上等于 p.hash & n,所以实际上就是 p.hash & n==0。正常情况下,计算节点在table中的下标的方法是:hash&(oldTable.length-1),扩容之后,table长度翻倍,计算table下标的方法是 hash&(newTable.length-1),也就是 hash&(oldTable.length*2-1),于是我们有了这样的结论:这新旧两次计算下标的结果,要不然就相同,要不然就是新下标等于旧下标加上旧数组的长度。 |
![]() |
| 上面示例中,原数组大小为16,扩容后大小为32,那么也就是进行数组下标定位算法时,第五位变成1。 |
| 那么进行&运算后,第五位为0时,就是跟原来的结果是一致的,即该元素进行扩容后数组下标没有改变; |
| 若为1,则新table按位与的结果就比旧table的结果多了10000(二进制),而这个二进制10000就是旧table的长度16。 |
| 换言之,新的散列值下标需要不需要加上旧数组的长度,就看hash值第五位(即原oldCap位置)是0还是1就行了。 |
| > e.hash & oldCap 就是用于计算oldCap位置(扩容后就是n-1位置)是为0还是1,上述示例中,oldCap值为16,就是10000。第5位为1也就是扩容后的 e.hash & (n-1) |
| ## 协助扩容 helpTransfer() |
在 putVal(K key, V value, boolean onlyIfAbsent)方法中,当需要添加槽位号的头节点元素为hash值为 MOVED(-1),说明当前节点是ForwardingNode 节点。 |
意味着有其他线程正在进行扩容,那么当前现在直接帮助它进行扩容,因此调用 helpTransfer()来协助扩容。 |
| ```java |
| //ConcurrentHashMap.putVal() |
| else if ((fh = f.hash) == MOVED) |
| tab = helpTransfer(tab, f); |
| ``` |
| ```java |
| //ConcurrentHashMap.helpTransfer() |
| final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {//入参为table数组 f为头节点 |
| Node<K,V>[] nextTab; int sc; |
| if (tab != null && (f instanceof ForwardingNode) && |
| //判断此时是否仍然在执行扩容, ForwardingNode的nextTable=nu11的时候说明扩容已经结束了 |
| (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) { |
| int rs = resizeStamp(tab.length);//生成扩容戳 |
| while (nextTab == nextTable && table == tab && |
| (sc = sizeCtl) < 0) {//说明当前扩容还未完成情况下,通过循环不断尝试加入到协助扩容中 |
| //// transferindex<=0 表示所有的Node都已经分配了线程 |
| //sc=rs+ MAX_RESIZERS 表示扩容线程数达到最大扩容线程数 |
| //(sc >>> RESIZE_STAMP_SHIFT) != rs,如果在同一轮扩容中,那么sc无符号右移比较高位和rs的值,那么应该是相等的。如果不相等,说明扩容结束了 |
| //sc==rs+1表示扩容结束 |
| if ((sc >>> RESIZE_STAMP_SHIFT) != rs |
| sc == rs + MAX_RESIZERS |
| break; |
| if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {//CAS尝试给sc+1 成功则加入扩容 |
| transfer(tab, nextTab); |
| break; |
| } |
| } |
| return nextTab; |
| } |
| return table;//返回新的数组 |
| } |
| ``` |
| # Issues |
| ### ConcurrentHashMap 1.7和1.8的不同? |
在JDK1.7的实现上, Conrruenthashmap由一个个 Segment组成,简单来说, Concurrenthashmap是一个 Segment数组,它通过继承 Reentrantlock来进行加锁,通过每次锁住一个 segment来保证每个 segment内的操作的线程安全性从而实现全局线程安全。 |
![]() |
| 每个Segment中,又相当于有一个HashMap,当每个操作分布在不同的 segment上的时候,默认情况下,理论上可以同时支持16个线程的并发写入。 |
| 相较于JDK1.7,1.8有以下变化: |
| 1.1.8中取消了Segment分段锁的设计,直接使用Node数组来实现,采用CAS机制+synchronized。 |
| 2.采用数组+链表+红黑树的结构存储Map中的元素。 |
| ### 什么是ConcurrentHashMap的弱一致性? |
| 因为添加元素和统计元素个数是分开独立的,所以有可能元素添加成功 但是拿到的size()不是最新的 |
| ### 计算size() |
| 1.7中统计size大小是通过: |
| ``` |
| 先采用不加锁的方式,连续计算元素的个数,最多计算3次: |
| 1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的; |
| 2、如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数; |
| ``` |
| 1.8中采用分段锁的设计思想 |
| 竞争激烈情况下,不适合像一般的用while 循环里面自旋CAS来实现。 |
| ```java |
| while(true){ |
| cas(count+1) |
| } |
| ``` |
| 过多的自旋反而会耗费性能,所以CHM中采用分段锁的设计思想: |
| * 首先通过baseCount+x 看是否能操作成功; |
| * 失败则说明有竞争,然后通过随机一个Countercells[]数组下标,进行+x的操作; |
| * 失败,则说明竞争比较激烈,判断是否需要扩容; |
| * 最后将baseCount和CounterCells数组的所有的value值进行累加得到size()。 |
| ```java |
| int n = tab.length, stride; |
| if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) //每个线程处理桶的最小数目,可以看出核数越高步长越小,最小16个。 |
| stride = MIN_TRANSFER_STRIDE; // subdivide range |
| if (nextTab == null) { |
| try { |
| @SuppressWarnings("unchecked") |
| Node<K,V>[] nt = (Node<K,V>[])new Node[n << 1]; //扩容到2倍 |
| nextTab = nt; |
| } catch (Throwable ex) { // try to cope with OOME |
| sizeCtl = Integer.MAX_VALUE; //扩容保护 |
| return; |
| } |
| nextTable = nextTab; |
| transferIndex = n; //扩容总进度,>=transferIndex的桶都已分配出去。 |
| } |
| int nextn = nextTab.length; |
| //扩容时的特殊节点,标明此节点正在进行迁移,扩容期间的元素查找要调用其find()方法在nextTable中查找元素。 |
| ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); |
| //当前线程是否需要继续寻找下一个可处理的节点 |
| boolean advance = true; |
| boolean finishing = false; //所有桶是否都已迁移完成。 |
| for (int i = 0, bound = 0;😉 { |
| Node<K,V> f; int fh; |
| //此循环的作用是确定当前线程要迁移的桶的范围或通过更新i的值确定当前范围内下一个要处理的节点。 |
| while (advance) { |
| int nextIndex, nextBound; |
| if (--i >= bound |
| advance = false; |
| //迁移总进度<=0,表示所有桶都已迁移完成。 |
| else if ((nextIndex = transferIndex) <= 0) { |
| i = -1; |
| advance = false; |
| } |
| else if (U.compareAndSwapInt |
| (this, TRANSFERINDEX, nextIndex, |
| nextBound = (nextIndex > stride ? |
| nextIndex - stride : 0))) { //transferIndex减去已分配出去的桶。 |
| //确定当前线程每次分配的待迁移桶的范围为[bound, nextIndex) |
| bound = nextBound; |
| i = nextIndex - 1; |
| advance = false; |
| } |
| } |
| //当前线程自己的活已经做完或所有线程的活都已做完,第二与第三个条件应该是下面让"i = n"后,再次进入循环时要做的边界检查。 |
| if (i < 0 |
| int sc; |
| if (finishing) { //所有线程已干完活,最后才走这里。 |
| nextTable = null; |
| table = nextTab; //替换新table |
| sizeCtl = (n << 1) - (n >>> 1); //调sizeCtl为新容量0.75倍。 |
| return; |
| } |
| //当前线程已结束扩容,sizeCtl-1表示参与扩容线程数-1。 |
| if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { |
| if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) |
| return; |
| finishing = advance = true; |
| i = n; // recheck before commit |
| } |
| } |
| else if ((f = tabAt(tab, i)) == null) |
| advance = casTabAt(tab, i, null, fwd); //如果i处是ForwardingNode表示第i个桶已经有线程在负责迁移了。 |
| else if ((fh = f.hash) == MOVED) |
| advance = true; // already processed |
| else { |
| synchronized (f) { //桶内元素迁移需要加锁。 |
| if (tabAt(tab, i) == f) { |
| Node<K,V> ln, hn; |
| if (fh >= 0) { //>=0表示是链表结点 |
| //由于n是2的幂次方(所有二进制位中只有一个1),如n=16(0001 0000),第4位为1,那么hash&n后的值第4位只能为0或1。所以可以根据hash&n的结果将所有结点分为两部分。 |
| int runBit = fh & n; |
| Node<K,V> lastRun = f; |
| //找出最后一段完整的fh&n不变的链表,这样最后这一段链表就不用重新创建新结点了。 |
| for (Node<K,V> p = f.next; p != null; p = p.next) { |
| int b = p.hash & n; |
| if (b != runBit) { |
| runBit = b; |
| lastRun = p; |
| } |
| } |
| if (runBit == 0) { |
| ln = lastRun; |
| hn = null; |
| } |
| else { |
| hn = lastRun; |
| ln = null; |
| } |
| //lastRun之前的结点因为fh&n不确定,所以全部需要重新迁移。 |
| for (Node<K,V> p = f; p != lastRun; p = p.next) { |
| int ph = p.hash; K pk = p.key; V pv = p.val; |
| if ((ph & n) == 0) |
| ln = new Node<K,V>(ph, pk, pv, ln); |
| else |
| hn = new Node<K,V>(ph, pk, pv, hn); |
| } |
| //低位链表放在i处 |
| setTabAt(nextTab, i, ln); |
| //高位链表放在i+n处 |
| setTabAt(nextTab, i + n, hn); |
| setTabAt(tab, i, fwd); //在原table中设置ForwardingNode节点以提示该桶扩容完成。 |
| advance = true; |
| } |
| else if (f instanceof TreeBin) { //红黑树处理。 |
| ... |
| ``` |
| ### put()元素的过程 |
![]() |
| 1.判断是否为空 |
| 2.table是否未被初始化 |
| 3.命中槽位是否存在元素,为空则直接插入 |
| 4.命中的槽位是否为MOVE节点,是则加入协助扩容 |
| 5.遍历所属的链表,找到相同的key值则覆盖,否则新节点添加至尾部 |
| ### CHM读操作需要加锁么? |
| 虽然ConcurrentHashMap的读不需要锁,但是需要保证能读到最新数据,所以必须加volatile。 |
即数组的引用需要加volatile,同时一个Node节点中的val和next属性也必须要加volatile,之所以不会读到过期的值,是根据java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景。 |
| ```java |
| static class Node<K,V> implements Map.Entry<K,V> { |
| final int hash; |
| final K key; |
| volatile V val; //value值加volatile保证可见性 |
| volatile Node<K,V> next;////next值加volatile保证可见性 |
| } |
| ``` |
每天都是一个开始,送给正在奋斗的自己和你!







浙公网安备 33010602011771号