ConcurrentHashMap为什么是线程安全的?

JDK1.7
  CpncurrentHashMap简介:
  ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里面扮演锁的角色;HashEntry则用于存储键值对数据。
  一个ConcurentHashMap里面包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里面包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segmnet锁。
   ConcurrentHashMap 使用分段锁Segment来保护不同段的数据,在插入和获取元素的时候,必须先通过散列算法定位到Segmant。
  ConcurrentHashMap 完全允许多个读操作并发进行,读操作并不需要加锁。ConcurrentHashMap 实现技术是保证HashEntry 几乎是不可变的以及volatile 关键字。
get操作
   get操作先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到table,整个get过程没有加锁,而是通过volatile保证get总是可以拿到最新的值。
put操作
   put操作:ConcurrentHashMap初始化的时候会初始化第一个 槽segment[0],对于其他槽,再插入第一个值的时候再初始化。ensureSegment方法考虑了并发情况,多个线程同时进入初始化同一个槽segment[k],但只要又一个成功就可以了。(循环CAS操作,保证多线程下只有一个线程可以成功)
   put方法会通过tryLock方法尝试获得锁,获得了锁,node为null进入try语句块,没有获得锁,调用scanAndLockForPut方法自旋等待获得锁。
  scanAndLockForPut方法里面在尝试获得锁的过程中,会对对应hashcode的链表进行遍历,如果遍历完毕仍然找不到与key相同的HashEntry节点,则为后续的put操作提前创建HashEntry。当tryLock一定次数后仍然无法获得锁,
  则通过lock申请锁,在获得锁之后,Segment对链表进行遍历,如果某个HashEntry节点具有相同的key,则更新该HashEntry的value值,否则就新建一个HashEntry节点,采用头插法,将它设置为链表的新head节点并将原头节点设置为head的下一个节点。
   新建过程中,如果节点(含新建节点)总数超过threshold(数组容量的0.75),则调用rehash()方法对Segment进行扩容,最后将新建的HashEntry写入到新的数组中。

在1.8下的实现

  改进一:取消segment字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对缩小锁的粒度,进一步减少并发冲突的概率,并大量采用了CAS+synchronized来保证并发安全性。

  改进二:将原先table数据+单向链表的数据结构,变更为table数组+单向链表+红黑树结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均匀,那么table数组中的每个队列长度主要为0或者1.但实际情况并非总是如此理想,虽然ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表的方式,那么查询某个节点是事件复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的事件复杂度降低到O(longN),可以改进性能 。

  使用Node(1.7为Entry)作为链表的数据节点,仍然包含key,value,hash和next四个属性。红黑树的情况使用的是TreeNode(extends Node)。
  根据数组元素中,第一个节点数据类型是Node还是TreeNode可以判断该位置下是链表还是红黑树。
  总结来说,put方法就是沿用HashMap的put方法的思想,根据hash值计算这个新插入的点在table中的位置i;如果i位置是空的,直接放进去否则进行判断,如果i位置是树节点,按照树的方式插入新的节点,否则把i插入到链表的末尾。
  整体流程上,就是首先定义不允许key或value为null的情况放入,对于每个放入的值,首先利用spread方法对hashcode进行一次hash计算,由此来确定的那个这个值在table中的位置。
  如果这个位置是空的,那么直接放入,而且不需要加锁操作。
  如果这个位置存在结点,说明发生了hash碰撞,首先判断或者节点的类型。如果是链表节点,则得到的节点就是hash值相同的节点组成的链表的头节点。需要依次向后遍历去顶这个新加入的值所在位置。如果遇到hash值与key值都与新加入节点是一致的情况,则只需要更新value值即可。否则依次向后遍历,知道链表尾插入这个节点。如果加入这个节点以后链表长度大于8,就把这个链表转换成红黑树。如果这个节点类型已经是树节点,直接调用树节点的插入方法进行插入新的值。
  初始化
  构造方法中没有真正的初始化,真正的初始化放在了向ConcurrrentHashMap中插入元素的时候发生的。具体的方法就是initTable.
  transfer
  当ConcurrentHashMap容量不足的时候,需要对table进行扩容。这个方法的基本思想跟HashMap是很相似的,但是由于它是支持并发扩容的,所以要复杂的多。
  为何要并发扩容?因为在扩容的时候,总是涉及到从一个”数组“到另一个“数组”拷贝的操作,如果这个操作能够并发进行,就能够利用并发处理去减少扩容带来的时间影响。
  整个扩容操作分为两个部分:
  第一部分是构建一个nextTable,它的容量是原来的2倍。
  第二部分就是将原来的table中的元素复制到nextTable中,这里允许多线程操作。
  整个扩容流程就是遍历和复制:
  为null或者已经处理过的节点,会被设置为forwardNode节点,当线程准备扩容时,发现节点是forwardNode节点,跳过这个节点,继续寻找未处理的节点,找到了,对节点上锁,
  如果这个位置是Node节点(fh>0),说明它是一个链表,就构造一个反序链表,把它们分别放在nextTable的i和i+n的位置上
  如果这个位置是TreeBin节点(fh<0),也做一个反序处理,并且判断是否需要红黑树转链表,把处理的结果分别放在nextTable的i和i+n的位置上
  遍历过所有的节点以后就完成了复制工作,这是让nextTable作为新的Table,并且更新sizeCtl为新容量的0.75倍,完成扩容。
  并发扩容其实就是将数据迁移任务拆分成多个小迁移任务,在实现上使用了一个变量stride作为步长空值,每个线程每次负责迁移其中的一部分。
  remove
  移除方法的基本流程和put方法很类似,之不够操作由插入数据变为移除数据,而且如果存在红黑树的情况下,会检查是否需要将红黑树转为链表的步骤。
  treeifyBin
  用于将过长的链表转换为TreeBin对象。但是它并不是直接转换,而是进行一次容量判断,如果容量没有达到转换要求,直接进行扩容操作并返回;如果满足条件才将链表的结构转换为TreeBin,这与HashMap不同的是,它并没有把TreeNode直接放入红黑树,而是利用了TreeBin这个小容器来封装所有的TreeNode。
  size
  在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经由处理了,可以注意一下put函数,里面由addCount()函数,早就计算好的,然后你size的时候直接给你,JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大意义的,因为size是实时在变的。
  在具体实现上,计算大小的核心方法都是 sumCount()

在这里插入图片描述

可以看见,统计数量时使用了baseCount和CountCell类型的变量countCells。其实baseCount就是记录容器数量的,而countCells则是记录CAS更新baseCounter值时,由于高并发而导致失败的值。这两个变量的变化在addCount()方法中有体现,大致流程是:
  1、对baseCount做CAS自增操作。
  2、如果并发导致baseCountCAS失败了,使用counterCells。
  3、如果countCells CAS失败了,在fullAddCount方法中,会继续死循环操作,直到成功。
   HashTable
   HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况小HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取数据,所以竞争越激烈效率越低。
   HashMap和HashTable有什么区别?
A:1、HashMap是线程不安全的,HashTable是线程安全的;
2、由于线程安全,所以HashTable的效率比不上HashMap;
3、HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而HashTable不允许;
4、HashMap默认初始化数组的大小为16.HashTable为11,前者扩容时,扩大两倍,后者扩大两倍+1;
5、HashMap需要重新计算hash值,而HashTable直接使用对象的hashCode
   Java中的另一个线程安全的与HashMap极其相似的类是什么呢?同样是线程安全,它与HashTable在线程同步上有什么不同?
   A:ConcurrentHashMap类(是Java并发包Java.util.concurrent中提供的一个线程安全且高效的HashMap的实现)。
  HashTable是使用synchronize关键字加锁的原理(就是对对象加锁);
   而针对ConcurrentHashMap,在JDK1.7中采用分段锁的方法;JDK1.8中直接采用了CAS(无锁算法)+synchronized,也采用分段锁的方式并大大缩小了锁的粒度。
   HashMap&ConcurrentHashMap的区别?
   A:除了加锁,原理上无太大区别。
   另外,HashMap的键值对允许有null,但是ConcurentHashMap都不允许。在数据结构上,红黑树相关节点类
  为什么ConcurrentHashMap比HashTable效率高?
   A:HashTable使用一把锁(锁住整个链表结构)处理并发问题,多个线程竞争一把锁,容易阻塞;
   ConcurrentHashMap
   JDK1.7中使用分段锁(RenTrantLock+Segment+HashEntry),相当于把一个HashMap分成多个段,把每段分配一把锁,这样支持多线程访问。锁粒度:基于Segment。包含多个HashEntry。
   JDK1.8中使用CAS+synchronized+Node+红黑树。锁粒度:Node(首节点)(实现Map.Entry<K,V>)。锁粒度降低了。
   针对ConcurrentHashMap锁机制具体分析(JDK1.7 VS JDK1.8)?
   JDK1.7中,采用分段锁的机制,实现并发的更新操作,底层采用数组+链表的存储结构,包括两个核心静态内部类Segment和HashEntry。
   1、Segment继承ReentrantLock(重入锁)用来充当锁的角色,每个Segment对象守护每个散列映射表的若干个桶;
   2、HashEntry用来封装映射表的键-值对;
   3、每个桶是由若干个HashEnry对象链接起来的链表。
   JDK1.8中,采用Node+CAS数组存储键值对;当HashEntry独享组成的链表长度超过TREEIFY_THRESHOLD时,链表转换为红黑树,提升性能。地城变更为数组+链表+红黑树。
   ConcurrentHashMap在JDK1.8中,为什么要使用内置synchronized来代替重入锁ReentrantLock?
   A:1、JVM开发团队在1.8中对synchronized做了大量性能上的优化,而且基于JVM的synchronized优化空间更大,更加自然。
   2、在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多内存。
ConcurrentHashMap 简单介绍?
A:
①、重要的常量:
private transient volatile int sizeCtl;
当为负数时,-1 表示正在初始化,-N 表示 N - 1 个线程正在进行扩容; 当为 0 时,表示 table 还没有初始化;
当为其他正数时,表示初始化或者下一次进行扩容的大小。
②、数据结构:
Node 是存储结构的基本单元,继承 HashMap 中的 Entry,用于存储数据; TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储
结构,用于红黑树中存储数据;
TreeBin 是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。
③、存储对象时(put() 方法):
1.如果没有初始化,就调用 initTable() 方法来进行初始化;
2.如果没有 hash 冲突就直接 CAS 无锁插入;
3.如果需要扩容,就先进行扩容;
4.如果存在 hash 冲突,就加锁来保证线程安全,两种情况:一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入;
5.如果该链表的数量大于阀值 8,就要先转换成红黑树的结构,break 再一次进入循环
6.如果添加成功就调用 addCount() 方法统计 size,并且检查是否需要扩容。
④、扩容方法 transfer():默认容量为 16,扩容时,容量变为原来的两倍。helpTransfer():调用多个工作线程一起帮助进行扩容,这样的效率就会更高。
⑤、获取对象时(get()方法):
1.计算 hash 值,定位到该 table 索引位置,如果是首结点符合就返回;
2.如果遇到扩容时,会调用标记正在扩容结点 ForwardingNode.find()方法, 查找该结点,匹配就返回;
3.以上都不符合的话,就往下遍历结点,匹配就返回,否则最后就返回 null。

Q:ConcurrentHashMap 的并发度是什么?
A:1.7 中程序运行时能够同时更新 ConccurentHashMap 且不产生锁竞争的最大线程数。默认为 16,且可以在构造函数中设置。当用户设置并发度时, ConcurrentHashMap 会使用大于等于该值的最小 2 幂指数作为实际并发度(假如用户设置并发度为 17,实际并发度则为 32)。

1.8中并发度则无太大的实际意义了,主要用处就是当设置的初始容量小于并发度,将初始容量提升至并发度大小。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值