我对ConcurrentHashMap一无所知

我对ConcurrentHashMap一无所知

上期讲到HashMap,很明显它里面的所有方法,都是不支持多线程抢占执行的,一旦在并发场景下,就可能会出现以下问题。

//环形链表问题

//在多个线程执行put时,假如同时触发了resize,而恰好一个线程停在了next = e.next位置,线程2此时来执行
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next; // 线程一执行此处
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

//线程2一股脑跑完了后,将链表的顺序倒置一遍(jdk1.7中是头插法),结果就导致了线程1中的next与e相互引用,从而导致进入死循环。

那么并发下有什么好的替换品呢?当然有,Hashtable与ConcurrentHashMap,这两个互为竞品,但显然hashtable效率不如ConcurrentHashMap,点进去一看,hashtable里面的方法基本是hashmap的方法加了synchronized关键字修饰,那么意味着凡是对hashtable进行修改的方法,都需要获取到锁,这里锁的粒度比较大,故而效率低下。


下面开始本篇重点内容ConcurrentHashMap 1.7源码解读。

成员属性

// node数组最大容量:2^30=1073741824
private static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认初始值,必须是2的幕数
private static final int DEFAULT_CAPACITY = 16;

//数组可能最大值,需要与toArray()相关方法关联
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

//并发级别,遗留下来的,为兼容以前的版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

// 负载因子
private static final float LOAD_FACTOR = 0.75f;

// 链表转红黑树阀值,> 8 链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;

//树转链表阀值,小于等于6(tranfer时,lc、hc=0两个计数器分别++记录原bin、新binTreeNode数量,<=UNTREEIFY_THRESHOLD 则untreeify(lo))
static final int UNTREEIFY_THRESHOLD = 6;

static final int MIN_TREEIFY_CAPACITY = 64;
private static final int MIN_TRANSFER_STRIDE = 16;
private static int RESIZE_STAMP_BITS = 16;

// 2^15-1,help resize的最大线程数
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

// 32-16=16,sizeCtl中记录size大小的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;

// forwarding nodes的hash值
static final int MOVED     = -1;

// 树根节点的hash值
static final int TREEBIN   = -2;

// ReservationNode的hash值
static final int RESERVED  = -3;

// 可用处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();

//存放node的数组
transient volatile Node<K,V>[] table;
/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义
 *当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容
 *当为0时:代表当时的table还没有被初始化
 *当为正数时:表示初始化或者下一次进行扩容的大小
 */
private transient volatile int sizeCtl;

构造方法

 public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        //老规矩,上来先检查参数的合法性
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (concurrencyLevel > MAX_SEGMENTS)
            concurrencyLevel = MAX_SEGMENTS;

        // 找到比concurrencyLevel大且最接近它的2的N次幂
        int sshift = 0;
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;//调整到2的n次方
        }
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;//这里相当于计算hash时&操作的值
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //---------上面算出的是segment的长度-----
        
        //---------下面要算每一个segment里面的hashEntry数量
        int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity) // 向上取整
            ++c;
        
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        // create segments and segments[0] 这一步是精髓,在创建开始时,直接先创建一个segment[0] 方便后面创建segment的时候直接获取到segment里面的属性,如cap、size等
        Segment<K,V> s0 =
                new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                        (HashEntry<K,V>[])new HashEntry[cap]);
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]//
        this.segments = ss;
    }

scanAndLockForPut方法

将cpu的性能压榨到极致!

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) {//<1>
                if (e == null) { //<1.1>
                    //e=null代表两种意思,第一种就是遍历链表到了最后,仍然没有发现指定key的entry;
                    //第二种情况是刚开始时确实太过entryForHash找到的HashEntry就是空的,即通过hash找到的table中对应位置链表为空
                    //当然这里之所以还需要对node==null进行判断,是因为有可能在第一次给node赋值完毕后,然后预热准备工作已经搞定,
                    //然后进行循环尝试获取锁,在循环次数还未达到<2>以前,某一次在条件<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))//<1.2>   遍历过程发现链表中找到了我们需要的key的坑位
                    retries = 0;
                else//<1.3>   当前位置对应的key不是我们需要的,遍历下一个
                    e = e.next;
            }
            else if (++retries > MAX_SCAN_RETRIES) {//<2>
                // 尝试获取锁次数超过设置的最大值,直接进入阻塞等待,这就是所谓的有限制的自旋获取锁,
                //之所以这样是因为如果持有锁的线程要过很久才释放锁,这期间如果一直无限制的自旋其实是对系统性能有消耗的,
                //这样无限制的自旋是不利的,所以加入最大自旋次数,超过这个次数则进入阻塞状态等待对方释放锁并获取锁。
                lock();
                break;
            }
            else if ((retries & 1) == 0 &&
                    (f = entryForHash(this, hash)) != first) {//<3>
                // 遍历过程中,有可能其它线程改变了遍历的链表,这时就需要重新进行遍历了。
                e = first = f; // re-traverse if entry changed
                retries = -1;
            }
        }
        return node;
    }

put方法

   final V put(K key, int hash, V value, boolean onlyIfAbsent) {
        //先尝试对segment加锁,如果直接加锁成功,那么node=null;如果加锁失败,则会调用scanAndLockForPut方法去获取锁,
        //在这个方法中,获取锁后会返回对应HashEntry(要么原来就有要么新建一个)
        HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
        V oldValue;
        try {
            //这里是一个优化点,由于table自身是被volatile修饰的,然而put这一块代码本身是加锁了的,所以同一时间内只会有一个线程操作这部分内容,
            //所以不再需要对这一块内的变量做任何volatile修饰,因为变量加了volatile修饰后,变量无法进行编译优化等,会对性能有一定的影响
            //故将table赋值给put方法中的一个局部变量,从而使得能够减少volatile带来的不必要消耗。
            HashEntry<K,V>[] tab = table;
            int index = (tab.length - 1) & hash;
            //这里有一个问题:为什么不直接使用数组下标获取HashEntry,而要用entryAt来获取链表?
            //这里结合网上内容个人理解是:由于Segment继承的是ReentrantLock,所以它是一个可重入锁,那么是否存在某种场景下,
            //会导致同一个线程连续两次进入put方法,而由于put最终使用的putOrderedObject只是禁止了写写重排序无法保证内存可见性,
            //所以这种情况下第二次put在获取链表时必须用entryAt中的volatile语义的get来获取链表,因为这种情况下下标获取的不一定是最新数据。
            HashEntry<K,V> first = entryAt(tab, index);//先获取需要put的<k,v>对在当前这个segment中对应的链表的表头结点。

            for (HashEntry<K,V> e = first;;) {//开始遍历first为头结点的链表
                if (e != null) {//<1>
                    //e不为空,说明当前键值对需要存储的位置有hash冲突,直接遍历当前链表,如果链表中找到一个节点对应的key相同,
                    //依据onlyIfAbsent来判断是否覆盖已有的value值。
                    K k;
                    if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                        //进入这个条件内说明需要put的<k,y>对应的key节点已经存在,直接判断是否更新并最后break退出循环。
                        oldValue = e.value;
                        if (!onlyIfAbsent) {
                            e.value = value;
                            ++modCount;
                        }
                        break;
                    }
                    e = e.next;//未进入上面的if条件中,说明当前e节点对应的key不是需要的,直接遍历下一个节点。
                }
                else {//<2>
                    //进入到这个else分支,说明e为空,对应有两种情况下e可能会为空,即:
                    // 1>. <1>中进行循环遍历,遍历到了链表的表尾仍然没有满足条件的节点。
                    // 2>. e=first一开始就是null(可以理解为即一开始就遍历到了尾节点)
                    if (node != null) //这里有可能获取到锁是通过scanAndLockForPut方法内自旋获取到的,这种情况下依据找好或者说是新建好了对应节点,node不为空
                        node.setNext(first);
                    else// 当然也有可能是这里直接第一次tryLock就获取到了锁,从而node没有分配对应节点,即需要给依据插入的k,v来创建一个新节点
                        node = new HashEntry<K,V>(hash, key, value, first);
                    int c = count + 1; //总数+1 在这里依据获取到了锁,即是线程安全的!对应了上述对count变量的使用规范说明。
                    if (c > threshold && tab.length < MAXIMUM_CAPACITY)//判断是否需要进行扩容
                        //扩容是直接重新new一个新的HashEntry数组,这个数组的容量是老数组的两倍,
                        //新数组创建好后再依次将老的table中的HashEntry插入新数组中,所以这个过程是十分费时的,应尽量避免。
                        //扩容完毕后,还会将这个node插入到新的数组中。
                        rehash(node);
                    else
                        //数组无需扩容,那么就直接插入node到指定index位置,这个方法里用的是UNSAFE.putOrderedObject
                        //网上查阅到的资料关于使用这个方法的原因都是说因为它使用的是StoreStore屏障,而不是十分耗时的StoreLoad屏障
                        //给我个人感觉就是putObjectVolatile是对写入对象的写入赋予了volatile语义,但是代价是用了StoreLoad屏障
                        //而putOrderedObject则是使用了StoreStore屏障保证了写入顺序的禁止重排序,但是未实现volatile语义导致更新后的不可见性,
                        //当然这里由于是加锁了,所以在释放锁前会将所有变化从线程自身的工作内存更新到主存中。
                        //这一块对于putOrderedObject和putObjectVolatile的区别有点混乱,不是完全理解,网上也没找到详细解答,查看了C源码也是不大确定。
                        //希望有理解的人看到能指点一下,后续如果弄明白了再更新这一块。
                        setEntryAt(tab, index, node);
                    ++modCount;
                    count = c;
                    oldValue = null;
                    break;
                }
            }
        } finally {
            unlock();
        }
        return oldValue;
    }


ensureSegment

这个方法也是一个很关键的方法,用来确保每次创建segment不会冲突。

private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
    long u = (k << SSHIFT) + SBASE; // raw offset
    Segment<K,V> seg;
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        Segment<K,V> proto = ss[0]; // 以初始化时创建的第一个坑位的ss[0]作为模版进行创建
        int cap = proto.table.length; // 直接就获取到了segment里面hashEntry的大小以及计算好的loadFactor,细节!
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        //通过unsafe类直接获取到ss里地址偏移量u位置的segement
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
            == null) { // 第一次检查是否有其它线程创建了这个Segment
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                   == null) { // 第二次检查是否有线程创建了这个segment
                //这里通过自旋的CAS方式对segments数组中偏移量为u位置设置值为s,这是一种不加锁的方式,
                //万一有多个线程同时执行这一步,那么只会有一个成功,而其它线程在看到第一个执行成功的线程结果后
                //会获取到最新的数据从而发现需要更新的坑位已经不为空了,那么就跳出while循环并返回最新的seg
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

总结

源码看起来真累啊,顶不住了,先就到这吧,再往下深挖就需要把整个设计细节都弄明白,想想还是算了。核心部分的代码过了一遍,不得不再次感叹大哥李那惊为天人的实力!下一篇源码,咱们尝试一下reentretlock以及AQS源码。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值