Java并发----ConcurrentHashMap02--源码分析

来源:互联网 发布:中医体质软件 编辑:程序博客网 时间:2024/06/13 15:45

    通过分析Hashtable就知道,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分/离技术(锁分段就是进一步对一组独立的对象进行分解)。它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。
    有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。

    ConcurrentHashMap我们关注的操作有:get,put,remove 这3个操作。
    对于哈希表,Java中采用链表的方式来解决hash冲突的。一个HashMap的数据结构看起来类似下图:

    实现了同步的HashTable也是这样的结构,它的同步使用锁来保证的,并且所有同步操作使用的是同一个锁对象。这样若有n个线程同时在get时,这n个线程要串行的等待来获取锁。

    ConcurrentHashMap中对这个数据结构,针对并发稍微做了一点调整。它把区间按照并发级别(concurrentLevel),分成了若干个segment。默认情况下内部按并发级别为16来创建。对于每个segment的容量,默认情况也是16。当然并发级别(concurrentLevel)和每个段(segment)的初始容量都是可以通过构造函数设定的。创建好默认的ConcurrentHashMap之后,它的结构大致如下图:


看起来只是把以前HashTable的一个hash bucket创建了16份而已。有什么特别的吗?没啥特别的。
继续看每个segment是怎么定义的:

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    Segment继承了ReentrantLock,表明每个segment都可以当做一个锁。(ReentrantLock前文已经提到,不了解的话就把当做synchronized的替代者吧)这样对每个segment中的数据需要同步操作的话都是使用每个segment容器对象自身的锁来实现。只有对全局需要改变时锁定的是所有的segment。
    上面的这种做法,就称之为“分离锁(lock striping)”。有必要对“分拆锁”和“分离锁”的概念描述一下:
    分拆锁(lock spliting)就是若原先的程序中多处逻辑都采用同一个锁,但各个逻辑之间又相互独立,就可以拆(Spliting)为使用多个锁,每个锁守护不同的逻辑。
    分拆锁有时候可以被扩展,分成可大可小加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁(lock striping)。(摘自《Java并发编程实践》)

    看上去,单是这样就已经能大大提高多线程并发的性能了。还没完,继续看我们关注的get,put,remove这三个函数怎么保证数据同步的。

1、源码解读

    ConcurrentHashMap中主要实体类就是三个:ConcurrentHashMap(整个Hash表),Segment(桶),HashEntry(节点),对应上面的图可以看出之间的关系
    ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。如果使用传统的技术,如HashMap中的实现,如果允许可以在hash链的中间添加或删除元素,读操作不加锁将得到不一致的数据。ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的。HashEntry代表每个hash链中的一个节点,其结构如下所示:

    static final class HashEntry<K,V> {        final int hash;        final K key;        volatile V value;        volatile ConcurrentHashMap.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一次。

这是定位段的方法:

    private Segment<K,V> segmentForHash(int h) {        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;        return (Segment<K,V>) UNSAFE.getObjectVolatile(segments, u);    }
    关于Hash表的基础数据结构,这里不想做过多的探讨。Hash表的一个很重要方面就是如何解决hash冲突,ConcurrentHashMap 和HashMap使用相同的方式,都是将hash值相同的节点放在一个hash链中。与HashMap不同的是,ConcurrentHashMap使用多个子Hash表,也就是段(Segment)。

ConcurrentHashMap的数据成员

    public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>            implements ConcurrentMap<K, V>, Serializable {        final int segmentMask;        final int segmentShift;        final Segment<K,V>[] segments;    }
所有的成员都是final的,其中segmentMask和segmentShift主要是为了定位段,参见上面的segmentForHash方法。
每个Segment相当于一个子Hash表,在每个Segment中通过HashEntry来表示链结构,它的数据成员如下:
    static final class Segment<K,V> extends ReentrantLock implements Serializable {        static final int MAX_SCAN_RETRIES =                Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;        //table数组存储段中节点,每个数组元素是个hash链,用HashEntry表示。        // table是volatile,这使得能够读取到最新的 table值而不需要同步        transient volatile ConcurrentHashMap.HashEntry<K, V>[] table;        transient int count;  //用来统计该段数据的个数        //modCount统计段结构改变的次数,主要是为了检测对多个段进行遍历过程中某个段是否发生改变,在讲述跨段操作时会还会详述        transient int modCount;        transient int threshold; // 表示需要进行rehash的界限值        final float loadFactor;  //负载因子。        ......    }
---------------------------------------------------------------------------------------------------------------------

首先来看一下ConcurrentHashMap中最主要的一个构造函数,如下:

public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {          // 参数有效性判断          if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)              throw new IllegalArgumentException();          // concurrencyLevel是用来计算segments的容量          if (concurrencyLevel > MAX_SEGMENTS)              concurrencyLevel = MAX_SEGMENTS;          int sshift = 0;          int ssize = 1;          // ssize是大于或等于concurrencyLevel的最小的2的N次方值          while (ssize < concurrencyLevel) {              ++sshift;              ssize <<= 1;          }          // 初始化segmentShift和segmentMask          this.segmentShift = 32 - sshift;          this.segmentMask = ssize - 1;          // 哈希表的初始容量          if (initialCapacity > MAXIMUM_CAPACITY)              initialCapacity = MAXIMUM_CAPACITY;          int c = initialCapacity / ssize; // 计算哈希表的实际容量          if (c * ssize < initialCapacity)              ++c;          int cap = MIN_SEGMENT_TABLE_CAPACITY; // segments中的HashEntry数组的长度          while (cap < c)              cap <<= 1;          // segments          Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor),                               (HashEntry<K,V>[])new HashEntry[cap]);          Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];          UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]          this.segments = ss;      } 

1、获取元素get

    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;    }
如上获取元素的操作是不带锁的,效率会提高。

2.put操作

在之前的JDK版本中,Segment的put操作开始时就会先加锁,直到put完成才解锁。在JDK 1.7中采用了自旋的机制,进一步减少了加锁的可能性。源码如下:

    public V put(K key, V value) {        ConcurrentHashMap.Segment<K,V> s;        if (value == null)  //ConcurrentHashMap 中不允许用 null 作为映射值            throw new NullPointerException();        int hash = hash(key); // key通过一次hash运算得到一个hash值。        int j = (hash >>> segmentShift) & segmentMask;        if ((s = (ConcurrentHashMap.Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck                (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment            s = ensureSegment(j); // 使用Unsafe的方式从Segment数组中获取该索引对应的Segment对象。        //向这个Segment对象中put值,这个put操作也基本是一样的步骤(通过&运算获取HashEntry的索引,然后set)。        return s.put(key, hash, value, false);    }
int j = (hash >>> segmentShift) & segmentMask;
    将得到hash值向右按位移动segmentShift位,然后再与segmentMask做&运算得到segment的索引j。
在初始化的时候我们说过segmentShift的值等于32-sshift,例如concurrencyLevel等于16,则sshift等于4,则segmentShift为28。hash值是一个32位的整数,将其向右移动28位就变成这个样子:0000 0000 0000 0000 0000 0000 0000 xxxx,然后再用这个值与segmentMask做&运算,也就是取最后四位的值。这个值确定Segment的索引。
Segment中的put操作

    static final class Segment<K,V> extends ReentrantLock implements Serializable {        final V put(K key, int hash, V value, boolean onlyIfAbsent) {            ConcurrentHashMap.HashEntry<K,V> node = tryLock() ? null :                    scanAndLockForPut(key, hash, value);            V oldValue;            try {                ConcurrentHashMap.HashEntry<K,V>[] tab = table;                int index = (tab.length - 1) & hash;                ConcurrentHashMap.HashEntry<K,V> first = entryAt(tab, index);                for (ConcurrentHashMap.HashEntry<K,V> e = first;;) {                    if (e != null) {                        K k;                        if ((k = e.key) == key ||                                (e.hash == hash && key.equals(k))) {                            oldValue = e.value;                            if (!onlyIfAbsent) {                                e.value = value;                                ++modCount;                            }                            break;                        }                        e = e.next;                    }                    else {                        if (node != null)                            node.setNext(first);                        else                            node = new ConcurrentHashMap.HashEntry<K,V>(hash, key, value, first);                        int c = count + 1;                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)                            rehash(node);                        else                            setEntryAt(tab, index, node);                        ++modCount;                        count = c;                        oldValue = null;                        break;                    }                }            } finally {                unlock();            }            return oldValue;        }    }
先不考虑自旋等待的问题,假如put一开始就拿到锁,那么它会执行以下逻辑:

  1. 根据之前计算出来的hash值找到数组相应bucket中的第一个链节点。这里需要注意的是:
    a. 因为ConcurrentHashMap在计算Segment中数组长度时会保证该值是2的倍数,而且Segment在做rehash时也是每次增长一倍,因而数组索引只做"(tab.length - 1) & hash"计算即可。
    b. 因为table字段时一个volatile变量,因而在开始时将该引用赋值给tab变量,可以减少在直接引用table字段时,因为该字段是volatile而不能做优化带来的损失,因为将table引用赋值给局不变量后就可以把它左右普通变量以实现编译、运行时的优化。
    c. 因为之前已经将volatile的table字段引用赋值给tab局不变量了,为了保证每次读取的table中的数组项都是最新的值,因而调用entryAt()方法获取数组项的值而不是通过tab[index]方式直接获取(在put操作更新节点链时,它采用Unsafe.putOrderedObject()操作,此时它对链头的更新只局限与当前线程,为了保证接下来的put操作能够读取到上一次的更新结果,需要使用volatile的语法去读取节点链的链头)。
  2. 遍历数组项中的节点链,如果在节点中能找到key相等的节点,并且当前是put()操作而不是putIfAbsent()操作,纪录原来的值,更新该节点的值,并退出循环,put()操作完成。
  3. 如果在节点链中没有找到key相等的节点,创建一个新的节点,并将该节点作为当前链头插入当前链,并将count加1。和读取节点链连头想法,这里使用setEntryAt()操作以实现对链头的延时写,以提升性能,因为此时并不需要将该更新写入到内存,而在锁退出后该更新自然会写入内存[参考Java的内存模型,注1]。然后当节点数操作阀值(capacity*loadFactor),而数组长度没有达到最大数组长度,会做rehash。另外,如果scanAndLockForPut()操作返回了一个非空HashEntry,则表示在scanAndLockForPut()遍历key对应节点链时没有找到相应的节点,此时很多时候需要创建新的节点,因而它预创建HashEntry节点(预创建时因为有些时候它确实不需要再创建),所以不需要再创建,只需要更新它的next指针即可,这里使用setNext()实现延时写也时为了提升性能,因为当前修改并不需要让其他线程知道,在锁退出时修改自然会更新到内存中,如果采用直接赋值给next字段,由于next时volatile字段,会引起更新直接写入内存而增加开销。
3、删除元素
    public V remove(Object key) {        int hash = hash(key);        Segment<K,V> s = segmentForHash(hash);        return s == null ? null : s.remove(key, hash, null);    }
  整个操作是先定位到段,然后委托给段的remove操作。当多个删除操作并发进行时,只要它们所在的段不相同,它们就可以同时进行。下面是Segment的remove方法实现:

Segment中的remove操作
在JDK 1.6版本中,remove操作比较直观,它先找到key对应的节点链的链头(数组中的某个项),然后遍历该节点链,如果在节点链中找到key相等的节点,则为该节点之前的所有节点重新创建节点并组成一条新链,将该新链的链尾指向找到节点的下一个节点。这样如前面rehash提到的,同时有两条链存在,即使有另一个线程正在该链上遍历也不会出问题。然而Doug Lea又挖掘到了新的优化点,为了减少新链的创建同时利用CPU缓存的特性,在1.7中,他不再重新创建一条新的链,而是只在当起缓存中将链中找到的节点移除,而另一个遍历线程的缓存中继续存在原来的链。当移除的是链头是更新数组项的值,否则更新找到节点的前一个节点的next指针。这也是HashEntry中next指针没有设置成final的原因。当然remove操作如果第一次尝试获得锁失败也会如put操作一样先进入自旋状态,这里的scanAndLock和scanAndLockForPut类似,只是它不做预创建节点的步骤,不再细说:

   static final class Segment<K,V> extends ReentrantLock implements Serializable {        /**         * Remove; match on key only if value null, else match both.         */        final V remove(Object key, int hash, Object value) {            if (!tryLock())                scanAndLock(key, hash);            V oldValue = null;            try {                ConcurrentHashMap.HashEntry<K,V>[] tab = table;                int index = (tab.length - 1) & hash;                ConcurrentHashMap.HashEntry<K,V> e = entryAt(tab, index);                ConcurrentHashMap.HashEntry<K,V> pred = null;                while (e != null) {                    K k;                    ConcurrentHashMap.HashEntry<K,V> next = e.next;                    if ((k = e.key) == key ||                            (e.hash == hash && key.equals(k))) {                        V v = e.value;                        if (value == null || value == v || value.equals(v)) {                            if (pred == null)                                setEntryAt(tab, index, next);                            else                                pred.setNext(next);                            ++modCount;                            --count;                            oldValue = v;                        }                        break;                    }                    pred = e;                    e = next;                }            } finally {                unlock();            }            return oldValue;        }    }
整个remove实现并不复杂,但是需要注意如下几点。
    1.当要删除的结点存在时,删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改。
    2.remove执行的开始就将table赋给一个局部变量tab,这是因为table是 volatile变量,读写volatile变量的开销很大。编译器也不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量没有多大影响,编译器会做相应优化。

4、迭代元素

    在迭代方面,ConcurrentHashMap使用了一种不同的迭代方式。在这种迭代方式中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是  在改变时new新的数据从而不影响原有的数据 。iterator完成后再将头指针替换为新的数据 。这样iterator线程可以使用原来老的数据。而写线程也可以并发的完成改变。





0 0
原创粉丝点击