容器是Java编程中使用频率很高的组件,但Java默认提供的基本容器(ArrayList,HashMap等)均不是线程安全的。当容器和多线程并发编程相遇时,程序员又该何去何从呢?html
一般有两种选择:java
一、使用synchronized关键字,将对容器的操做有序错开,确保同一时刻对同一个容器只存在一个操做。Vector,HashTable等封装后的容器本质也是这种解决思路,只不过synchronized关键字不须要咱们来书写而已。程序员
二、使用java.util.concurrent包下提供的并发容器。好比常见的ConcurrentHashMap、CopyOnWriteArrayList等。编程
第一种选择的优势是上手快,简单直接,易于调试,若是不考虑性能的话,几乎没有任何使用场景的限制,能够保证数据操做的强一致性;那么它的缺点也是很明显的,因为每次对容器的操做都锁住了整个容器,若是对容器进行高并发的操做,将致使操做性能急剧降低。数组
第二种选择的优势是concurrent包下的并发容器一般都作了性能上的高度优化,能保障高并发场景下的操做性能;但缺点是这些容器的背后实现原理相对复杂,并且对使用场景有必定限制,通常只能保证数据操做的弱一致性。安全
本文将重点介绍并发容器背后的典型设计思路与实现原理,读者了解了这些实现思路后,也能够更好的理解并发容器的使用场景的限制。多线程
关于ConcurrentHashMap的实现原理,在JDK1.8与JDK1.8以前有不一样的实现,关于它们具体的实现细节网上已经有不少优秀的文章进行介绍,好比:并发
一、《JDK1.7 ConcurrentHashMap原理分析》app
二、《JDK1.8 ConcurrentHashMap原理分析》jvm
三、 《ConcurrentHashMap在JDK1.7与JDK1.8中的对比》
此处便不在赘述了。
本文重点用简洁易懂的语言带领读者快速掌握ConcurrentHashMap在JDK1.8中高并发实现的原理。
首先咱们简单回顾一下普通HashMap的实现原理。
如上图所示,咱们将Map中储存的每个Entry抽象为一个Node。Node根据其Key值Hash取余后,映射到Table(一个Node数组)的某一个槽位上进行储存。若是出现Hash冲突(即两个Node的Key值Hash取余结果相同),则以链表的形式在出现冲突的Table槽位上继续追加Node。若是某一个槽位以链表的形式储存了过多的Node(8个以上),则将链表转换为红黑树储存,避免查询Node时对长链表的遍历,以下降查询Node的时间复杂度。当Map中容纳的Node总数大于Table长度乘以加载因子factor(默认0.75)时,Map会将Table成倍扩容,以减小Hash冲突的几率。
传统的HashTable之因此并发性能不好,缘由在于锁的范围过大,更新任何一个数据,都要将全Map锁住。
其实中HashMap的实现原理不难看出,HashMap自己自然就呈现出边界清晰的分段储存特性,即每个Table中的一个槽位,便可认为是一个储存段。那么,若是咱们将锁的精度精确到每个储存段,就能够实现更新每个数据,只会对与该数据相关的局部数据段加锁。而每一个储存段的头结点,便可做为加锁对象。
JDK1.8中的核心源码以下:
Node<K,V> f; f = tabAt(tab, i = (n - 1) & hash); //取出Tab指定槽中的头结点 synchronized (f) { //对这个头结点加锁 //... ... }
若是某个槽位中尚不存在任何头结点(即头结点为null),此时咱们不能对null进行加锁,又如何规避该槽位首次插入Node时可能遭遇的并发冲突呢?
可使用CAS(Compare And Swap(Set))进行Node的首次插入。CAS的核心原理是更新某个数据前,检查该数据的值是否仍是以前获取获得的旧值,若是是则说明该值尚未被其余线程修改,能够直接修改成新值,不然则说明该值已经被其余线程修改了,则设置失败。检查旧值是否被修改与设置新值这两步操做由CPU提供的单指令直接完成,保证原子性。
使用CAS技术加上CAS失败后的不断重试,便可实现无锁化更新数据。毕竟CAS失败的几率很低,不断重试也不会占用过多CPU。(乐观锁与自旋锁的理念)
JDK1.8中的核心源码以下:
for (Node<K, V>[] tab = table; ; ) { if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K, V>(hash, key, value, null))) break; //CAS失败则跳出循环,开始下一次循环,从新读取头结点 } }
ConcurrentHashMap的读操做都是不加锁的。能够保证的是,读取某一个指定key的值时能够读取到最近一次更新完成的结果。更标准的说法是,上一次对keyA的更新结果happens-before后续对keyA的读取操做。
注:happens-before是jvm用来定义两个action之间(acitonA和actionB)的偏序关系,从而明确在CPU容许重排序的状况下,actionA发生的结果是必定要对后续发生的actionB可见的。
因为读操做不加锁,读操做可能会与其余线程的写操做重叠,ConcurrentHashMap可能会读取到其余线程写操做的中间状态。好比putAll在执行过程当中有并发的get操做,那么get操做可能只会读取到插入的部分数据,同时并发的size操做的返回结果也是不许确的,只可用于估算类业务,不可用于精准的控制流程判断。再好比使用迭代器遍历Map时,另一个线程正在删除Map,那么在读取过程当中碰巧尚未被删除的数据会被读取到,而已经被删除的数据不会被读取到(不会抛出ConcurrentModificationException)。
所谓写时复制,即任何要改变CopyOnWriteArrayList的操做(add、set等),其内部实现都是深拷贝一份CopyOnWriteArrayList的底层数组,而后在深拷贝的副本上进行数据的修改。修改完成后,再用新的副本与替换原来的CopyOnWriteArrayList底层数组。
JDK1.8中的核心代码以下:
public boolean add(E e) { final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); int len = elements.length; Object[] newElements = Arrays.copyOf(elements, len + 1); //深拷贝底层数组 newElements[len] = e; //在副本上进行修改 setArray(newElements); //修改完成后用副本替换底层数组 return true; } finally { lock.unlock(); } }
写时复制的好处是,任何的读操做都不用加锁,并且保证读取到的是读那一刻List完整的快照数据。好比当CopyOnWriteArrayList的迭代器建立后,不管List自己如何变化,迭代器能感知到的都是它在被建立那一刻时List的状态,任何其余线程对List的改变,对本迭代器都不可见。不会出现ConcurrentHashMap的迭代器可能读取到其余线程修改过程当中容器的中间状态的状况。因为CopyOnWriteArrayList读操做没法感知最新正在变化的数据,因此CopyOnWriteArrayList也是弱一致性的。
CopyOnWriteArrayList能够保证的是,读操做能够读取到最近一次更新完成的结果。
写时复制技术由于每次修改都须要完整拷贝一次底层数组,因此有额外的性能开销,可是特别适用于读多写少的数据访问场景。
一、ConcurrentHashMap和CopyOnWriteArrayList都是无锁化的读取,因此读操做发生时没法确保目前全部其余线程的写操做已经完成,不可用于要求数据强一致性的场景。
二、ConcurrentHashMap和CopyOnWriteArrayList均可以保证读取时能够感知到已经完成的写操做。
三、ConcurrentHashMap读操做可能会感知到同一时刻其余线程对容器写操做的中间状态。CopyOnWriteArrayList永远只会读取到容器在读取时刻的快照状态。
四、ConcurrentHashMap使用锁分段技术,缩小锁的范围,提升写的并发量。CopyOnWriteArrayList使用写时复制技术,保证并发写入数据时,不会对已经开启的读操做形成干扰。
五、ConcurrentHashMap适用于高并发下对数据访问没有强一致性需求的场景。CopyOnWriteArrayList适用于高并发下可以容忍只读取到历史快照数据,且读多写少的场景。