JDK1.7和1.8 ConcurrentHashMap底层原理介绍

前言

ConcurrentHashMap是并发安全的HashMap,jdk1.7和jdk1.8中的底层实现有比较大的区别
ConcurrentHashMap当然还是一个HashMap,而对于HashMap的底层实现上本文不做赘述,可以看之前的关于HashMap底层原理介绍的文章
HashTable是Java中比较旧的键值对存储的实现,使用上和HashMap基本一致,底层实现上略有区别比如存储null的key和value时,数组初始化和扩容时的容量大小 ,继承的父类,hash值的使用等,但最大的区别还是HashTable是并发安全的,而并发安全的实现是在方法上使用了synchronized关键字,这种的实现效率比较低,可以想象一种场景,两个线程put的元素位置并不是同一个,但synchronized也强制顺序执行

jdk7 ConcurrentHashMap实现

底层结构

  1. 除了HashMap中的数组链表,ConcurrentHashMap中使用分段锁的机制来实现并发,Segment是ConcurrentHashMap中的静态内部类,继承了ReentrantLock,因此它天生就具有锁的特性。
  2. Segment数组的大小就是并发级别,默认16,可以在构造方法中指定,但最终的并发级别是大于等于我们指定值的2的n次方
  3. Segment数组中存储的元素是HashEntry的对象,HashEntry数组的大小也是大于等于我们给定值的2的n次方,最小是2

初始化

  1. HashMap的构造方法中并不会初始化数组,因为在我们的程序中调用构造方法创建了HashMap后可能后续没有使用到它,所以HashMap是在put方法中才进行计算阈值、数组大小、初始化数组等操作。ConcurrentHashMap在构造方法中会确定Segment数组和HashEntry数组的大小,创建一个Segment数组,并创建一个S0的Segment对象存放在Segment数组的0位置,其他位置都是空。这样在执行put方法时在数组其他位置生成Segment对象时就可以直接取用S0对象中的元数据属性比如数组大小,负载因子
  2. 数组下标的计算依旧是hash值和数组长度-1进行与运算

并发添加元素

  1. put方法中多线程并发生成Segment对象时用到了Unsafe类,其中用的比较多的方法比如
    compareAndSwapObject: 通过CAS方式来修改对象属性
    putOrderedObject :并发安全的给对象偏移量位置上赋值,可以用来给数组的某个位置赋值
    getObjectVolatile:并发安全的获取对象偏移量位置上的值,可以获取数组某个下标的元素
  2. ReentrantLock的tryLock方法是尝试加锁,返回成功还是失败的结果,lock方法没有返回值是阻塞的,直到加锁成功
  3. put方法执行时,先根据key计算出对应的Segment数组下标,使用Unsafe类while循环(自旋锁)的方式生成Segment对象。然后调用Segment对象的put方法,会先加锁,加锁时会有自旋重试加锁,达到最大次数后会用lock阻塞方法获取。
  4. 扩容:Segment数组一旦确定不会改变,扩容时只会扩容某一个Segment数组下面的HashEntry数组。扩容的条件是HashEntry数组容量大于等于阈值。扩容的基本过程是创建一个两倍大小的数组然后进行元素的迁移,迁移时的逻辑使用lastRun机制,有多次对链表的循环遍历,第一次先找lastRun节点(在新数组位于同一位置且在当前链表的末尾连续),后面几次遍历头插法划分成高位低位链表(顺序与原来可能不一致)
    扩容时普通链表的迁移

size方法统计数量

对每个Segment都要进行统计,两次统计的数量相等且modCount的值也相等就取该值,否则重新统计,多次统计都不相同就将所有segment加锁统计

jdk8 ConcurrentHashMap实现

底层结构

  1. jdk8的ConcurrentHashMap没有Segment的概念,put元素时在数组的下标位置上直接使用Synchronized加锁。数据结构上依旧是数组链表加红黑树
  2. 链表元素的存储使用继承了Map.Entry的静态内部类Node,红黑树节点使用TreeNode类,而对于红黑树这个对象会使用一个TreeBin类来表示,它继承了Node类,这里和jdk1.8的HashMap不同,HashMap中的数组元素存储红黑树是存储的根节点,而这里的ConcurrentHashMap中数组元素存储红黑树实际存储的是TreeBin类的对象,这个类依旧有一个属性是TreeNode类型的红黑树根节点,还提供了一些与锁相关的属性和方法用来锁住这棵树比如lockRoot(),unLockRoot(),实现上就是使用Unsafe类修改TreeBin中的几个读写锁的属性
  3. ConcurrentHashMap在构造函数中只会初始化sizeCtl值,并不会直接初始化table,而是延缓到第一次put操作。
  4. volatile int 类型的sizeCtl属性在数组不同状态下有相应的变化
    默认为0
    新建而未初始化时用于记录初始容量大小
    执行第一次put操作的线程会执行Unsafe修改sizeCtl为-1,有且只有一个线程能够修改成功,其它线程通过Thread.yield()让出CPU时间片等待table初始化完成。
    为-N 时表示有N-1个线程正在进行扩容操作
    扩容和初始化完成后会将sizeCtl更新成阈值

统计元素个数

  1. 在put方法执行成功后会将集合的元素个数加1,由于这个过程是并发的,所以jdk8中有一套专门的逻辑来处理这个操作,核心方法是addCount
  2. 执行的基本逻辑是使用Unsafe类先将baseCount属性值加1,如果失败,会借助一个counterCells数组来协助完成加1操作。这个数组存储的是CounterCell类的对象,CounterCell类只有一个volatile long类型的value值,也是用来表示元素个数。当前线程生成一个随机值并和CounterCell数组长度减一进行与运算计算出下标后,通过Unsafe类操作这个位置的CounterCell的value属性加1,如果失败再重新生成随机值计算下标重新用Unsafe加1,两次都失败的话会扩容这个数组,扩容后再重复上面的操作
  3. CounterCell数组扩容后的大小为原来的两倍,扩容前会判断这个大小不能大于等于CPU核数。初始化数组,new一个CounterCell放入数组,给数组中的CounterCell value加1都会先获取cellBusy标记,判断数组是否处于忙碌状态
  4. size()获取集合中元素个数是通过baseCount以及合并统计CounterCell数组中CounterCell的value值计算出来的

扩容

这里的扩容是存储键值对的Node数组的扩容,不是上面说的CounterCell数组的扩容

  1. 条件:扩容时的条件是元素个数大于阈值且数组的长度小于2的30次方。数组转红黑树的条件是链表节点个数大于等于8且数组中的元素个数大于等于64,如果小于64则进行数组的扩容。逻辑上跟jdk8中的HashMap差不多但是对于阈值8的判断有些不同,HashMap中的链表树化条件是大于8而不是大于等于
  2. 扩容过程:
  • 扩容的逻辑位置就是上面说的链表树化前会判断数组元素个数大于等于64,如果小于64则进行数组的扩容。添加完元素后执行addCount方法,其中会检查扩容条件,满足条件时进行扩容
  • put方法执行时判断插入的数组下标位置有元素且是ForwardingNode类型,如果是就会帮助这个数组进行扩容。ForwardingNode类型的典型标志就是hash值为-1,也存储了扩容时新数组的引用,只有table发生扩容的时候,ForwardingNode才会发挥作用,作为一个占位符放在table中表示当前节点为null或者已经被移动
  • 先计算一个stride(步长),最小是16,这个可以理解为线程一次负责转移的数组中元素个数。从数组右边开始向左数stride个数的数组位置就是本次转移的边界,线程会从数组的最右边开始向左依次进行元素迁移任务。
    基本的转移过程就是新建2倍大小的数组并迁移元素,迁移的过程中对于链表和红黑树总的来说都会分成数组的高位低位链表一次性转移,其中的链表迁移与jdk7中的一致,也是lastRun机制,循环遍历头插法分成高位低位链表
  • 一次步长的数组元素转移完后会再向前(左)找其他下标位置是否有线程在转移,没有就继续帮助转移。该线程转移任务完成后会等待其他线程扩容也完毕,然后重新获取新数组进行put
已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 技术工厂 设计师:CSDN官方博客 返回首页