使用场景
常用的HashMap不是线程安全的,当有多线程使用场景时,即想线程安全,又想拥有Map的能力可以选择HashTable ,因为它是针对我们常用的方法上加上了synchronized 锁,但在高并发的场景下,效率低是它的弊端。如果还很在意效率,那么更好的选择是使用ConcurrentHashMap。看下这个例子,启动100个线程,每个线程循环100次,像容器中应该放10000个元素。我们看到运行结果可以发现,HashMap并不是10000,这就说明它在多线程并发的情况下,出现了线程不安全的问题。而ConcurrentHashMap返回的结果没问题。
put()方法内部整体有4种情况
在上面的代码中,可以分为两部分内容:
第一部分:首先开启了无限循环,在里面进行了4中情况的判断。
○ case1:【是否需创建table数组】,若table数组为null或者长度为0,则创建table数组。
○ case2:【若寻址后的位置没有被占用】,创建Node节点,插入到这个位置。
○ case3:【若寻址后的位置是正在迁移状态】,则helpTransfer(),一起进行扩容迁移操作。
○ case4:【其他情况】,将节点插入到链表中或者红黑树中。
第二部分:执行addCount,将ConcurrentHashMap中存储的k,v总数+1。
一、初始化table数组
如果 sizeCtl=-1 ,则表示table数组正在被别的线程初始化。默认 sizeCtl=0 ,当table数组初始化或者扩容完毕的时候, sizeCtl 会表示扩容阈值。
若table数组没有被初始化完毕,则会一直在while循环中,直到table数组初始化完毕:
假设现在有 4条线程 同时的要去创建table数组,那么当有一条线程已经优先开始初始化table数组操作的时候,sizeCtl就会被赋值为 -1 ,那么其他线程就会执行Thread.yield()让出cpu,并继续while循环,然后再执行Thread.yield(),在那spin旋转,直到那个最早的线程创建好创建table数组之后,所有线程都会跳出while继续往下执行。
二、寻址后位置没被占用
流程解释:通过hash值计算出来应该插入的下标 i ,如果这个位置是空的,即:还没有保存Node元素,那么就根据我们要put的key和value来创建一个新的Node,并插入到下标为i的位置上。采用CAS来保证只有一个线程可以赋值成功。如果我们还是有A,B,C,D这4个线程都执行到了这个判断语句中,假设线程A第一个执行的这个CAS操作,那么只有它会执行成功,其余的3个线程(B,C,D)则会执行失败,casTabAt的结果为false。那么线程A会执行break语句跳出for循环,而其他三个线程会再次执行for循环,并执行到case4的代码段中。
tabAt(Node<K,V>[] tab, int i)
作用:获得tab数组下标为i位置上的Node元素
数组的寻址公式为:a[i]_address = base_address + i*data_type_size,通过该方式可以获得对应下标为i的值,即:获得tab[i]的值。
public native Object getObjectVolatile(Object o, long offset);
此方法和getObject功能类似,不过附加了volatile语义,也就是强制从主存中获取属性值。(获取给定对象o的指定内存偏移量处获取一个对象引用)类似的方法有getIntVolatile 、 getDoubleVolatile 等等。这个方法要被使用的属性由volatile修饰,否则功能和getObject方法相同。offset= ((long)i << ASHIFT) +ABASE,表示从ABASE开始,计算第i个元素的偏移量。所以:tabAt(tab, i)就等同于tab[i]
ABASE:为基础偏移量 ABASE = U.arrayBaseOffset(Node[].class); 返回值为16(因为数组对象是由对象头(8字节)+指针(4字节)+数组长度(4字节)组成的,所以从16开始。)
返回数组类型的第一个元素的偏移地址(基础偏移地址)。
如果 arrayIndexScale 方法返回的比例因子不为0,你可以通过结合基础偏移地址和比例因子访问数组的所有元素。
比例因子 int scale = U.arrayIndexScale(ak);
public native int arrayIndexScale(Class<?> arrayClass)○ 返回数组类型的比例因子(其实就是数据中元素偏移地址的增量,因为数组中的元素的地址是连续的)。
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
Integer.numberOfLeadingZeros(int i)
○ 给定一个int类型数据,返回这个数据的二进制串中从最左边算起连续的“0”的总数量。
○ 而ASHIFT其实就是将scale数值转换为按位左移对应的数值。
○ 即:通过scale=4,那么计算ASHFIT=2,而N<<2其实就相当于N*2*2=N*4=N*scale
举例:如果scale=8(十进制)=1000(二进制)
那么计算出Integer.numberOfLeadingZeros(scale)=28
ASHIFT=31-28=3;N<<ASHIFT = N<<3 = N*2*2*2 = N*8 = N*scale
casTabAt(Node<K,V>[] tab, int i, Node<K,V> c,Node<K,V> v)
public final native boolean compareAndSwapObject(Object o, long offset, Objectexpected, Object x);
针对Object对象进行CAS操作。即是对应Java变量引用o,原子性地更新o中偏移地址为offset的属性的值为x,当且仅的偏移地址为offset的属性的当前值为expected才会更新成功返回true,否则返回false。
o:目标Java变量引用。
offset:目标Java变量中的目标属性的偏移地址。
expected:目标Java变量中的目标属性的期望的当前值。
x:目标Java变量中的目标属性的目标更新值。
三、其它情况 put()最核心逻辑
由于case3情况涉及内容关键点在case4其它情况,所以跳过case3先看case4。
当table表被初始化了,并且出现哈希冲突了,并且 Node f 这个位置没有发生移动的情况下,就会走到这个代码段中。这种情况又是大概率发生的情况。源码如下所示:
对 f 进行synchronize加锁是针对table数组的某个下标Slot(元素)进行并发竞争加锁的,极大的控制了加锁影响的范围问题。
part1:向链表中插入Node的操作
fh表示Node f的hash值,如果大于等于0,则表示链表的Node节点。而红黑树节点的hash值为TREEBIN=-2。
binCount=1对应链表中的第2个Node节点。从链表的头节点遍历到末尾节点: 如果f节点的hash值与put的key的hash值相同,并且两个key值也是相同,那么如果onlyIfAbsent=false,则将新的value值替换旧的value值,否则不替换value值。执行完毕后,break跳出循环。遍历到末尾节点,依然没有找到key值且hash值相同的Node,则将新Node加入到链表末尾。执行完毕后,break跳出循环。
part2:向红黑树中插入Node的操作(红黑树本次不涉及)
part3:扩容或者转换元素存储类型的操作
如果Node链表长度大于等于9,则执行 treeifyBin(tab, i) 方法进行扩容或者转换元素存储操作。
treeifyBin(Node<K,V>[] tab, int index)
若table数组长度小于64则执行扩容操作。若数组长度大于等于64,进行红黑树转换。
tryPresize(int size)
先通过tableSizeFor根据size计算出2的n次方所有值中所有大于size值中最小的值。
tableSizeFor(int c) 返回指定容量的最小2的n次方
即:输入c返result:满足result=(2^k>=c),k取最小值。
由于table数组长度都是2的n次方,且初始值为16,所以可以通过高位连续多少个0来判断数组的长度是否相同。如果是偶数,则原样输出;如果是奇数则(n-1)*2
而tryPresize(int size) 方法的入参size已经是原长度的 2倍 了,但是还是会在其基础上,再进行最终长度c的计算,计算方式如下所示:
【如果】size长度超过了最大长度的一半( MAXIMUM_CAPACITY >>> 1 ),则size的最新长度等于最大长度( MAXIMUM_CAPACITY )
【否则】size的最新长度等于 1.5*size+1 的最近2的n次幂的值
为什么是1.5倍size加1呢?
从int c 的计算过程中可以看到,无论是取 MAXIMUM_CAPACITY 作为c的值,还是通过tableSizeFor 方法计算出入参的2的n次幂的最小值,其实都是希望size的值再扩展1倍,但是最终扩容长度一定要符合2n,所以才采用将1.5倍加1作为 tableSizeFor(...)方法的入参。
在正常情况下,sizeCtl表示table数组的阈值,所以肯定是大于等于0的。while循环里一共有3个判断逻辑:
【1】table数组没有初始化完毕。这块就是创建table数组,没什么别的复杂逻辑。
操作过程包括:
(1)将sizeCtl赋值为-1,表示当前线程正在对table进行操作;(2)创建table,并将sc赋值为table数组的3/4长度;(3)将sizeCtl赋值为扩容阈值,表示针对table的操作执行完毕。
【2】数组超过最大值,或者扩容发生越界。(MAXIMUM_CAPACITY=1<<30)针对如上特殊情况,即直接break跳出循环。
【3】table还是那个table,这个过程中没有被其他线程重建过。
resizeStamp方法的具体作用是返回table数组长度相关信息。
Integer.numberOfLeadingZeros(n)
作用是传入一个int类型数据,返回这个数据的二进制串中从最左边算起连续的“0”的总数量。因为int类型的数据长度为32所以高位不足的地方会以“0”填充。
举例上面resizeStamp的计算过程:
【1】假设n=16,二进制为010000,从左侧最高位开始计算,连续有 27 个0,那么 Integer.numberOfLeadingZeros(16)就返回27。
【2】 RESIZE_STAMP_BITS =16,那么1<<(RESIZE_STAMP_BITS - 1)=1<<(16-1)=1<<15
【3】我们在计算27 | 15,转换为二进制就是:00011011 | 00001000000000000000 =00001000000000011011
综上所述, resizeStamp 返回的结构由三部分组成,就是:
【第17~32位】16个0
【第16位】1
【第1~15位】以二进制对table数组长度进行转换,然后计算从最左边算起连续的“0”的总数量,然将0的总数量以二进制方式进行展现。
执行完 resizeStamp 方法后,继续if判断,sc表示sizeCtl,如果sc < 0,则说明table数组正在被其他线程操作着(比如:扩容),但是这段方法其实不会执行的,因为外层的while循环中的判断条件是 while ((sc = sizeCtl) >= 0) {...} ,所以如果sc为负数,也不会进入到while循环中。(在后续的JDK版本已经删除该段代码)
rs << RESIZE_STAMP_SHIFT) + 2 是什么意思?
【第32位】1
【高15位】记录了旧数组的容量大小。
【低16位】保存了参与扩容的线程数量,假设低16位是n,则n-1就是扩容的线程数
为什要+2而不是从是从1开始计数呢?
答:因为数组初始化时,sizeCtl设置为-1,所以1的那个位置被占了,所以从2开始计算。
transfer(Node<K,V>[] tab, Node<K,V>[] nextTab)
这个方法主要是用来执行扩容和之后的数据迁移操作的。整体看这个方法的主要流程:
【1】计算迁移时的步长( stride )。
【2】如果入参nextTab传入null,那么创建初始化 nextTab 。
【3】开启无限循环。首先计算 需要转移的节点范围 。然后将待转移的节点范围中的每个节点的数据进行转移 。
step1、计算迁移时的步长
扩容时,计算每次转移的固定节点数(步长)
如果NCPU大于1,则stride=n/8/NCPU,否则stride=n;但是,如果计算出来的stride小于16,那么stride就被赋值为16。其中:最小转移的节点数为:MIN_TRANSFER_STRIDE=16
假设现在有 A 、B 两个线程共同执行transfer,入参tab的长度为 32 ,nextTable=null
n=tab.length= 32;NCPU= 8;那么n>>>3=32/8=4,由于4<16,所以stride= 16
step2、初始化nextTab
先创建n的2倍长度的新数组。n如果等于 32,那么nt数组的长度就是 64 。然后将新的table数组nt赋值给nextTable和nextTab变量。即:nextTable=nextTab=nt最后transferIndex=n= 32 。
Step3:扩容及数据迁移
先创建ForwardingNode
数据结构如下:
然后开启无限循环
while循环中其实主要做了两件事:
【1】 i 表示要数据迁移的数组下标。那么 --i,其实相当于往前遍历一步,随着每次执行for循环,都会从队尾往前走。
【2】如果多线程执行,会在CAS赋值 transferIndex 的时候发生碰撞。transferIndex确定下一个待迁移的边界。所以每个线程迁移数据的范围是从bound到i,迁移长度为stride。 transferIndex就是当出现多线程并发扩容时,这个线程共享的全局变量用来给各个线程分配节点的。其他变量都是线程内部自有的,线程私有参数。
扩容时4种情况处理:
c-1> :扩容迁移结束逻辑
当扩容的活儿都分配完毕了,那么当前线程就不用执行扩容迁移行为了。此时i=-1
由于finishing为false,所以第一次执行这段代码时,会直接跳过if(finishing)的代码块。 第二个if判断是,由于当前线程无需再执行扩容迁移任务了,所以将总参与线程数-1。
sc和sizeCtl有两部分含义:高16位表示数组长度信息( 初始化时表示当前初始化容量或者扩容时表示下一次扩容的目标容量);低16位表示参与扩容数据迁移的总线程数。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT),表示如果自己并不是最后一个退出的工作线程(即:还有其他线程在执行迁移工作),则直接return退出即可;如果是最后一个线程,则需要执行收尾工作。
finishing=true之后,再次执行此代码块,就会执行if(finishing)部分。
这段代码会将table数组赋值为扩容后的新数组nextTab(长度为旧数组的2倍)
再根据新的数组nextTab,设置新的阈值。
c-2:如果下标i处没有节点,则不需要进行扩容迁移操作
c-3> :下标为i的这个位置已经被处理过了
c-4> :执行扩容迁移操作
首先,为下标为i的元素Node<K,V> f执行加锁操作。
计算runBit值——int runBit = fh & n; n为旧table数组的长度(假如n=16,则二进制为10000,那么其实后面四个0才是原数组对应的下标位)综上所述,如果runBit=0,则说明元素不需要迁移,因为还是在低位区ln;否则(n=16,即:10000)需要迁移,因为是在高位区hn(也就是我们2倍扩展新生成的位置);
知识扩展:[节点在数组中的存储位置是通过哈希值与数组长度进行位运算来确定的。通常使用的公式是 index = hash & (arrayLength - 1)这是因为当 arrayLength 是 2 的幂次方时,arrayLength - 1 的二进制表示是全 1 的形式,例如 arrayLength = 16 时,arrayLength - 1 = 15,二进制为 1111。通过这种位与运算,可以将哈希值映射到数组的有效索引范围内]
针对原table数组中旧数据拆出高位区和低位区的处理方式如下图所示:
组装好ln和hn后,将其插入到相应的高区位下标i+n和低区位i上。
将旧的table数组对应i的位置插入fwd节点,表明该位置已经处理过了。(因为hash=MOVED=-1)
四、寻址后的位置正在迁移状态
MOVED变量的值为-1
通过hash寻址到了我们应该插入的下标为i的位置上,已经存在了Node f,并且这个f的hash值等于-1,说明当前这个下标为i的位置,正在执行移动操作。那么就会通过执行helpTransfer方法来协助其他线程进行扩容操作。
helpTransfer(Node<K,V>[] tab, Node<K,V> f)
● f.nextTable存储的是扩容后新的table数组。
● int rs = resizeStamp(tab.length);返回的是旧数组的长度信息。
● (sc = sizeCtl) < 0说明当前还是在对旧表操作中的状态,即:扩容数据转移还在操作中。
● U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)含义是,由于当前线程要帮忙去执行扩容和数据迁移操作,所以将总参与线程数+1。
- addCount kv总数+1
该方法主要作用就是维护ConcurrentHashMap中总的kv数量值,当存储的总kv值超过了阈值,那么会执行扩容操作。先看简化框架代码:
【1】计算当前存入key-value的总数
【2】存储的总kv数量达到了阈值,执行扩容
1、计算当前存入key-value的总数
第一次执行这段方法的时候,counterCells默认等于null。
假设现在有线程A和线程B两个线程同时执行addCount的这段代码并且产生了竞争,那么counterCells就会被初始化。
s=b+x,其中b是baseCount,x是addCount的第一个入参,表示总容量需增加n个kv。
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x))操作只有一条线程可执行成功,假设线程A执行将baseCount修改为s成功,则不用进入if判断的方法体中。而线程B则需要进入if的方法体中。所以:总量是有两部分组成的,baseCount就是其中之一。
线程B由于执行CAS操作失败,那么对于这种所有竞争失败的线程,都会执行fullAddCount方法。这个方法里面就是计算总量的第二部分——CounterCell.value。
fullAddCount(long x, boolean wasUncontended)
fullAddCount代码较多,总体可以分为两部分:
【1】如果获得随机数为 0,则初始化当前线程的探针哈希值。
【2】开启无限循环,利用 CounterCell 进行计数。
第一部分比较简单,只需要记住一下几个方法的作用:
【ThreadLocalRandom.getProbe()】用来获得随机数
【ThreadLocalRandom.localInit()】初始化当前线程的探针哈希值
【ThreadLocalRandom.advanceProbe(h)】更改当前线程的探针哈希值
第二部分则利用CounterCell进行计数,一共分为3种情况:
【1】 counterCells 不为空且数组里面有元素
【2】 cellsBusy 为0且 counterCells 为空
【3】尝试修改 baseCount 的值
图解源码逻辑:
c1:counterCells不为空且数组里面有元素
counterCells表示CounterCell的数组
如果(a = as[(n - 1) & h]) == null,则表明随机数h待插入的下标位置没有元素。cellsBusy=0表示当前处理CounterCell是空闲的状态,那么就创建CounterCell,然后通过CAS的方式将cellsBusy赋值为1,表明现在正在处理CounterCell中,将其插入到conuterCells数组中后,将cellsBusy赋值为0,表明操作完毕
如果wasUncontended=false(表当前线程CAS竞争失败)则将wasUncontended重置为true。
如果随机数h待插入的下标位置存在CounterCell a,尝试将a的value值加x
如果counterCells已经发生了变化(因为下面会有扩容的情况发生)
多线程之间设置CounterCell的value值时发生了碰撞,那么扩展CounterCell的长度,以减少碰撞次数
c2:cellsBusy为0且counterCells为空
如果满足这种条件,那么创建一个长度为2的CounterCell数组counterCells,并将x赋值进数组,跳出循环
c3:尝试修改baseCount的值
如果修改baseCount成功,那么则跳出循环
sumCount()
ConcurrentHashMap里总的kv数量就是:【baseCount数量】+【sum(CounterCells里所有CounterCell的value)】
2、存储的总kv数量达到了阈值,执行扩容
【1】如果sc为负值,表明正在执行扩容操作中,那么也加入扩容的“大部队”中
【2】否则,表明table数组没有扩容,那么,发起扩容操作。
其中,rs的计算依然需要左移16位: