ConcurrentHashMap原理深度分析

一、背景

线程不安全的HashMap

因为多环境下,使用HashMap进行put操作会引起循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。

效率低下的HashTable容器

HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常非常底下,因为当一个线程访问HashTable的同步方法时,可能会进入阻塞状态,如线程1使用put进行添加元素,线程2不但不能使用put添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈,效率越低下。

锁分段技术

HashTable容器在竞争激烈的并发环境下表现出来效率底下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器中有多把锁,每一把用于容器中一部分数据,那么当多线程访问容器时,线程间就不存在锁的竞争。从而可以有效的提高访问效率,这就是ConCurrentHashMap锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中数据的时候,其他段的数据也能被其他线程访问,有些方法需要跨段,比如size()和ContainsValue(),他们可能锁定整个表,而不是锁定仅仅某个段,者需要顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里按顺序很重要,否则极有可能出现死锁,在ConcurrentHashMap内部,段数据组是final的,并且器成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不能保证数据成员也是final的,这是需要实现上的保证,这可以确保不会出现死锁,因为获得锁的顺序是固定的。
在这里插入图片描述
ConcurrentHashMap是有segment数据结构和HashEntry数组结构组成,segment是一种可重入锁ReetrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据,一个ConcurrentHashMap里面包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构,一个Segment里面包含一个HashEntry数组,每个HashEntry是一个链表结构的元素。每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的segment锁。

二、应用场景

当有一个大数组时需要在多个线程共享时就可以考虑是否把它给分成多个节点,避免大锁。并可以考虑通过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代每个Hash链中的一个节点,其结构如下所示:

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

可以看到除了value不是final的,其它值都是final的。这意味不能从hash链的中间或者尾部添加或删除节点,因为这需要修改next引用值,所有的节点的修改只能从头部开始,对于put操作可以一律添加值hash链表的头部,但是对于remove操作,可能需要从中间删除一个节点,这就是需要将删除节点整个复制一边,最后一个节点指向要删除的下一个节点。这个在讲删除操作时还会详述,为了确保读操作能看到最新的值,将value设置成volatile,这避免了加锁。
其它
为了加快定位段以及段中hash槽的速度,每个hash槽的长度是2^n,这使得通过位运算可以定位段和段中hash槽的位置。当并发级别默认为值16时,也就是段的个数,hash值的高4位决定分配到那个段中,但是我们也不要忘记:hash槽的个数不应该是2^n,这可导致hash槽分配不均匀,这需要对hash值重新在hash一次。
定位操作:

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

既然ConcurrentHashMap使用分段segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过哈希算法定位到segment,可以看到ConcurrentHashMap会首先使用hash的变种算法对元素的hashCode进行一次再哈希。
再哈希,其目的是为了减少哈希冲突,是元素能够均匀的分布在不同的segment上,从而提高容器的存取效率,假如哈希的质量级别差到极点。那么所有的元素都在同一一个segment中,不仅存取元素缓慢,分段也就时区意义。我做了一个测试,不通过再哈希直接进行哈希计算。
System.out.println(Integer.parseInt(“0001111”, 2) & 15);
System.out.println(Integer.parseInt(“0011111”, 2) & 15);
System.out.println(Integer.parseInt(“0111111”, 2) & 15);
System.out.println(Integer.parseInt(“1111111”, 2) & 15);
计算后输出的哈希值全是15,通过这个例子可以发现如果不进行再哈希,哈希冲突会非常严重,因为只要低位一样,无论高位是什么数,其哈希值总是一样。我们再把上面的二进制数据进行再哈希后结果如下,为了方便阅读,不足32位的高位补了0,每隔四位用竖线分割下。

0100|0111|0110|0111|1101|1010|0100|1110
1111|0111|0100|0011|0000|0001|1011|1000
0111|0111|0110|1001|0100|0110|0011|1110
1000|0011|0000|0000|1100|1000|0001|1010

可以发现每一位的数据都散列开了,通过这种再哈希能让数字的每一位都能参加到哈希运算当中,从而减少哈希冲突。ConcurrentHashMap通过以下哈希算法定位segment。
默认情况下segmentShift为28,segmentMask为15,再哈希后的数最大是32位二进制数据,向右无符号移动28位,意思是让高4位参与到hash运算中, (hash >>> segmentShift) & segmentMask的运算结果分别是4,15,7和8,可以看到hash值没有发生冲突。
数据结构
所有的成员都是final的,其中segmentMask和segmentshfit主要是为了定位段,参见上面的segmentFor方法,关于Hash表的基础结构,这里不过度探讨,hash表的的一个重要方面就是如何解决hash冲突,ConcurrentHashMap 和 HashMap使用相同的方式,就是将hash值相同的节点放在一个hash链中。与hashMap不同的是,ConcurrentHashMap使用多个子hash表,也就是段(segment)。
每个segment相当一个子hash表,它的数据成员如下:

 /**
     * Stripped-down version of helper class used in previous version,
     * declared for the sake of serialization compatibility
     */
    static class Segment<K,V> extends ReentrantLock implements Serializable {
        private static final long serialVersionUID = 2249069246763182397L;
         //loadFactor表示负载因子。
        final float loadFactor;
        Segment(float lf) { this.loadFactor = lf; }
    }

删除操作remove(key)

/**
     * {@inheritDoc}
     *
     * @throws NullPointerException if the specified key is null
     */
  public V remove(Object key) {  
   hash = hash(key.hashCode());   
   return segmentFor(hash).remove(key, hash, null);   
}

整个操作是先定位到段,然后委托给段的remove操作。当多个删除操作并发进行时,只要它们所在的段不相同,它们就可以同时进行。
下面是Segment的remove方法实现:

V remove(Object key, int hash, Object value) {  
     lock();  
     try {  
         int c = count - 1;  
         HashEntry<K,V>[] tab = 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;  

                 // All entries following removed node can stay  
                 // in list, but all preceding ones need to be  
                 // cloned.  
                 ++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; // write-volatile  
             }  
         }  
         return oldValue;  
     } finally {  
         unlock();  
     }  
 }

整个操作是在持有段锁的情况下执行的,空白行之前的行主要是定位到要删除的节点e。接下来,如果不存在这个节点就直接返回null,否则就要将e前面的结点复制一遍,尾结点指向e的下一个结点。e后面的结点不需要复制,它们可以重用。
中间那个for循环是做什么用的呢?(*号标记)从代码来看,就是将定位之后的所有entry克隆并拼回前面去,但有必要吗?每次删除一个元素就要将那之前的元素克隆一遍?这点其实是由entry的不变性来决定的,仔细观察entry定义,发现除了value,其他所有属性都是用final来修饰的,这意味着在第一次设置了next域之后便不能再改变它,取而代之的是将它之前的节点全都克隆一次。至于entry为什么要设置为不变性,这跟不变性的访问不需要同步从而节省时间有关
下面是个示意图
在这里插入图片描述
在这里插入图片描述
 第二个图其实有点问题,复制的结点中应该是值为2的结点在前面,值为1的结点在后面,也就是刚好和原来结点顺序相反,还好这不影响我们的讨论。
整个remove实现并不复杂,但是需要注意如下几点。第一,当要删除的结点存在时,删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改。第二,remove执行的开始就将table赋给一个局部变量tab,这是因为table是 volatile变量,读写volatile变量的开销很大。编译器也不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量没有多大影响,编译器会做相应优化。
get操作
ConcurrentHashMap的get操作是直接委托给segment的get方法,直接看segment的get方法:

V get(Object key, int hash) {  
     if (count != 0) { // read-volatile 当前桶的数据个数是否为0 
         HashEntry<K,V> e = getFirst(hash);  得到头节点
         while (e != null) {  
             if (e.hash == hash && key.equals(e.key)) {  
                 V v = e.value;  
                 if (v != null)  
                     return v;  
                     /**
                     *
                     /
                 return readValueUnderLock(e); // recheck  
             }  
             e = e.next;  
         }  
     }  
     returnnull;  
 } 
V readValueUnderLock(HashEntry<K,V> e) {  
     lock();  
     try {  
         return e.value;  
     } finally {  
         unlock();  
     }  
 }

如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次。这似乎有些费解,理论上结点的值不可能为空,这是因为 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重新上锁再读一遍,以保证得到的是正确值。
put 操作
同样的普通操作也是委托段segment的put方法,下面是put方法:

V put(K key, int hash, V value, boolean onlyIfAbsent) {  
     lock();  
     try {  
         int c = count;  
         if (c++ > threshold) // ensure capacity  
             rehash();  
         HashEntry<K,V>[] tab = 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;  
         }  
         else {  
             oldValue = null;  
             ++modCount;  
             tab[index] = new HashEntry<K,V>(key, hash, first, value);  
             count = c; // write-volatile  
         }  
         return oldValue;  
     } finally {  
         unlock();  
     }  
 }

由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁,put方法首先定位segment,然后在segment里进行操作。插入操作需要经历两个步骤,第一不判断是否需要对segment里的HashEntry数组进行扩容,第二步定位添加元素的位置然后放进去HashEntry数组里。

containKey方法操作

//判断是否包含key
boolean containsKey(Object key, int hash) {  
     if (count != 0) { // read-volatile  
         HashEntry<K,V> e = getFirst(hash);  
         while (e != null) {  
             if (e.hash == hash && key.equals(e.key))  
                 return true;  
             e = e.next;  
         }  
     }  
     returnfalse;  
 } 

size()操作
如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有segment里元素的大小后求和,segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有segment的count相加就可以得到整个ConcurrentHashMap大小?不是的,虽然相加是可以获得segment的count的最新值,但是拿到后可能累加前使用的count值发生变化,那么结果就统计不准了。所以安全的做法,是在统计size的时候把segment的put,remove、clean方法全部锁住,但是这种做法效率特别低。
因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。

发布了21 篇原创文章 · 获赞 4 · 访问量 513

猜你喜欢

转载自blog.csdn.net/weixin_39617728/article/details/104856416