Java泛型底层源码解析--ConcurrentHashMap(JDK1.7)

1. Concurrent相关历史

JDK5中添加了新的concurrent包,相对同步容器而言,并发容器通过一些机制改进了并发性能。因为同步容器将所有对容器状态的访问都串行化了,这样保证了线程的安全性,所以这种方法的代价就是严重降低了并发性,当多个线程竞争容器(bins)时,吞吐量严重降低。因此Java5.0开始针对多线程并发访问设计,提供了并发性能较好的并发容器,引入了Java.util.concurrent包,在线程安全的基础上提供了更好的写并发能力,但同时降低了对读一致性的要求(这点挺符合CAP理论的)。与Vector和Hashtable、Collections.synchronizedXxx()同步容器等相比,util.concurrent中引入的并发容器主要解决了两个问题: 

1)根据具体场景进行设计,尽量避免synchronized,提供并发性。

2)定义了一些并发安全的复合操作,并且保证并发环境下的迭代操作不会出错。

util.concurrent中容器在迭代时,可以不封装在synchronized中,可以保证不抛异常,但是未必每次看到的都是"最新的、当前的"数据。

并发编程实践中,ConcurrentHashMap是一个经常被使用的数据结构,它的设计与实现非常精巧,大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响,无论对于Java并发编程的学习还是Java内存模型的理解,ConcurrentHashMap的设计以及源码都值得非常仔细的阅读与揣摩。

2. 对比说明

HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,即每次锁住整张表让线程独占,相当于所有线程进行读写时都去竞争一把锁,导致效率非常低下。ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的Hashtable,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。

有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。

3. CouncurrentHashMap实现原理

ConcurrentHashMap为了提高本身的并发能力,在内部采用了一个叫做Segment的结构,一个Segment其实就是一个类HashTable的结构,Segment内部维护了一个链表数组,我们用下面这一幅图来看下ConcurrentHashMap的内部结构详情图:

 

从宏观上来看,大体结构图可以简单描绘为如下:

  不难看出,ConcurrentHashMap采用了二次hash的方式,第一次hash将key映射到对应的segment,而第二次hash则是映射到segment的不同桶(bucket)中。

  为什么要用二次hash,主要原因是为了构造分离锁,使得对于map的修改不会锁住整个容器,提高并发能力。当然,没有一种东西是绝对完美的,二次hash带来的问题是整个hash的过程比hashmap单次hash要长,所以,如果不是并发情形,不要使用concurrentHashmap。

 4. 源代码解析

Segment

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile int count;    //Segment中元素的数量
    transient int modCount;          //对table的大小造成影响的操作的数量(比如put或者remove操作)
    transient int threshold;        //阈值,Segment里面元素的数量超过这个值那么就会对Segment进行扩容
    final float loadFactor;         //负载因子,用于确定threshold
    transient volatile HashEntry<K,V>[] table;    //链表数组,数组中的每一个元素代表了一个链表的头部
}

HashEntry

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

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

ConcurrentHashMap构造函数

 1 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
 2     if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
 3         throw new IllegalArgumentException();
 4   
 5     if (concurrencyLevel > MAX_SEGMENTS)
 6         concurrencyLevel = MAX_SEGMENTS;
 7   
 8     // Find power-of-two sizes best matching arguments
 9     int sshift = 0;
10     int ssize = 1;
11     while (ssize < concurrencyLevel) {
12         ++sshift;
13         ssize <<= 1;
14     }
15     segmentShift = 32 - sshift;
16     segmentMask = ssize - 1;
17     this.segments = Segment.newArray(ssize);
18   
19     if (initialCapacity > MAXIMUM_CAPACITY)
20         initialCapacity = MAXIMUM_CAPACITY;
21     int c = initialCapacity / ssize;
22     if (c * ssize < initialCapacity)
23         ++c;
24     int cap = 1;
25     while (cap < c)
26         cap <<= 1;
27   
28     for (int i = 0; i < this.segments.length; ++i)
29         this.segments[i] = new Segment<K,V>(cap, loadFactor);
30 }

  CurrentHashMap的初始化一共有三个参数

  initialCapacity:表示初始的容量

  loadFactor:表示负载参数,最后一个是

  concurrencyLevel:代表ConcurrentHashMap内部的Segment的数量

  其中,concurrencyLevel 一经指定,不可改变,后续如果ConcurrentHashMap的元素数量增加导致ConrruentHashMap需要扩容,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中链表数组的容量大小,这样的好处是扩容过程不需要对整个ConcurrentHashMap做rehash,而只需要对Segment里面的元素做一次rehash就可以了。

  整个ConcurrentHashMap的初始化方法还是非常简单的,先是根据concurrencyLevel来new出Segment,这里Segment的数量是不大于concurrencyLevel的最大的2的指数,就是说Segment的数量永远是2的指数个,这样的好处是方便采用移位操作来进行hash,加快hash的过程。接下来就是根据intialCapacity确定Segment的容量的大小,每一个Segment的容量大小也是2的指数,同样使为了加快hash的过程。

  这边需要特别注意一下两个变量,分别是segmentShift和segmentMask,这两个变量在后面将会起到很大的作用,假设构造函数确定了Segment的数量是2的n次方,那么segmentShift就等于32减去n,而segmentMask就等于2的n次方减一。

ConcurrentHashMap之get操作

JDK1.6的代码如下:

V get(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)) { 
                V v = e.value; 
                if (v != null) 
                    return v; 
                return readValueUnderLock(e); // recheck 
            } 
            e = e.next; 
        } 
    } 
    return null; 
}

  1.6的jdk采用了乐观锁的方式处理了get方法,取出key对应的value的值,如果拿出的value的值是null,则可能这个key--value对正在put的过程中,如果出现这种情况,那么就加锁来保证取出的value是完整的,如果不是null,则直接返回value。

JDK1.7代码如下:

 

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

 

  1.7并没有判断value=null的情况,不知为何,仔细思考了一下,个人认为无论是1.6还是1.7的实现,实际上都是一种乐观的方式,而乐观的方式带来的是性能上的提升,但同时也带来数据的弱一致性,如果你的业务是强一致性的业务,可能就要考虑另外的解决办法(用Collections包装或者像jdk6中一样二次加锁获取)

  具体ConcurrentHashMap为什么会带来数据的若一致性详情可查看博客:

  http://ifeve.com/concurrenthashmap-weakly-consistent/

ConcurrentHashMap之put操作

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

  对于put,ConcurrentHashMap采用自旋锁的方式,不同于1.6的直接获取锁

  注:个人理解,这里采用自旋锁可能作者是觉得在分段锁的状态下,并发的可能本来就比较小,并且锁占用时间又并不是特别长,因此自旋锁可以减小线程唤醒和切换的开销。针对自旋锁相关的详情,请参看博文(原理详情):http://blog.csdn.net/sunp823/article/details/49886051,比较测试:http://www.cnblogs.com/softidea/p/5530761.html

ConcurrentHashMap之remove操作

  Remove操作的前面一部分和前面的get和put操作一样,都是定位Segment的过程,然后再调用Segment的remove方法:

 1 final V remove(Object key, int hash, Object value) {
 2     if (!tryLock())
 3         scanAndLock(key, hash);
 4     V oldValue = null;
 5     try {
 6         HashEntry<K,V>[] tab = table;
 7         int index = (tab.length - 1) & hash;
 8         HashEntry<K,V> e = entryAt(tab, index);
 9         HashEntry<K,V> pred = null;
10         while (e != null) {
11             K k;
12             HashEntry<K,V> next = e.next;
13             if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
14                 V v = e.value;
15                 if (value == null || value == v || value.equals(v)) {
16                     if (pred == null)
17                         setEntryAt(tab, index, next);
18                     else
19                         pred.setNext(next);
20                     ++modCount;
21                     --count;
22                     oldValue = v;
23                 }
24                 break;
25             }
26             pred = e;
27             e = next;
28         }
29     } finally {
30         unlock();
31     }
32     return oldValue;
33 }

  首先remove操作也是确定需要删除的元素的位置,不过这里删除元素的方法不是简单地把待删除元素的前面的一个元素的next指向后面一个就完事了,前面已经说过HashEntry中的next是final的,一经赋值以后就不可修改,在定位到待删除元素的位置以后,程序就将待删除元素前面的那一些元素全部复制一遍,然后再一个一个重新接到链表上去,看一下下面这一幅图来了解这个过程:

假设链表中原来的元素如上图所示,现在要删除元素3,那么删除元素3以后的链表就如下图所示:

 

注意:下面的图1和2的元素顺序相反了,为什么这样,不防再仔细看看源码或者再读一遍上面remove的分析过程,元素复制是从待删除元素位置起将前面的元素逐一复制的,然后再将后面的链接起来。

ConcurrentHashMap之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是如何判断在统计的时候容器是否发生了变化呢?

  前面我们提到了一个Segment中的有一个modCount变量,代表的是对Segment中元素的数量造成影响的操作的次数,这个值只增不减,size操作就是遍历了两次Segment,每次记录Segment的modCount值,然后将两次的modCount进行比较,如果相同,则表示期间没有发生过写入操作,就将原先遍历的结果返回,如果不相同,则把这个过程再重复做一次,如果再不相同,则就需要将所有的Segment都锁住,然后一个一个遍历了,具体的实现大家可以看ConcurrentHashMap的源码,这里就不贴了。

总结

    concurrentHashmap主要是为并发设计,与Collections的包装不同,他不是采用全同步的方式,而是采用非锁get方式,通过数据的弱一致性带来性能上的大幅提升,同时采用分段锁的策略,提高并发能力。

 

posted @ 2017-02-16 22:30  星火燎原智勇  阅读(...)  评论(... 编辑 收藏