并发编程之ConcurrentHashMap 源码解析

ConcurrentHashMap的实现原理

一、为什么要使用ConcurrentHashMap

  1. 实际工作中hash表是用使用很频繁的一种存储技术,常使用的是HashMap 和HashTable,但是在多线程环境下,使用HashMap会导致死循环,导致cpu接近100%,死循环的原因是多线程会导致HashMap的Entry链表形成环状数据结构,(一般是扩容时resize操作导致)一旦形成环状数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。
  2. HashTable的低效率性,HashTable的实现原理跟HashMap类似,HashTable的key和value不允许为null,且所有get和put操作,都加上了Synchronized锁,如果执行put操作,会锁住整个表,在多线程环境下,频繁的切换锁,串行化执行,效率较很差。
  3. ConcurrentHashMap的出现就是把HashTable的整个锁,给分成一个一个的段Segment,锁与锁之间是独立的,每个锁负责维护一部分HashEntry,当一个线程访问其中的一个段数据时,其他线程也可以访问其他段的数据。

二、ConcurrentHashMap 源码解析
ConcurrentHashMap的主干结构就是Segment,以前是Segment的主要字段定义

static final class Segment<K,V> extends ReentrantLock implements Serializable {
		transient volatile HashEntry<K,V>[] table;// 链表数组,
        transient int count;	// Segment中元素的个数
        transient int modCount; // 操作table大小的次数
        transient int threshold;  // 用于扩容的阀值
        final float loadFactor;  // 负载因子

}

HashEntry数组为储存元素的最小单元,一个Segmeng维护一个HashEntry数组。

static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;
        }

可以看到HashEntry的一个特点,除了value以外,其他的几个变量都是final的,这样做是为了防止链表结构被破坏,出现ConcurrentModification的情况。

ConcurrentHashMap初始化

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;
        // Find power-of-two sizes best matching arguments
         *// **2的sshift  等于 ssize,***,代表ssize左移的次数
        int sshift = 0;
        // ssize为segment的长度,根据concurrencyLevel  计算得出
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        //segmentShift和segmentMask这两个变量在定位segment时会用到
        //segmentShift 用来定位参与散列运算的位数
        // segmentMask 是散列运算的掩码
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
            //计算cap的大小,即Segment中HashEntry的数组长度,cap也一定为2的n次方
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        // 创建segment 数组,并初始化一个s0.
        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;
    }

initialCapacity 初始化容量,
loadFactor 负载因子,
concurrencyLevel 代表ConcurrentHashMap内部Segment的数量,默认为16,concurrencyLevel 一经指定,是不可变的,也就说初始化Segment的数量就是一定的,如果ConcurrentHashMap需要扩容,只需要对Segment中的HashEntry数组进行扩容就行,这样做的好处是不需要对整个ConcurrentHashMap进行rehash,只需要对Segment中的元素rehash。
concurrencyLevel 的最大值是 1 << 16,最大值是为65535,也就是最大的并发数65535,
Segment数组的大小 ssize 是由 concurrentLevel 来决定的,但是却不一定等于concurrentLevel,ssize一定是大于或等于concurrentLevel的最小的2的次幂。比如:默认情况下concurrentLevel是16,则ssize为16;若concurrentLevel为14,ssize为16;若concurrentLevel为17,则ssize为32。为什么Segment的数组大小一定是2的次幂?其实主要是便于通过按位与的散列算法来定位Segment的index。
初始化过程总结:主要是通过初始值、负载因子、并发级别,确定Segment的大小。

三、定位Segment
能够快速的定位Segment,有利于快速的存取元素。
由ConcurrentMent初始化的时候,可知我们得到了全局变量 segmentShift :2的sshift次方等于ssize,segmentShift=32-sshift。若segments长度为16,segmentShift=32-4=28;若segments长度为32,segmentShift=32-5=27。而计算得出的hash值最大为32位,无符号右移segmentShift,则意味着只保留高几位(其余位是没用的),然后与段掩码segmentMask位运算来定位Segment。
segmentMask :段掩码,,假如segments数组长度为16,则段掩码为16-1=15;segments长度为32,段掩码为32-1=31。这样得到的所有bit位都为1,可以更好地保证散列的均匀性
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
由此可知ConcurrentHashMap 在put元素时会经过两次hash,第二次hash有利用减少冲突,使元素能够均匀的分布在Segment上。

四、put方法
put方法分为两步,一是 定位Segment,然后往Segment中put元素。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            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;
        }

Segment的put 也分为两步,一是先判断是否需要对HashEntry进行扩容,二是定位要添加元素的位置,然后将其放在HashEntry里。
1、判断扩容
在插入元素前先判断Segment里HashEntry数组是否超过容量,如果超过阀值,则扩容
2、如何扩容
先把元素的数组扩容为容量的两倍,然后把原数组的元素,通过再散列插入大新数组,只会扩容其中的一个Segment。

五、get方法
get方式源码就不贴了,可自行在jdk1.7环境下查看,get操作不需要加锁,get方法中获取的变量都是用volatile修饰的,volatile可以在多线程环境下保证内存的可见性,所以不会读到过期数据。
六、remove方法
remove方法首先也需要确定元素位置,然后将待删除元素位置前的元素,统一复制一遍,重新一个一个的插入新的链表中,
七、size方法
Segment的全局变量count是一个volatile类型的,虽然在多线程环境下可保证可见性,但是统计每一个Segment的大小,需要想加,加的过程中count 可能发生变化。所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment的大小,如果统计过程中,count发生了变化,则再采用加锁的方式来统计。如何判断容器发生了变化,通过比较modCount 和size的大小。

八、总结
ConcurrentHashMap在多线程环境下,具有很高的并发能力,能够提高生产效率。

发布了11 篇原创文章 · 获赞 11 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/love_gzd/article/details/86658633