Java并发容器(一):Java并发容器和框架--ConcurrencyHashMap(JDK1.7)

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过程是

  1. 判断底层数组有没有容量(有一些构造方法是可以绕过初始化容量的)
    1. 没有容量就进行初始化
  2. 判断有没有发生哈希冲突(即该元素在数组的存放位置是不是已经有值)
  3. 判断插入的数组位置是不是一个红黑树
    1. 是的话就使用树的插入方式
  4. 不是的话,就遍历链表找到尾节点进行插入
  5. 最后再判断插入成功后是否达到树化条件

那么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的过程是

  1. 先计算新的长度和新的阈值
  2. 生成新的数组,将旧的数组里面的项遍历存放进新的数组里面
    1. 如果没有哈希冲突的项,重新计算索引值,然后放入新的数组中
    2. 如果产生了哈希冲突的项
      1. 如果是红黑树,进行树的操作(这里不详细说了)
      2. 如果是链表,遍历链表,也是重新计算索引值(因为扩容的规则为2倍,且计算索引值的方法采用求余方法,因此会分成了两部分,一个是原来位置,一个是下一段的同样位置【将扩容后的链表分为两段】)
  3. 返回新的数组

分析完了存放和扩容过程,线程不安全的问题就知道在哪里了

假如在扩容过程中,另外一个线程执行了插入会怎样?结果会产生一个死锁,也就是生成了一个死循环

从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的初始化通过下面几个参数来初始化

  1. initCapacity
  2. loadFactory
  3. 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种构造方法

在这里插入图片描述

前三种构造方法
无参构造

在这里插入图片描述
下面来看看这些默认值大小
在这里插入图片描述
从源码上可以看到

  1. DEFAULT_INITIAL_CAPACITY:默认的容量为16
  2. DEFAULT_LOAD_FACTORY:默认的负载因子为0.75
  3. 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;
    }

总结一下这个主要的构造方法步骤

  1. 判断输入进来的容量、负载因子、并发等级是否大于0,其中并发等级是大于等于0
    1. 如果不合法,就直接抛出异常
  2. 计算并发等级
    1. 大于最大的并发等级(2^16)就会默认为最大的并发等级
    2. 不大于最大的并发等级,会使用while循环计算最近的大二次幂,并且同时记录计算过程中的转化次数
  3. 计算segmentShift和segmentMask
  4. 计算initCapacity
    1. 判断initCapacity(容量)是否大于最大容量(2^30)
    2. 如果大于就定为最大值
  5. 计算hashEntry数组的长度(前面提到过hashEntry数组的长度是initCapacity / concurrencyLevel)
    1. 使用容量和并发等级来计算数组长度,并且是一个向上取整
    2. 同理,还要去取计算结果最近的大二次幂(而且这里要注意,长度的最小值为2)
  6. 使用负载因子、阈值(容量 * 负载因子)和hashEntry数组长度去实例S0
  7. 使用计算出的segments数组长度来创建segments数组
  8. 将S0放入segments数组的索引0处的位置

注意事项

  1. 最大的并发等级为2^16
  2. 最大的initCapacity为2^30
  3. 最小的hashEntry数组长度为2,也就是说hashEntry数组里面不可能只有1个项
  4. 计算hashEntry数组长度的时候是进行向上取整
  5. 默认的并发等级为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

从整体流程中就知道是如何控制这种并发情况的产生了

  1. 首先去判断value是否为空
    1. 如果为空则会直接抛出异常
  2. 计算key的hash值
  3. 计算segments位置的索引(使用hash、segmentShift、segmentMask、SSHIFT和SBACE来生成)
  4. 判断segments数组索引处是否已经存在
    1. 如果不存在,则调用ensureSegment方法(这一步就是解决上面的并发问题)
  5. 最后将使用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是怎样保证并发安全的

总结一下整个流程

  1. 获取当前的segments数组
  2. 计算segments数组索引位置,要在哪个索引去添加segment
  3. 第一次判断是否有其他线程往这个位置创建了segment
    1. 如果有, 直接return空segment
    2. 如果没有,下一步
  4. 下一步利用在构建方法就初始化好的S0去取得构建segment需要的参数,比如里面的HashEntry数组扩容阈值、长度、负载因子
  5. 取得了参数之后,并不是立即构建,而是再次去检验有没有其他线程已经在这个位置构建了segment
    1. 如果有,直接return空segment
    2. 如果没有,下一步
  6. 此时,才开始创建一个segment
  7. 利用乐观锁和CAS去将创建的segment放入segments数组对应的索引位置处
    1. 当然,在这里乐观锁是判断有没有线程去往这个位置创建segment
      1. 如果没有,进行CAS存放segment(CAS失败就继续)
      2. 如果有,就结束
    2. 结束条件为CAS成功,或者有其他线程往这个位置添加了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锁的一个体现
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值