ConcurrentHashMap 1.7

ConcurrentHashMap 1.7

一、数据结构

  • ConcurrentHashMap在1.7的结构图如下:

在这里插入图片描述

  • ConcurrentHashMap由Segment数组组成,Segment继承了ReentrantLock可以提供锁的功能,也表示并发度,是最大并行访问的线程数量,每一个Segment内部包含一个HashEntry数组用于元素存储,

  • HashEntry则是一个K-V的存储单元,尾部可以挂HashEntry使用链地址法解决hash冲突

  • 1.7中的ConcurrentHashMap源码大约1600行,去除大量注释外,我们只需要关注核心方法,

1.1 HashEntry

  • 如下是HashEntry的核心属性,setNext用于线程安全的设置尾节点,使用CAS机制。
static final class HashEntry<K,V> {

        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;

        final void setNext(HashEntry<K,V> n) {
            UNSAFE.putOrderedObject(this, nextOffset, n);
        }
    }

1.2 Segment

  • ConcurrentHashMap由若干Segment组成(默认16),Segment继承ReentrantLock,数量表示并发度,是最大并行访问的线程数量,每一个Segment内部包含一个HashEntry数组用于元素存储
  • 如下给出了Segment的属性和全部方法,但是省去了方法体,我们先了解Segment的方法,Segment内部持有HashEntry的数组并且具备锁的功能,它包含put、rehash、remove、clear等,ConcurrentHashMap很多方法底层也是调用这些方法
static final class Segment<K,V> extends ReentrantLock implements Serializable {
		//最大扫描尝试
        static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
        //保存元素的HashEntry
        transient volatile HashEntry<K,V>[] table;
       //元素个数
        transient int count;
        //修改次数
        transient int modCount;
        //阈值
        transient int threshold;
        //负载因子
        final float loadFactor;
		//构造函数
        Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
           //省略...
        }

		//添加方法
        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
             
        }

        //扩容方法
        private void rehash(HashEntry<K,V> node) { 
			//省略...
		}

        //put操作扫描加锁
        private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) { 
			//省略...
		}
		
		//remove和replace操作扫描加锁
        private void scanAndLock(Object key, int hash) { 
			//省略...
		}

        //移除方法
        final V remove(Object key, int hash, Object value) { 
			//省略...
		}
		
		//替换
        final boolean replace(K key, int hash, V oldValue, V newValue) { 
			//省略...
		}
		
		//替换
        final V replace(K key, int hash, V value) { 
			//省略...
        }
		
		//清除
        final void clear() { 
			//省略...
        }
    }

二、初始化

  • 容量(16):必须是2的幂次
  • 负载因子(0.75):小于1
  • 并发度(16):必须是2的幂次
  • HashEntry(2): 在自定义的情况下会根据并发度和初始容量计算并且也是2的幂次,容量/并发度= HashEntry大小 ,比如容量是128并发度是16,那HashEntry大小就是8
  • 初始化的时候只会初始化第一个Segment,其余的用的时候才会初始化
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {
        //省略其他初始化参数设置和校验
        // create segments and segments[0]
        //创建第一个Segment
        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]
        //初始化好的segments数组目前只包含第一个s0
        this.segments = ss;
    }

三、Segment方法

3.1 put

  • put操作先定位Segment,再定位HashEntry,需要进行2次Hash操作,下面是先定位到Segment
public V put(K key, V value) {
        Segment<K,V> s;
        //1.value不能为null
        if (value == null) throw new NullPointerException();
        int hash = hash(key);
        int j = (hash >>> segmentShift) & segmentMask;
        //2.定位Segemnt,如果为null就先初始化,CAS操作
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        //使用Segment的put操作
        return s.put(key, hash, value, false);
}
  • 下面是调用Segment的put操作,操作需要加锁,如果tryLock失败成功就继续执行,如果tryLock失败,则进去scanAndLockForPut尝试一定次数的自旋,先看看tryLock成功的场景
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            //1.tryLock成功,node为null
            HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                //3.entryAt通过hash定位并获取table中指定下标处的头元素(内部使用volatile保证线程安全)
                HashEntry<K,V> first = entryAt(tab, index);
                //4.从链表头结点开始遍历
                for (HashEntry<K,V> e = first;;) {
                    //5.如果头结点不为空,那么往后遍历,一旦找到一样的key就替换并break,内部会处理onlyIfAbsent的情况
                    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;
                    }
                    //6.到这里有两种情况:第一种情况是first为空,说明定位到的HashEntry数组该位置没有元素,
                    //第二种情况是first不为空,但是链表遍历到最后了,链表中不存在key这个键,
                    //这两种情况都需要将新的key构造节点并插入
                    else {
                        //7.node不为null,说明在scanAndLockForPut里面自旋等待锁的时候,线程并没有傻等着,而是已经把节点构造好了,
                        //既然构造好了,那还等啥,直接头插法设置next即可,并且这个setNext是线程安全的,在前面的HashEntry已经提过
                        if (node != null)
                            node.setNext(first);
                        else
                            //8.到这里说明node还没构造好,可能是tryLock一下就成功了还没来得及构造节点,那就构造一下
                            node = new HashEntry<K,V>(hash, key, value, first);
                        //9.插入之后Segment持有的元素加一
                        int c = count + 1;
                        //10.判断是否需要扩容
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            //11.如果不扩容,因为是头插法,node成了新的头,自然要把node设置到HashEntry数组的指定位置,
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                //12.解锁
                unlock();
            }
            //13.返回旧值
            return oldValue;
        }
  • scanAndLockForPut:put操作加锁,里面会尝试查询key对应节点是否存在,如果没有则会预创建一个节点,这种预创建的思想有利于后面提高性能
  • 并且内部尝试自旋的次数是受限的(单核1次多核64次),自旋过程会访问该HashEntry后面链接的元素,将其加入Cpu高速缓存利于提高性能,而且回在必要的时候把要插入的节点构造好后面就不需要再构造了,
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
            //1.获取头结点
            HashEntry<K,V> first = entryForHash(this, hash);
            HashEntry<K,V> e = first;
            HashEntry<K,V> node = null;
            int retries = -1; // negative while locating node
            //2.自旋尝试,尝试次数(单核1次多核64次)
            while (!tryLock()) {
                HashEntry<K,V> f; // to recheck first below
                if (retries < 0) {
                    if (e == null) {
                        //头结点为null,或者找到了尾节点,那么retries变量置0
                        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
                        retries = 0;
                    else
                        //不断遍历将变量加入到CPU高速缓存,提高性能
                        e = e.next;
                }
                else if (++retries > MAX_SCAN_RETRIES) {
                    lock();
                    break;
                }
                //这里retries为什么要为1?我猜测是这样的,也就是每一次retires置0之后的下一次循环一定会进来这里判断一次,假设头节点e为null
                //构建了一个新的节点,那么下一次就会进入else if判断,会将retries自增为1,但是不会进去,因此就会执行下面第三个逻辑,进去之
                //后就会检查头节点是否有改变,这样我们发现,也就是一旦做了构造新节点或者找到存在的节点的话,就会在下一个tryLock失败会后判
                //断是否有其他线程改变了头节点
                else if ((retries & 1) == 0 &&
                         (f = entryForHash(this, hash)) != first) {
                    e = first = f; // re-traverse if entry changed
                    retries = -1;
                }
            }
            return node;
        }
  • 注意这里面有三个分支条件,每次循环只会走其中一个分支,第一个分支逻辑:持续遍历该链表(else),如果节点链中不存在要插入的节点,则预创建一个节点(if),如果存在,停止遍历(else if)
  • 第二个分支逻辑:retries达到最大尝试次数,阻塞加锁,下次获取到锁后就会退出方法
  • 第三个分支逻辑:retries为1,并且firsy被更改(因为此时自己还未获取到锁,first有可能被其他线程修改),如果修改过entryForHash会获取到新的头结点设置到first,然后重新遍历。

3.2 get

  • get操作不需要加锁,先通过hash值定位到Segement,然后遍历HashEntry,代码就不贴了,核心在下面:
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
UNSAFE.getObjectVolatile(segments, u))
  • 核心是利用UNSAFE.getObjectVolatile,根据指定内存的位置保证能够读取到变量的最新数据。

3.3 rehash

  • 扩容条件:元素个数大于阈值触发扩容,
  • 扩容什么?:注意扩容是针对HashEntry来说的,Segement指定之后是不会扩容的,也就是并发度不会修改,但是每一个Segement内部的HashEntry数组可扩容
private void rehash(HashEntry<K,V> node) {
            HashEntry<K,V>[] oldTable = table;
            //1.容量翻倍,计算新阈值,创建新HashEntry数组,(计算掩码用于后面与运算)
            int oldCapacity = oldTable.length;
            int newCapacity = oldCapacity << 1;
            threshold = (int)(newCapacity * loadFactor);
            HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
            int sizeMask = newCapacity - 1;
            //2.循环遍历,复制旧数组的值到新数组
            for (int i = 0; i < oldCapacity ; i++) {
                HashEntry<K,V> e = oldTable[i];
                if (e != null) {
                    //3.不为null才需要复制过去,先计算新的下标
                    HashEntry<K,V> next = e.next;
                    int idx = e.hash & sizeMask;
                    //4.处理没有next的节点(一个slot桶里面只有一个节点)
                    if (next == null)   //  Single node on list
                        newTable[idx] = e;
                    //5.处理后面有后继节点的情况
                    else { // Reuse consecutive sequence at same slot
                        HashEntry<K,V> lastRun = e;
                        int lastIdx = idx;
                        //6.这里遍历是为了找到这样一个节点last,在laste后面的子
                        //节点在新数组中的位置都是一样的,比如链表后面跟了6个节点,假设编号1-6
                        //从低4个开始,456在新数组中的定位是一样的,那么就直接把4号节点就是lastRun
                        //然后直接把4号节点放到新数组即可,后面的56不需要复制了,节约复制的开销
                        for (HashEntry<K,V> last = next;last != null;last = last.next) {
                            int k = last.hash & sizeMask;
                            if (k != lastIdx) {
                                lastIdx = k;
                                lastRun = last;
                            }
                        }
                        //7.按照前面的解释,直接把4号节点复制过去
                        newTable[lastIdx] = lastRun;
                        // Clone remaining nodes
                        //8.复制剩余节点,构造新节点加入到新数组
                        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);
                        }
                    }
                }
            }
            //9.扩容过程中处理的是旧的数组,node其实还没有处理,将node加进去
            int nodeIndex = node.hash & sizeMask; // add the new node
            //node设置next,再直接插入
            node.setNext(newTable[nodeIndex]);
            newTable[nodeIndex] = node;
            table = newTable;
        }
  • rehash的过程是在加了锁之后做的,因此只会有一个线程扩容(对一个Segment而言),扩容就是一个复制的过程,注意所做的优化,就是避免没有必要的复制,这里面隐含了一个小知识,就是扩容之后原来的节点在数组中所处的下标只会有2种可能,要么不变,要么就是原来的下标加上x,(x是原来的容量也是新容量的一半)。

四、ConcurrentHashMap方法

4.1 size

  • size不是Segment的方法,是ConcurrentHashMap的方法;
  • size的原理是:求所有Segment的修改次数之和,连续计算2次,如果没有变化,那就说明过程中集合没有被修改,就返回size。如果不一致,那就会将前面的过程尝试一定次数,如果还不行,就需要加锁计算,加锁后会初始化全部的Segment。
  • 由此可见:size是一个有可能影响效率的动作,
public int size() {
        final Segment<K,V>[] segments = this.segments;
        int size;
        boolean overflow; // true if size overflows 32 bits
        long sum;         // sum of modCounts
        long last = 0L;   // previous sum
        int retries = -1; // first iteration isn't retry
        try {
            for (;;) {
                //1.如果尝试达到了限制,就加锁,默认是2次
                if (retries++ == RETRIES_BEFORE_LOCK) {
                    for (int j = 0; j < segments.length; ++j)
                        ensureSegment(j).lock(); // force creation
                }
                sum = 0L;
                size = 0;
                overflow = false;
                //2.求所有的Segment的修改次数,注意写入才会改变modCount,读取不会
                for (int j = 0; j < segments.length; ++j) {
                    Segment<K,V> seg = segmentAt(segments, j);
                    if (seg != null) {
                        sum += seg.modCount;
                        int c = seg.count;
                        if (c < 0 || (size += c) < 0)
                            overflow = true;
                    }
                }
                //3.如果连续两次一样,就返回
                if (sum == last)
                    break;
                //4.如果不一样,就记下当前的次数,待下一次统计之后再比较
                last = sum;
            }
        } finally {
            //5.如果加了锁,就解锁
            if (retries > RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    segmentAt(segments, j).unlock();
            }
        }
        return overflow ? Integer.MAX_VALUE : size;
    }
  • size操作在高并发下可能影响性能,因为有可能要对全部Segment加锁,

4.2 remove

  • ConcurrentHashMap的remove方法是调用Segement的remove方法实现的
final V remove(Object key, int hash, Object value) {
            //1.尝试加锁失败就加锁
            if (!tryLock())
                scanAndLock(key, hash);
            V oldValue = null;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> e = entryAt(tab, index);
                HashEntry<K,V> pred = null;
                while (e != null) {
                    K k;
                    HashEntry<K,V> next = e.next;
                    //1.找到了Key
                    if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
                        V v = e.value;
                        //2.检查value,如果指定了value在value相等时才删除,没有指定就相当于指定为null
                        if (value == null || value == v || value.equals(v)) {
                            if (pred == null)
                                setEntryAt(tab, index, next);
                            else
                                pred.setNext(next);
                            ++modCount;
                            --count;
                            oldValue = v;
                        }
                        break;
                    }
                    pred = e;
                    e = next;
                }
            } finally {
                unlock();
            }
            return oldValue;
        }
  • remove方法没有太多特殊的,它需要加锁,另外会支持指定value的删除,如果只是指定key相当于value指定为null

4.3 containsKey

  • containsKey判断是否包含key,会先定位到segment,然后遍历HashEntry数组

4.4 containsValue

  • containsValue性能很差,因为它需要遍历全部的Segemnt来查找,同时遍历的时候会统计修改次数,如果被修改了,那么就需要加锁,有点类似于size方法,需要慎用

4.5 clear

  • clear比较简单,就是遍历全部的Segment,调用Segment的clear方法,Segment的clear就是加锁,然后将所有节点置为null。

五、小结

  • 初始化
1.延迟加载:只会初始化第一个Segemnt,其余的在用的时候才会初始化,
2.所有大小都是2的幂次,容量,并发度,HashEntry大小
3.默认并发度16,加载因子0.75,HashEntry大小2
  • 如何提高性能并保证线程安全?
1.使用分段锁技术,Segment代表一个锁,不同Segment之间的加锁操作互不影响由此提高并发修改的线程
数来提高性能
2.既然Segemnt互不影响,不同Segment的写操作加锁自然就不存在线程安全问题,但是对于同一个Segement的写(删除)操作
需要加锁
  • 加锁的操作和过程细节?
1.put,remove,replace等写操作需要加锁
2.先尝试获取锁,不行就会自选尝试一定次数的加锁,如果还不成功,就会阻塞式获取锁
3.加锁中自旋次数是1或者64,取决于CPU核心数,并且会不断访问链表上的数据保证其在高速缓存提高性能
4.加锁自旋的过程,可能会将需要插入的节点构造好(等待锁的时候做点事情,不要干等着啊)
  • size操作
连续统计两次如果没有被修改,那就返回size,反之全局加锁,可能影响性能
  • 扩容
1.扩容只针对Segment里面的HashEntry数组扩容,并发度不会变,元素个数超过阈值就会触发
2.只会单线程扩容(jdk8可以多线程扩容)
3.扩容通过复制的方式,用到了一个优化手段,将链表尾部定位到一个桶的元素全部挂过去,不用一个
一个复制
  • ConcurrentHashMap的两个思想:懒加载和预创建
  • Segement数量:建议CPU核心数+1(不过会转换为2的幂次),过小导致锁冲突高,过大的话,每一个Segemnt里面的元素少,CPU缓存命中率降低(前面说的put操作的时候,那个自旋里面的遍历操作没意义了,试想Segment无限大,ConcurrentHashMap就是一个数组,这时候前面的遍历没有意义,不能将可能要访问的数据加入到缓存)

六、参考

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值