【Java】阻塞队列 BlcokingQueue 原理、与等待唤醒机制condition/await/singal的关系、多线程安全总结
创始人
2024-05-29 13:22:11
0

在实习过程中使用阻塞队列对while + sleep 轮询机制进行了改造,提升了发送接收的效率,这里做一点点总结。

自从Java 1.5之后,在java.util.concurrent包下提供了若干个阻塞队列,BlcokingQueue继承了Queue接口,是线程安全的。阻塞功能使得生产者和消费者两端的能力得以平衡,当阻塞队列是空时,从队列中获取元素的操作将会被阻塞。当阻塞队列是满时,往队列里添加元素的操作将会被阻塞。

等待唤醒机制 condition/await/singal 和阻塞队列的关系将在第四部分介绍。

比较和交换(Compare And Swap,CAS) 将在文章第五部分介绍。

用过 Golang 的会发现,这怎么这么像 channel?之后应该会写写关于这俩的博客。

文章目录

    • #1 阻塞队列的种类
    • #2 常用方法
      • 队列主要方法
      • 阻塞队列主要方法
    • #3 阻塞队列的原理 - 以 ArrayBlockingQueue 为例
      • `put()` 方法:
      • `take()` 方法
      • 非阻塞队列ConcurrentLinkedQueue
    • #4 等待唤醒机制 wait/notify/notifyAll、condition/await/singal
      • 轮询
      • wait/notify/notifyAll
      • condition/await/singal
    • #5 多线程安全 - CAS

#1 阻塞队列的种类

  • ArrayBlockingQueue:基于数组实现的一个阻塞队列,在创建ArrayBlockingQueue对象时必须制定容量大小。并且可以指定公平性与非公平性,默认情况下为非公平的,即不保证等待时间最长的队列最优先能够访问队列。
  • LinkedBlockingQueue:基于链表实现的一个阻塞队列,在创建LinkedBlockingQueue对象时如果不指定容量大小,则默认大小为Integer.MAX_VALUE,可被看作无界队列。
  • PriorityBlockingQueue:以上2种队列都是先进先出队列,而PriorityBlockingQueue 会按照元素的优先级对元素进行排序,每次出队的元素都是优先级最高的元素。此阻塞队列为无界阻塞队列,即容量没有上限。
  • DelayQueue:基于PriorityQueue,一种延时阻塞队列,也有比较排序能力。DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue也是一个无界队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞
  • SynchronousQueue:它的容量为0(不是1),所以没有地方来暂存元素,导致每次取数据都要先阻塞,直到有数据放入;同理,每次放数据的时候也会阻塞,直到有消费者来取。它不需要去持有元素,它做的就是直接传递。
  • LinkedTransferQueue:是一个由链表结构组成的无界阻塞TransferQueue队列,多了tryTransfer(可以设置等待时间)和transfer(等待有消费者后这个方法才会执行下去)方法。可以算是 LinkedBolckingQueue 和 SynchronousQueue 和合体。基于CAS无锁算法实现,性能理论高一点。

Queue接口下面还有一种非阻塞队列ConcurrentLickedQueue,不会使线程阻塞,而是使用CAS非阻塞算法+不停重试来实现线程安全的。

在这里插入图片描述

#2 常用方法

队列主要方法

对于非阻塞队列,一般情况下建议使用offer、poll和peek三个方法,因为使用offer、poll和peek三个方法可以通过返回值判断操作成功与否,而使用add和remove方法却不能达到这样的效果。注意,非阻塞队列中的方法都没有进行同步措施。

  • add(E e):将元素e插入到队列末尾,如果插入成功,则返回true;如果插入失败(即队列已满),则会抛出异常;
  • remove():移除队首元素,若移除成功,则返回true;如果移除失败(队列为空),则会抛出异常;
  • element():返回队列的头节点,但是不会删除这个元素。当队列为空时同样会报NoSuchElementException的错误;
  • offer(E e):将元素e插入到队列末尾,如果插入成功,则返回true;如果插入失败(即队列已满),则返回false;
  • poll():移除并获取队首元素,若成功,则返回队首元素;否则返回null;
  • peek():获取队首元素,若成功,则返回队首元素;否则返回null

阻塞队列主要方法

上面列举的5个方法在阻塞队列中都存在,但是要注意这5个方法在阻塞队列中都进行了同步措施。

  • put(E e):阻塞方法,向队尾存入元素,如果队列满,则等待;
  • take():阻塞方法,从队首取元素,如果队列为空,则等待;
  • offer(E e,long timeout, TimeUnit unit):向队尾存入元素,如果队列满,则等待一定的时间,当时间期限达到时,如果还没有插入成功,则返回false;否则返回true;
  • poll(long timeout, TimeUnit unit):从队首取元素,如果队列空,则等待一定的时间,当时间期限达到时,如果取到,则返回null;否则返回取得的元素;
操作类型抛出异常阻塞线程有返回值超时返回
存入addputofferoffer(E e, long timeout, TimeUnit unit)
取出removetakepollpoll(long timeout, TimeUnit unit)

#3 阻塞队列的原理 - 以 ArrayBlockingQueue 为例

ArrayBlockingQueue中用来存储元素的实际上是一个数组,takeIndexputIndex分别表示队首元素和队尾元素的下标,count表示队列中元素的个数。lock是一个可重入锁,notEmptynotFull是等待条件。

读操作和写操作都需要先获取到 ReentrantLock 独占锁才能进行下一步操作。进行读操作时如果队列为空,线程就会进入到读线程专属的notEmpty的 Condition 的队列中去排队,等待写线程写入新的元素;同理队列已满,这个时候写操作的线程进入到写线程专属的notFull队列中去排队,等待读线程将队列元素移除并腾出空间。

public class ArrayBlockingQueue extends AbstractQueue
implements BlockingQueue, java.io.Serializable {private static final long serialVersionUID = -817911632652898426L;/** 用于存放元素的数组 The queued items */private final E[] items;/** 下一次读取的位置 items index for next take, poll or remove */private int takeIndex;/** 下一次写入的位置 items index for next put, offer, or add. */private int putIndex;/** 队列中元素的数量 Number of items in the queue */private int count;/** Concurrency control uses the classic two-condition algorithm* found in any textbook.*//** Main lock guarding all access */private final ReentrantLock lock;/** Condition for waiting takes */private final Condition notEmpty;/** Condition for waiting puts */private final Condition notFull;
}

put() 方法:

在put方法中,首先用checkNotNull方法检查插入的元素是不是null,如果不是 null 会用Reentrantlock上锁,并且使用的上锁方法是lock.lockInterruptibly()。这个方法在获取锁的同时是可以响应中断的,这也正是我们的阻塞队列调用put方法时,在尝试获取锁但还没拿到锁的期间可以响应中断的底层原因。

紧接着在while循环中,它会检查当前队列是不是已经满了,也就是count的长度是否等于数组长度。如果等于代表已经满了,于是我们便会进行等待,直到有空余的时候,我们才会执行下一步操作,调用enqueue方法让元素进入队列,最后用unlock方法解锁。

public void put(E e) throws InterruptedException {//判断添加元素e是否为空,为空会抛出异常checkNotNull(e);//获取锁final ReentrantLock lock = this.lock;//加锁lock.lockInterruptibly();try {//队列已满,线程等待while (count == items.length)notFull.await();//入队方法 signal()藏在enqueue(e)方法里了enqueue(e);} finally {//释放锁lock.unlock();}}private void enqueue(E x) {// assert lock.getHoldCount() == 1;// assert items[putIndex] == null;final Object[] items = this.items;//插入数据items[putIndex] = x;if (++putIndex == items.length)putIndex = 0;count++;//通知消费者线程,当前队列中有数据可供消费notEmpty.signal();
}

take() 方法

跟put方法实现很类似,只不过put方法等待的是notFull信号,而take方法等待的是notEmpty信号。

//从队列中取出数据
public E take() throws InterruptedException {//获取锁final ReentrantLock lock = this.lock;//加锁lock.lockInterruptibly();try {//当队列中无数据,删除线程阻塞等待while (count == 0)notEmpty.await();//有数据,执行队头元素出队操作return dequeue();} finally {//释放锁lock.unlock();}}

非阻塞队列ConcurrentLinkedQueue

顺便看看使用CAS非阻塞算法+不停重试来实现线程安全的这个队列。

ConcurrentLinkedQueue是一种非阻塞的线程安全队列,与阻塞队列LinkedBlockingQueue相对应,同样也是使用链表实现的FIFO队列,但不同的是它没有使用任何锁机制,而是用CAS来实现线程安全。

public boolean offer(E e) {// 检查e是不是null,是的话抛出NullPointerException异常。checkNotNull(e);// 创建新的节点final Node newNode = new Node(e);// 将“新的节点”添加到链表的末尾。for (Node t = tail, p = t;;) {Node q = p.next;// 情况1:q为空,p是尾节点插入if (q == null) {// CAS操作:如果“p的下一个节点为null”(即p为尾节点),则设置p的下一个节点为newNode。// 如果该CAS操作成功的话,则比较“p和t”(若p不等于t,则设置newNode为新的尾节点),然后返回true。// 如果该CAS操作失败,这意味着“其它线程对尾节点进行了修改”,则重新循环。if (p.casNext(null, newNode)) {if (p != t) // hop two nodes at a timecasTail(t, newNode);  // Failure is OK.return true;}}// 情况2:p和q相等else if (p == q)p = (t != (t = tail)) ? t : head;// 情况3:其它elsep = (p != t && t != (t = tail)) ? t : q;}
}

非阻塞队列ConcurrentLinkedQueue主要流程就是在判空以后,会进入一个大循环中,p.casNext()方法,这个方法正是利用了CAS来操作的,这个死循环去配合CAS其实就是我们平时说的乐观锁的思想。

#4 等待唤醒机制 wait/notify/notifyAll、condition/await/singal

从上一部分可以发现,阻塞队列 = 等待唤醒机制 + 队列,例如 ArrayBlockingQueue 使用了 ReentrantLock + Condition 和一个列表队列实现。这里就顺便说说 Java 中的阻塞唤醒机制。此部分参考这篇好文。

轮询

生产者和消费者线程各自使用while循环,每隔片刻就去判断Queue的状态,队列为空时生产者才可插入数据,队列不为空时消费者才能取出数据,否则一律sleep等待。

轮询最简单但是也最不优雅。

  • sleep 时不会释放线程拥有的锁。
  • 轮询的方式太耗费CPU资源:如果线程过多,比如几百上千个线程同时在那轮询,会给CPU带来较大负担。
  • 无法保证原子性:如果生产者的操作非原子性,消费者极可能获取到脏数据。

wait/notify/notifyAll

等待唤醒机制底层通过维护线程队列的方式,避免了过多线程同时自旋造成的CPU资源浪费,颇有点“用空间换时间”的味道。

当一个生产者线程无法插入数据时,就让它在队列里休眠(阻塞),此时生产者线程会释放CPU资源,等到消费者抢到CPU执行权并取出数据后,再由消费者唤醒生产者继续生产。

  • synchronized保证原子性
  • waitnotify实现等待唤醒
// 线程consumer  
synchronize(obj){ obj.wait();//没东西了,等待
}  
// 线程producer
synchronize(obj){ obj.notify();//有东西了,唤醒 
}

在synchronized机制下,所有等待的线程都在同一个队列里,而notify又恰巧是随机唤醒线程(也就是说,有可能生产者唤醒生产者)。

一般建议是改用notifyAll把所有线程都唤醒,然后大家一起参与执行权的竞争

  • 如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
  • 当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只要一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争
  • 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

condition/await/singal

虽然 wait/notifyAll 能解决“己方唤醒己方,最终导致全部线程阻塞”的乌龙事件,但唤醒全部线程又不够精确,会造成无谓的线程竞争(实际只需要唤醒敌方线程即可)。

作为改进版,可以使用 ReentrantLock 的 Condition 替代 synchronized 的 wait/notify

// 显式锁(相对地,synchronized锁被称为隐式锁)
private final ReentrantLock lock = new ReentrantLock();
private final Condition producerCondition = lock.newCondition();
private final Condition consumerCondition = lock.newCondition();//生产
lock.lock(); 
producerCondition.await(); 
lock.unlock();  //消费
lock.lock(); 
consumerCondition.signal(); 
lock.unlock();

你可以认为lock.newCondition() 创建了一个队列,调用producerCondition.await()会把生产者线程放入生产者的等待队列中,当消费者调用producerCondition.signal() 时会唤醒从生产者的等待队列中唤醒一个生产者线程出来工作。

也就是说,ReentrantLock的Condition通过拆分线程等待队列,让线程的等待唤醒更加精确了,想唤醒哪一方就唤醒哪一方。

可以看到,上面的伪代码和前面阻塞队列的put() 方法逻辑几乎一模一样

#5 多线程安全 - CAS

补充一下这个概念。

在计算机科学中,比较和交换(Compare And Swap,CAS) 是用于实现多线程同步的原子指令。 它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值。

它包含 3 个参数 CAS(V,E,N)。V 表示要更新的变量(内存值),E 表示预期值(旧的),N 表示新值。当且仅当 V 值等 于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。

CAS 操作是抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。当多个线程同时 使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理, CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁用到的机制就是CAS,Compare and Swap。

自旋: 就是不停的判断比较,看能否将值交换

CAS的三大问题

  • ABA问题:一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
  • 循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升。
  • 只能保证一个共享变量的原子操作:对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。

相关内容

热门资讯

iPhone手机怎么玩安卓系统... 你有没有想过,你的iPhone手机竟然也能玩安卓系统?没错,就是那个一直以来让你觉得遥不可及的安卓世...
平板删安卓系统更新不了,原因及... 最近是不是你也遇到了这样的烦恼?平板电脑上的安卓系统更新不了,是不是让你头疼得要命?别急,今天就来给...
苹果组装机安卓系统卡,卡顿背后... 你有没有发现,最近用苹果手机的时候,有时候系统有点卡呢?这可真是让人头疼啊!你知道吗,其实这背后还有...
安卓系统原生浏览器,功能与体验... 你有没有发现,每次打开手机,那个小小的浏览器窗口总是默默无闻地在那里,陪你浏览网页、搜索信息、看视频...
安卓机如何上苹果系统,跨平台体... 你是不是也和我一样,对安卓机和苹果系统之间的切换充满了好奇?想象你的安卓手机里装满了各种应用,而苹果...
安卓导入系统证书失败,原因分析... 最近在使用安卓手机的时候,你是不是也遇到了一个让人头疼的问题——导入系统证书失败?别急,今天就来给你...
安卓原生系统有哪些手机,盘点搭... 你有没有想过,为什么有些手机用起来就是那么流畅,那么顺心呢?这背后可大有学问哦!今天,就让我带你一起...
安卓系统关机了怎么定位,安卓系... 手机突然关机了,是不是有点慌张呢?别担心,今天就来教你一招,让你的安卓手机即使关机了,也能轻松定位到...
安卓系统游戏加速器,畅享无延迟... 你有没有发现,手机游戏越来越好玩了?不过,有时候游戏体验可能并不那么顺畅,是不是因为手机性能不够强大...
安卓4系统天气功能,尽在掌握 安卓4系统天气功能大揭秘在当今这个数字化的世界里,手机已经不仅仅是一个通信工具,它更是一个集成了各种...
安卓系统如何玩碧蓝幻想,攻略与... 你有没有想过,在安卓系统上玩《碧蓝幻想》竟然可以这么酷炫?没错,就是那个让你沉迷其中的二次元大作!今...
安卓系统搜不到图朵,图朵生成之... 最近是不是你也遇到了这样的烦恼?手机里明明有那么多美美的图片,但是用安卓系统搜索的时候,却怎么也找不...
魁族8刷安卓系统,系统升级后的... 哇,你知道吗?最近在安卓系统圈子里,有一个话题可是引起了不小的轰动,那就是魁族8刷安卓系统。你是不是...
微信正版安装安卓系统,畅享沟通... 你有没有想过,你的微信是不是正版安装的安卓系统呢?这可不是一个小问题哦,它关系到你的微信使用体验和隐...
电视能刷安卓系统吗,电视也能刷... 电视能刷安卓系统吗?揭秘智能电视的无限可能想象你家的电视不再只是用来观看节目的工具,而是变成了一个功...
安卓系统开通通知功能,畅享智能... 你知道吗?最近安卓系统更新后,新增了一个超级实用的功能——开通通知功能!这可是个大喜事,让咱们的生活...
苹果系统安卓爱思助手,系统兼容... 你有没有发现,手机的世界里,苹果系统和安卓系统就像是一对欢喜冤家,总是各有各的粉丝,各有各的拥趸。而...
安卓系统占用很大内存,揭秘内存... 手机里的安卓系统是不是让你感觉内存不够用,就像你的房间堆满了杂物,总是找不到地方放新东西?别急,今天...
安卓系统p30,安卓系统下的摄... 你有没有发现,最近安卓系统P30在手机圈里可是火得一塌糊涂呢!这不,我就来给你好好扒一扒这款手机的那...
siri被安卓系统进入了,智能... 你知道吗?最近科技圈可是炸开了锅,因为一个大家伙——Siri,竟然悄悄地溜进了安卓系统!这可不是什么...