ConcurrentHashMap源码分析
ConcurrentHashMap是线程安全的,他是写同步读无锁。 也就是put()的时候会加锁,get()的时候不会加锁
ConcurrentHashMap面试题
JDK7和JDK8ConcurrentHashMap 比较
JDK7ConcurrentHashMap是如何保证并发安全的
JDK7ConcurrentHashMap底层原理
JDK8是如何保证并发安全的
JDK8 put流程
1.7
ConcurrentHashMap底层是由两层嵌套数组来实现的:
- ConcurrentHashMap对象中有一个属性segments,类型为Segment[];
- Segment对象中有一个属性table,类型为HashEntry[];
属性
- segment
segment继承了ReenTrantLock
- DEFAULT_INITIAL_CAPACITY hashEntry数组的长度默认为16
- DEFAULT_CONCURRENCY_LEVEL 并发级别
segment数组的大小就是通过并发级别算出来的>=并发级别的最大二次幂
- MIN_SEGMENT_TABLE_CAPACITY
每个segment的容量最小要是2
构造器
-
确定segment数组的长度 ssize
通过并发级别concurrency 来算ssize为大于等于并发级别的最小二次幂
-
确定每个segment对应的hashEntry数组的长度 cap
通过initialCapacity/ssize 算出每个segment应该对应的Entry个数,但是要进行向上取整操作才能保证把我们要求的initialCapacity都放下,而且要保证cap要大于MIN_SEGMENT_TABLE_CAPACITY即最小容量。此外,cap也必须是2的幂次方。
-
new Segment对象 Segment<K,V> s0 new出第0个segment位置的放的对象
为什么需要先创建一个segment对象放在第0个位置
因为我们往Segment放hashEntry的时候用的是segment对象的put方法,那放之前肯定是要有segment对象存在的才能调用其put方法。如果我们每次都new segment对象的时候还要确定其长度各种,所以提前创一个s0,到时候直接用s0的属性即可。
-
new Segment数组
-
用UNSAFE.putOrderedObject把Segment对象放到Segment数组的第0个位置
put()方法
-
计算key的hashcode,通过hashcode得到在Segment的索引j
-
通过UNSAFE.getObject得到Segment第j个位置的值s
-
第j个位置是null,则ensureSegment 生成segment对象
-
调用segment的put方法
- 获取锁tryLock()
-
获取到直接返回true
tryLock() 获取到就返回true,获取不到就返回false不会像lock()一样获取不到就阻塞。因为tryLock()调的是nonfairTryAcquire。
由于tryLock() 不会阻塞 所以可以通过while(tryLock())在等锁的过程,提前把 HashEntry给new好。 -
获取不到调用scanAndLockForPut
-
entryForHash
根据hash算出hashEntry对应的索引,获取到HashEntry对象,没有就返回null
-
通过retries的值控制走的逻辑
- 遍历链表,看key相等就换value,到最后都没有就创建node
- 尝试次数到64就用lock() 阻塞
- 尝试的过程中发现当前索引的值已被改变,就又走第一个逻辑遍历有没有相等的key
-
-
- entryAt获取HashEntry数组第i个位置上的值
- 遍历HashEntry数组第i个位置的node
- 不为null,就对比key的值,相同则替换value
- 为null 就new 一个 HashEntry(头插法),通过setEntryAt将创建好的对象放到HashEntry数组的第i个位置
- rehash 大于阈值就扩容
new 一个原来两倍大的数组
第一次循环把链表尾端连续的新idx相同的一串直接拿过去(类似蜘蛛纸牌)
第二次循环再依次遍历单个的移
- 获取锁tryLock()
1.8
属性
- nextTable
扩容时用来存数据的临时中间表
- sizeCtl 用来标志table初始化和扩容的,不同取值代表不同的含义
代码里的sc就是这个sizeCtl- 0:table还没有初始化
- -1:table正在初始化
- 小于-1:表示table正在扩容
- 大于0:初始化完成后,代表table最大存放元素的个数,即要扩容的阈值,默认为0.75*n
- Moved为-1时,表示当前table正在扩容
- MIN_TRANSFER_STRIDE:默认16,table扩容时,每个线程最少迁移的槽位个数
- baseCount 统计size
- counterCells
利用counterCells来计数,统计所有添加的元素的个数
内部类TreeBin
TreeBin是ConcurrentHashMap的内部类,new 一个TreeBin就是一个红黑树对象
为什么1.8 HashMap不需要这个TreeBin对象呢
因为1.8ConcurrentHashMap是要给对象加锁的,但是红黑树的根节点会改变的,如果按1.8HashMap的结构的话,线程A给在firstNode加锁后,万一根节点变化了,那加的锁相当于没用了,这样别的线程就也可以改当前红黑树了。
所以这里new 一个TreeBin,TreeBin内部有一个root属性,这样TreeBin对象是永远不会变的,给TreeBin对象加锁就没问题了。
内部类 ForwardingNode
ForwardingNode是一个node,他在数组正在被扩容的时候被创建,并放在正在被transfer的位置上,hash值为MOVED即负一,
ForwardingNode的nextTable属性存的是新数组。
这样当有线程putVal 获取到的hash值为MOVED,就知道当前这个数组在扩容,就会helpTransfer帮助扩容
构造器
putVal()方法
- 自旋:
- if 如果数组为空 则初始化数组 initTable()
- else if 数组不为空则通过hash计算索引,索引位置为空则通过CAS new Node结点
这里CAS失败的,就又会自旋 - else if 如果正在扩容,则帮助扩容helpTransfer()
f.hash==MOVED MOVED是负1 当前索引位置的对象对应的hash值是负1,说明有别的线程正在扩容,然后当前线程就去帮助扩容 - else 在当前位置上的对象f(链表头结点/红黑树根节点)上用synchronized加锁
在当前索引处的对象还是加锁的对象时,即头结点没有发生变化时继续:- 如果是链表,遍历链表,如果有key相同的则替换其value,否则new Node 通过尾插法插入链表。
- 是红黑树,通过putTreeVal()向红黑树插入元素
- if 判断是否需要树化treeifyBin()
- addCount() 扩容
initTable() 初始化数组
由于可能多个线程同时来初始化,为了保证只有一个线程能够初始化,用了Thread.yield() 和cas ,这里利用SIZECTL这个参数 实现了无锁 并发,保证只有一个能进去初始化,其他运行到都是释放资源(thread.yield)
- 通过自旋+ Thread.yield() 初始化失败的线程就Thread.yield() 把运行状态让出来(暂时放弃cpu的执行)
- 用CAS方式修改SIZECTL为-1,能修改成功的线程进行初始化
SIZECTL为-1表示正在初始化
sc=n-(n>>>2) ??是什么意思 - 初始化完成后,SIZECTL改为table最大存放元素的个数
addCount() 添加元素个数
baseCount是用来统计存的元素的个数的,如果都去竞争baseCount则效率低,因此用一个CounterCell数组,让线程来竞争CounterCell数组某个位置的CounterCell对象,最后对CounterCell数组进行统计
- 添加元素个数
- CounterCell为空去通过CAS去修改baseCount
- CounterCell不为空或上面竞争baseCount失败的线程
- 数组不为空, 线程生成一个随机数,通过与CounterCell数组的长度进行与操作,来得到在CounterCell中的索引位置,通过CAS方式对当前索引上的value属性+x
- 数组为空或上一步CAS修改失败 则 fullAddCount(x, uncontended)
- 统计整个ConcurrentHashMap的元素个数s sumCount()
就是把baseCount和CounterCell中的值都求和 - while 进行扩容(扩容完后可能新数组又需要扩容
当 s>sizeCtrl即扩容阈值 且数组不为空 且 链表长度是小于MAXIMUM_CAPACITY 则扩容- sc<0 表示有别的线程正在扩容
- sc>=0
- 通过CAS将sc改成一个负数
- 通过transfer() 迁移
一句话总结:addCount()+fullAddCount() 就是往baseCount或CounterCell 加添加进的元素的个数的,最终就是为了保证一定能加成功,加锁方式用的是CAS,最后统计size就是统计这两部分的和。
fullAddCount()
cellsBusy :用来表示当前CounterCell是否在被其他线程使用,如果是0表示没有被其他线程使用。 只要对CounterCell进行操作,都要是保证cellsBusy为0的时候,用CAS方式将cellsBusy改为1,相当于用CAS的方式加锁。
ThreadLocalRandom.getProbe() 对线程生成一个随机数,且这个随机数是不变的对这个线程是唯一的。 也就是对这个线程来说每次调getProbe()的结果都是一样的
ThreadLocalRandom.advanceProbe(h)生成一个新的值
uncontended:用来控制是否用CAS修改当前cell的对象。因为如果进入fullAddCount()函数之前就已经尝试过CAS修改当前cell的对象值了,失败uncontended就为false。这样这里先判断uncontended为false就不让他执行下面的了。
collide:用来控制是否扩容
一句话总结:fullAddCount()就是保证一定把x加进去,具体做的事情其实就是自旋的方式创建CounterCell数组,对应索引没对象就创建,有就在此基础上加x。一直没加成功的条件下,会扩容。 只是这些操作的前提都是用CAS操作来加锁的,保证线程安全。
- 计算当前线程的对应随机数
- 自旋:
- CounterCell数组不为空
- if 对应索引处为null 即没有对象
则创建cellsBusy对象,在修改cellsBusy成功的情况将创好的对象赋值到CounterCell数组 - else if uncontended为 true的情况 有对象 则通过CAS方式对此对象+x
- else if collide为true的情况进行对CounterCell扩容
collide为true是什么情况呢?也就是什么时候或扩容?
两次hash计算得到的索引对应的位置都有对象,并且通过cas对此对象修改失败 - 上面都没成功就rehash 重新为线程生成随机数
- if 对应索引处为null 即没有对象
- CounterCell数组为空 且cellsBusy为0即其他线程没有使用
- 通过CAS方式将cellsBusy改为1,也就相当于是给CounterCell数组加锁。
- 加锁成功才初始化,即new 一个长度为2的CounterCell数组,并在线程算出来的对应索引处new CounterCell(x) 对象
- 将cellsBusy改为0,即释放锁
- 以上情况都不满足,则还是用CAS对baseCount去修改
- CounterCell数组不为空
transfer() 转移数据
ForwardNode 就是把正在被扩容的数组的node都包装成ForwardNode,hash值为MOVED即负1,这样其他线程对这个数组进行put的时候发现对应位置对应的值为-1就会helpTransfer
advance :advance为true 则一直往前找自己扩容的区域 ,找到后会被修改为false
finishing:为true即当前线程的扩容任务已做完
bound到i为当前线程的扩容范围(i>bound)
transferIndex: 每个线程进来会根据transferIndex来计算自己应该扩容的区域[bound,i],transferIndex相当于[0,transferIndex]的范围都还没被迁移,[transferIndex,n]的范围已经在被别的线程迁移了
- 准备工作
- 计算当前线程扩容的步长stride 默认为16
- 初始化
nextTab即新数组为null就创建一个长度为原数组2倍的数组,transferIndex为n (即原数组会从后往前进行迁移) - 创建 ForwardingNode<K,V> 对象fwd,hash值为MOVED即-1,其属性放着nextTab。
老数组的一个位置为null或者被迁移到新数组后,就会把fwd放在当前位置。表示此数组正在扩容。别的线程访问到就会帮助扩容。
- 循环 进行迁移
- while(advance)得到当前线程需要扩容的区域[bound,i]
通过cas修改transferIndex来得到当前线程需要扩容的区域[bound,i] - 当前位置为null,cas将fwd放在此位置
- 当前位置为fwd,advance改为true继续前进
- synchronized 加锁
- 当前位置是链表,和1.7ConcurrentHashMap链表迁移逻辑一样
遍历链表像蜘蛛纸牌一样,把最后的新位置一样的连续结点一起拿过去;再遍历链表,算出新的对应位置一个一个移过去。 - 当前位置是红黑树,和1.8HashMap红黑树迁移逻辑一样
通过每个TreeNode的prev和next 看作双向链表,然后通过高低指针得到两条链(每条对应新数组的新位置),分别看两条链小于untreeify阈值则untreeify,不小于则还是红黑树。
- 当前位置是链表,和1.7ConcurrentHashMap链表迁移逻辑一样
- while(advance)得到当前线程需要扩容的区域[bound,i]
- 结束条件
当前线程已经把所有可以迁移的都迁移完了(i<n)- 但其他线程还在迁移(通过SIZECTL和初始值不等判断得到),则直接return
因为在扩容的时候SIZECTL会被设置为一个很小的负值,每个线程进来扩都会将SIZECTL+1,扩完走的时候会把SIZECTL-1。当前线程走的时候SIZECTL和当初的很小的负值不相等说明还有别的线程在扩容。 - 整个数组已被迁移完,即当前线程是最后一个迁移的,则将新数组赋值给table
- 但其他线程还在迁移(通过SIZECTL和初始值不等判断得到),则直接return
- 准备工作
- 获取迁移区域 和结束逻辑
- 迁移的位置为null或本来就是fwd
- 迁移的位置是链表,加锁迁移
- 迁移的位置是红黑树,加锁迁移
helpTransfer()
CopyOnWriteArray
读写分离 空间换时间
从数据一致性角度讲,保证最终一致性
setArray后 当前副本就变成正本,原来的正本就要被抛弃
跳表 ConcurrentSkipListMap
保证在并发场景下 key有序的map,跳表的时间复杂度O(logn)
保证map的key的顺序,底层的数据结构基于链表O(n),又维护了一个索引,根据索引去找就更快了
Redis的sortset是基于跳表的
TreeMap也是key有序的map,但是不是线程安全的