JDK1.7 ConcurrentHashMap源码分析

JDK1.7 ConcurrentHashMap源码分析

ConcurrentHashMap的基本策略是将table细分为多个Segment保存在数组segments中,每个Segment本身又是一个可并发的哈希表,同时每个Segment都是一把ReentrantLock锁,只有在同一个Segment内才存在竞态关系,不同的Segment之间没有锁竞争,这就是分段锁机制。Segment内部拥有一个HashEntry数组,数组中的每个元素又是一个链表。

为了减少占用空间,除了第一个Segment之外,剩余的Segment采用的是延迟初始化的机制,仅在第一次需要时才会创建(通过ensureSegment实现)。为了保证延迟初始化存在的可见性,访问segments数组及table数组中的元素均通过volatile访问,主要借助于Unsafe中原子操作getObjectVolatile来实现,此外,segments中segment的写入,以及table中元素和next域的写入均使用UNSAFE.putOrderedObject来完成。这些操作提供了AtomicReferenceArrays的功能。

构造方法

@SuppressWarnings("unchecked")
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;
    // 寻找与给定参数concurrencyLevel匹配的最佳Segment数组ssize,必须是2的幂
    // 如果concurrencyLevel是2的幂,那么最后选定的ssize就是concurrencyLevel
    // 否则concurrencyLevel,ssize为大于concurrencyLevel最小2的幂值
    // concurrencyLevel为7,则ssize为2的3次幂,为8
    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;
    //计算每个Segment中,table数组的初始大小
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    while (cap < c)
        cap <<= 1;
    // 创建segments和第一个segment
    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); //原子按顺序写入segments[0]
    this.segments = ss;
}

/**
 * map转化为ConcurrentHashMap
 * @param m the map
 */
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
    this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                  DEFAULT_INITIAL_CAPACITY),
         DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    putAll(m);
}

构造器中各个参数的含义:

initialCapacity:创建ConccurentHashMap对象的初始容量,即ConccurentHashMap中HashEntity的总数量,创建时未指定initialCapacity则默认为16,最大容量为MAXIMUM_CAPACITY。

loadFactor:负载因子,用于计算Segment的threshold域,

concurrencyLevel:即ConccurentHashMap的并发度,支持同时更新ConccurentHashMap且不发生锁竞争的最大线程数。concurrencyLevel不能代表ConccurentHashMap实际并发度,ConccurentHashMap会使用大于等于该值的2的幂指数的最小值作为实际并发度,实际并发度即为segments数组的长度。创建时未指定concurrencyLevel则默认为16。

并发度对ConccurentHashMap性能具有举足轻重的作用,如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。

ensureSegment方法

ensureSegment用于确定指定的Segment是否存在,不存在则会创建。源码如下:

@SuppressWarnings("unchecked")
private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
    long u = (k << SSHIFT) + SBASE; // raw offset
    Segment<K,V> seg;
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
    	// 以初始化时创建的第一个坑位的ss[0]作为模版进行创建
        Segment<K,V> proto = ss[0]; // use segment 0 as prototype
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        // 二次检查是否有其它线程创建了这个Segment
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
            == null) { // recheck
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            //这里通过自旋的CAS方式对segments数组中偏移量为u位置设置值为s,这是一种不加锁的方式,
            //万一有多个线程同时执行这一步,那么只会有一个成功,而其它线程在看到第一个执行成功的线程结果后
            //会获取到最新的数据从而发现需要更新的坑位已经不为空了,那么就跳出while循环并返回最新的seg
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                   == null) {
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

使用getObjectVolatile()方法提供的原子读语义获取指定Segment,如果为空,以构造ConcurrentHashMap对象时创建的Segment为模板,创建新的Segment。ensureSegment在创建Segment期间为不断使用getObjectVolatile()检查指定Segment是否为空,防止其他线程已经创建了Segment。

scanAndLockForPut方法

 private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    HashEntry<K,V> first = entryForHash(this, hash);//根据key的hash值查找头节点
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    int retries = -1; // negative while locating node
    while (!tryLock()) {//尝试获取锁,成功则直接返回,失败则开始自旋
        HashEntry<K,V> f; // 用于后续重新检查头节点
        if (retries < 0) {
            if (e == null) {//结束遍历节点
                if (node == null) // 创建节点
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            else if (key.equals(e.key))//找到节点,结束遍历
                retries = 0;
            else
                e = e.next;
        }
        else if (++retries > MAX_SCAN_RETRIES) {//达到最大尝试次数
            lock();//进入加锁方法,失败则会进入排队,阻塞当前线程
            break;
        }
        else if ((retries & 1) == 0 &&
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // 头节点变化,需要重新遍历,说明有新节点加入或被移除
            retries = -1;
        }
    }
    return node;
}

分析:while循环每执行一次,都会尝试获取锁,成功则会返回。retries 初始值设为-1是为了遍历当前hash对应桶的链表,找到则停止遍历,未找到则会预创建一个节点;同时,如果头节点发生变化,则会重新进行遍历,直到自旋次数大于MAX_SCAN_RETRIES,使用lock加锁,获取锁失败则会进入等待队列。

为什么scanAndLockForPut中要遍历一次链表?

前面已经提过scanAndLockForPut使用自旋次数受限制的自旋锁进行优化加锁的方式,此外,遍历一次链表也是一种优化方法,主要是尽可能使当前链表中的节点进入CPU高速缓存,提高缓存命中率,以便获取锁定后的遍历速度更快。实际上加锁后并没有使用已经找到的节点,因为它们必须在锁定下重新获取,以确保更新的顺序一致性,但是遍历一次后通常可以更快地重新定位。这是一种预热优化的方式,scanAndLock中也使用了该优化方式。

scanAndLock内部实现方式与scanAndLockForPut相似,但比scanAndLockForPut更简单,scanAndLock不需要预创建节点。因此scanAndLock主要用于remove和replace操作,而scanAndLockForPut则用于put,这里就不再贴源码。

Segment put方法

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        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;
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

Segment中put的大体流程是先对Segment加锁,然后根据(tab.length-1)&hash找到对应的slot,然后遍历slot对应的链表,如果key对应的entry已经存在,根据onlyIfAbsent标志决定是否替换旧值,如果key对应的entry不存在,创建新节点插入链表头部,期间若容量超过限制,判断是否需要进行rehash。

put实现还是比较简单的,下面谈谈其中主要的几个优化点。

scanAndLockForPut的作用已经介绍过了,如果锁能很快的获取,有限次数的自旋可防止线程进入阻塞,有助于提升性能;此外,自旋期间会遍历链表,希望遍历的链表被CPU Cache所缓存,为后续实际put过程中的链表遍历操作提升性能;最后scanAndLockForPut还会预创建节点。

rehash方法

rehash主要的作用的是扩容,将扩容前table中的节点重新分配到新的table。由于table的capacity都是2的幂,按照2的幂次方扩容为原来的一倍,扩容前在slot i中的元素,扩容后要么还是在slot i里,或者i+扩容前table的capacity的slot中,这样使得只需要移动原来桶中的部分元素即可将所有节点分配到新的table。

为了提高效率,rehash首先找到第一个后续所有节点在扩容后index都保持不变的节点,将这个节点加入扩容后的table的index对应的slot中,然后将这个节点之前的所有节点重排即可。

@SuppressWarnings("unchecked")
private void rehash(HashEntry<K,V> node) {
    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;
    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)   // slot中只有一个元素
                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;
                //复制lastRun之前的所有节点
                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; //添加新节点
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}

最好的情况,每个slot链表的所有节点在扩容后index都保持不变,那么只需移动头节点,不用创建新节点即可完成扩容和节点重新分配;最差的情况,每个链表的倒数两个节点在扩容后index不同,那么需要重建并复制所有节点。

get方法

get与containsKey两个方法的实现几乎完全一致,都不需要加锁读数据,下面以get源码说明:

public V get(Object key) {
    Segment<K,V> s; 
    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;
}

首先计算key的hash码,计算Segment的index,使用getObjectVolatile()方法提供的原子读语义获取Segment,再计算Segment中slot的索引,使用getObjectVolatile()方法提供的原子读语义获取slot头节点,遍历链表,判断是否存在key相同的节点以及获得该节点的value。由于遍历过程中其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据,这就是ConcurrentHashMap的弱一致性。如果要求强一致性,那么必须使用Collections.synchronizedMap()。

size方法

public int size() {
    final Segment<K,V>[] segments = this.segments;
    int size;
    boolean overflow; // 为true表示size溢出32位
    long sum;         // 所有segment中modCount的总和
    long last = 0L;  
    int retries = -1; // 第一次迭代不计入重试,因此总共会重试3次
    try {
        for (;;) {
            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;
                    int c = seg.count;
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            if (sum == last)
                break;
            last = sum;
        }
    } finally {
        //由于只有在retries等于RETRIES_BEFORE_LOCK时才会执行强制加锁,并且由于是用的retries++,
        //所以强制加锁完毕后,retries的值是一定会大于RETRIES_BEFORE_LOCK
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}

首先不加锁循环所有的Segment(通过Unsafe的getObjectVolatile()以保证原子读语义),计算所有Segment的count之后,同时计算所有Segment的modcount之和sum,如果sum与last相等,说明迭代期间没有发生其他线程修改ConcurrentHashMap的情况,返回size;当重试次数超过预定义的值(RETRIES_BEFORE_LOCK为2)时,对所有的Segment依次进行加锁,再计算size的值。需要注意的是,加锁过程中会强制创建所有的不存在Segment,否则容易出现其他线程创建Segment并进行put,remove等操作。由于retries初始值为-1,因此会尝试3次才会对所有的Segment加锁。

弱一致性

ConcurrentHashMap是弱一致的。 ConcurrentHashMap的get,containsKey,clear,iterator 都是弱一致性的。

get和containsKey都是无锁的操作,均通过getObjectVolatile()提供的原子读语义来获得Segment以及对应的链表,然后遍历链表。由于遍历期间其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据。如在get执行UNSAFE.getObjectVolatile(segments, u)之后,其他线程若执行了clear操作,则get将读到失效的数据。

由于clear没有使用全局的锁(采用的是分段锁),当清除完一个segment之后,开始清理下一个segment的时候,已经清理segments可能又被加入了数据,因此clear返回的时候,ConcurrentHashMap中是可能存在数据的。因此,clear方法是弱一致的。

ConcurrentHashMap中的迭代器主要包括entrySet、keySet、values方法,迭代器在遍历期间如果已经遍历的table上的内容变化了,迭代器不会抛出ConcurrentModificationException异常。如果未遍历的数组上的内容发生了变化,则有可能反映到迭代过程中,这就是ConcurrentHashMap迭代器弱一致的表现。

到此,jdk1.7中ConcurrentHashMap的源码分析完成。

jdk1.8中的数据结构

其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。
ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为以下步骤:

1.根据 key 计算出 hashcode 。
2.判断是否需要进行初始化。
3.即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
4.如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
5.如果都不满足,则利用 synchronized 锁写入数据。
6.如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

在这里插入图片描述
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

ConcurrentHashMap的get操作
根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
如果是红黑树那就按照树的方式获取值。
就不满足那就按照链表的方式遍历获取值。
在这里插入图片描述

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值