为什么要用ConcurrentHashMap


一、HashMap的线程安全问题

1、线程安全

在我刚开始接触并发的时候,对于线程安全这个词语很不理解,线程有什么安全性可言呢!后来慢慢地发

现线程安全实际上指的是线程的内存安全,多个线程在对共享数据进行操作时,可能会出现数据不一致的情况

所以HashMap的线程安全问题,我们可以理解为多个线程在HashMap这种数据结构中的数据进行操作可能

会出现的数据不一致的情况;显然HashMap不是线程安全的,在并发插入元素时,有可能出现环形链表,让下

一次读操作出现死循环,下面会进行详细的阐述

2、ReHash的问题

HashMap的容量是有限的,当经过多次元素插入时,HashMap达到一定的饱和度时,Key映射位置发生冲突

的几率会逐渐提高的,这个时候HashMap需要扩展它的长度,也就是Resize,ReHash就是Resize过程中的一个

步骤,影响Resize的因素主要有两个:

  • Capacity : HashMap的当前长度,HashMap的长度一定是2的幂次方

  • LoadFactor:HashMap的负载因子,默认值为0.75f

判断HashMap是否进行Resize的条件如下:HashMap.Size >= Capacity*LoadFactor

Resize的具体过程:

  1. 扩容:创建一个新的Entry数组,长度是原数组的2倍

  2. ReHash:遍历原数组,将所有的Entry重新Hash到新数组(在长度改变后,Hash规则也随之改变),

Hash的具体公式如下: index = HashCode(Key)& (Length - 1),当原数组长度为8的时候,

Hash之后的结果是和111B做运算;新数组的长度为16,Hash运算是和1111B做运算,结果不同

   //ReHash的源码如下
   public void transfer(Entry[] newTable,boolean rehash){
       int newCapacity = newTable.length;
       for(Entry<K,V> e:table){
           while(e!= null){
               Entry<K,V> next = e.next;
               if(rehash){
                   e.hash = null == e.key? 0:hash(e.key);
               }
               int i = indexFor(e.hash,newCapacity);
               e.next = newTable[i];
               newTable[i] = e;
               e = next;
           }
       }
   }
  1. 至于ReHash形成环的过程实在是太复杂了,这里我就不说了,形成的效果图如下:

3XvAsg.jpg

如果说调用Get查找一个不存在的Key时,而这个Key的Hash结果恰好等于3的时候,由于位置3带有环形

链表,所以程序将会进入死循环。这就是高并发环境下HashMap的线程安全问题

(3)解决方案

对于HashMap的线程安全问题可以使用HashTable和Collections.synchronizedMap来解决,但是这两种方案

在性能方面比较低下,无论是读操作还是写操作,它们都会给整个的集合加锁,导致同一时间其他的操作被阻塞

此时,兼具线程安全和运行效率的ConcurrentHashMap就应运而生了

二、ConcurrentHashMap原理 JDK 1.7

1.ConcurrentHashMap的结构

3Xz2xf.jpg

ConcurrentHashMap相比HashMap其实就是多了一个锁分段技术(Segment),Segment本身就是

HashMap对象,同HashMap一样,Segment包含一个HashEntry数组,数组的每一个HashEntry既是一个

键值对,也是一个链表的头节点;在ConcurrentHashMap集合中一共有2的N次方个,共同保存在一个名为

segments的数组中

从ConcurrentHashMap的结构图中可以看出它是一个二级哈希表,在一个总的哈希表下面,有若干个子

哈希表,这跟数据库的水平拆分非常相似;每一个Segment就像一个自治区,读写是高度自治的,Segment

之间是互不影响的,这就是我们说的锁分段技术

2.典型场景下的工作方式

  • 不同Segment的写入是可以并发执行的

  • 同一Segment的写和读是可以并发执行的

  • 同一个Segment的写入是需要上锁的,线程会出现阻塞状态

由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁,在保证线程安全的同时降低了

锁的粒度,让并发操作的效率更高

3.ConcurrentHashMap的读写过程

(1)读过程(Get方法)

  • 为输入的Key做Hash运算,得到Hash值

  • 通过Hash值,定位到对应的Segment对象

  • 再次通过hash值,定位到Segment当中数组的具体位置

(2)写过程 (Put方法)

  • 为输入的Key做Hash运算,得到hash值

  • 通过hash值,定位到对应的Segment对象

  • 获取可重入锁

  • 再次通过hash值,定位到Segment当中数组的具体位置

  • 插入或者覆盖HashEntry对象

  • 释放锁

从读写的步骤中可以看出,ConcurrentHashMap在读写时都需要二次定位,首先定位到Segment,之后定位

到Segment内的具体数组下标

4.size()方法如何解决一致性的问题

size()方法的目的是统计ConcurrentHashMap的总元素数量,自然需要把每个Segment内部的元素数量

汇总起来,但是如果在统计Segment元素数量的过程中,已经统计过的Segment瞬间插入新的元素,会不出现

不一致的情况呢?size()方法的执行过程是这样的

  • 遍历所有的Segment

  • 把Segment的元素数量累加起来

  • 把Segment的修改次数累加起来

  • 判断Segment的总修改次数是否大于上一次的总修改次数;如果大于,说明统计过程中有修改,重新统计

尝试次数加1;如果不是,说明没有修改,统计结束

  • 如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计

  • 再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等

  • 释放锁,统计结束

这样设计是因为尽量不锁住所有Segment,首先假设Size过程中不会有修改,当尝试一定次数时,才会转化

成悲观锁,锁住所有的Segment保证强一致性

三、ConcurrentHashMap原理 JDK 1.8

1.JDK 1.8相较于1.7有哪些改进

JDK 1.7已经解决了并发的问题,并且能支持N个Segment这么多次数的并发,但是查询遍历链表的效率太低

所以在JDK 1.8中采用了红黑树,保证了查询的效率是O(logn);而且抛弃了原有的Segment分段锁,而采用了

CAS + synchronized来保证并发的安全性

2.JDK 1.8的结构图

3jwaqI.jpg

3.源码解析

JDK 1.8中使用Node代替HashEntry,但所用是相同的

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;                //哈希值
        final K key;
        volatile V val;
        volatile Node<K,V> next;       //下一个节点

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

        public final K getKey()       { return key; }
        public final V getValue()     { return val; }
        public final int hashCode()   { return key.hashCode() ^ val.hashCode(); }
        public final String toString(){ return key + "=" + val; }
        public final V setValue(V value) {
            throw new UnsupportedOperationException();
        }

put()方法的过程,由于put()方法源码较多,我们只看关键步骤即可

  • 根据key计算出hashcode

  • 判断是否需要进行初始化

  • f即为当前key定位出的Node,如果为空表示当前位置可以写入数据,利用CAS尝试写入,失败则自旋保证成功

  • 如果当前位置的hashcode == MOVED == -1,则需要进行扩容

  • 如果都不满足,则利用synchronized 锁写入数据

  • 如果数量大于TREEIFY_THRESHOLD(默认为8),则要转换成红黑树

get()方法的过程

  • 根据计算出的hashcode 寻址,如果在桶上直接返回值

  • 如果是红黑树,那就按照树的方式获取值

  • 如果都不满足那就按照链表的方式遍历获取值