ConcurrentHashMap的实现原理与使用(JDK1.7)
ConcurrentHashMap可以理解成是一个并发安全的HashMap
在并发编程中,HashMap是不安全的,会导致一个死循环的问题,还有一个并发安全的HashTable,但使用HashTable的效率非常低下(加Synchronic),于是就出现了ConcurrentHashMap
线程不安全的HashMap
在并发编程中,使用HashMap进行put操作是会引起死循环的,最终结果就会导致CPU利用率接近100%
下面分析一下为什么会不安全(当然,线程不安全的HashMap对于两个线程同时Put也会出现覆盖问题,这里不进行讨论)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断是不是绕过了构造方法里面的初始化容量,如果是的话进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//首先这里要认识两点
//1. hash值一般会大于等于n-1
//2. n的值在前面已经等于tab.length了
//这个if是判断是否产生了哈希冲突,并且用p来记录产生了与之冲突的旧键值对
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//产生了哈希冲突
Node<K,V> e; K k;
//发生哈希冲突,且键相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//进行替换值
e = p;
//发生哈希冲突且键不相等
//判断是不是红黑树
else if (p instanceof TreeNode)
//红黑树采用红黑树插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//不是红黑树,就是链表了
else {
//使用循环进行遍历链表,然后找到链表尾巴
//这里的binCount是一个计数器,记录此时链表的数量,是否达到树化条件
for (int binCount = 0; ; ++binCount) {
//判断循环是否来到了尾节点,如果是尾节点就插入元素
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//判断插入元素后是不是达到树化条件
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
//没有下一个值就结束循环
break;
}
//再次判断是不是存在key相同的冲突(因为可能会存在并发,所以这个判断是有必要的)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//记录这个位置,循环找下一个位置
p = e;
}
}
//如果前面循环找到了位置,就进行插入
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//这里再判断有没有达到树化条件
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
可以看到总的put过程是
- 判断底层数组有没有容量(有一些构造方法是可以绕过初始化容量的)
- 没有容量就进行初始化
- 判断有没有发生哈希冲突(即该元素在数组的存放位置是不是已经有值)
- 判断插入的数组位置是不是一个红黑树
- 是的话就使用树的插入方式
- 不是的话,就遍历链表找到尾节点进行插入
- 最后再判断插入成功后是否达到树化条件
那么HashMap的put死锁是在哪里形成的呢?
关键就在于第一步,判断底层数组容量够不够,不够就会进行resize
下面我们来看看这个resize的过程
final Node<K,V>[] resize() {
//取出之前底层的数组
Node<K,V>[] oldTab = table;
//取出之前底层的数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//取出旧阈值(底层数组的长度乘上负载因子)
int oldThr = threshold;
int newCap, newThr = 0;
//判断阈值情况
if (oldCap > 0) {
//如果阈值已经是最大阈值了
if (oldCap >= MAXIMUM_CAPACITY) {
//让阈值变成Integer最大值,且不再进行扩容
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果阈值还没到最大值,将新的数组长度初始化为旧长度的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//而且还会将新阈值变成旧阈值的2倍
newThr = oldThr << 1; // double threshold
}
//如果旧的长度为0,代表未进行初始化
//此时oldThr就是默认的阈值
else if (oldThr > 0) // initial capacity was placed in threshold
// 让新数组的长度为默认的阈值
newCap = oldThr;
// 如果把默认的阈值改成小于0,并且又未进行初始化
//那就按照默认的方式去做
else { // zero initial threshold signifies using defaults
// 新的长度为默认容量
newCap = DEFAULT_INITIAL_CAPACITY;
// 新的阈值为默认的容量乘上负载因子
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果得出新的阈值为0
// 需要重新定义新的阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 将新的阈值赋值给threshold
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 下面就是一个替代过程
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍历旧的数组
for (int j = 0; j < oldCap; ++j) {
// 使用临时变量e来获取数组项,也就是链表或者红黑树
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//清空旧的数组的这个项目,let gc work
oldTab[j] = null;
//如果这个项,里面只有一个值,也就是之前存储没有发生哈希冲突
if (e.next == null)
//进行rehash并且放入新的数组里面
//用值的hash值与上新数组的最大索引值
newTab[e.hash & (newCap - 1)] = e;
// 如果这个项是一个红黑树
else if (e instanceof TreeNode)
// 对应执行红黑树方面的操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 如果这个项是一个链表
else { // preserve order
//下面就是对链表进行遍历操作
// 为什么这里有两个链表?
// 先留下个疑问
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 根据hash值与上旧的容量来分类
// 如果为0就存入lo链表中
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 如果不为0,那就放在hi链表中
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 至此我们就形成了两个链表了
// 一个lo和一个li
// lo链表存放在新链表的位置是原来旧链表的位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//li链表存放在新链表的位置是原来旧链表的位置加上旧链表的容量
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
//至此我们可以知道这两个链表是干什么用的了
//其实就是将原先的链表分为两部分去存储,去哪部分取决于hash值
//如果hash取得为0,那么就是原来的位置
//如果不为0,那么就是下一段的同样位置
//这样做的目的是:其实没有什么特殊目的,这一部分的计算相当于重新计算索引值
}
}
}
}
return newTab;
}
可以看到resize的过程是
- 先计算新的长度和新的阈值
- 生成新的数组,将旧的数组里面的项遍历存放进新的数组里面
- 如果没有哈希冲突的项,重新计算索引值,然后放入新的数组中
- 如果产生了哈希冲突的项
- 如果是红黑树,进行树的操作(这里不详细说了)
- 如果是链表,遍历链表,也是重新计算索引值(因为扩容的规则为2倍,且计算索引值的方法采用求余方法,因此会分成了两部分,一个是原来位置,一个是下一段的同样位置【将扩容后的链表分为两段】)
- 返回新的数组
分析完了存放和扩容过程,线程不安全的问题就知道在哪里了
假如在扩容过程中,另外一个线程执行了插入会怎样?结果会产生一个死锁,也就是生成了一个死循环
从resize源码上可以看到会重新生成链表,假如现在有两个线程在同时操控着一个HashMap,A线程存放了a,b,c,d,e五个值,假如此时形成了一条链表为a->b->c,然后发现需要进行扩容操作了,而且已经在组装新的链表,但此时线程调度挂起了A线程,转而执行B线程,B去添加元素也发现需要进行扩容,组装后的链表变为c->a->b,此时A线程继续执行,生成了链表a->b->c,此时就会产生一个环,由a->b->c->a,从而产生了死锁
所以,产生死锁的关键就在于在扩容过程中会对链表进行重新组装
而为了解决这个HashMap的死锁问题,有以下两种方案
- 使用线程安全的HashTable(HashTable其实就是加了锁而已,使用synchronic保证了线程安全,使用put方法时,不允许其他线程进行干扰,连get方法也不能执行,不过在线程多的情况下会导致竞争激烈,从而导致效率低下)
- 使用线程安全的ConcurrentHashMap(ConcurrentHashMap采用了锁分段的技术,有效地提升并发访问率)
锁分段技术
对于HashTable来说,效率低下的原因是所有访问HashTable的线程都必须要争抢同一把锁,假如现在容器里面有多把锁,每一把锁用来保护容器其中一部分的数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,这就是锁分段技术
而ConcurrentHashMap就是利用了锁分段技术来减少锁竞争,首先将数据分成一段段地进行存储,然后给每一段数据配一把锁,当一个线程占用锁去访问其中一个段数据的时候,其他段的数据也能被其他线程访问
ConcurrentHashMap的结构
前面已经提到过,采用分段技术并且每段加锁,所以ConcurrentHashMap应该要由两部分组成
- Segment数组:Segment数组存储的是可重入锁,也就是ReentrantLock,存储的是每一个分段的锁
- HashEntry数组:HashEntry数组其实就是HashMap底层的那个数组,存储的就是键值对
Segment扮演的是锁的角色,而HashEntry扮演的则是容器的角色。
在ConcurrentHashMap里面只有一个Segment数组,而Segment里面的每一个项存储的则是HashEntry数组,也就相当于存的是HashMap,而HashEntry是数组+链表的结构(与当前JDK版本的HashMap一样),与HashMap十分相似,每个Segment守护着一个HashEntry链表里的所有元素,当对HashEntry数组里的数据进行修改时,必须要获取其Segment的锁
整体的结构如下所示
ConcurrentHashMap的初始化
ConcurrentHashMap的初始化通过下面几个参数来初始化
- initCapacity
- loadFactory
- concurrencyLevel
通过上面几个参数,可以对应初始化ConcurrentHashMap的segment数组、段偏移量segmentShift、段掩码segmentMask和每个segment里的HashEntry数组
下面就来说明一下这几个参数的意义
initCapacity
前面提到过,ConcurrentHashMap的结构是一个Segments数组,而一个segment是一个HashEntry数组链表(相当于一个HashMap链表),而HashEntry就会去存储键值对,而initCapacity是指ConcurrentHashMap里面所有HashEntry数组的总长度(每个segment存有一个HashEntry数组,相当于每个segment拥有一个HashMap)
loadFactory
loadFactory就很熟悉了,其实就是扩容因子
concurrencyLevel
翻译来看就是并发等级,意思就是同一时间可以承担最大的并发量,而这个最大的并发量是由Segments数组的长度来决定的,段落越多,拥有更多的锁,可以支持更高的并发量去获取锁,所以concurrenyLevel也间接决定了Segments数组的长度,与HashMap同理,长度要为二次幂才能支持与运算等效于求余操作,所以concurrencyLevel会取最接近的大二次幂,而每个segment可以拥有的键值对是相同数量的,所以每个segment拥有键值对的数量为initCapacity/concurrencyLevel
构造方法
从源码上可以看到,ConcurrencyHashMap总共有5种构造方法
前三种构造方法
无参构造
下面来看看这些默认值大小
从源码上可以看到
- DEFAULT_INITIAL_CAPACITY:默认的容量为16
- DEFAULT_LOAD_FACTORY:默认的负载因子为0.75
- DEFAULT_CONCURRENCY_LEVEL:默认的并发等级为16
第二种构造方法
第三种构造方法
第四种构造方法
源码如下
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
// 校验负载因子,初始化容量和并发等级是否合法,不合法会直接抛出异常
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 判断并发等级是否大于最大值(前面提到过,并发等级其实等效为Segments数组的长度)
// 这里同时判断的是Segments数组可以达到的最大长度(最大并发等级为2^16)
if (concurrencyLevel > MAX_SEGMENTS)
// 如果定义的最大并发等级大于最大值就设为最大值
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
// 这里定义的两个变量
// 1.sshift是转化次数
// 2.ssize是实际Segments数组的长度
// 前面提到过,数组长度必须为2的幂次方才可以支持与运算等效于求余运算
int sshift = 0;
int ssize = 1;
//使用while循环求出并发等级的最近的大二次幂()
//并且记录转化次数
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//下面这两个参数在put方法再介绍
//segmentMask是用来计算元素放在segments数组的索引的
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
//同样判断容量是不是大过最大值(2^30)
if (initialCapacity > MAXIMUM_CAPACITY)
//如果大于最大容量,那就设为最大容量
initialCapacity = MAXIMUM_CAPACITY;
// 接下来就是计算每个segment的hashEntry数组长度
int c = initialCapacity / ssize;
// 下面的操作是对hashEntry数组长度进行向上取整
if (c * ssize < initialCapacity)
++c;
// 再判断计算出来的hashEntry长度是否大于最小长度(2)
// 并且使用while循环再次计算出最近的大二次幂
// 也就是说,不单单是segments数组要为二次幂,里面的hashEntry数组长度也要为二次幂
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
//接下来就是初始化segments数组了
//这里首先去创建一个segment
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
//根据创建出来的segments数组长度去创建segments数组
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
//将刚创建好的segment放入segments数组的索引0处的位置(这一步作用在put方法再讲)
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
总结一下这个主要的构造方法步骤
- 判断输入进来的容量、负载因子、并发等级是否大于0,其中并发等级是大于等于0
- 如果不合法,就直接抛出异常
- 计算并发等级
- 大于最大的并发等级(2^16)就会默认为最大的并发等级
- 不大于最大的并发等级,会使用while循环计算最近的大二次幂,并且同时记录计算过程中的转化次数
- 计算segmentShift和segmentMask
- 计算initCapacity
- 判断initCapacity(容量)是否大于最大容量(2^30)
- 如果大于就定为最大值
- 计算hashEntry数组的长度(前面提到过hashEntry数组的长度是initCapacity / concurrencyLevel)
- 使用容量和并发等级来计算数组长度,并且是一个向上取整
- 同理,还要去取计算结果最近的大二次幂(而且这里要注意,长度的最小值为2)
- 使用负载因子、阈值(容量 * 负载因子)和hashEntry数组长度去实例S0
- 使用计算出的segments数组长度来创建segments数组
- 将S0放入segments数组的索引0处的位置
注意事项
- 最大的并发等级为2^16
- 最大的initCapacity为2^30
- 最小的hashEntry数组长度为2,也就是说hashEntry数组里面不可能只有1个项
- 计算hashEntry数组长度的时候是进行向上取整
- 默认的并发等级为16,默认的容量也为16,但HashEntry长度最低为2,所以,使用空构造方法最终产生的initCapacity其实是为32,虽然没有进行替换,即initCapacity仍然为16,但本质上所有hashEntry数组的总长度是32
ConcurrentHashMap的put方法
下面我们就来看下ConcurrentHashMap的put方法的大概逻辑
public V put(K key, V value) {
Segment<K,V> s;
//判断value是否为空
if (value == null)
//如果为空则会报错
throw new NullPointerException();
//获取key的哈希值
int hash = hash(key);
//使用哈希值、segmentShift和segmentMask来计算segments数组的索引
//但此时j还不是真实的索引值
int j = (hash >>> segmentShift) & segmentMask;
//真实的索引值是(j << SSHIFT) + SBASE)
//这里的if是先获取segments数组里面的对应位置里面的segment
//也可以理解成判断有无产生冲突
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//如果没有产生冲突,调用ensureSegment
//这步理解成第一次构建segment里面的entry数组
s = ensureSegment(j);
//无论是否产生冲突,此时entry数组已经形成,调用segment的put方法
//将键值对放进segment的hashEntry数组中
return s.put(key, hash, value, false);
}
总结一下整个流程
首先,进入put方法有两种情况
- 一种是要生成segment
- 另外一种是不需要生成segment
假如此时有两个线程进来,如果是第一次插入,就需要生成segment,所以在Put方法必须做好控制来避免两个线程都去生成segment,只能由一个线程去生成segment
从整体流程中就知道是如何控制这种并发情况的产生了
- 首先去判断value是否为空
- 如果为空则会直接抛出异常
- 计算key的hash值
- 计算segments位置的索引(使用hash、segmentShift、segmentMask、SSHIFT和SBACE来生成)
- 判断segments数组索引处是否已经存在
- 如果不存在,则调用ensureSegment方法(这一步就是解决上面的并发问题)
- 最后将使用segment的put方法来存入键值对
ensureSegment方法
源码如下
private Segment<K,V> ensureSegment(int k) {
//获取当前的segements数组
final Segment<K,V>[] ss = this.segments;
//计算索引,可以看到这里计算索引的方式和put方面里面是一样的
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
//再次去检查看索引的位置是不是还是空的
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//如果仍然是空的,利用0索引处的segment来当原型去构建鑫的segment
//所以,构造方法里面之所以一开始就给segments数组添加0索引处的项
//其实是为了来当模板去继续增加后面的segment
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//通过前面的segment[0]来获取构建segment项的参数后
//再次检查是否有线程去构建这个位置的segement
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
//如果没有,正式进行构建
//经过前面3层检查,才开始new一个segment
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
//采用循环去检查此时是否有其他线程进行构建这个位置的segment
//并且使用cas来比较这个位置是不是空,如果是空就将新建的segment放入这个位置
//假如cas后,正常放入,break
//假如cas后,发现有其他线程放入,不进行放入,while循环再次检测,并且退出循环
//总的来说就是利用CAS来保证原子性操作
//这里使用while循环可能是避免此时有其他线程进行删除动作
//相当于是一个乐观锁,自旋起来,不过旋转次数可能不多
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
从segment方法,可以看出ConcurrentHashMap对创建segment是怎样保证并发安全的
总结一下整个流程
- 获取当前的segments数组
- 计算segments数组索引位置,要在哪个索引去添加segment
- 第一次判断是否有其他线程往这个位置创建了segment
- 如果有, 直接return空segment
- 如果没有,下一步
- 下一步利用在构建方法就初始化好的S0去取得构建segment需要的参数,比如里面的HashEntry数组扩容阈值、长度、负载因子
- 取得了参数之后,并不是立即构建,而是再次去检验有没有其他线程已经在这个位置构建了segment
- 如果有,直接return空segment
- 如果没有,下一步
- 此时,才开始创建一个segment
- 利用乐观锁和CAS去将创建的segment放入segments数组对应的索引位置处
- 当然,在这里乐观锁是判断有没有线程去往这个位置创建segment
- 如果没有,进行CAS存放segment(CAS失败就继续)
- 如果有,就结束
- 结束条件为CAS成功,或者有其他线程往这个位置添加了segment
- 当然,在这里乐观锁是判断有没有线程去往这个位置创建segment
所以,ensureSegment保证了创建segment的并发安全性,那么插入键值对的安全性在哪里保证呢?
Segement的put方法
现在已经创建好了segment,下面就是往这个segment里面的HashEntry去存入键值对了,存入键值对时需要进行的扩容、保证并发安全性的操作都在里面
下面是源码
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//首先去进行tryLock尝试去获取锁
//这里能调用tryLock方法是因为concurrencyHashMap集成了ReentrantLock
//tryLock只会去进行尝试获取锁,但并不会阻塞线程
//线程仍然会执行下面的操作
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
//这里下面的操作就是跟JDK1.7的HashMap的Put方法一致了
//所以不进行说明了,也是采用尾插
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//最终将锁给释放
unlock();
}
return oldValue;
}
整体的过程如下
- 调用tryLock方法尝试去进行加锁
- 加锁失败,调用scanAndLockForPut方法
- 加锁成功,创建的键值对是一个空键值对
- 遍历链表、比较key去存放、扩容(与JDK1.7的HashMap的put方法是一样的,采用尾插)
- 加锁失败的话,代表有线程去操控,所以会在scanAndLockForPut里面进行自旋,直到有值
- 加锁成功,就代表没有线程操控,就自己完成新增
所以,最关键的地方还是在scanAndLockForPut里面
scanAndLockForPut方法
源码如下
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//调用entryForHash的方法,去获取该键值对所处HashEntry数组里面的链表的头节点
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
//这个retries很重要
//是用来控制循环的
int retries = -1; // negative while locating node
//不断进行tryLock,相当于是一个乐观锁进行自旋
//直到加锁成功,才会退出。如果加锁一直失败,就会不断更新状态
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
//第一个if会将链表遍历到尾
if (retries < 0) {
//这个if判断当前的节点是不是尾节点
//如果到尾了,也要跳出这个if(retries < 0)
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
//如果key相等,则会跳出这个if(retries < 0),下次循环不再进入
//key相等的话,其实就是需要替换Value
else if (key.equals(e.key))
retries = 0;
//如果不是尾节点,且key也没出现相同的,那么就需要比较下一个节点
else
e = e.next;
}
//这里是限制自旋次数的
//假如自旋次数过多,就会调用lock方法彻底阻塞该线程
else if (++retries > MAX_SCAN_RETRIES) {
//这里当尝试tryLock过多后,阻塞线程
//单处理器只会1次,多处理器则是2^64
lock();
break;
}
//这里是用来避免其他线程对这条链表进行了修改操作的
//假如有其他线程对链表进行了修改操作,那么就要进行重新循环链表到尾节点
//但如果每次都去判断是否有线程修改了,会很耗费性能
//所以这里规定,只有偶数的时候,且头节点发生了变化,才会进行重新循环
//因为一般被锁住后就不会有其他线程干扰
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
//让节点变回头节点
//并且让retries变为-1继续第一个if遍历到尾节点
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
可以看到,这个方法就是保证了concurrencyHashMap插入键值对时的并发安全,使用乐观锁不断去更新同步当前链表的情况
-
首先获取链表的头节点
-
定义retries来控制遍历链表
-
使用while循环来不断尝试tryLock,如果tryLock失败,代表有线程还在干扰这个链表,进行下面的操作进行同步状态
-
遍历链表到尾节点,或者到key相同的节点
-
并且判断自旋次数是否达到过最大值,如果达到最大值(单处理器为1,多处理器为2^64),则阻塞该线程
-
当自旋次数为偶数时,判断有无其他线程干扰,干扰的话需要重新遍历链表(因为链表发生了变化)
-
while循环继续判断有无线程干扰
-
返回遍历得到的节点
注意
- 加锁失败后,代表有线程干扰,需要通过自旋乐观锁去更新链表尾节点的信息
- scanAndLockForPut就是获取segments锁的一个体现