• Java集合---ConcurrentHashMap原理分析


     

     集合是编程中最经常使用的数据结构。而谈到并发,差点儿总是离不开集合这类高级数据结构的支持。比方两个线程须要同一时候訪问一个中间临界区(Queue)。比方常会用缓存作为外部文件的副本(HashMap)。

    这篇文章主要分析jdk1.5的3种并发集合类型(concurrent,copyonright,queue)中的ConcurrentHashMap,让我们从原理上细致的了解它们,能够让我们在深度项目开发中获益非浅。

        通过分析Hashtable就知道,synchronized是针对整张Hash表的。即每次锁住整张表让线程独占,ConcurrentHashMap同意多个改动操作并发进行。其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的改动。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每一个段事实上就是一个小的hash table,它们有自己的锁。

    仅仅要多个改动操作发生在不同的段上。它们就能够并发进行。
    有些方法须要跨段,比方size()和containsValue(),它们可能须要锁定整个表而而不仅仅是某个段,这须要按顺序锁定全部段,操作完成后,又按顺序释放全部段的锁。这里“按顺序”是非常重要的。否则极有可能出现死锁,在ConcurrentHashMap内部。段数组是final的,而且其成员变量实际上也是final的。可是,仅仅是将数组声明为final的并不保证数组成员也是final的,这须要实现上的保证。这能够确保不会出现死锁,由于获得锁的顺序是固定的。

     一、结构解析

       ConcurrentHashMap和Hashtable主要差别就是环绕着锁的粒度以及怎样锁,能够简单理解成把一个大的HashTable分解成多个。形成了锁分离。

    如图:

    而Hashtable的实现方式是---锁整个hash表

    二、应用场景

    当有一个大数组时须要在多个线程共享时就能够考虑是否把它给分层多个节点了。避免大锁。

    并能够考虑通过hash算法进行一些模块定位。

    事实上不止用于线程,当设计数据表的事务时(事务某种意义上也是同步机制的体现),能够把一个表看成一个须要同步的数组。假设操作的表数据太多时就能够考虑事务分离了(这也是为什么要避免大表的出现)。比方把数据进行字段拆分,水平分表等.

    三、源代码解读

     ConcurrentHashMap中主要实体类就是三个:ConcurrentHashMap(整个Hash表),Segment(桶)。HashEntry(节点),相应上面的图能够看出之间的关系

    /** 
    * The segments, each of which is a specialized hash table 
    */  
    final Segment<K,V>[] segments; 

    不变(Immutable)和易变(Volatile)
    ConcurrentHashMap全然同意多个读操作并发进行。读操作并不须要加锁。假设使用传统的技术,如HashMap中的实现。假设同意能够在hash链的中间加入或删除元素。读操作不加锁将得到不一致的数据。ConcurrentHashMap实现技术是保证HashEntry差点儿是不可变的。

    HashEntry代表每一个hash链中的一个节点,其结构例如以下所看到的:

     

    1. static final class HashEntry<K,V> {  
    2.     final K key;  
    3.     final int hash;  
    4.     volatile V value;  
    5.     final HashEntry<K,V> next;  
    6. }  

     

    能够看到除了value不是final的。其他值都是final的,这意味着不能从hash链的中间或尾部加入或删除节点,由于这须要改动next 引用值,全部的节点的改动仅仅能从头部開始。对于put操作,能够一律加入到Hash链的头部。可是对于remove操作,可能须要从中间删除一个节点,这就须要将要删除节点的前面全部节点整个复制一遍。最后一个节点指向要删除结点的下一个结点。这在解说删除操作时还会详述。为了确保读操作能够看到最新的值。将value设置成volatile。这避免了加锁。


    其他
    为了加快定位段以及段中hash槽的速度。每一个段hash槽的的个数都是2^n,这使得通过位运算就能够定位段和段中hash槽的位置。当并发级别为默认值16时。也就是段的个数,hash值的高4位决定分配在哪个段中。可是我们也不要忘记《算法导论》给我们的教训:hash槽的的个数不应该是 2^n。这可能导致hash槽分配不均。这须要对hash值又一次再hash一次。(这段似乎有点多余了 )

     

    这是定位段的方法:

    1. final Segment<K,V> segmentFor(int hash) {  
    2.     return segments[(hash >>> segmentShift) & segmentMask];  
    3. } 

    数据结构
    关于Hash表的基础数据结构。这里不想做过多的探讨。

    Hash表的一个非常重要方面就是怎样解决hash冲突,ConcurrentHashMap 和HashMap使用相同的方式,都是将hash值相同的节点放在一个hash链中。与HashMap不同的是,ConcurrentHashMap使用多个子Hash表,也就是段(Segment)。

    以下是ConcurrentHashMap的数据成员:

     

    复制代码
    1. public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>  
    2.         implements ConcurrentMap<K, V>, Serializable {  
    3.     /** 
    4.      * Mask value for indexing into segments. The upper bits of a 
    5.      * key's hash code are used to choose the segment. 
    6.      */  
    7.     final int segmentMask;  
    8.   
    9.     /** 
    10.      * Shift value for indexing within segments. 
    11.      */  
    12.     final int segmentShift;  
    13.   
    14.     /** 
    15.      * The segments, each of which is a specialized hash table 
    16.      */  
    17.     final Segment<K,V>[] segments;  
    18. } 
    复制代码

     

    全部的成员都是final的。当中segmentMask和segmentShift主要是为了定位段。參见上面的segmentFor方法。


    每一个Segment相当于一个子Hash表,它的数据成员例如以下:

     

    复制代码
    1.     static final class Segment<K,V> extends ReentrantLock implements Serializable {  
    2. private static final long serialVersionUID = 2249069246763182397L;  
    3.         /** 
    4.          * The number of elements in this segment's region. 
    5.          */  
    6.         transient volatile int count;  
    7.   
    8.         /** 
    9.          * Number of updates that alter the size of the table. This is 
    10.          * used during bulk-read methods to make sure they see a 
    11.          * consistent snapshot: If modCounts change during a traversal 
    12.          * of segments computing size or checking containsValue, then 
    13.          * we might have an inconsistent view of state so (usually) 
    14.          * must retry. 
    15.          */  
    16.         transient int modCount;  
    17.   
    18.         /** 
    19.          * The table is rehashed when its size exceeds this threshold. 
    20.          * (The value of this field is always <tt>(int)(capacity * 
    21.          * loadFactor)</tt>.) 
    22.          */  
    23.         transient int threshold;  
    24.   
    25.         /** 
    26.          * The per-segment table. 
    27.          */  
    28.         transient volatile HashEntry<K,V>[] table;  
    29.   
    30.         /** 
    31.          * The load factor for the hash table.  Even though this value 
    32.          * is same for all segments, it is replicated to avoid needing 
    33.          * links to outer object. 
    34.          * @serial 
    35.          */  
    36.         final float loadFactor;  
    37. } 
    复制代码

     

    count用来统计该段数据的个数,它是volatile(volatile 变量使用指南),它用来协调改动和读取操作。以保证读取操作能够读取到差点儿最新的改动。协调方式是这种,每次改动操作做了结构上的改变,如添加/删除节点(改动节点的值不算结构上的改变)。都要写count值,每次读取操作開始都要读取count的值。

    这利用了 Java 5中对volatile语义的增强,对同一个volatile变量的写和读存在happens-before关系。modCount统计段结构改变的次数,主要是为了检測对多个段进行遍历过程中某个段是否发生改变,在讲述跨段操作时会还会详述。threashold用来表示须要进行rehash的界限值。

    table数组存储段中节点。每一个数组元素是个hash链,用HashEntry表示。table也是volatile,这使得能够读取到最新的 table值而不须要同步。loadFactor表示负载因子。

    先来看下删除操作remove(key)。

     

    复制代码
    1. public V remove(Object key) {  
    2.  hash = hash(key.hashCode());  
    3.     return segmentFor(hash).remove(key, hash, null);  
    4. }  
    整个操作是先定位到段,然后托付给段的remove操作。

    当多个删除操作并发进行时,仅仅要它们所在的段不相同,它们就能够同一时候进行。以下是Segment的remove方法实现: 1. V remove(Object key, int hash, Object value) { 2. lock(); 3. try { 4. int c = count - 1; 5. HashEntry<K,V>[] tab = table; 6. int index = hash & (tab.length - 1); 7. HashEntry<K,V> first = tab[index]; 8. HashEntry<K,V> e = first; 9. while (e != null && (e.hash != hash || !key.equals(e.key))) 10. e = e.next; 11. 12. V oldValue = null; 13. if (e != null) { 14. V v = e.value; 15. if (value == null || value.equals(v)) { 16. oldValue = v; 17. // All entries following removed node can stay 18. // in list, but all preceding ones need to be 19. // cloned. 20. ++modCount; 21. HashEntry<K,V> newFirst = e.next; 22. *for (HashEntry<K,V> p = first; p != e; p = p.next) 23. *newFirst = new HashEntry<K,V>(p.key, p.hash, 24. newFirst, p.value); 25. tab[index] = newFirst; 26. count = c; // write-volatile 27. } 28. } 29. return oldValue; 30. } finally { 31. unlock(); 32. } 33. }

    复制代码

     

    整个操作是在持有段锁的情况下运行的,空白行之前的行主要是定位到要删除的节点e。接下来,假设不存在这个节点就直接返回null,否则就要将e前面的结点复制一遍。尾结点指向e的下一个结点。e后面的结点不须要复制。它们能够重用。

    中间那个for循环是做什么用的呢?(*号标记)从代码来看。就是将定位之后的全部entry克隆并拼回前面去,但有必要吗?每次删除一个元素就要将那之前的元素克隆一遍?这点事实上是由entry的不变性来决定的。细致观察entry定义,发现除了value,其他全部属性都是用final来修饰的,这意味着在第一次设置了next域之后便不能再改变它,取而代之的是将它之前的节点全都克隆一次。至于entry为什么要设置为不变性。这跟不变性的訪问不须要同步从而节省时间有关

    以下是个示意图

    删除元素之前:

    删除元素3之后:

     

    第二个图事实上有点问题,复制的结点中应该是值为2的结点在前面,值为1的结点在后面,也就是刚好和原来结点顺序相反,还好这不影响我们的讨论。

    整个remove实现并不复杂,可是须要注意例如以下几点。第一,当要删除的结点存在时。删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性改动。第二。remove运行的開始就将table赋给一个局部变量tab。这是由于table是 volatile变量,读写volatile变量的开销非常大。编译器也不能对volatile变量的读写做不论什么优化。直接多次訪问非volatile实例变量没有多大影响,编译器会做相应优化。



    接下来看put操作,相同地put操作也是托付给段的put方法。以下是段的put方法:

     

    复制代码
    1. V put(K key, int hash, V value, boolean onlyIfAbsent) {  
    2.     lock();  
    3.     try {  
    4.         int c = count;  
    5.         if (c++ > threshold) // ensure capacity  
    6.             rehash();  
    7.         HashEntry<K,V>[] tab = table;  
    8.         int index = hash & (tab.length - 1);  
    9.         HashEntry<K,V> first = tab[index];  
    10.         HashEntry<K,V> e = first;  
    11.         while (e != null && (e.hash != hash || !key.equals(e.key)))  
    12.             e = e.next;  
    13.   
    14.         V oldValue;  
    15.         if (e != null) {  
    16.             oldValue = e.value;  
    17.             if (!onlyIfAbsent)  
    18.                 e.value = value;  
    19.         }  
    20.         else {  
    21.             oldValue = null;  
    22.             ++modCount;  
    23.             tab[index] = new HashEntry<K,V>(key, hash, first, value);  
    24.             count = c; // write-volatile  
    25.         }  
    26.         return oldValue;  
    27.     } finally {  
    28.         unlock();  
    29.     }  
    30. } 
    复制代码

     

    该方法也是在持有段锁(锁定整个segment)的情况下运行的,这当然是为了并发的安全,改动数据是不能并发进行的,必须得有个推断是否超限的语句以确保容量不足时能够rehash。接着是找是否存在相同一个key的结点。假设存在就直接替换这个结点的值。否则创建一个新的结点并加入到hash链的头部,这时一定要改动modCount和count的值,相同改动count的值一定要放在最后一步。put方法调用了rehash方法,reash方法实现得也非常静止,主要利用了table的大小为2^n,这里就不介绍了。而比較难懂的是这句int index = hash & (tab.length - 1)。原来segment里面才是真正的hashtable,即每一个segment是一个传统意义上的hashtable,如上图。从两者的结构就能够看出差别,这里就是找出须要的entry在table的哪一个位置,之后得到的entry就是这个链的第一个节点,假设e!=null,说明找到了。这是就要替换节点的值(onlyIfAbsent == false)。否则。我们须要new一个entry,它的后继是first,而让tab[index]指向它,什么意思呢?实际上就是将这个新entry插入到链头,剩下的就非常easy理解了

    改动操作还有putAll和replace。putAll就是多次调用put方法,没什么好说的。replace甚至不用做结构上的更改,实现要比put和delete要简单得多。理解了put和delete。理解replace就不在话下了,这里也不介绍了。
    获取操作
    首先看下get操作,相同ConcurrentHashMap的get操作是直接托付给Segment的get方法,直接看Segment的get方法:

     

    复制代码
    1. V get(Object key, int hash) {  
    2.     if (count != 0) { // read-volatile 当前桶的数据个数是否为0 
    3.         HashEntry<K,V> e = getFirst(hash);  得到头节点
    4.         while (e != null) {  
    5.             if (e.hash == hash && key.equals(e.key)) {  
    6.                 V v = e.value;  
    7.                 if (v != null)  
    8.                     return v;  
    9.                 return readValueUnderLock(e); // recheck  
    10.             }  
    11.             e = e.next;  
    12.         }  
    13.     }  
    14.     return null;  
    15. }  
    复制代码

     

    get操作不须要锁。

    第一步是訪问count变量,这是一个volatile变量。由于全部的改动操作在进行结构改动时都会在最后一步写count 变量,通过这种机制保证get操作能够得到差点儿最新的结构更新。对于非结构更新,也就是结点值的改变。由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。接下来就是依据hash和key对hash链进行遍历找到要获取的结点,假设没有找到,直接訪回null。对hash链进行遍历不须要加锁的原因在于链指针next是final的。可是头指针却不是final的,这是通过getFirst(hash)方法返回,也就是存在 table数组中的值。这使得getFirst(hash)可能返回过时的头结点,比如,当运行get方法时。刚运行完getFirst(hash)之后,还有一个线程运行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。

    这是能够同意。通过对count变量的协调机制。get能读取到差点儿最新的数据,尽管可能不是最新的。要得到最新的数据,仅仅有採用全然的同步。

    最后,假设找到了所求的结点。推断它的值假设非空就直接返回,否则在有锁的状态下再读一次。

    这似乎有些费解,理论上结点的值不可能为空。这是由于 put的时候就进行了推断,假设为空就要抛NullPointerException。空值的唯一源头就是HashEntry中的默认值,由于 HashEntry中的value不是final的,非同步读取有可能读取到空值。细致看下put操作的语句:tab[index] = new HashEntry<K,V>(key, hash, first, value),在这条语句中,HashEntry构造函数中对value的赋值以及对tab[index]的赋值可能被又一次排序。这就可能导致结点的值为空。这里当v为空时。可能是一个线程正在改变节点,而之前的get操作都未进行锁定,依据bernstein条件,读后写或写后读都会引起数据的不一致,所以这里要对这个e又一次上锁再读一遍,以保证得到的是正确值。

    复制代码
    1. V readValueUnderLock(HashEntry<K,V> e) {  
    2.     lock();  
    3.     try {  
    4.         return e.value;  
    5.     } finally {  
    6.         unlock();  
    7.     }  
    8. }
    复制代码

    还有一个操作是containsKey,这个实现就要简单得多了,由于它不须要读取值:

     

    复制代码
    1. boolean containsKey(Object key, int hash) {  
    2.     if (count != 0) { // read-volatile  
    3.         HashEntry<K,V> e = getFirst(hash);  
    4.         while (e != null) {  
    5.             if (e.hash == hash && key.equals(e.key))  
    6.                 return true;  
    7.             e = e.next;  
    8.         }  
    9.     }  
    10.     return false;  
    11. } 
    复制代码
  • 相关阅读:
    webkit webApp 开发技术要点总结
    EJB 教程推荐
    MySQL 教程分享
    php 教程列表
    html 学习资料列表
    JAVA 教程推荐
    php+mysql预查询prepare 与普通查询的性能对比
    Spring 5 新特性:函数式Web框架
    Java多线程之并发协作生产者消费者设计模式
    php使用file函数、fseek函数读取大文件效率分析
  • 原文地址:https://www.cnblogs.com/zhchoutai/p/8592102.html
Copyright © 2020-2023  润新知