jdk1.7中的ConcurrentHashMap

本篇文章先拿jdk1.7热身,

在并发编程中使用HashMap的put操作可能导致死循环,这是因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next结点永远不为空,就会产生死循环获取Entry。这段话很抽象,我们用个例子来解释下:

定义一个初始容量为4,加载因子为0.75的HashMap,即当向HashMap添加第四个键值对时需要扩容,我们用两个线程来模拟下怎样产生死循环的。

假设为扩容前的HashTable为:大白话学习ConcurrentHashMap_加锁

假设线程1和线程2同时对该hashTable进行扩容操作,当且仅当线程1在执行第一个元素时被阻塞,直至线程2完成后才被唤醒继续执行。

假设e为当前键值对,e.next为下一个键值对,线程2进行扩容时对键值对添加顺序为:

e为Lang,e.next为Kun

e为Kun,e.next为Simon

e为Simon,e.next为null

扩容后的新的HashTable为

大白话学习ConcurrentHashMap_加锁_02

当线程2完成后,线程1继续执行,此时的线程1的指向

e为Lang,e.next为Kun

e为Kun,e.next为Lang

我们发现键值对2和键值对3互为指向,所有会产生循环链表。大白话学习ConcurrentHashMap_加锁_03

这也就解释了为什么HashTable在多线程中不安全的原因。为了解决线程安全问题,java提供了一个可用于线程安全的容器Hashtable,我们看一下它的特点

2、Hashtable的特点

打开Hashtable的源码,发现很多方法都是使用synchronized关键字来保证线程安全的。

 public synchronized V get(Object key)
  public synchronized V put(K key, V value)

在线程竞争激烈的情况下使用这种方式效率是非常低下的,因为当多个线程访问Hashtable的同步方法时,会进入阻塞轮询状态,所以会导致线程的竞争越激烈,效率就越低下。

3、ConcurrentHashMap

在并发编程中,使用HashMap会导致死循环,使用Hashtable效率低,所以就产生了ConcurrentHashMap。

3.1ConcurrentHashMap的数据结构

大白话学习ConcurrentHashMap_键值对_04

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成,Segment采用的是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap的结构类似,是一种数组和链表的结构,即HashEntry数组。当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。

3.2ConcurrentHashMap的初始化

ConcurrentHashMap提供了三个参数用于初始化Segment数组和HashEntry数组,

public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel)

initialCapacity为总的HashEntry数组的初始化容量,loadFactor为加载因子,concurrencyLevel为并发级别,也就是Segment数组的大小,而每个Segment包含相同的HashEntry数组。在这里,我们可能会有疑问,每个Segment包含多少HashEntry数组呢?

假设总的HashEntry容量默认为16,Segment为16,16%16=0,那么每个Segment包含1个HashEntry,如果总的HashEntry容量定义为17,Segment为16,17%16=1,又因为因为每个Segment包含相同的HashEntry数组,那么每个Segment应该包含2个HashEntry数组,事实上源码确实是这样的操作的,只不过采用的移位的方式,我们看一下源码

 int ssize = 1;
//计算segment数组的长度,即ssize
 while (ssize < concurrencyLevel) {
            ssize <<= 1;
        }
        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;
        while (cap < c)
            cap <<= 1;
        // create segments and segments[0]
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]);

由上面的源码可以看出,segments数组的长度是通过concurrencyLevel计算出来的,cap是每个Segmnet下的HashEntry数组长度,它借助于一个比较变量c确定来进行移位计算,c的取值就是(总的初始容量)/segment数组的长度。

3.1核心操作

3.1.2put操作

如果要在ConcurrentHashMap中添加一组键值对,我们应该有三个步骤①找到segmnet数组中的segment索引②找到HashEntry数组下的存放键值对索引下标③循环以该索引下标为头结点的链表判断并存放键值对。

在HashMap中,数组的索引下标是根据hash函数计算的,ConcurrentHashMap也是如此,只不过要要进行两次hash计算才能找到数组的下标,我们看看源码时如何计算的

  • 计算segment下标
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;

segmentMask是数组的容量,hash>>>segmnetShift是了将高位也参加与运算,能够更好的平均散列在segment数组中,参数j就是找到的segment下标

  • 计算HashEntry数组的下标
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
 HashEntry<K,V> first = entryAt(tab, index);

ConcurrentHashMap的计算与HashMap一样,都是利用(数组长度-1)&hash

  • 链表遍历

当一个键值对定位到来链表中的头结点时,循环遍历链表中是否存在与自己相同的key,如果相同,则进行替换并返回被替换的值,如果不存在,则直接头插法将该键值对插入到链表中,对应的源码及解析如下:

  for (HashEntry<K,V> e = first;;) {
                    if (e != null) {//判断插入的结点是否weinull
                        K k;
                        //判断链表中是否存在与key相等的键值对
                        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 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;
                    }
                }

Note:当HashEntry中的键值对大于阈值时,就需要扩容,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容。扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

3.1.3get操作

 public V get(Object key) {
        Segment<K,V> s; // manually integrate access methods to reduce overhead
        HashEntry<K,V>[] tab;
        int h = hash(key);
     //定位segment下标
        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;
    }

get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空的才会加锁重读,我们知道HashTable容器的get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不加锁的呢?原因是它的get方法里将要使用的共享变量都定义成volatile,如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁。之所以不会读到过期的值,是根据java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景

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

final修饰保证变量不可变性,volatile修饰保证了线程可见性,这表明多线程读取的时候一定是最新值,所以在get操作不需要加锁。

3.3怎样保证线程安全

ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。我们看一下源码中的Segment定义

 static final class Segment<K,V> extends ReentrantLock implements Serializable {

从上Segment的继承体系可以看出,Segment实现了ReentrantLock,也就带有锁的功能。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);

当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时,会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒。

大白话学习ConcurrentHashMap_多线程_05