ConcurrentHashMap源码分析(一)

15 篇文章 0 订阅
1 篇文章 0 订阅

因为jdk版本的不同,jdk1.8版本中的ConcurrentHashMap与jdk1.6、jdk1.7版本的实现大有不同,所以本篇文章主要对比HashTable和HashMap对jdk1.6、1.7版本的ConcurrentHashMap做一个详细的总结,以后会写文章对ConcurrentHashMap做一个详细的总结。

一、设计思路
HashMap:线程不安全的,内部用数组+链表的数据结构存储数据。
HashTable:线程安全的,内部使用数组+链表的数据结构存储数据,与HashMap的不同时在修改元素的方法上使用synchronized锁,保证线程安全,这样就对整个存储结构都加锁,导致只能有一个线程对数据进行操作,虽然线程安全了,但是大大降低了效率。

ConcurrentHashMap:线程安全的,实质还是采用数组+链表的存储结构。内部使用分段锁的技术,只有map的一部分加锁,使得多个线程能够同时操作不同的片段,而读取数据的时候不加锁,加快执行的速度。因为多数增删改操作是对某一段进行操作的,所以在扫描整个map的时候,一些获取集合信息的方法的实现与以前大有不同,比如:size()、containsValue()等方法。

ConcurrentHashMap的分段锁是Segment,内部拥有一个Entry数组,数组的每个元素又是一条链表,他类似语言HashMap的实现,又继承了ReentrantLock。ConcurrentHashMap中的HashEntry和HashMap中的HashEntry有一定的差异性。
ConcurrentHashMap中的HashEntry中的结构:

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

hash用final修饰:表示一个key的hash值是不能改的,我们知道在HashMap中扩容后,key的hash值是需要重新计算的,根据新的hash值来存储的;而在CurrentHashMap中这个key的Hash值一直是第一次计算的值,那么他扩容的时候是怎么拷贝的?我们在后面讲到这里再去分析。

value和next都用了volatile 来修饰,保证一个键值对的修改可以对其他线程随时可见。

二、并发度
并发度可以理解为程序运行能够同时更新ConcurrentHashMap而不产生竞争的最大线程数量。实际上就是Segment分段锁的个数,即Segment[]的数组长度。ConcurrentHashMap默认的数组长度是16,用户也可以自己指定数组的长度。但是ConcurrentHashMap会使数组的长度为大于等于该值的最小2的次幂数。

并发度过小,会带来严重的所竞争问题,导致性能下降;并发度过大,导致原本位于同一个Segment的访问会分散到不同的Segment中,cpu cache的命中率下降,从而引起性能的下降。

三、分段锁的创建
与jdk1.6不同,jdk1.7中除了第一个Segment之外,所以的Segment都会延迟初始化机制:每一次put前都要检查这个Segment是否为空,如果是就要调用ensureSegment()来确保对应的Segment被创建。

ensureSegment()可以再并发环境下被调用,但是该方法并没有使用锁来控制竞争,而是采用Unsafe的getObjectVolatile()提供的原子语意结合CAS来确保Segment的被创建的原子性。

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) {
            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];
            if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                == null) { // recheck
                Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
                while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                       == null) {
                    if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                        break;
                }
            }
        }
        return seg;
    }

四、加入元素——put/putIfAbsent/putAll方法
加入的思路:
1.根据key值得到对应的Segment
2.确定Segment是存在的
3.调用Segment的put方法加入元素
ConcurrentHashMap的put方法:

public V put(K key, V value) {
        Segment<K,V> s;
        //不能加入value=null的键值对
        if (value == null)
            throw new NullPointerException();
         //计算Hash值
        int hash = hash(key);
        //通过位运算得到对应的Segment数组的下标
        int j = (hash >>> segmentShift) & segmentMask;
        //用Unsafe的getObjectVolatile()提供的原子语意结合CAS来确保Segment的被创建的原子性。
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            //确保Segment存在
            s = ensureSegment(j);
        //加入元素的过程交个Segment的put方法。
        return s.put(key, hash, value, false);
    }

Segment的put(),将元素加入到对应的Segment中。
这个方法中,尝试获取锁的过程前,put()方法通过tryLock()尝试获得锁,在尝试获得对应的锁的时候,对相应的哈希表进行遍历,如果遍历完毕仍然找不到key相同的HashEntry节点,则为后续提前创建一个HashEntry,如果找到与key相同的节点的话,那就把这个节点返回,如果创建完毕还是没有获取到锁,则申请锁。
put()的思路:
在获得锁之后,Segment对链表进行遍历,如果某个HashEntry节点具有相同的key,则更新该HashEntry的value值,否则新建一个HashEntry节点,将它设置为链表的新head节点并将原头节点设为新head的下一个节点。新建过程中如果节点总数(含新建的HashEntry)超过threshold,则调用rehash()方法对Segment进行扩容,最后将新建HashEntry写入到数组中。
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 {
                //当前Segment的tab数组重新赋值一份
                HashEntry<K,V>[] tab = table;
                //根据hash值和数组长度获取当前元素应该在的数组下标
                int index = (tab.length - 1) & hash;
                //找到这个下标对应元素的第一个节点
                HashEntry<K,V> first = entryAt(tab, index);
                //从第一个节点开始遍历
                for (HashEntry<K,V> e = first;;) {
                    //第一个节点不为空,就是下标对应的数组元素不为空
                    if (e != null) {
                        K k;
                        //判断这个节点的key是否一样
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            //保存节点的旧值
                            oldValue = e.value;
                            //节点存在,重新赋值,modCount++,结束循环
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        //向后移动一个节点
                        e = e.next;
                    }
                    //节点为空
                    else {
                        //尝试获取锁的时候,根据key找到的节点不为空
                        if (node != null)
                            node.setNext(first);
                        else//为空,就重新创建一个节点
                            node = new HashEntry<K,V>(hash, key, value, first);
                        //总个数+1
                        int c = count + 1;
                        //检验当前个数是否超过了闸值,如果超过。需要扩容
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            //扩容并把节点加入进去
                            rehash(node);
                        else
                            //把节点设置进去
                            setEntryAt(tab, index, node);
                        //modCount的值+1
                        ++modCount;
                        //改变count的值为c,c已经加一了
                        count = c;
                        //因为当前节点在原链表中找不到,所以把旧节点的值设置为null
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

我们来看看rehash(…)方法是怎么扩容的
因为rehash()方法只在put()方法中被调用,因为put()加了锁,所以rehash()方法没有加锁。
扩容的实现:
先说明几个变量的意义,源码中不重复了:
第一次判断:
e:当前数组的第一个节点
idx:第一个节点应该在的新的数组的下标
next:第二个节点
如果next == null,那么直接把这个节点放在idx的位置,继续遍历下一个节点即可
第二次判断:
next != null时,从头开始遍历这条链,涉及到几个变量:
last:当前遍历的节点
k:根据当前节点hash值和HashEntry数组的长度计算的数组的位置
lastIdx:当前遍历节点在数组中的位置
lastRun:与当前节点位置相同的第一个节点
遍历完成后,这条链会变成这样的:
这里写图片描述

  private void rehash(HashEntry<K,V> node) {
         //原来的table复制一份
         HashEntry<K,V>[] oldTable = table;
         //获取原来table的长度
         int oldCapacity = oldTable.length;
         //扩容为原来的2倍
         int newCapacity = oldCapacity << 1;
         //通过加载因子获得闸值
         threshold = (int)(newCapacity * loadFactor);
         //根据容量创建一个新的数组来存放元素
         HashEntry<K,V>[] newTable =
             (HashEntry<K,V>[]) new HashEntry[newCapacity];
         //为了计算hash值而计算的
         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)   //  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循环将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; // add the new node
         node.setNext(newTable[nodeIndex]);
         newTable[nodeIndex] = node;
         table = newTable;
     }

需要注意的是,在并发环境下,其他线程调用remove()、put()等方法都会导致链表头节点的改变,因此在此过程中需要检查,如果链表头节点发生变化,那么需要重新遍历。而如果其他线程导致其中的某个节点被删除,即使该变化是非原子写的操作(删除节点后链接后续节点调用的是Unsafe.putOrderedObject(),该方法不提供原子写语义),可能导致当前线程无法观察到,但是因为不影响遍历的正确性,所以可以忽略。

之所以在获得锁的过程中,对整个链表遍历,主要是遍历的链表被cpu cache缓存,为后续的put操作中遍历链表操作提升性能。

在put()方法中,链接新节点到下一个节点(HashEntry.setNext()),以及将链表写入到数组中(setEntryAt())都是通过Unsafe的putOrderedObject()来实现的,这里并没有使用原子写语意的putObjectVolatile()的原因是:jvm会保证获得锁到释放锁之见的所有对象都会在锁释放之后更新到主内存中的,从而保证这些变更对其他线程可见。

五、删除元素:remove()
该方法和put方法一样,在尝试获得锁而没有获得到时,对链表进行遍历,提高后续查找的性能。
源码:

final V remove(Object key, int hash, Object value) {
            //尝试获取锁
            if (!tryLock())
                scanAndLock(key, hash);
            V oldValue = null;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                //得到位置的第一个元素
                HashEntry<K,V> e = entryAt(tab, index);
                //表示前一个元素,后面遍历时用的遍历
                HashEntry<K,V> pred = null;
                //遍历整天链,找e
                while (e != null) {
                    K k;
                    HashEntry<K,V> next = e.next;
                    //对比key
                    if ((k = e.key) == key ||
                        (e.hash == hash && key.equals(k))) {
                        V v = e.value;
                        //比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;
        }

六、获取元素:get与containsKey
get与containsKey两个方法几乎完全一致:他们都没有使用锁,而是通过Unsafe对象的getObjectVolatile()方法提供的原子读语义,来获得Segment以及对应的链表,然后对链表遍历判断是否存在key相同的节点以及获得该节点的value。但由于遍历过程中其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据,这一点是ConcurrentHashMap在弱一致性上的体现。如果要求强一致性,那么必须使用Collections.synchronizedMap()方法。

七、size、containsValue
些方法都是基于整个ConcurrentHashMap来进行操作的,他们的原理也基本类似:首先不加锁循环执行以下操作:循环所有的Segment(通过Unsafe的getObjectVolatile()以保证原子读语义),获得对应的值以及所有Segment的modcount之和。如果连续两次所有Segment的modcount和相等,则过程中没有发生其他线程修改ConcurrentHashMap的情况,返回获得的值。

当循环次数超过预定义的值时,这时需要对所有的Segment依次进行加锁,获取返回值后再依次解锁。值得注意的是,加锁过程中要强制创建所有的Segment,否则容易出现其他线程创建Segment并进行put,remove等操作。代码如下:

当循环次数超过预定义的值时,这时需要对所有的Segment依次进行加锁,获取返回值后再依次解锁。值得注意的是,加锁过程中要强制创建所有的Segment,否则容易出现其他线程创建Segment并进行put,remove等操作。代码如下:

for(int j =0; j < segments.length; ++j)

ensureSegment(j).lock();// force creation

一般来说,应该避免在多线程环境下使用size和containsValue方法。

注1:modcount在put, replace, remove以及clear等方法中都会被修改。

注2:对于containsValue方法来说,如果在循环过程中发现匹配value的HashEntry,则直接返回true。

最后,与HashMap不同的是,ConcurrentHashMap并不允许key或者value为null,按照Doug Lea的说法,这么设计的原因是在ConcurrentHashMap中,一旦value出现null,则代表HashEntry的key/value没有映射完成就被其他线程所见,需要特殊处理。在JDK6中,get方法的实现中就有一段对HashEntry.value == null的防御性判断。但Doug Lea也承认实际运行过程中,这种情况似乎不可能发生。

后续还会分析ConcurrentHashMap在JDK1.8中的体现,以及和HashTable的区别,请持续关注。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值