美文网首页
ConcurrentHashMap详细总结

ConcurrentHashMap详细总结

作者: niaoge2016 | 来源:发表于2016-07-28 21:03 被阅读599次

    上文详细介绍了HashMap的内容,但是由于HashMap不是线程安全的,因此使用起来会存在一定的风险。本文主要介绍另一个线程安全的类ConcurrentHashMap

    ConcurrentHashMap的内部存储结构

    ConcurrentHashMap 类中包含两个静态内部类 HashEntry 和 Segment。HashEntry 用来封装映射表的键 / 值对;Segment 用来充当锁的角色,每个 Segment 对象守护整个散列映射表的若干个桶。每个桶是由若干个 HashEntry 对象链接起来的链表。一个 ConcurrentHashMap 实例中包含由若干个 Segment 对象组成的数组。下面是它的结构示意图:

    ConcurrentHashMap结构示意图

    下面分别对上文提到的各个部分做具体的介绍:

    • HashEntry 类
      HashEntry 用来封装散列映射表中的键值对。在 HashEntry 类中,key,hash 和 next 域都被声明为 final 型,value 域被声明为 volatile 型。

    源代码如下:

        static final class HashEntry<K,V> { 
            final K key;                       // 声明 key 为 final 型
            final int hash;                   // 声明 hash 值为 final 型 
            volatile V value;                 // 声明 value 为 volatile 型
            final HashEntry<K,V> next;      // 声明 next 为 final 型 
    
            HashEntry(K key, int hash, HashEntry<K,V> next, V value) { 
                this.key = key; 
                this.hash = hash; 
                this.next = next; 
                this.value = value; 
            } 
     }
    

    在 ConcurrentHashMap 中,在散列时如果产生“碰撞”,将采用“分离链接法”来处理“碰撞”:把“碰撞”的 HashEntry 对象链接成一个链表。由于 HashEntry 的 next 域为 final 型,所以新节点只能在链表的表头处插入。 下图是在一个空桶中依次插入 A,B,C 三个 HashEntry 对象后的结构图:

    插入三个节点后桶的结构示意图

    *注:由于只能在表头插入,所以链表中节点的顺序和插入的顺序相反。

    • Segment 类
      Segment 类继承于 ReentrantLock 类,从而使得 Segment 对象能充当锁的角色。每个 Segment 对象用来守护其(成员对象 table 中)包含的若干个桶。
      table 是一个由 HashEntry 对象组成的数组。table 数组的每一个数组成员就是散列映射表的一个桶。
      count 变量是一个计数器,它表示每个 Segment 对象管理的 table 数组(若干个 HashEntry 组成的链表)包含的 HashEntry 对象的个数。每一个 Segment 对象都有一个 count 对象来表示本 Segment 中包含的 HashEntry 对象的总数。

    源代码如下:

        static final class Segment<K,V> extends ReentrantLock implements Serializable { 
            /** 
             * 在本 segment 范围内,包含的 HashEntry 元素的个数
             * 该变量被声明为 volatile 型
             */ 
            transient volatile int count; 
    
            /** 
             * table 被更新的次数
             */ 
            transient int modCount; 
    
            /** 
             * 当 table 中包含的 HashEntry 元素的个数超过本变量值时,触发 table 的再散列
             */ 
            transient int threshold; 
    
            /** 
             * table 是由 HashEntry 对象组成的数组
             * 如果散列时发生碰撞,碰撞的 HashEntry 对象就以链表的形式链接成一个链表
             * table 数组的数组成员代表散列映射表的一个桶
             * 每个 table 守护整个 ConcurrentHashMap 包含桶总数的一部分
             * 如果并发级别为 16,table 则守护 ConcurrentHashMap 包含的桶总数的 1/16 
             */ 
            transient volatile HashEntry<K,V>[] table; 
    
            /** 
             * 装载因子
             */ 
            final float loadFactor; 
    
            Segment(int initialCapacity, float lf) { 
                loadFactor = lf; 
                setTable(HashEntry.<K,V>newArray(initialCapacity)); 
            } 
    
            /** 
             * 设置 table 引用到这个新生成的 HashEntry 数组
             * 只能在持有锁或构造函数中调用本方法
             */ 
            void setTable(HashEntry<K,V>[] newTable) { 
                // 计算临界阀值为新数组的长度与装载因子的乘积
                threshold = (int)(newTable.length * loadFactor); 
                table = newTable; 
            } 
    
            /** 
             * 根据 key 的散列值,找到 table 中对应的那个桶(table 数组的某个数组成员)
             */ 
            HashEntry<K,V> getFirst(int hash) { 
                HashEntry<K,V>[] tab = table; 
                // 把散列值与 table 数组长度减 1 的值相“与”,
     // 得到散列值对应的 table 数组的下标
                // 然后返回 table 数组中此下标对应的 HashEntry 元素
                return tab[hash & (tab.length - 1)]; 
            } 
     }
    

    下图是依次插入 ABC 三个 HashEntry 节点后,Segment 的结构示意图

    插入三个节点后 Segment 的结构示意图
    • ConcurrentHashMap 类
      ConcurrentHashMap 在默认并发级别会创建包含 16 个 Segment 对象的数组。每个 Segment 的成员对象 table 包含若干个散列表的桶。每个桶是由 HashEntry 链接起来的一个链表。如果键能均匀散列,每个 Segment 大约守护整个散列表中桶总数的 1/16。

    源代码如下:

        public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> 
            implements ConcurrentMap<K, V>, Serializable { 
    
        /** 
         * 散列映射表的默认初始容量为 16,即初始默认为 16 个桶
         * 在构造函数中没有指定这个参数时,使用本参数
         */ 
        static final     int DEFAULT_INITIAL_CAPACITY= 16; 
    
        /** 
         * 散列映射表的默认装载因子为 0.75,该值是 table 中包含的 HashEntry 元素的个数与
     * table 数组长度的比值
         * 当 table 中包含的 HashEntry 元素的个数超过了 table 数组的长度与装载因子的乘积时,
     * 将触发 再散列
         * 在构造函数中没有指定这个参数时,使用本参数
         */ 
        static final float DEFAULT_LOAD_FACTOR= 0.75f; 
    
        /** 
         * 散列表的默认并发级别为 16。该值表示当前更新线程的估计数
         * 在构造函数中没有指定这个参数时,使用本参数
         */ 
        static final int DEFAULT_CONCURRENCY_LEVEL= 16; 
    
        /** 
         * segments 的掩码值
         * key 的散列码的高位用来选择具体的 segment 
         */ 
        final int segmentMask; 
    
        /** 
         * 偏移量
         */ 
        final int segmentShift; 
    
        /** 
         * 由 Segment 对象组成的数组
         */ 
        final Segment<K,V>[] segments; 
    
        /** 
         * 创建一个带有指定初始容量、加载因子和并发级别的新的空映射。
         */ 
        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; 
    
            // 寻找最佳匹配参数(不小于给定参数的最接近的 2 次幂) 
            int sshift = 0; 
            int ssize = 1; 
            while(ssize < concurrencyLevel) { 
                ++sshift; 
                ssize <<= 1; 
            } 
            segmentShift = 32 - sshift;       // 偏移量值
            segmentMask = ssize - 1;           // 掩码值 
            this.segments = Segment.newArray(ssize);   // 创建数组
    
            if (initialCapacity > MAXIMUM_CAPACITY) 
                initialCapacity = MAXIMUM_CAPACITY; 
            int c = initialCapacity / ssize; 
            if(c * ssize < initialCapacity) 
                ++c; 
            int cap = 1; 
            while(cap < c) 
                cap <<= 1; 
    
            // 依次遍历每个数组元素
            for(int i = 0; i < this.segments.length; ++i) 
                // 初始化每个数组元素引用的 Segment 对象
               this.segments[i] = new Segment<K,V>(cap, loadFactor); 
        } 
    
        /** 
         * 创建一个带有默认初始容量 (16)、默认加载因子 (0.75) 和 默认并发级别 (16) 
      * 的空散列映射表。
         */ 
        public ConcurrentHashMap() { 
            // 使用三个默认参数,调用上面重载的构造函数来创建空散列映射表
           this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); 
        }
    }
    

    ConcurrentHashMap重要方法详细分析

    • 用分离锁实现多个线程间的并发写操作
      在 ConcurrentHashMap 中,线程对映射表做读操作时,一般情况下不需要加锁就可以完成,对容器做结构性修改的操作才需要加锁。下面以 put 操作为例说明对 ConcurrentHashMap 做结构性修改的过程。
      首先,根据 key 计算出对应的 hash 值:
      public V put(K key, V value) { 
            if (value == null)          //ConcurrentHashMap 中不允许用 null 作为映射值
                throw new NullPointerException(); 
            int hash = hash(key.hashCode());        // 计算键对应的散列码
            // 根据散列码找到对应的 Segment 
            return segmentFor(hash).put(key, hash, value, false); 
     }
    

    然后,根据 hash 值找到对应的Segment 对象:

        /** 
         * 使用 key 的散列码来得到 segments 数组中对应的 Segment 
         */ 
     final Segment<K,V> segmentFor(int hash) { 
        // 将散列值右移 segmentShift 个位,并在高位填充 0 
        // 然后把得到的值与 segmentMask 相“与”
        // 从而得到 hash 值对应的 segments 数组的下标值
        // 最后根据下标值返回散列码对应的 Segment 对象
            return segments[(hash >>> segmentShift) & segmentMask]; 
     }
    

    最后,在这个 Segment 中执行具体的 put 操作:

        V put(K key, int hash, V value, boolean onlyIfAbsent) { 
                lock();  // 加锁,这里是锁定某个 Segment 对象而非整个 ConcurrentHashMap 
                try { 
                    int c = count; 
    
                    if (c++ > threshold)     // 如果超过再散列的阈值
                        rehash();              // 执行再散列,table 数组的长度将扩充一倍
    
                    HashEntry<K,V>[] tab = table; 
                    // 把散列码值与 table 数组的长度减 1 的值相“与”
                    // 得到该散列码对应的 table 数组的下标值
                    int index = hash & (tab.length - 1); 
                    // 找到散列码对应的具体的那个桶
                    HashEntry<K,V> first = tab[index]; 
    
                    HashEntry<K,V> e = first; 
                    while (e != null && (e.hash != hash || !key.equals(e.key))) 
                        e = e.next; 
    
                    V oldValue; 
                    if (e != null) {            // 如果键 / 值对以经存在
                        oldValue = e.value; 
                        if (!onlyIfAbsent) 
                            e.value = value;    // 设置 value 值
                    } 
                    else {                        // 键 / 值对不存在 
                        oldValue = null; 
                        ++modCount;         // 要添加新节点到链表中,所以 modCont 要加 1  
                        // 创建新节点,并添加到链表的头部 
                        tab[index] = new HashEntry<K,V>(key, hash, first, value); 
                        count = c;               // 写 count 变量
                    } 
                    return oldValue; 
                } finally { 
                    unlock();                     // 解锁
                } 
            }
    

    注:这里的加锁操作是针对(键的 hash 值对应的)某个具体的 Segment,锁定的是该 Segment 而不是整个 ConcurrentHashMap*。因为插入键 / 值对操作只是在这个 Segment 包含的某个桶中完成,不需要锁定整个ConcurrentHashMap。此时,其他写线程对另外 15 个Segment 的加锁并不会因为当前线程对这个 Segment 的加锁而阻塞。同时,所有读线程几乎不会因本线程的加锁而阻塞(除非读线程刚好读到这个 Segment 中某个 HashEntry 的 value 域的值为 null,此时需要加锁后重新读取该值)。

    • 用 HashEntery 对象的不变性来降低读操作对加锁的需求
      下面我们分别来分析线程写入的两种情形:对散列表做非结构性修改的操作和对散列表做结构性修改的操作。
      非结构性修改操作只是更改某个 HashEntry 的 value 域的值。由于对 Volatile 变量的写入操作将与随后对这个变量的读操作进行同步。当一个写线程修改了某个 HashEntry 的 value 域后,另一个读线程读这个值域,Java 内存模型能够保证读线程读取的一定是更新后的值。所以,写线程对链表的非结构性修改能够被后续不加锁的读线程“看到”。
      对 ConcurrentHashMap 做结构性修改,实质上是对某个桶指向的链表做结构性修改。如果能够确保:在读线程遍历一个链表期间,写线程对这个链表所做的结构性修改不影响读线程继续正常遍历这个链表。那么读 / 写线程之间就可以安全并发访问这个 ConcurrentHashMap。
      结构性修改操作包括 put,remove,clear。下面我们分别分析这三个操作。
      clear 操作只是把 ConcurrentHashMap 中所有的桶“置空”,每个桶之前引用的链表依然存在,只是桶不再引用到这些链表(所有链表的结构并没有被修改)。正在遍历某个链表的读线程依然可以正常执行对该链表的遍历。
      从上面的代码清单“在 Segment 中执行具体的 put 操作”中,我们可以看出:put 操作如果需要插入一个新节点到链表中时 , 会在链表头部插入这个新节点。此时,链表中的原有节点的链接并没有被修改。也就是说:插入新健 / 值对到链表中的操作不会影响读线程正常遍历这个链表。
      下面来分析 remove 操作,先让我们来看看 remove 操作的源代码实现。
        V remove(Object key, int hash, Object value) { 
                lock();         // 加锁
                try{ 
                    int c = count - 1; 
                    HashEntry<K,V>[] tab = table; 
                    // 根据散列码找到 table 的下标值
                    int index = hash & (tab.length - 1); 
                    // 找到散列码对应的那个桶
                    HashEntry<K,V> first = tab[index]; 
                    HashEntry<K,V> e = first; 
                    while(e != null&& (e.hash != hash || !key.equals(e.key))) 
                        e = e.next; 
    
                    V oldValue = null; 
                    if(e != null) { 
                        V v = e.value; 
                        if(value == null|| value.equals(v)) { // 找到要删除的节点
                            oldValue = v; 
                            ++modCount; 
                            // 所有处于待删除节点之后的节点原样保留在链表中
                            // 所有处于待删除节点之前的节点被克隆到新链表中
                            HashEntry<K,V> newFirst = e.next;// 待删节点的后继结点
                            for(HashEntry<K,V> p = first; p != e; p = p.next) 
                                newFirst = new HashEntry<K,V>(p.key, p.hash, 
                                                              newFirst, p.value); 
                            // 把桶链接到新的头结点
                            // 新的头结点是原链表中,删除节点之前的那个节点
                            tab[index] = newFirst; 
                            count = c;      // 写 count 变量
                        } 
                    } 
                    return oldValue; 
                } finally{ 
                    unlock();               // 解锁
                } 
            }
    

    和 get 操作一样,首先根据散列码找到具体的链表;然后遍历这个链表找到要删除的节点;最后把待删除节点之后的所有节点原样保留在新链表中,把待删除节点之前的每个节点克隆到新链表中。下面通过图例来说明 remove 操作。
    假设写线程执行 remove 操作,要删除链表的 C 节点,另一个读线程同时正在遍历这个链表。

    执行删除之前的原链表 执行删除之后的新链表

    从上图可以看出,删除节点 C 之后的所有节点原样保留到新链表中;删除节点 C 之前的每个节点被克隆到新链表中。
    注意:它们在新链表中的链接顺序被反转了
    在执行 remove 操作时,原始链表并没有被修改,也就是说:读线程不会受同时执行 remove 操作的并发写线程的干扰。
    综合上面的分析我们可以看出,写线程对某个链表的结构性修改不会影响其他的并发读线程对这个链表的遍历访问。

    ConcurrentHashMap常考问题总结

    (1)HashMap和ConcurrentHashMap的区别

    1. HashMap是非线程安全的,ConcurrentHashMap是线程安全的。
    2. ConcurrentHashMap将整个Hash桶进行了分段segment,也就是将整个大的数组分成了几个小的分段segment,而且每个segment上都有锁存在,在插入的时候只需要对具体哪个部分的segment进行加锁即可。
    3. ConcurrentHashMap让锁的粒度更加精确,并发性能更好。

    (2)ConcurrentHashMap如何让保证线程安全
    参考上文 ConcurrentHashMap重要方法详细分析 部分的解析。

    参考资料

    [1]探索 ConcurrentHashMap 高并发性的实现机制
    http://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/index.html

    相关文章

      网友评论

          本文标题:ConcurrentHashMap详细总结

          本文链接:https://www.haomeiwen.com/subject/qdnajttx.html