ConcurrentHashMap1.7版本源码深度解读

1,932 阅读5分钟

每天打螺丝,何日造航母。练就神仙技,奈何三十五。

ConcurrentHashMap是concurrent并发包下重要的工具类,它的设计和实现非常的巧妙,它将数据分段,每段数据使用一个AQS锁,从而减小了锁的粒度。

1.ConcurrentHashMap的结构

一个ConcurrentHashMap是由多个Segment(段)组成的。Segment类继承了ReentrantLock类,这意味着每个Segment拥有了一个AQS,多个线程的操作落到同一个Segment上时,发生了锁的竞争。ConcurrentHashMap默认有16个Segment,在初始化之后,Segment个数不可修改。

一个Segment包含了一个HashEntry的数组,每个HashEntry都是一个单向链表,HashEntry的数组可以扩容,扩容后数组的长度为原来的2倍。HashEntry类如下图所示:

我们看到value和next都是volatile修饰的,这保证了数据的可见性。

2.put方法详解

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    //计算key的hash值
    int hash = hash(key);
    //hash为32位无符号数,segmentShift默认为28,向右移28位,剩下高4位
    //然后和segmentMask(默认值为15)做与运算,结果还是高4位
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
       // 对 segment[j] 进行初始化
      s = ensureSegment(j);
    //放入到对应的段内
    return s.put(key, hash, value, false);
}

第一步是根据hash值快速获取到相应的Segment,第二步就是Segment内部的put操作了。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    //获取该segment的独占锁
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        //用hash值和(数组长度-1)做与运算,得出数组的下标
        int index = (tab.length - 1) & hash;
        //first 是数组该位置处的链表的表头
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            //判断是不是到了尾部,尾部==null
            if (e != null) {
                K k;
                //key相同,值更新
                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 {
                //node不为空,则node作为头节点,使用的是头插法
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                // 如果超过了该 segment 的阈值,这个 segment 需要扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else
                    // 没有达到阈值,将 node 放到数组 tab 的 index 位置,
                    // 其实就是将新的节点设置成原链表的表头
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

如何进行扩容:

private void rehash(HashEntry<K,V> node) {
    
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    //扩容为原来的2倍
    int newCapacity = oldCapacity << 1;
    //计算阀值
    threshold = (int)(newCapacity * loadFactor);
    HashEntry<K,V>[] newTable =
        (HashEntry<K,V>[]) new HashEntry[newCapacity];
    int sizeMask = newCapacity - 1;
    //循环旧的HashEntry数组
    for (int i = 0; i < oldCapacity ; i++) {
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            int idx = e.hash & sizeMask;
            //该链表上只有一个节点
            if (next == null)   //  Single node on list
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                HashEntry<K,V> lastRun = e;
                int lastIdx = idx;
                for (HashEntry<K,V> last = next;
                     last != null;
                     last = last.next) {
                    //计算在新数组中的下标
                    int k = last.hash & sizeMask;
                    //当前节点的下标和上一个节点的下标不一致时,修改最终节点值
                    //注意如果后面的节点和前面的节点下标一致,
                    //那么后面的节点保持原有的顺序,直接带到新tab[k]的链表中
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                //采用头插法,最后一个节点作为头节点
                newTable[lastIdx] = lastRun;
                //重新计算节点在数组中的位置,采用头插法插入到链表
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    //添加新节点
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    //将newTable赋值给该segment的table
    table = newTable;
}

自旋获取aqs锁:

private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    HashEntry<K,V> first = entryForHash(this, hash);
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    int retries = -1; // negative while locating node
     //如果尝试加锁失败,那么就对segment[hash]对应的链表进行遍历找到需要put的这个entry所在的链表中的位置,
    //这里之所以进行一次遍历找到坑位,主要是为了通过遍历过程将遍历过的entry全部放到CPU高速缓存中,
    //这样在获取到锁了之后,再次进行定位的时候速度会十分快,这是在线程无法获取到锁前并等待的过程中的一种预热方式。
    while (!tryLock()) {
        HashEntry<K,V> f; // to recheck first below
        //获取锁失败,初始时retries=-1必然开始先进入第一个if
        if (retries < 0) {
            //e=null代表两种意思,
            //1.第一种就是遍历链表到了最后,仍然没有发现指定key的entry;
            //2.第二种情况是刚开始时entryForHash(通过hash找到的table中对应位置链表的结点)找到的HashEntry就是空的
            if (e == null) {
                /*
                当然这里之所以还需要对node==null进行判断,是因为有可能在第一次给node赋值完毕后,然后预热准备工作已经搞定,然后进行循环尝试获取锁,在循环次数还未达到<2>64次以前,某一次在条件<3>判断时发现有其它线程对这个segment进行了修改,那么retries被重置为-1,从而再一次进入到<1>条件内,此时如果再次遍历到链表最后时,因为上一次遍历时已经给node赋值过了,所以这里判断node是否为空,从而避免第二次创建对象给node重复赋值。
                */
                if (node == null) // speculatively create node
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            else if (key.equals(e.key))
                retries = 0;
            else
                e = e.next;
        }
        else if (++retries > MAX_SCAN_RETRIES) {
            // 尝试获取锁次数超过设置的最大值,直接进入阻塞等待,这就是所谓的有限制的自旋获取锁,
            //之所以这样是因为如果持有锁的线程要过很久才释放锁,这期间如果一直无限制的自旋其实是对cpu性能有消耗的,
            //这样无限制的自旋是不利的,所以加入最大自旋次数,超过这个次数则进入阻塞状态等待对方释放锁并获取锁
            lock();
            break;
        }
        // 遍历过程中,有可能其它线程改变了遍历的链表,这时就需要重新进行遍历了。
        //判断是否初始化了结点 并且 判断链表头结点是否改变(1.7使用头插法)
        else if ((retries & 1) == 0 &&
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
    }
    return node;
}

这个方法用了一个while循环去获取aqs锁,有两种情况需要说明下:

1.如果尝试的次数超过了最大自旋次数,会进入到aqs的等待队列,避免了cpu的空转。

2.如果在循环的过程中,其他的线程获取到了锁,并且改变了遍历的链表,那么自旋计数器重置为-1,从链表的头节点重新开始遍历。

3.get方法

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

get方法并没有加锁,基本思路是:

1.先定位到所在的segment

2.定位对应的segment的tab数组内的位置

3.然后遍历链表元素,如果找到相同的key就返回对应的value

4.总结:

ConcurrentHashMap1.7采用了分而治之的思想,将数据分段,每个段持有一把aqs锁。

它的写操作都是需要获取锁之后再操作,而读操作不需要获取锁,这也说明它适合读多写少的业务场景。

线程在获取不到aqs锁的情况下,不会立即进入到等待队列,会进行一定次数的自旋。

本文正在参加「金石计划」 ”