站在巨人肩膀上看源码-ConcurrentHashMap

1、出现背景

一、线程不安全的HashMap

由于多线程环境下,使用Hashmap进行put操做会引发死循环,致使CPU利用率接近100%,因此在并发状况下不能使用HashMap。node

二、效率低下的HashTable容器

HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的状况下HashTable的效率很是低下。由于当一个线程访问HashTable的同步方法时,其余线程访问HashTable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,而且也不能使用get方法来获取元素,因此竞争越激烈效率越低。也就是说对于Hashtable而言,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占。至关于全部线程进行读写时都去竞争一把锁,致使效率很是低下。c++

三、ConcurrentHashMap的锁分段技术

HashTable容器在竞争激烈的并发环境下表现出效率低下的缘由,是由于全部访问HashTable的线程都必须竞争同一把锁。那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不一样数据段的数据时,线程间就不会存在锁竞争,从而能够有效的提升并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分红一段一段的存储,而后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其余段的数据也能被其余线程访问。 另外,ConcurrentHashMap能够作到读取数据不加锁,而且其内部的结构可让其在进行写操做的时候可以将锁的粒度保持地尽可能地小,不用对整个ConcurrentHashMap加锁。
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap相似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每一个HashEntry是一个链表结构的元素, 每一个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先得到它对应的Segment锁。数组

2、ConcurrentHashMap的内部结构

ConcurrentHashMap为了提升自己的并发能力,在内部采用了一个叫作Segment的结构,一个Segment其实就是一个类Hash Table的结构,Segment内部维护了一个链表数组,咱们用下面这一幅图来看下ConcurrentHashMap的内部结构:
249993-20170904153519210-1495243581.png安全

从上面的结构咱们能够了解到,ConcurrentHashMap定位一个元素的过程须要进行两次Hash操做,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部,所以,这一种结构的带来的反作用是Hash的过程要比普通的HashMap要长,可是带来的好处是写操做的时候能够只对元素所在的Segment进行加锁便可,不会影响到其余的Segment,这样,在最理想的状况下,ConcurrentHashMap能够最高同时支持Segment数量大小的写操做(恰好这些写操做都很是平均地分布在全部的Segment上),因此,经过这一种结构,ConcurrentHashMap的并发能力能够大大的提升。数据结构

一、Segment

咱们再来具体了解一下Segment的数据结构:多线程

static final class Segment<K,V> extends ReentrantLock implements Serializable { 
    transient volatile int count; 
    transient int modCount; 
    transient int threshold; 
    transient volatile HashEntry<K,V>[] table; 
    final float loadFactor; 
}

详细解释一下Segment里面的成员变量的意义:并发

  • count:Segment中元素的数量
  • modCount:对table的大小形成影响的操做的数量(好比put或者remove操做)
  • threshold:阈值,Segment里面元素的数量超过这个值就会对Segment进行扩容
  • table:链表数组,数组中的每个元素表明了一个链表的头部
  • loadFactor:负载因子,用于肯定threshold

count用来统计该段数据的个数,它是volatile变量,它用来协调修改和读取操做,以保证读取操做可以读取到几乎最新的修改。协调方式是这样的,每次修改操做作告终构上的改变,如增长/删除节点(修改节点的值不算结构上的改变),都要写count值,每次读取操做开始都要读取count的值。这利用了 Java 5中对volatile语义的加强,对同一个volatile变量的写和读存在happens-before关系。modCount统计段结构改变的次数,主要是为了检测对多个段进行遍历过程当中某个段是否发生改变,在讲述跨段操做时会还会详述。threashold用来表示须要进行rehash的界限值。table数组存储段中节点,每一个数组元素是个hash链,用HashEntry表示。table也是volatile,这使得可以读取到最新的 table值而不须要同步。loadFactor表示负载因子。app

二、HashEntry

Segment中的元素是以HashEntry的形式存放在链表数组中的,看一下HashEntry的结构:ssh

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

能够看到HashEntry的一个特色,除了value之外,其余的几个变量都是final的,这意味着不能从hash链的中间或尾部添加或删除节点,由于这须要修改next 引用值,全部的节点的修改只能从头部开始。对于put操做,能够一概添加到Hash链的头部。可是对于remove操做,可能须要从中间删除一个节点,这就须要将要删除节点的前面全部节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。这在讲解删除操做时还会详述。为了确保读操做可以看到最新的值,将value设置成volatile,这避免了加锁。函数

3、ConcurrentHashMap的初始化

下面咱们来结合源代码来具体分析一下ConcurrentHashMap的实现,先看下初始化方法

public ConcurrentHashMap(int initialCapacity, 
                         float loadFactor, int concurrencyLevel) { 
    if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) 
        throw new IllegalArgumentException(); 
   
    if (concurrencyLevel > MAX_SEGMENTS) 
        concurrencyLevel = MAX_SEGMENTS; 
   
    // Find power-of-two sizes best matching arguments 
    int sshift = 0; 
    int ssize = 1; 
    while (ssize < concurrencyLevel) { 
        ++sshift; 
        ssize <<= 1; 
    } 
    segmentShift = 32 - sshift; 
    segmentMask = ssize - 1; 
    this.segments = Segment.newArray(ssize); 
   
    if (initialCapacity > MAXIMUM_CAPACITY) 
        initialCapacity = MAXIMUM_CAPACITY; 
    int c = initialCapacity / ssize; 
    if (c * ssize < initialCapacity) 
        ++c; 
    int cap = 1; 
    while (cap < c) 
        cap <<= 1; 
   
    for (int i = 0; i < this.segments.length; ++i) 
        this.segments[i] = new Segment<K,V>(cap, loadFactor); 
}

CurrentHashMap的初始化一共有三个参数,一个initialCapacity,表示初始的容量,一个loadFactor,表示负载参数,最后一个是concurrentLevel,表明ConcurrentHashMap内部的Segment的数量,ConcurrentLevel一经指定,不可改变,后续若是ConcurrentHashMap的元素数量增长致使ConrruentHashMap须要扩容,ConcurrentHashMap不会增长Segment的数量,而只会增长Segment中链表数组的容量大小,这样的好处是扩容过程不须要对整个ConcurrentHashMap作rehash,而只须要对Segment里面的元素作一次rehash就能够了
整个ConcurrentHashMap的初始化方法仍是很是简单的,先是根据concurrentLevel来new出Segment,这里Segment的数量是不大于concurrentLevel的最大的2的指数,就是说Segment的数量永远是2的指数个,这样的好处是方便采用移位操做来进行hash,加快hash的过程。接下来就是根据intialCapacity肯定Segment的容量的大小,每个Segment的容量大小也是2的指数,一样使为了加快hash的过程。
这边须要特别注意一下两个变量,分别是segmentShift和segmentMask,这两个变量在后面将会起到很大的做用,假设构造函数肯定了Segment的数量是2的n次方,那么segmentShift就等于32减去n,而segmentMask就等于2的n次方减一。

4、ConcurrentHashMap的get操做

前面提到过ConcurrentHashMap的get操做是不用加锁的,咱们这里看一下其实现:

public V get(Object key) { 
     int hash = hash(key.hashCode()); 
     return segmentFor(hash).get(key, hash); 
 }

第二行,对hash值进行了二次hash,之因此要进行再哈希,其目的是为了减小哈希冲突,使元素可以均匀的分布在不一样的Segment上,从而提升容器的存取效率。
看第三行,segmentFor这个函数用于肯定操做应该在哪个segment中进行,几乎对ConcurrentHashMap的全部操做都须要用到这个函数,咱们看下这个函数的实现:

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

这个函数用了位操做来肯定Segment,根据传入的hash值向右无符号右移segmentShift位,而后和segmentMask进行与操做,结合咱们以前说的segmentShift和segmentMask的值,就能够得出如下结论:假设Segment的数量是2的n次方,根据元素的hash值的高n位就能够肯定元素到底在哪个Segment中。
在肯定了须要在哪个segment中进行操做之后,接下来的事情就是调用对应的Segment的get方法:

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; 
}

先看第二行代码,这里对count进行了一次判断,其中count表示Segment中元素的数量。咱们能够来看一下count的定义:

transient volatile int count;

能够看到count是volatile的,实际上这里面利用了volatile的语义:
对volatile字段的写入操做happens-before于每个后续的同一个字段的读操做。由于实际上put、remove等操做也会更新count的值,因此当竞争发生的时候,volatile的语义能够保证写操做在读操做以前,也就保证了写操做对后续的读操做都是可见的,这样后面get的后续操做就能够拿到完整的元素内容。
而后,在第三行,调用了getFirst()来取得链表的头部:

1 HashEntry<K,V> getFirst(int hash) {
2     HashEntry<K,V>[] tab = table;
3     return tab[hash & (tab.length - 1)];
4 }

一样,这里也是用位操做来肯定链表的头部,hash值和HashTable的长度减一作与操做,最后的结果就是hash值的低n位,其中n是HashTable的长度以2为底的结果。
在肯定了链表的头部之后,就能够对整个链表进行遍历,看第4行,取出key对应的value的值,若是拿出的value的值是null,则可能这个key,value对正在put的过程当中,若是出现这种状况,那么就加锁来保证取出的value是完整的,若是不是null,则直接返回value。


get方法没有使用锁来同步,只是判断获取的entry的value是否为null,为null时才使用加锁的方式再次去获取。
这个实现很微妙,没有锁同步的话,靠什么保证同步呢?咱们一步步分析。
第一步,先判断一下 count != 0;count变量表示segment中存在entry的个数。若是为0就不用找了。假设这个时候刚好另外一个线程put或者remove了这个segment中的一个entry,会不会致使两个线程看到的count值不一致呢?看一下count 变量的定义:

transient volatile int count;

它使用了volatile来修改。在Java5以后,JMM实现了对volatile的保证:对volatile域的写入操做happens-before于每个后续对同一个域的读写操做。因此,每次判断count变量的时候,即便刚好其余线程改变了segment也会体现出来。
第二步,获取到要该key所在segment中的索引地址,若是该地址有相同的hash对象,顺着链表一直比较下去找到该entry。当找到entry的时候,先作了一次比较: if(v != null) 咱们用红色注释的地方。这是为什么呢?考虑一下,若是这个时候,另外一个线程刚好新增/删除了entry,或者改变了entry的value,会如何?
前面说过HashEntry类的结构,除了 value,其它成员都是final修饰的,也就是说value能够被改变,其它都不能够改变,包括指向下一个HashEntry的next也不能被改变。
(1)在get代码的①和②之间,另外一个线程新增了一个entry。若是另外一个线程新增的这个entry又刚好是咱们要get的,这事儿就比较微妙了。下图大体描述了put 一个新的entry的过程。
249993-20170911110607094-1237419873.jpg

由于每一个HashEntry中的next也是final的,无法对链表最后一个元素增长一个后续entry因此新增一个entry的实现方式只能经过头结点来插入了。
newEntry对象是经过 new HashEntry(K k , V v, HashEntry next) 来建立的。若是另外一个线程恰好new 这个对象时,当前线程来get它。由于没有同步,就可能会出现当前线程获得的newEntry对象是一个没有彻底构造好的对象引用。没有锁同步的话,new 一个对象对于多线程看到这个对象的状态是没有保障的,这里一样有可能一个线程new这个对象的时候尚未执行完构造函数就被另外一个线程获得这个对象引用。因此才须要判断一下:if (v != null) 若是确实是一个不完整的对象,则使用锁的方式再次get一次。
有没有可能会put进一个value为null的entry? 不会的,已经作了检查,这种状况会抛出异常,因此 ②处的判断彻底是出于对多线程下访问一个new出来的对象的状态检测。
(2)在get代码的①和②之间,另外一个线程修改了一个entry的value。value是用volitale修饰的,能够保证读取时获取到的是修改后的值。
(3)在get代码的①以后,另外一个线程删除了一个entry。
假设咱们的链表元素是:e1-> e2 -> e3 -> e4 咱们要删除 e3这个entry,由于HashEntry中next的不可变,因此咱们没法直接把e2的next指向e4,而是将要删除的节点以前的节点复制一份,造成新的链表。它的实现大体以下图所示:
249993-20170911135032750-1643188943.jpg

若是咱们get的也恰巧是e3,可能咱们顺着链表刚找到e1,这时另外一个线程就执行了删除e3的操做,而咱们线程还会继续沿着旧的链表找到e3返回。这里没有办法实时保证了。
咱们第①处就判断了count变量,它保障了在 ①处能看到其余线程修改后的。①以后到②之间,若是再次发生了其余线程再删除了entry节点,就无法保证看到最新的了。不过这也没什么关系,即便咱们返回e3的时候,它被其余线程删除了,暴漏出去的e3也不会对咱们新的链表形成影响。
这实际上是一种乐观设计,设计者假设 ①以后到②之间 发生被其它线程增、删、改的操做可能性很小,因此不采用同步设计,而是采用了过后(其它线程这期间也来操做,而且可能发生非安全事件)弥补的方式。而由于其余线程的“改”和“删”对咱们的数据都不会形成影响,因此只有对“新增”操做进行了安全检查,就是②处的非null检查,若是确认不安全事件发生,则采用加锁的方式再次get。这样作减小了使用互斥锁对并发性能的影响。可能有人怀疑remove操做中复制链表的方式是否代价太大,这里我没有深刻比较,不过既然Java5中这么实现,我想new一个对象的代价应该已经没有早期认为的那么严重。

5、ConcurrentHashMap的put操做

看完了get操做,再看下put操做,put操做的前面也是肯定Segment的过程,这里再也不赘述,直接看关键的segment的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(); 
    } 
}

首先对Segment的put操做是加锁完成的,而后在第五行,若是Segment中元素的数量超过了阈值(由构造函数中的loadFactor算出)这须要进行对Segment扩容,而且要进行rehash,关于rehash的过程你们能够本身去了解,这里不详细讲了。
第8和第9行的操做就是getFirst的过程,肯定链表头部的位置。
第11行这里的这个while循环是在链表中寻找和要put的元素相同key的元素,若是找到,就直接更新更新key的value,若是没有找到,则进入21行这里,生成一个新的HashEntry而且把它加到整个Segment的头部,而后再更新count的值。
该方法也是在持有段锁(锁定整个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插入到链头,剩下的就很是容易理解了。

6、ConcurrentHashMap的remove操做

Remove操做的前面一部分和前面的get和put操做同样,都是定位Segment的过程,而后再调用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(); 
    } 
}

首先remove操做也是肯定须要删除的元素的位置,不过这里删除元素的方法不是简单地把待删除元素的前面的一个元素的next指向后面一个就完事了,咱们以前已经说过HashEntry中的next是final的,一经赋值之后就不可修改,在定位到待删除元素的位置之后,程序就将待删除元素前面的那一些元素所有复制一遍,而后再一个一个从新接到链表上去,看一下下面这一幅图来了解这个过程:
249993-20170912135512172-986817539.png

假设链表中原来的元素如上图所示,如今要删除元素3,那么删除元素3之后的链表就以下图所示:
249993-20170912135542141-563857047.png


整个操做是在持有段锁的状况下执行的,空白行以前(第11行以前)的行主要是定位到要删除的节点e。接下来,若是不存在这个节点就直接返回null,不然就要将e前面的结点复制一遍,尾结点指向e的下一个结点。e后面的结点不须要复制,它们能够重用。
中间那个for循环是作什么用的呢?(第22行)从代码来看,就是将定位以后的全部entry克隆并拼回前面去,但有必要吗?每次删除一个元素就要将那以前的元素克隆一遍?这点实际上是由entry的不变性来决定的,仔细观察entry定义,发现除了value,其余全部属性都是用final来修饰的,这意味着在第一次设置了next域以后便不能再改变它,取而代之的是将它以前的节点全都克隆一次。至于entry为何要设置为不变性,这跟不变性的访问不须要同步从而节省时间有关。
整个remove实现并不复杂,可是须要注意以下几点。第一,当要删除的结点存在时,删除的最后一步操做要将count的值减一。这必须是最后一步操做,不然读取操做可能看不到以前对段所作的结构性修改。第二,remove执行的开始就将table赋给一个局部变量tab,这是由于table是 volatile变量,读写volatile变量的开销很大。编译器也不能对volatile变量的读写作任何优化,直接屡次访问非volatile实例变量没有多大影响,编译器会作相应优化。

7、总结

ConcurrentHashMap背景:出现的缘由是由于咱们起先使用的是HashMap和HashTable,可是随着并发量的增长,HashMap并无使用同步,在多线程状况下使用HashMap的时候就会出现并发问题,而HashTable虽然是安全的,可是使用的是synchronized 锁整表操做,这样在性能上将会产生很大的影响。那么如何能设计出一款即安全,在效率上又高的集合呢,这样就有了ConcurrentHashMap的产生。ConcurrentHashMap采用的是锁分段技术,内部为Segment数组来进行细分,而每一个Segment又经过HashEntry数组来进行组装,当进行写操做的时候,只须要对这个key对应的Segment进行加锁操做,加锁同时不会对其余的Segment形成影响。总的Map包含了16个Segment(默认数量),每一个Segment内部包含16个HashEntry(默认数量),这样对于这个key所在的Segment加锁的同时,其余15个Segmeng还能正常使用,在性能上有了大大的提高。同时ConcurrentHashMap只是针对put方法进行了加锁,而对于get方法并无采用加锁的操做,由于具体的值,在Segment的HashEntry里面是volatile的,基于happens-before(先行发生)原则,对数据的写先行发生于对数据的读,因此再读取的时候获取到的必然是最新的结果。由于对数组的操做,在主内存和工做内存中,load和use、assgin和store是必然连在一块儿的,一旦使用(use)发生,那load必先行发生于use以前,use前必然从主内存中加载最新的值到工做内存的变量副本里。而一旦赋值(assgin),必然先行发生于store将值传递给主内存,在write到主内存中去。因此put方式无需加锁也能获取到最新的结果。size操做是先请求2次的count数量,若是有发生变化,则对put、remove、clean进行加锁,在统计完以后unlock。

相关文章
相关标签/搜索