ConcurrentHashMap原理分析

集合是编程中最经常使用的数据结构。而谈到并发,几乎老是离不开集合这类高级数据结构的支持。好比两个线程须要同时访问一个中间临界区(Queue),好比常会用缓存做为外部文件的副本(HashMap)。这篇文章主要分析jdk1.5的3种并发集合类型(concurrent,copyonright,queue)中的ConcurrentHashMap,让咱们从原理上细致的了解它们,可以让咱们在深度项目开发中获益非浅。html

    经过分析Hashtable就知道,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap容许多个修改操做并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不一样部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不一样的部分,每一个段其实就是一个小的hash table,它们有本身的锁。只要多个修改操做发生在不一样的段上,它们就能够并发进行。
有些方法须要跨段,好比size()和containsValue(),它们可能须要锁定整个表而而不只仅是某个段,这须要按顺序锁定全部段,操做完毕后,又按顺序释放全部段的锁。这里“按顺序”是很重要的,不然极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,而且其成员变量实际上也是final的,可是,仅仅是将数组声明为final的并不保证数组成员也是final的,这须要实现上的保证。这能够确保不会出现死锁,由于得到锁的顺序是固定的。java

 1、结构解析node

   ConcurrentHashMap和Hashtable主要区别就是围绕着锁的粒度以及如何锁,能够简单理解成把一个大的HashTable分解成多个,造成了锁分离。如图:c++

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

2、应用场景编程

当有一个大数组时须要在多个线程共享时就能够考虑是否把它给分层多个节点了,避免大锁。并能够考虑经过hash算法进行一些模块定位。数组

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

3、源码解读安全

 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插入到链头,剩下的就很是容易理解了

修改操做还有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. }
相关文章
相关标签/搜索