ConcurrentHashMap的前世(1.7)今生(1.8)

同步容器类

Map

HashMap线程不安全

  1. java7当中HashMap使用头插法(处于局部性的考虑),扩容时会将链表的顺序反转,所以在多线程扩容时会导致形成环形链表,并且会丢失数据,具体见:https://mp.weixin.qq.com/s/dzNq50zBQ4iDrOAhM4a70A。
  2. java8中HashMap使用尾插法,但是在多线程进行put操作时会发生数据覆盖的问题。

HashTable

对于可能产生线程安全问题的方法,HashTable在底层都是使用synchronized关键字来修饰这些方法,从而保证多线程下的线程安全。但是这样做会有很大的性能问题,因为每一个操作都会将整个HashTable锁起来,同一时刻最多只能有一个线程在操作HashTable,性能低下。

Collections.synchronizedMap(new HashMap<>())

调用这个方法其实是对Map进行了一次包装,给他添加了一个对象锁,然后在调用方法的时候会先用synchronized对这个对象上锁,在性能上与HashTable差不多。

ConcurrentHashMap

JDK1.7及以前

ConcurrentHashMap在JDK1.7及以前采用的是分段锁的思想,其内部有多把锁,每一把锁用于锁定容器中一部分的数据,这样的话当多线程访问不同数据段的数据时,线程间就不会存在锁竞争问题,提高了并发度。具体来说,ConcurrentHashMap内部包含一个Segment的数组,Segment的结构与1.7中的HashMap类似,是一种数组和链表的结构,即每一个Segment内部包含一个HashEntry数组,而每一个HashEntry就是链表中的一个结点。Segment继承于ReentrantLock,当对HashEntry数组进行修改时必须获得与它对应的Segment锁,从而保证线程安全性。

初始化:在ConcurrentHashMap的构造函数中主要完成了一些变量的计算。ssize是Segment数组的长度,因为在后续定位Segment的时候使用位运算来代替求余操作来计算,所以必须保证ssize是2的N次幂。sshift是ssize从1到最终的值向左位移了几次。segmentMask是ssize-1,是散列计算的掩码,二进制值全为1。segmentShift为32-sshift,参与计算求Segment数组下标的过程。cap为每个Segment中HashEntry数组的大小,是通过initialCapacity(用户输入的ConcurrentHashMap的初始化容量,默认为16)和Segment数组的长度ssize计算出来的,也是2的N次幂。然后对于Segment数组中的每一项,ConcurrentHashMap采用了懒加载的思想,它只创建了数组中的第一个元素(segments[0]),其它索引对应的元素会等到用到的时候再去创建,并且会获取下标为0的Segment的参数(如HashEntry数组的大小)来创建,然后通过CAS的方式将创建的Segment放到Segment数组上(只有在操作HashEntry数组的时候才会去加锁,所以此处可能有多个线程在操作,需要通过CAS来保证线程安全)。

hash再散列:ConcurrentHashMap会对hashCode进行一次再散列计算,原因就是ConcurrentHashMap在计算索引的时候使用的是按位与的方式来代替求余操作提高效率,这样的话如果低位相同但是高位不同的话得到的索引是相同的,发生冲突的概率非常大。所以为了减少哈希冲突,使得元素能够均匀的分布在不同的Segment当中上,ConcurrentHashMap使用扰动函数对hashCode进行了再散列,使得hashCode的每一位也可以参与到计算索引的过程中,从而减少哈希冲突。

get操作:get操作的全程是不加锁的,HashEntry[]、每个HashEntry的value和next都是用volatile来修饰的,保证了在多线程环境下读到的值都是最新的值(根据happens-before原则,对volatile字段的写入操作先于读操作),这是用volatile替换锁的典型场景。同时put操作会加锁,并且操作过程中对volatile变量的写入是通过Unsafe的putOrderedObject来完成的,会延迟volatile变量写入内存直到解锁(意味着put操作完成),也就是说在整个put操作期间的修改是对其他线程的get操作不可见的,保证了get操作在不加锁的情况下的数据一致性。对HashEntry数组扩容的时候,新旧两个数组都拥有对链表上全部元素的引用或副本的引用,所以在扩容的过程中get操作也可以获取到正确的值。

put操作:在进行put操作的时候会先定位在Segment数组中的索引并检查对应的Segment是否为null,如果为null则先以segments[0]为模板通过CAS的方式来创建出Segment,然后在Segment的HashEntry数组中进行插入,这个时候需要加锁。会先去调用一次tryLock函数尝试加锁(Segment继承于ReentrantLock,这里调用的是父类方法),如果成功则加锁成功,否则就开始开始循环调用tryLock函数来加锁(循环CAS),并统计循环调用的次数,当循环次数当达一定数值(默认为64)时,则为了避免一直进行忙循环而浪费CPU资源,会去直接调用ReentrantLock的lock()方法来竞争锁,因为这个方法会有排队和阻塞的过程,减少了对CPU资源的浪费。获取到锁以后,定位在HashEntry数组中的索引,然后去遍历链表,如果key相同则用新值替换旧值并返回旧值,然后解锁,put结束并对get操作可见。如果遍历到最后还是没有找到相同key,则新创建一个结点并将其next属性值设置为链表头节点(头插法),这时判断是否需要扩容,如果需要则先去扩容(扩容是包含在put操作里的,是线程安全的),最后将链表头结点设置为新创建的待插入节点并更新Segment的count属性,然后解锁put结束并对get操作可见。

扩容操作:每一次扩容都是扩容为原来的2倍直至最大容量,并会将原数组中的元素进行再散列后插入到新的数组中。注意到原数组中的元素在新数组中的位置要么是原位置,要么是原位置+原数组长度,因为新数组的掩码比旧数组的掩码在高位上多了一个1,进行&操作(相当于求余)后如果hash值在该位为0则为原位置,为1则为原位置+原数组长度。同时在扩容的过程中会遍历两次链表,第一次遍历寻找从某个结点到尾结点的所有节点进行再散列后在新数组中的位置相同的最长子链表,然后直接在新数组中给该部分的头结点添加一个引用,完成这部分的转移;第二次遍历是处理剩下的结点,对于这部分节点来说,每次都是创建一个该结点的副本,然后将它添加到新数组对应的位置上。这样做主要是为了保证在扩容过程中不改变原数组也可以完成新数组的创建,从而使得get操作不加锁也可以获取到正确的值,同时增加第一次遍历的操作可以避免创建结点的副本,从而节约内存空间并减小GC压力。最后用新数组的引用替换旧数组的引用,即table=newTable,然后其它线程可见(volatile修饰)并且GC就可以对旧数组进行回收。

remove操作:先定位Segment,然后进行与put操作一样的过程来加锁,获取到锁以后就开始进行链表删除结点的过程,最后解锁并返回旧值,删除操作结束。

size操作:ConcurrentHashMap并没有记录元素个数的属性,只能通过统计每一个Segment中元素的个数count并求和来完成size操作。为了避免加锁(将所有的Segment都加锁),ConcurrentHashMap的size操作采取了统计两遍count,然后判断在这两次的统计过程当中容器是否发生了变化:每个Segment内部有一个modCount变量,记录Segment被修改的次数,每次统计过程都会去统计modCount变量,通过比较前后两次统计得到的modCount变量的和是否相同来判断容器是否被修改。如果没有被修改则直接返回统计的得到的count的和,如果前后两次modCount的统计结果不一样,则容器被修改了,这个时候就会去对所有的Segment都加锁,然后统计count的和。

JDK1.8

JDK1.8中的ConcurrentHashMap取消了分段锁,采取了类似于1.8中的HashMap的设计,使用数组table+链表+红黑树来存储数据,同时使用synchronized+CAS操作来保证并发安全,并且相较于1.7版本,进一步细化了锁的粒度,每次加锁只对相应的哈希桶上的链表头结点或者红黑树的根节点加锁,提高了并发度。

sizeCtl属性:当table未初始化时,sizeCtl=0(未指定初始化容量)或sizeCtl>0(由指定的初始化容量计算而来,sizeCtl=x+x/2+1);当table初始化完成以后,sizeCtl为扩容的阈值(0.75*table.length);当sizeCtl<0时,有两种情况:table正在进行初始化,sizeCtl=-1;正在进行扩容,sizeCtl=-N,则有N-1个线程参与扩容。

初始化:当多个线程同时执行初始化时,会进入到一个while循环里,其中一个线程会通过CAS操作将sizeCtl设置为-1(相当于加锁),然后完成初始化的工作,其它线程检查到sizeCtl=-1就会执行yield()方法,让出CPU。

put操作:是一个无限循环的过程,直到操作成功。首先检查table是否初始化,然后通过再散列后的hash值定位在table的索引下标,并检查该位置是否初始化,如果没有则先创建一个结点并通过CAS操作设置在该位置。然后检查该位置的头结点的hash值是否为-1(代表正在进行扩容并且该索引位置已经转移到了新数组中,当前结点是一个占位结点),如果是则该线程先去进行辅助扩容的过程,完成后进入下一次循环(当扩容完成后这次循环操作的就是新数组,没有扩容完继续辅助扩容)。当前结点的hash不是-1时,会先用synchronized锁住头结点(如果是链表,使用的是尾插法,不会改变头结点;如果是红黑树,则头结点是一个TreeBin类型的结点,它对红黑树进行了一层包装,防止因为插入结点导致头结点发生改变,从而导致多个线程在该位置进行put操作)。判断当前索引位置是链表还是红黑树(通过头结点的hash值判断,链表结点hash值大于0,TreeBin结点的hash值为-2),如果是链表则进行链表的遍历,最后替换旧值或者使用尾插法插入新节点;如果是红黑树则进行红黑树的put操作。在遍历链表的过程中还会顺便统计链表的长度,如果链表的长度超过8且数组的长度大于64时,则会将该位置的链表变为一个红黑树(如果数组长度小于64则会进行扩容操作)。最后,将ConcurrentHashMap中元素个数加一,并判断是否需要进行扩容。

扩容:在以下三种情况中,该线程可能会触发扩容动作:1.在调用 addCount 方法增加集合元素计数后发现当前集合元素个数到达扩容阈值时就会触发扩容 ;2.扩容状态下其他线程对集合进行修改操作时(如put、remove等方法)遇到 ForwardingNode 节点会触发扩容 ;3.插入节点后链表长度达到 8 且数组长度小于64时会触发扩容 。在该线程参与扩容之前,会先根据CPU数和原数组长度计算出每个线程平均要负责多少个哈希桶的转移任务,最少为16个,然后根据要转移的哈希桶数和数组中哈希桶未分配的最大下标transferIndex来计算该线程负责转移的哈希桶的范围,变量i为上界,bound为下界。然后进入循环转移哈希桶的过程,对每个哈希桶进行转移之前,会先对该位置的头结点使用synchronized上锁,然后判断该位置是链表还是红黑树,如果是链表则会遍历两次链表,第一次遍历寻找从某个结点到尾结点的所有节点进行再散列后在新数组中的位置相同的最长子链表,然后将高位链表或低位链表的引用(原数组中的元素在新数组中只可能有两个位置)指向该链表的头部,第二次遍历是处理剩下的结点,创建结点副本并使用头插法拼接到高位链表或低位链表,最后将两条链表设置到对应的位置上,并将原数组的当前位置的头结点设置为占位对象ForwardingNode;如果是红黑树,则在遍历红黑树的过程中也会生成高位和低位两条链表,链表上的结点均为树结点且均是副本,最后如果生成的链表长度小于等于6,则将树结点的链表退化为链表结点的链表,否则将链表转化为红黑树,然后设置到新数组的对应位置上,并将原数组的当前位置的头结点设置为占位对象ForwardingNode,该哈希桶转移完成,开始下一次循环。每次循环都会将上界i减一,并判断是否小于下届bound,如果是代表本次的哈希桶区间处理完了则会去领取下一个哈希桶区间并继续处理,如果没有可分配的哈希桶区间了,则该线程退出辅助扩容的过程。对于最后一个线程来说,它在退出之前还会再去检查一遍原数组(上界i置成原数组长度n),看看是否有遗漏的哈希桶没有转移并将其转移,然后将table设置成新数组,sizeCtl设置成新的阈值。

扩容

get操作:ConcurrentHashMap的get操作全程是无锁的,因为ConcurrentHashMap内部所有的共享变量都是用volatile来修饰的,保证执行get操作的线程一定可以获取到最新的值,并且get操作不会修改共享变量,所以可以使用volatile来替换锁。并且在扩容的时候,对于已经转移了的哈希桶,通过占位结点的作用,会到新数组中执行查找过程;对于正在转移的哈希桶,转移的过程并没有破坏该哈希桶原来的引用结构,依旧可以获取到值;对于还未开始转移的哈希桶则可以正常获取到结果。

size操作:对于统计容器内的元素的个数的任务,ConcurrentHashMap采取了分散热点数据的思想,其内部维护了一个变量baseCount和一个数组CounterCell[](CounterCell内部有一个volatile修饰的变量,表示这个对象上记了多少个数)用来统计个数。其内部使用自旋CAS的方式来修改baseCount或counterCell[i],只要将两者其中之一修改成功则本次计数成功。具体来说:

  • 无竞争条件下,执行 put() 方法时,操作baseCount 实现计数
  • 首次竞争条件下,执行 put()方法,会初始化CounterCell ,并实现计数
  • CounterCell 一旦初始化,计数就优先使用CounterCell
  • 每个线程,要么修改CounterCell 、要么修改baseCount,实现计数
  • 如果 CAS 修改数组元素连续失败两次,就会进行 counterCells 数组的扩容(直到达到CPU数为止),从而减少了CAS循环的次数。

ConcurrentHashMap的size()就是将CounterCell数组中所有不为空的元素的value属性(volatile修饰)相加并与baseCount相加得到最终的结果。

Guess you like

Origin blog.csdn.net/zhang_qing_yun/article/details/119121142