您的位置:首页 > 编程语言 > Java开发

java多线程解说【拾壹】_并发容器

2018-02-26 00:37 435 查看
上篇文章:java多线程解说【拾】_12个原子操作类

上文中我们介绍了juc包中的12个原子操作类,本文我们了解一下juc包中的并发容器。

在JDK5后,java为我们提供了9个线程安全的并发容器,包括常用的HashMap、List和LinkedQueue等数据模型。下面选几个常用的容器进行下介绍

ConcurrentHashMap

在多线程场景下,当有多个线程去同时操作同一个HashMap时,HashMap是无法保证线程安全的。在JDK1.0时代,java为我们提供了线程安全的HashTable,HashTable可以保证线程安全,但是也有一个问题,就是它是通过对方法比如get、put方法增加synchronized关键字来实现的。这样带来的后果是,当一个线程尝试去get这个HashTable的某个key值时,其他线程想对这个HashTable执行任何操作都是阻塞的,所以高并发下效率很低。

而ConcurrentHashMap采用锁分段设计有效提升了并发访问率。ConcurrentHashMap将数据分成一段一段存储,每一段配有一把锁,这样当一个线程占有锁访问其中一段数据的时候,对其他段数据的访问是不影响的。下面详细说说ConcurrentHashMap的结构:

ConcurrentHashMap主要包括一个Segment(即上文说的段)和HashEntry数组。如果看过HashMap源码的同学应该对HashEntry比较熟悉,它就是HashMap中真正使用键值对来保存数据的对象。而简单来看,ConcurrentHashMap就是在HashMap结构的基础上加了一层Segment来实现锁的控制。说白了就是,一个ConcurrentHashMap对象中维护了一个Segment数组,Segment数组中的每个Segment中维护了一个HashEntry数组。



那么Segment到底是什么呢,为什么可以实现锁控制。通过看源码得知,它继承了可重入锁ReentrantLock,这样就可以通过使用其lock和unlock接口来实现对数据的锁控制。由此我们可以分析出,当对HashEntry数据的数据进行修改的时候,必须先获得与它对应的Segment锁。

ConcurrentHashMap内部维护了几个初始化参数,其中包括initialCapacity(初始容量,默认16)、loadFactor(负载因子,默认0.75)、concurrencyLevel(支持并发数,默认16)。前两个参数不用细说了,和HashMap中的参数一致,它们会决定Segment里HashEntry数组的长度和Segment的容量threshold

concurrencyLevel是决定Segment数组的长度的。Segment数组的长度为大于等于concurrencyLevel的最小2的N次方(N会记录为sshift)。比如默认情况下,concurrencyLevel=16,那么Segment数组长度也为16(记录为ssize)。

ConcurrentHashMap中还维护了segmentMasksegmentShift两个参数,分别用于定位参与hash计算的位数和掩码。他们的计算逻辑如下:

segmentMask =ssize-1,即二进制每位都是1;

segmentShift = 32 - sshift,因为ConcurrentHashMap中hash()方法计算出的hash值最大为32;

通过上述参数完成了初始化后,我们看看ConcurrentHashMap是如何完成数据操作的:

get(Object key)

观其源码的逻辑大意是,先取到key的hashCode,然后再通过ConcurrentHashMap里的hash()方法散列一下,目的是减少散列冲突,让元素均匀地分布在各个Segment里。通过计算找到key所在的Segment后,取得Segment的HashEntry数组遍历,取出key值对应的value。这里注意的是,整个get操作都是没有加锁的,是因为这个方法里所有需要用到的共享变量全部都定义为volatile类型,这样可以保证每次取到的都是最新的值。

put(Object key , Object value)

put操作的前几步和get操作是一样的,先通过2次散列计算出key对应的Segment,然后插入,插入的是加锁的。这里需要注意2点:

1.ConcurrentHashMap是不允许value值为null的;

2.ConcurrentHashMap是先判断元素个数是否达到容量(threshold)的,如果达到就扩容(默认乘2)再插入元素,这个顺序和HashMap不一样;

size()

因为ConcurrentHashMap中每个Segment的count值都是实时变化,所以想不加锁求所有Segment的count值和(即size)比较麻烦。源码的实现思路是,先连续2次不加锁求count和,再看看2次求和的时候容器是否发生变化。如果发生了变化再通过加锁求和。那么如何判断容器是否发生变化呢,这就用到了Segment中维护的一个modCount变量,这个变量会在put、remove和clean操作里加1,只需判断这个变量是否有变化就可以知道容器大小是否变化了。

ConcurrentSkipListMap

ConcurrentSkipListMap是线程安全的有序的哈希表,适用于高并发的场景。可看做是TreeMap的线程安全版。它们的区别在于,ConcurrentSkipListMap是通过跳表实现的,而TreeMap是通过红黑树实现的。

跳表(Skip List)是平衡树的一种替代的数据结构,但是和红黑树不相同的是,跳表对于树的平衡的实现是基于一种随机化的算法的,这样也就是说跳表的插入和删除的工作是比较简单的。

ConcurrentSkipListMap继承于AbstractMap类,也就意味着它是一个哈希表。ConcurrentSkipListMap内部维护了一个Index对象,与跳表中的索引相对应,包含右索引的指针(right)、下索引的指针(down)和哈希表节点node(ConcurrentSkipListMap内部对象)。HeadIndex继承于Index,ConcurrentSkipListMap中维护一个HeadIndex对象head,记录跳表的表头。

ConcurrentSkipListSet

可看做TreeSet的线程安全版。实现区别:ConcurrentSkipListSet是通过ConcurrentSkipListMap实现的,而TreeSet是通过TreeMap实现的。 ConcurrentSkipListSet继承于AbstractSet,实现了NavigableSet接口(有序的集合)。

ConcurrentSkipListSet内部维护了一个ConcurrentNavigableMap对象m,而m对象实际上是ConcurrentNavigableMap的实现类ConcurrentSkipListMap的实例。ConcurrentSkipListMap中的元素是key-value键值对;而ConcurrentSkipListSet是集合,它只用到了ConcurrentSkipListMap中的key。

[b]CopyOnWriteArrayList[/b]

线程安全的ArrayList,顾名思义其实现的思路就是Copy-on-write。CopyOnWriteArrayList内部维护了一个Object数组和一个可重入锁,当进行add操作时,先获取锁,然后将旧的Object数组复制出来,把新加入的元素添加到末尾,再写回,释放锁。由此可见CopyOnWriteArrayList的写入操作代价是很高的。相比而言,其get操作就简单许多,因为Object数组已经用volatile修饰,所以可以确保每次获取到的都是最新的值,因此get操作没有加锁。

相比另一个线程安全的List实现Vector来说,CopyOnWriteArrayList更适合读多写少的场景。

[b][b]CopyOnWriteArraySet[/b][/b]

是线程安全的无序的集合,可以将它理解成线程安全的HashSet。区别在于HashSet是通过HashMap实现的,而CopyOnWriteArraySet则是通过CopyOnWriteArrayList实现的。CopyOnWriteArraySet具有以下特性:

1.
它最适合于具有以下特征的应用程序:Set 大小通常保持很小,只读操作远多于可变操作,需要在遍历期间防止线程间的冲突。

2. 因为通常需要复制整个基础数组,所以可变操作(add()、set() 和 remove() 等)的开销很大。

3. 迭代器支持hasNext(), next()等不可变操作,但不支持可变 remove()等 操作。

[b]ConcurrentLinkedQueue[/b]

ConcurrentLinkedQueue是一个基于链表实现的无界线程安全队列,它采用先进先出的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部,当我们获取一个元素时,它会返回队列头部的元素。

入队列的时候首先要找到尾节点。ConcurrentLinkedQueue中也维护了和CLH队列差不多的head和tail节点,但区别是tail节点并不一定是真正的尾节点,只有当tail和真正的尾节点距离大于hops(默认为1)的时候,才将tail节点更新为尾节点。这样做的目的是,减少更新tail节点的频率以提升入队的效率。

出队列的逻辑和入队列逻辑差不多,首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用CAS的方式将头节点的引用设置成null,如果CAS成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。

[b]ArrayBlockingQueue[/b]

ArrayBlockingQueue基于数组实现的线程安全的有界的阻塞队列,内部通过互斥锁实现多线程对竞争资源的互斥访问。ArrayBlockingQueue对应的数组是有界限的,当竞争资源已被某线程获取时,其它要获取该资源的线程需要阻塞等待。ArrayBlockingQueue是按 FIFO(先进先出)原则对元素进行排序,元素都是从尾部插入到队列,从头部开始返回。

ArrayBlockingQueue内部维护了一个ReentraintLock和两个Condition(notEmpty和notFull),通过Condition可以实现对ArrayBlockingQueue的更精确的访问,比如当线程A要取数据时,数组正好为空,则该线程会执行notEmpty.await()进行等待;当其它线程B向数组中插入了数据之后,会调用notEmpty.signal()唤醒notEmpty上的等待线程。此时,线程A会被唤醒从而得以继续运行;或当某线程要插入数据时,数组已满,则该线程会它执行notFull.await()进行等待;当其它某个线程取出数据之后,会调用notFull.signal()唤醒notFull上的等待线程。

[b]LinkedBlockingQueue[/b]

LinkedBlockingQueue是一个基于单向链表实现的线程安全的阻塞队列。该队列按 FIFO(先进先出)排序元素,新元素插入到队列的尾部,并且队列获取操作会获得位于队列头部的元素。链接队列的吞吐量通常要高于基于数组的队列,但是在大多数并发应用程序中,其可预知的性能要低。

此外,LinkedBlockingQueue还是可选容量的(防止过度膨胀),即可以指定队列的容量。如果不指定,默认容量大小等于Integer.MAX_VALUE。

LinkedBlockingQueue内部维护了head、last、count、capacity、putLock、takeLock、notEmpty和notFull对象,分别用于:

head是链表的表头。取出数据时,都是从表头head处插入;

last是链表的表尾。新增数据时,都是从表尾last处插入;

count是链表的实际大小,即当前链表中包含的节点个数;

capacity是列表的容量,它是在创建链表时指定的;

putLock是插入锁,takeLock是取出锁;notEmpty是“非空条件”,notFull是“未满条件”。通过它们对链表进行并发控制;
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: