Java高并发|Java多线程之常用并发容器的使用


文章目录

  • ConcurrentHashMap
  • ConcurrentSkipListMap/ConcurrentSkipListSet
  • ConcurrentLinkedQueue
  • CopyOnWriteArrayList/CopyOnWriteArraySet
  • 阻塞队列

ConcurrentHashMap 为什么要使用ConcurrentHashMap
在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。
HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。
什么是Hash
散列算法,任意长度的输入,通过一种算法,变换成固定长度的输出,属于压缩的映射。Md5、Sha、取余都是散列算法,ConcurrentHashMap中是wang/jenkins算法
ConcurrentHashMap在1.7的实现
核心思想:分段锁的设计思想
JDK1.7中采用Segment + HashEntry的方式进行实现,结构如下:
Java高并发|Java多线程之常用并发容器的使用
文章图片

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment实际是一种可重入锁(ReentrantLock),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在实现上继承了ReentrantLock,这样就自带了锁的功能。
  • put操作
    当执行put方法插入数据时,根据key的hash值,在Segment数组中找到相应的位置,如果相应位置的Segment还未初始化,则通过CAS进行赋值,接着执行Segment对象的put方法通过加锁机制插入数据。
    模拟一个场景:线程A和线程B同时执行相同Segment对象的put方法(不同Segment对象则互不影响)
    • 线程A执行tryLock()方法成功获取该Segment段的锁,则把HashEntry对象插入到相应的位置
    • 线程B获取该Segment段的锁失败,则执行scanAndLockForPut()方法,在scanAndLockForPut方法中,会通过重复执行tryLock()方法尝试获取锁,在多处理器环境下,重复次数为64,单处理器重复次数为1,当执行tryLock()方法的次数超过上限时,则执行lock()方法挂起线程B;
    • 当线程A执行完插入操作时,会通过unlock()方法释放锁,接着唤醒线程B继续执行
  • get操作
    ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的。HashEntry代表每个hash链中的一个节点,可以看到其中的对象属性要么是final的,要么是volatile的
  • size操作
    因为ConcurrentHashMap是可以并发插入数据的,所以在准确计算元素时存在一定的难度,一般的思路是统计每个Segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的,因为在计算后面几个Segment的元素个数时,已经计算过的Segment同时可能有数据的插入或则删除
    JDK1.7中是按照如下方式实现的:
    • 先采用不加锁的方式,连续计算元素的个数,最多计算3次
    • 如果前后两次计算结果相同,则说明计算出来的元素个数是准确的
    • 如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数
ConcurrentHashMap在1.8的实现
JDK1.8中放弃了Segment分段锁的设计,采用Node + CAS + Synchronized来保证并发安全进行实现,结构如下:
Java高并发|Java多线程之常用并发容器的使用
文章图片

改进一:取消segments字段,直接采用transient volatile HashEntry[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能
  • put操作
    当执行put方法插入数据时,根据key的hash值,在Node数组中找到相应的位置,实现如下:
    • 如果相应位置的Node还未初始化,则通过CAS插入相应的数据
    • 如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点
    • 如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点
    • 如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值
    • 如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数baseCount
  • size操作
    JDK1.8中使用一个volatile类型的变量baseCount记录元素的个数,当插入新数据或则删除数据时,会通过addCount()方法更新baseCount
ConcurrentSkipListMap/ConcurrentSkipListSet ConcurrentSkipListMap是TreeMap的并发实现
ConcurrentSkipListSet是TreeSet的并发实现
什么是SkipList
二分查找要求元素可以随机访问,所以决定了需要把元素存储在连续内存。这样查找确实很快,但是插入和删除元素的时候,为了保证元素的有序性,就需要大量的移动元素了。
如果需要的是一个能够进行二分查找,又能快速添加和删除元素的数据结构,首先就是二叉查找树,二叉查找树在最坏情况下可能变成一个链表。
于是,就出现了平衡二叉树,根据平衡算法的不同有AVL树,B-Tree,B+Tree,红黑树等,但是AVL树实现起来比较复杂,平衡操作较难理解,这时候就可以用SkipList跳跃表结构。
传统意义的单链表是一个线性结构,向有序的链表中插入一个节点需要O(n)的时间,查找操作需要O(n)的时间。
跳跃表结构
Java高并发|Java多线程之常用并发容器的使用
文章图片

如果我们使用上图所示的跳跃表,就可以减少查找所需时间为O(n/2),因为我们可以先通过每个节点的最上面的指针先进行查找,这样子就能跳过一半的节点。
比如我们想查找19,首先和6比较,大于6之后,在和9进行比较,然后在和17进行比较…最后比较到21的时候,发现21大于19,说明查找的点在17和21之间,从这个过程中,我们可以看出,查找的时候跳过了3、7、12等点,因此查找的复杂度为O(n/2)。
跳跃表其实也是一种通过空间来换取时间的一个算法,通过在每个节点中增加了向前的指针,从而提升查找的效率。
跳跃表又被称为概率,或者说是随机化的数据结构,目前开源软件 Redis 和 Lucence都有用到它。
ConcurrentLinkedQueue 无界非阻塞队列,是LinkedList的并发版本
相关API
  • add/offer:添加元素
  • peek:get头元素并不把元素拿走
  • poll():get头元素把元素拿走
CopyOnWriteArrayList/CopyOnWriteArraySet 写的时候进行复制,可以进行并发的读
适用读多写少的场景:比如白名单,黑名单,商品类目的访问和更新场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。
弱点:内存占用高,数据一致性弱
阻塞队列 什么是阻塞队列
取数据和读数据不满足要求时,会对线程进行阻塞
常用阻塞队列
  • ArrayBlockingQueue
    数组结构组成有界阻塞队列。先进先出原则,初始化必须传大小,take和put时候用的同一把锁
  • LinkedBlockingQueue
    链表结构组成的有界阻塞队列。先进先出原则,初始化可以不传大小,put和take锁是分离的
  • PriorityBlockingQueue
    支持优先级排序的无界阻塞队列。排序,自然顺序升序排列。更改顺序:类自己实现compareTo()方法,初始化PriorityBlockingQueue指定一个比较器Comparator
  • DelayQueue
    使用了优先级队列的无界阻塞队列。支持延时获取,队列里的元素要实现Delay接口。DelayQueue非常有用,可以将DelayQueue运用在以下应用场景:
    • 缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素时,表示缓存有效期到了。
    • 还有订单到期,限时支付等等。
  • SynchronousQueue
    不存储元素的阻塞队列。每个put操作必须要等take操作
  • LinkedTransferQueue
    链表结构组成的无界阻塞队列
  • Transfer,tryTransfer
    生产者put时,当前有消费者take,生产者直接把元素传给消费者
  • LinkedBlockingDeque
    链表结构组成的双向阻塞队列。可以在队列的两端插入和移除,xxxFirst头部操作,xxxLast尾部操作。工作窃取模式。
阻塞队列的实现原理
在内部使用了Condition实现
生产者消费者模式
在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生产线程和消费线程的工作能力来提高程序整体处理数据的速度。
在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。为了解决这种生产消费能力不均衡的问题,便有了生产者和消费者模式。
【Java高并发|Java多线程之常用并发容器的使用】生产者和消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通信,而是通过阻塞队列来进行通信,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。

    推荐阅读