在上一章中咱们讲到在高并发下,使用hashMap会致使一系列的问题。那么咱们当咱们须要使用相似于hashMap那样的存储集合类的时候,咱们该怎么作呢?java
Java的集合容器框架中,主要有四大类别:List、Set、Queue、Map,你们熟知的这些集合类ArrayList、LinkedList、HashMap这些容器都是非线程安全的。node
若是有多个线程并发地访问这些容器时,就会出现问题。所以,在编写程序时,在多线程环境下必需要求程序员手动地在任何访问到这些容器的地方进行同步处理,这样致使在使用这些容器的时候很是地不方便。程序员
并发类容器是专门针对多线程并发设计的,使用了锁分段技术,只对操做的位置进行同步操做,可是其余没有操做的位置其余线程仍然能够访问,提升了程序的吞吐量。算法
采用了CAS算法和部分代码使用synchronized锁保证线程安全。编程
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap相似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每一个HashEntry是一个链表结构的元素,每一个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先得到与它对应的Segment锁。数据结构以下数组
ConcurrentHashMap初始化方法是经过initialCapacity、loadFactor和concurrencyLevel(参数concurrencyLevel是用户估计的并发级别,就是说你以为最多有多少线程共同修改这个map,根据这个来肯定Segment数组的大小concurrencyLevel默认是DEFAULT_CONCURRENCY_LEVEL = 16;)等几个参数来初始化segment数组、段偏移量segmentShift、段掩码segmentMask和每一个segment里的HashEntry数组来实现的。安全
并发级别能够理解为程序运行时可以同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度。ConcurrentHashMap默认的并发度为16,但用户也能够在构造函数中设置并发度。当用户设置并发度时,ConcurrentHashMap会使用大于等于该值的最小2幂指数做为实际并发度(假如用户设置并发度为17,实际并发度则为32)。微信
若是并发度设置的太小,会带来严重的锁竞争问题;若是并发度设置的过大,本来位于同一个Segment内的访问会扩散到不一样的Segment中,CPU cache命中率会降低,从而引发程序性能降低。(文档的说法是根据你并发的线程数量决定,太多会导性能下降)数据结构
segments数组的长度ssize是经过concurrencyLevel计算得出的。为了能经过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方(power-of-two size),因此必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值来做为segments数组的长度。假如concurrencyLevel等于1四、15或16,ssize都会等于16,即容器里锁的个数也是16。多线程
输入参数initialCapacity是ConcurrentHashMap的初始化容量,loadfactor是每一个segment的负载因子,在构造方法里须要经过这两个参数来初始化数组中的每一个segment。上面代码中的变量cap就是segment里HashEntry数组的长度,它等于initialCapacity除以ssize的倍数c,若是c大于1,就会取大于等于c的2的N次方值,因此segment里HashEntry数组的长度不是1,就是2的N次方。
在默认状况下, ssize = 16,initialCapacity = 16,loadFactor = 0.75f,那么cap = 1,threshold = (int) cap * loadFactor = 0。
既然ConcurrentHashMap使用分段锁Segment来保护不一样段的数据,那么在插入和获取元素的时候,必须先经过散列算法定位到Segment。ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再散列。
ConcurrentHashMap彻底容许多个读操做并发进行,读操做并不须要加锁。ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的以及volatile关键字。
get操做先通过一次再散列,而后使用这个散列值经过散列运算定位到Segment(使用了散列值的高位部分),再经过散列算法定位到table(使用了散列值的所有)。整个get过程,没有加锁,而是经过volatile保证get老是能够拿到最新值。
ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其余槽,在插入第一个值的时候再进行初始化。
ensureSegment方法考虑了并发状况,多个线程同时进入初始化同一个槽 segment[k],但只要有一个成功就能够了。
具体实现是
put方法会经过tryLock()方法尝试得到锁,得到了锁,node为null进入try语句块,没有得到锁,调用scanAndLockForPut方法自旋等待得到锁。
scanAndLockForPut方法里在尝试得到锁的过程当中会对对应hashcode的链表进行遍历,若是遍历完毕仍然找不到与key相同的HashEntry节点,则为后续的put操做提早建立一个HashEntry。当tryLock必定次数后仍没法得到锁,则经过lock申请锁。
在得到锁以后,Segment对链表进行遍历,若是某个HashEntry节点具备相同的key,则更新该HashEntry的value值,
不然新建一个HashEntry节点,采用头插法,将它设置为链表的新head节点并将原头节点设为新head的下一个节点。新建过程当中若是节点总数(含新建的HashEntry)超过threshold,则调用rehash()方法对Segment进行扩容,最后将新建HashEntry写入到数组中。
扩容是新建立了数组,而后进行迁移数据,最后再将 newTable 设置给属性 table。
为了不让全部的节点都进行复制操做:因为扩容是基于2的幂指来操做,假设扩容前某HashEntry对应到Segment中数组的index为i,数组的容量为capacity,那么扩容后该HashEntry对应到新数组中的index只可能为i或者i+capacity,所以不少HashEntry节点在扩容先后index能够保持不变。
假设原来table长度为4,那么元素在table中的分布是这样的
扩容后table长度变为8,那么元素在table中的分布变成:
能够看见 hash值为34和56的下标保持不变,而15,23,77的下标都是在原来下标的基础上+4便可,能够快速定位和减小重排次数。
该方法没有考虑并发,由于执行该方法以前已经获取了锁。
与put方法相似,都是在操做前须要拿到锁,以保证操做的线程安全性。
而后对链表遍历判断是否存在key相同的节点以及得到该节点的value。但因为遍历过程当中其余线程可能对链表结构作了调整,所以get和containsKey返回的多是过期的数据,这一点是ConcurrentHashMap在弱一致性上的体现。若是要求强一致性,那么必须使用Collections.synchronizedMap()方法。
这些方法都是基于整个ConcurrentHashMap来进行操做的,他们的原理也基本相似:首先不加锁循环执行如下操做:循环全部的Segment,得到对应的值以及全部Segment的modcount之和。在 put、remove 和 clean 方法里操做元素前都会将变量 modCount 进行变更,若是连续两次全部Segment的modcount和相等,则过程当中没有发生其余线程修改ConcurrentHashMap的状况,返回得到的值。
当循环次数超过预约义的值时,这时须要对全部的Segment依次进行加锁,获取返回值后再依次解锁。因此通常来讲,应该避免在多线程环境下使用size和containsValue方法。
改进一:取消segments字段,直接采用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(logN),能够改进性能。
使用 Node(1.7 为 Entry) 做为链表的数据结点,仍然包含 key,value,hash 和 next 四个属性。 红黑树的状况使用的是 TreeNode(extends Node)。
根据数组元素中,第一个结点数据类型是 Node 仍是 TreeNode 能够判断该位置下是链表仍是红黑树。
用于判断是否须要将链表转换为红黑树的阈值
用于判断是否须要将红黑树转换为链表的阈值
Node
Node是最核心的内部类,它包装了key-value键值对。
定义基本和1.7中的HashEntry相同。而这个map自己所持有的也是一个Node型的数组
增长了一个find方法来用以辅助map.get()方法。其实就是遍历链表,子类中会覆盖这个方法。
在map中还定义了Segment这个类,不过只是为了向前兼容而已,不作过多考虑。
TreeNode
树节点类,另一个核心的数据结构。当链表长度过长的时候,会转换为TreeNode。
与1.8中HashMap不一样点:
一、它并非直接转换为红黑树,而是把这些结点放在TreeBin对象中,由TreeBin完成对红黑树的包装。
二、TreeNode在ConcurrentHashMap扩展自Node类,而并不是HashMap中的扩展自LinkedHashMap.Entry<K,V>类,也就是说TreeNode带有next指针。
TreeBin
负责TreeNode节点。它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象。另外这个类还带有了读写锁机制。
特殊的ForwardingNode
一个特殊的 Node 结点,hash 值为 -1,其中存储 nextTable 的引用。有 table 发生扩容的时候,ForwardingNode 发挥做用,做为一个占位符放在 table 中表示当前结点为 null 或者已经被移动。
sizeCtl
用来控制 table 的初始化和扩容操做。
负数表明正在进行初始化或扩容操做
0为默认值,表明当时的table尚未被初始化
正数表示初始化大小或Map中的元素达到这个数量时,须要进行扩容了。
能够发现,在new出一个map的实例时,并不会建立其中的数组等等相关的部件,只是进行简单的属性设置而已,一样的,table的大小也被规定为必须是2的乘方数。
真正的初始化在放在了是在向ConcurrentHashMap中插入元素的时候发生的。如调用put、computeIfAbsent、compute、merge等方法的时候,调用时机是检查table==null。
get方法比较简单,给定一个key来肯定value的时候,必须知足两个条件 key相同 hash值相同,对于节点可能在链表或树上的状况,须要分别去查找。
总结来讲,put方法就是,沿用HashMap的put方法的思想,根据hash值计算这个新插入的点在table中的位置i,若是i位置是空的,直接放进去,不然进行判断,若是i位置是树节点,按照树的方式插入新的节点,不然把i插入到链表的末尾。
总体流程上,就是首先定义不容许key或value为null的状况放入 对于每个放入的值,首先利用spread方法对key的hashcode进行一次hash计算,由此来肯定这个值在table中的位置。
若是这个位置是空的,那么直接放入,并且不须要加锁操做。
若是这个位置存在结点,说明发生了hash碰撞,首先判断这个节点的类型。若是是链表节点,则获得的结点就是hash值相同的节点组成的链表的头节点。须要依次向后遍历肯定这个新加入的值所在位置。若是遇到hash值与key值都与新加入节点是一致的状况,则只须要更新value值便可。不然依次向后遍历,直到链表尾插入这个结点。若是加入这个节点之后链表长度大于8,就把这个链表转换成红黑树。若是这个节点的类型已是树节点的话,直接调用树节点的插入方法进行插入新的值。
前面说过,构造方法中并无真正初始化,真正的初始化在放在了是在向ConcurrentHashMap中插入元素的时候发生的。具体实现的方法就是initTable
当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做为步长控制,每一个线程每次负责迁移其中的一部分。
移除方法的基本流程和put方法很相似,只不过操做由插入数据变为移除数据而已,并且若是存在红黑树的状况下,会检查是否须要将红黑树转为链表的步骤。再也不重复讲述。
用于将过长的链表转换为TreeBin对象。可是他并非直接转换,而是进行一次容量判断,若是容量没有达到转换的要求,直接进行扩容操做并返回;若是知足条件才将链表的结构转换为TreeBin ,这与HashMap不一样的是,它并无把TreeNode直接放入红黑树,而是利用了TreeBin这个小容器来封装全部的TreeNode。
在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经有处理了,能够注意一下Put函数,里面就有addCount()函数,早就计算好的,而后你size的时候直接给你。JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大的意义的,由于size是实时在变的。
在具体实现上,计算大小的核心方法都是 sumCount()
能够看见,统计数量时使用了 baseCount、和CounterCell 类型的变量counterCells 。其实baseCount就是记录容器数量的,而counterCells则是记录CAS更新baseCounter值时,因为高并发而致使失败的值。这两个变量的变化在addCount() 方法中有体现,大体的流程就是:
一、对 baseCount 作 CAS 自增操做。
二、若是并发致使 baseCount CAS 失败了,则使用 counterCells。
三、若是counterCells CAS 失败了,在 fullAddCount 方法中,会继续死循环操做,直到成功。
HashTable容器你点进去会发现,纯粹是hashMaP方法中添加了synchronized,来保证线程安全。在线程竞争激烈的状况下HashTable的效率很是低下。由于当一个线程访问HashTable的同步方法,其余线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,因此竞争越激烈效率越低。
Q:HashMap 和 HashTable 有什么区别?
A:①、HashMap 是线程不安全的,HashTable 是线程安全的;
②、因为线程安全,因此 HashTable 的效率比不上 HashMap;
③、HashMap最多只容许一条记录的键为null,容许多条记录的值为null,而 HashTable 不容许;
④、HashMap 默认初始化数组的大小为16,HashTable 为 11,前者扩容时,扩大两倍,后者扩大两倍+1;
⑤、HashMap 须要从新计算 hash 值,而 HashTable 直接使用对象的 hashCode
Q:Java 中的另外一个线程安全的与 HashMap 极其相似的类是什么?一样是线程安全,它与 HashTable 在线程同步上有什么不一样?
A:ConcurrentHashMap 类(是 Java并发包 java.util.concurrent 中提供的一个线程安全且高效的 HashMap 实现)。
HashTable 是使用 synchronize 关键字加锁的原理(就是对对象加锁);
而针对 ConcurrentHashMap,在 JDK 1.7 中采用分段锁的方式;JDK 1.8 中直接采用了CAS(无锁算法)+ synchronized,也采用分段锁的方式并大大缩小了锁的粒度。
HashMap & ConcurrentHashMap 的区别?
A:除了加锁,原理上无太大区别。
另外,HashMap 的键值对容许有null,可是ConCurrentHashMap 都不容许。
在数据结构上,红黑树相关的节点类
Q:为何 ConcurrentHashMap 比 HashTable 效率要高?
A:HashTable 使用一把锁(锁住整个链表结构)处理并发问题,多个线程竞争一把锁,容易阻塞;
ConcurrentHashMap
JDK 1.7 中使用分段锁(ReentrantLock + Segment + HashEntry),至关于把一个 HashMap 分红多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry。
JDK 1.8 中使用 CAS + synchronized + Node + 红黑树。锁粒度:Node(首结点)(实现 Map.Entry<K,V>)。锁粒度下降了。
Q:针对 ConcurrentHashMap 锁机制具体分析(JDK 1.7 VS JDK 1.8)?
JDK 1.7 中,采用分段锁的机制,实现并发的更新操做,底层采用数组+链表的存储结构,包括两个核心静态内部类 Segment 和 HashEntry。
①、Segment 继承 ReentrantLock(重入锁) 用来充当锁的角色,每一个 Segment 对象守护每一个散列映射表的若干个桶;
②、HashEntry 用来封装映射表的键-值对;
③、每一个桶是由若干个 HashEntry 对象连接起来的链表。
JDK 1.8 中,采用Node + CAS + Synchronized来保证并发安全。取消类 Segment,直接用 table 数组存储键值对;当 HashEntry 对象组成的链表长度超过 TREEIFY_THRESHOLD 时,链表转换为红黑树,提高性能。底层变动为数组 + 链表 + 红黑树。
Q:ConcurrentHashMap 在 JDK 1.8 中,为何要使用内置锁 synchronized 来代替重入锁 ReentrantLock?
A:
一、JVM 开发团队在1.8中对 synchronized作了大量性能上的优化,并且基于 JVM 的 synchronized 优化空间更大,更加天然。
二、在大量的数据操做下,对于 JVM 的内存压力,基于 API 的 ReentrantLock 会开销更多的内存。
Q: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中并发度则无太大的实际意义了,主要用处就是当设置的初始容量小于并发度,将初始容量提高至并发度大小。
5、总结
本章主要介绍ConcurrentHashMap的源码以及与HashMap的区别。以及为何平时咱们都不用HashTable。但愿你们能够经过本章学习到分段表的设计思想以及以及前面学习到的cas操做的应用,了解容器和它的方法,以及懂得在合适场景选择最合适的容器和方法。
——————————————————————分割线————————————————————————————
下一节将给你们介绍下其余的并发容器,同时也是并发专题的最后一篇。后续若是有了解到新的知识点会继续更新相关专题。以及对之前老的博客进行更新,使其更加细致。让每一个开发者都能看懂。
其余阅读 并发编程专题
你们有问题能够加我微信哈~