ConcurrentHashMap介绍


在JDK1,4之前只有vector和HashTable是线程安全集合,在JDK 5之开始增加了安全的Map接口ConcurrentMap和线程安全的队列BlockingQueue。

在这里插入图片描述通过继承关系图可知:
ConcurrentHashMap是HashMap的安全版本

ConcurrentMap

也是键值对形式来存储数据

public interface ConcurrentMap<K, V> extends Map<K, V> 

实现自Map接口,及Map中所有的接口ConcurrentMap同样具有
在这里插入图片描述
特有方法说明:

//如果指定键已经不在和某个值关联,则他和给定值关联
V putIfAbsent(K key, V value);
//只有目前将键映射到给定的value时,才移除该键值对   返回值Boolean类型, true:成功 false:失败
boolean remove(Object key, Object value);
//只有当key和oldValue同时存在时,才会将oldValue替换为newValue
boolean replace(K key, V oldValue, V newValue);
//只有在集合中存在该key,才完成替换
V replace(K key, V value);

提供的原子操作方法。

ConcurrentHashMap的安全性体现

//用来存储数据 是一个Segment数组
final Segment<K,V>[] segments;

//segment是继承自ReentrantLock,实现了锁机制
static final class Segment<K,V> extends ReentrantLock implements Serializable {
       //重入次数   加锁操作发送冲突需要考虑重入问题
        static final int MAX_SCAN_RETRIES =
            Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
        //数据存放在table中,是volatile修饰
        transient volatile HashEntry<K,V>[] table;
        Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
            this.loadFactor = lf;
            this.threshold = threshold;
            this.table = tab;
        }
}

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

        HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
 }

在这里插入图片描述
ConcurrentHashMap是segment数组+哈希表结构。
通过源码可知:segment是ReentrantLock来修饰的,其本质是一把锁,称之为分段锁。
构造函数中concurrencyLevel称之为并发度,该参数是用来实例化segment数组的大小,默认的大小是16个。
,即同一时刻并发量线程量至少是16个,在ConcurrentHashMap中变更操作(put,remove,replace)加锁处理,针对get读操作是可以共享操作,读操作可以同时有多个线程操作。
并发度concurrencyLevel默认是16,也可以自行指定,指定的并发度需要满足2的倍数关系,目的方法快速的进行key哈希找到对应存储位置,并发度一旦确定之后是不在改变的,在Concurrent使用过程中数量超过扩容阈值时,也只是对segment下的哈希表进行扩容。
分段锁将数据分成一段一段的存储,然后给每一段数据进行加锁,当一个线程占用锁访问其中一段数据时,其他段的数据也会被其他线程访问。
在这里插入图片描述
在每个线程操作是只会锁住其中一个segment,不同的线程在操作ConcurrentHashMap时只要所操作的数据在不同的segment中,线程之间是互不干扰的。

ConcurrentHashMap的高并发主要来源于:
1、采用分段锁实现多个线程间的共享访问。
2、用HashEntry对象的不变性来降低执行读操作的线程在遍历链表期间对加锁的要求。
3、对于同一个volatile变量的读/写操作,协调不同线程间的读写内存的可见性问题。

1.8之后底层换成了CAS,把锁分段机制放弃了,CAS基本上是可以达到无锁境界
CAS + volatile无锁编程

在数据结构上, JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加细粒度的锁。

将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。
在这里插入图片描述
问题:JDK1.8 中为什么使用内置锁 synchronized替换可重入锁 ReentrantLock?

  • 在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 ->
    偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。

  • 减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承AQS来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。

ConcurrentHashMap的主要方法流程

ConcurrentHashMap的put方法流程(JDK1.7):

   public V put(K key, V value) {
       //key,value不能为null
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
       //通过key进行哈希到对应segment位置
        int hash = hash(key);
        int j = (hash >>> segmentShift) & segmentMask;
        //通过位置j获取当前的对应segment起始位置
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }

  #内部类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下的table
                HashEntry<K,V>[] tab = table;
                //通过key的哈希值进行哈希找到对应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) {
                            //put方法处理:将新value替换oldvalue
                                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进行扩容,而不是对整个ConcurrentHashMap进行扩容
      private void rehash(HashEntry<K,V> node) {
          //在segment下的table
            HashEntry<K,V>[] oldTable = table;
            int oldCapacity = oldTable.length;
            //按照原大小2倍关系进行扩容 
            int newCapacity = oldCapacity << 1;
            threshold = (int)(newCapacity * loadFactor);
            HashEntry<K,V>[] newTable =(HashEntry<K,V>[]) new HashEntry[newCapacity];
            int sizeMask = newCapacity - 1;
            //将原有table上的所有hashentry节点进行重新哈希到新table上
            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 = newTable;
        }

JDK1.8:
先定位到相应的 Segment ,然后再进行 put 操作。
首先会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
1、尝试自旋获取锁。
2、如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。
大致可以分为以下步骤:
1、根据 key 计算出 hash 值;
2、判断是否需要进行初始化;
3、定位到 Node,拿到首节点 f,判断首节点 f:
1、如果为 null ,则通过 CAS 的方式尝试添加;
2、如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容;
3、如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入;
4、当在链表长度达到 8 的时候,数组扩容或者将链表转换为红黑树。

关于ConcurrentHashMap 的几个关键问题

ConcurrentHashMap 的 get 方法是否要加锁,为什么?
get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。
这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 效率高的原因之一。
在这里插入图片描述
get 方法不需要加锁与 volatile 修饰的哈希桶数组有关吗?
没有关系。哈希桶数组table用 volatile 修饰主要是保证在数组扩容的时候保证可见性。
在这里插入图片描述
ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?
我们先来说value 为什么不能为 null。因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。
而用于单线程状态的 HashMap 却可以用containsKey(key) 去判断到底是否包含了这个 null 。
我们用反证法来推理:
假设 ConcurrentHashMap 允许存放值为 null 的 value,这时有A、B两个线程,线程A调用ConcurrentHashMap.get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的 key。那么,我们可以用 ConcurrentHashMap.containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回 false 。
但是在我们调用 ConcurrentHashMap.get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap.put(key, null)的操作。那么我们调用containsKey方法返回的就是 true 了,这就与我们的假设的真实情况不符合了,这就有了二义性。

至于 ConcurrentHashMap 中的 key 为什么也不能为 null 的问题,源码就是这样写的,哈哈。如果面试官不满意,就回答因为作者Doug不喜欢 null ,所以在设计之初就不允许了 null 的 key 存在。
ConcurrentHashMap 的并发度是什么?
并发度可以理解为程序运行时能够同时更新 ConccurentHashMap且不产生锁竞争的最大线程数。在JDK1.7中,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度,默认是16,这个值可以在构造函数中设置。
如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。
在JDK1.8中,已经摒弃了Segment的概念,选择了Node数组+链表+红黑树结构,并发度大小依赖于数组的大小。
ConcurrentHashMap 迭代器是强一致性还是弱一致性?
与 HashMap 迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。
ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。
这样迭代器线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。
JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?

  • 数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
  • 保证线程安全机制:JDK1.7 采用Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8采用CAS+synchronized保证线程安全。
  • 锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8调整为对每个数组元素加锁(Node)。
  • 链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
  • 查询时间复杂度:从JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。
    ConcurrentHashMap 和 Hashtable 的效率哪个更高?为什么?
    ConcurrentHashMap 的效率要高于 Hashtable,因为 Hashtable 给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在 JDK1.7 中采用分段锁实现线程安全,在 JDK1.8 中采用CAS+synchronized实现线程安全。
    具体说一下Hashtable的锁机制?
    Hashtable 是使用 synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!
    在这里插入图片描述
    多线程下安全的操作 map还有其他方法吗?
    还可以使用Collections.synchronizedMap方法,对方法进行加同步锁。
    如果传入的是 HashMap 对象,其实也是对 HashMap 做的方法做了一层包装,里面使用对象锁来保证多线程场景下,线程安全,本质也是对 HashMap 进行全表锁。在竞争激烈的多线程环境下性能依然也非常差,不推荐使用!
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值