Java并发(二):并发容器之ConcurrentHashMap

ConcurrentHashMap

  本来是想以JDK1.8为例的,只是后来发现源码用的是1.7的,所以这里就干脆以1.7进行说明吧,关于ConcurrentHashMap1.8中大体功能没变,主要是加了红黑树结构转换、CAS级别的锁控制。
  1.7中ConcurrentHashMap包含一个Segment[]数组(默认大小16),Segment和HashHap的结构类似,内部包含一个HashEntry<K,V>数组,每个HashEntry元素都是一个链表结构,也就是说每个Segment都是一个数组+链表的结构。
  同时Segment还继承自ReentrantLock,也就是说Segment本身还是一个可重入锁,这样的设计的好处在于,需要锁住该段数据时,直接用该HashEntry所在的Segment执行lock即可, 不需要额外的定义锁。
  这种分析下,Segment在HashMap中充当锁的角色,同时管理HashEntry[],如扩容等操作。而Segment.HashEntry才是真正存储数据的地方。下面是1.7中的部分源码

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {
   ...
   final Segment<K,V>[] segments;
   ...
   static final class Segment<K,V> extends ReentrantLock implements Serializable { 
        //这段区域的元素个数
        transient volatile int count;
        // 记录该段在读取期间,表被其它线程更新的次数
        transient int modCount;        
        //扩容阈值,当元素个数>=该值,扩容
        transient int threshold;
        //hashEntry数组
        transient volatile HashEntry<K,V>[] table;
        //加载因子,确定扩容阈值 = (int)(capacity * loadFactor)
        final float loadFactor; 
        ...

初始化
  
初始化有三个重要的参数

  • initialCapacity:初始容量,这里是指整个map的容量,所以该值会平分到每个Segment。在initialCapacity/concurrencyLevel无法整除的情况下,初始化过程中也会保证最终每个segments中hashEntry的长度为大于该值的第一个2的n次方,比如在容量为31,segments.length为16的情况下,由于31/16=1无法整除,则每个segments中hashEntry的长度为2。不过在jdk1.7中只初始化了数组中的第一个元素
  • loadFactor:加载因子
  • concurrencyLevel:并发等级,由于锁在Segment上,所以这里并发等级即代表Segment[]大小,默认16。如果设为单数,在初始化过程中也会修改为大于该值的第一个2的n次方,比如给3则取4,给9则取16。
 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;
      
        int sshift = 0;
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        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;
        while (cap < c)
            cap <<= 1;
        
        // 初始化segments[0],内部包含的hashEntry[]长度=cap为2的n次方
        Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);

        // 初始化segments[],ssize为2的n次方
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
    }

    static final class Segment<K,V> extends ReentrantLock implements Serializable {
        ...
          Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
            this.loadFactor = lf;
            this.threshold = threshold;
            this.table = tab;
        }
   ...

   从源码可以看出,初始化主要就是对segements[]数组及其元素进行初始化,最终保证了segemtns.length长度(并发等级)=2的n次方,segment.hashEntry链表长度=容量/并发等级=2的n次方。

put
   
put操作原理说起来比较简单,根据key的hash值得到其应存储的段,即segments[]下标,然后lock该segment(如果该段数量已达扩容阈值则进行rehash扩容操作)。同样根据hash值与HashEntry长度进行运算,再次得到其应存储在HashEntry中的下标,然后迭代链表,如果已存在相同key,则覆盖value,否则将值添加到链表头部。对应源码如下

public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        // 得到key的hash值
        int hash = hash(key.hashCode());
        int j = (hash >>> segmentShift) & segmentMask;

        // 根据hash值计算得到segments[]下标,如果该位置下的segment尚未初始化则初始化。
        if ((s = (Segment<K,V>)UNSAFE.getObject          
             (segments, (j << SSHIFT) + SBASE)) == null) 
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }
...
  static final class Segment<K,V> extends ReentrantLock implements Serializable {
...
       final V put(K key, int hash, V value, boolean onlyIfAbsent) {
	// 获取独占锁
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
    	// 同样根据hash值计算得出,应存储在HashEntry[]中的下标位置
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        // 获取hashEntry[index]链表头部元素,通过.next迭代链表,如果已存在则覆盖值
        HashEntry<K,V> first = entryAt(tab, index);  
        for (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 HashEntry<K,V>(hash, key, value, first); 

                int c = count + 1;			// 该段元素个数+1,count变量为volatile

                // 元素个数 > 扩容阈值,扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);   
                else
                    setEntryAt(tab, index, node);  // 设置tab[index] = node
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}
...

  在put过程中存在一个并发问题,由于put会获取头部元素通过.next进行迭代,并最终将新元素置为链表的头部元素,当多个线程对一个segment执行put操作时,如何保证某个put操作对其它线程的可见性?即如何保证其它线程可以获取到最新put的元素(对应上方代码中的entryAt方法)。 虽然HashEntry[]数组本身是volatile的,但这只是保证扩容后,数组引用发生变化时被及时看到,并不能保证对元素的修改对其它线程也是及时可见的。不能表达数组元素是final、volatile的特性,这是Java语言的设计上的一个缺失。
  所以在put方法中,获取链表头部entryAt(tab,index) 和 设为头部元素setEntryAt(tab,index)的方法中,用到了UNSAFE这个类,UNSAFE.putOrderedObject(...)是一个有序或者有延迟的putObjectVolatile方法,即它最终会强制该操作更新到主存,但并一定立即强制,但是Java的happend-before原则会保证其一定会在后续其它线程get之前更新到主存。UNSAFE.getObjectVolatile(...)的方式则在获取时强制当前线程从主内存中重新获取数据。所以通过unsafe的配合使用,其实也就起到了volatile的效果,保证了可见性。
  关于更多unsafe类可参考:https://www.cnblogs.com/throwable/p/9139947.html

// 设置tab[i]元素=e,putOrderedObject是一个有序或者有延迟的putObjectVolatile方法,即最终会强制该操作更新到主存,但并一定立即强制,但是Java的happend-before原则会保证其一定会在其它线程get之前更新到主存。
static final <K,V> void setEntryAt(HashEntry<K,V>[] tab, int i,
        HashEntry<K,V> e) {
       UNSAFE.putOrderedObject(tab, ((long)i << TSHIFT) + TBASE, e);
}
// 获取tab[i],通过Unsafe.getObjectVolatile来确保数组元素可见性。 并发情况下,如果某线程对该位置元素进行了修改或覆盖,其它线程及时可见。
static final <K,V> HashEntry<K,V> entryAt(HashEntry<K,V>[] tab, int i) {
    return (tab == null) ? null :
        (HashEntry<K,V>) UNSAFE.getObjectVolatile
        (tab, ((long)i << TSHIFT) + TBASE);
}

rehash扩容

//扩容,整个过程处于lock中
private void rehash(HashEntry<K,V> node) {
	// 创建新数组:长度=原长度*2, 并根据加载因子计算出新数组的扩容阈值
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    int newCapacity = oldCapacity << 1; 
    threshold = (int)(newCapacity * loadFactor);
    HashEntry<K,V>[] newTable =
        (HashEntry<K,V>[]) new HashEntry[newCapacity];
    int sizeMask = newCapacity - 1;
    // 迭代原数组元素,由于整体长度发生了变化,这里重新计算位置,由于是双倍扩容,所以会保证原处于tab[i]位置的元素,要么处于[i]处,或[i+扩容数]处。
    for (int i = 0; i < oldCapacity ; i++) {
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            int idx = e.hash & sizeMask;
            if (next == null)   //  Single node on list
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                HashEntry<K,V> lastRun = e;
                int lastIdx = idx;
                for (HashEntry<K,V> last = next;
                     last != null;
                     last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                newTable[lastIdx] = lastRun;
                // Clone remaining nodes
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    // 修改table引用指向,由于table是及时可见的,所以在该赋值操作执行后,table即对其它线程可见。
    // 在该操作执行之前,其它线程操作的仍是旧table
    table = newTable; 
}

   注:整个扩容过程都处于lock中,扩容期间创建了一个新的HashEntry[],长度为原长度*2,并根据加载因子设置其新的扩容阈值。创建完毕之后,开始复制元素,由于长度发生了变化,这里需要重新定位每个元素,在这个过程中有可能破坏原有链表结构,生成新的链表结构,但由于是双倍扩容,所以元素的位置要么仍处于原有位置,要么处于原位置+新增长度的位置。如假设原长度为16,有Key的计算得到的hash=21,则扩容前该key位于21 &(16-1)= 5下标处,扩容后该key将位于 21 & (32 -1) = 21下标处(= 5 + 16)。key.hash=6的,则在扩容前后都将仍然处于6下标处。(注:扩容的是segment内部的HashEntry[],而非segments本身)

size()
  当需要统计整个ConcurrentHashMap里元素的大小时,就必须统计所有Segment里元素的大小后求和。前面说过Segment中有一个全局的volatile int count变量及一个modCount变量,但在多线程并发下,并不能仅仅通过count的相加来得到总大小,原因是拿到Segent的count之后(比如这样的代码int i = s.count),如果s.count又发生了变化,就会导致统计结果不准确。所以最安全的做法,是在统计期间,将所有段都锁住,这期间的所有put、remove等操作都将处于阻塞状态,但这种方法效率并不高。
  所以最终在处理size()统计时,java采用了种比较中性的方法:认为count累加过程中发生变化的几率很少但仍可能会存在,所以ConcurrentHashMap的做法是先在2次不锁的情况下进行统计,如果某次统计过程中,没有任何segment被并发修改,则返回统计得到的size。否则在第三次统计时,执行全表锁定来统计。

 static final class Segment<K,V> extends ReentrantLock implements Serializable { 
        //这段区域的元素个数
        transient volatile int count;
        // 记录该段在读取期间,表被其它线程更新的次数
        transient int modCount;   
 public int size() {
       
        final Segment<K,V>[] segments = this.segments;
        int size;         // 返回的结果
        boolean overflow; // true if size overflows 32 bits
        long sum;         // 被修改的总次数
        long last = 0L;   // previous sum
        int retries = -1; // first iteration isn't retry
        try {
            for (;;) {
                // 尝试2次不锁定情况下进行统计,从第3次开始锁定
                if (retries++ == RETRIES_BEFORE_LOCK) {
                    for (int j = 0; j < segments.length; ++j)
                        ensureSegment(j).lock(); // force creation
                }
                sum = 0L;
                size = 0;
                overflow = false;
                for (int j = 0; j < segments.length; ++j) {
                    Segment<K,V> seg = segmentAt(segments, j);
                    if (seg != null) {
                        sum += seg.modCount;  // sum+segment被修改的次数
                        int c = seg.count;
                        if (c < 0 || (size += c) < 0) // size+segment中的数量
                            overflow = true;
                    }
                }
                if (sum == last)
                    break;
                last = sum;
            }
        } finally {
            if (retries > RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    segmentAt(segments, j).unlock();  //释放锁
            }
        }
        return overflow ? Integer.MAX_VALUE : size;
    }

其它并发控制(迭代、get/containsKey/containsValue)

  对于ConcurrentHashMap,迭代过程并不会抛出异常,这里不仅指单线程对Map迭代的同时执行put、remove等操作不会导致异常,还包括多线程在并发操作时也不会导致异常,比如t1在迭代,t2在put,t3在remove的情况下,对于t1来说,t2和t3的操作都是及时可见的。
  从源码上来说,不管是用foreach还是Iterator,不管是keySet、entrySet等等,底层都是通过迭代器来实现的,这里以map.entrySet为例,最终迭代都会通过HashIterator来实现

 final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
        public Iterator<Map.Entry<K,V>> iterator() {
            return new EntryIterator();
        }
... 

 final class EntryIterator extends HashIterator implements Iterator<Entry<K,V>>
    {
        public Map.Entry<K,V> next() {
            HashEntry<K,V> e = super.nextEntry();
            return new WriteThroughEntry(e.key, e.value);
        }
    }     
abstract class HashIterator {
        int nextSegmentIndex;
        int nextTableIndex;
        HashEntry<K,V>[] currentTable;  // 迭代的当前HashEntry[]
        HashEntry<K, V> nextEntry;
        HashEntry<K, V> lastReturned;
        // 初始化
        HashIterator() {
            nextSegmentIndex = segments.length - 1;
            nextTableIndex = -1;
            advance();                 // 获取nextEntry,如果列表为空,则为null。
        }
    
        final void advance() {
            for (;;) {
// 2)从链表尾部往前迭代,取该链表(即HashEntry[])第一个不为空的元素赋予nextEntry
                if (nextTableIndex >= 0) {
                    if ((nextEntry = entryAt(currentTable,
                                             nextTableIndex--)) != null)
                        break;
                }
                else if (nextSegmentIndex >= 0) {  
// 1)迭代segments,找到第一个不为空的segment,且其HashEntry[]也不为null的segment
                    Segment<K,V> seg = segmentAt(segments, nextSegmentIndex--);
                    if (seg != null && (currentTable = seg.table) != null)
                        nextTableIndex = currentTable.length - 1; // 获取链表尾部索引
                }
                else
                    break;
            }
        }

        final HashEntry<K,V> nextEntry() {
            HashEntry<K,V> e = nextEntry;
            if (e == null)
                throw new NoSuchElementException();
            lastReturned = e; // cannot assign until after null check
            if ((nextEntry = e.next) == null) // 迭代到链表最后一个元素,执行一次advance()。
                advance();
            return e;
        }

        public final boolean hasNext() { return nextEntry != null; }

对上面代码作一个流程整理的话,基本如下
   在执行map.entrySet()时创建一个EntrySet对象:entrySet = new EntrySet(),
   再通过EntrySet的iterator()创建迭代器:it = entrySet.iterator = new EntryIterator();
   EntryIterator类是HashIterator的子类,所以也会调用到HashIterator的构造器,而在这个构造器中,使用尾部向头部的方式,遍历整个segments[]数组,找到一个不为null的segment元素,且segment.hashEntry[]也不为null,且hashEntry[]含非null元素的hashEntry[],将该数组中的最后一个非null元素(从尾部向头部遍历就是第一个)赋予nextEntry,即nextEntry代表最后一个链表结构的头部。 如果集合为空或到达最后一个元素,则nextEntry最终为null,迭代器在进行hasNext()检测时将返回false,迭代操作终止。
  在迭代过程中,其它线程对map的put、remove等操作都会被及时的监测到,原理在于segmentAt(Segment<K,V>[] ss, indexj),entryAt(HashEntry[] table,index)这两个方法内部使用了UNSAFE.getObjectVolatile(...)方法,该方法会强制从主存中获取最新的对象,而在上面也说过put、remove等操作底层使用了 UNSAFE.putOrderedObject(...)该操作会保证在后续获取操作之前将数据强制刷新到主存,其次hashEntry.next本身也是volatile,所以整个迭代过程中移除某个元素、或在头部插入新元素都是可见的,而且也是线程安全的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值