只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

49、阻塞等待

内容来自王争 Java 编程之美

在讲解条件变量和信号量时,我们留给大家思考如何使用条件变量或信号量来实现阻塞并发队列
本节我们就结合 JUC 源码来看下工业级的阻塞并发队列,到底是怎么实现的,都有哪些值得我们学习的地方

1、阻塞并发队列

阻塞并发队列具有两个特点

  • 线程安全:也就是名称中 "并发" 的含义
  • 支持读写阻塞:也就是名称中 "阻塞" 的含义
  • 读阻塞指的是:队列为空时,读取操作会被阻塞,直到队列有可读的数据为止
  • 写阻塞指的是:队列已满时,写入操作会被阻塞,直到队列有可写的空位为止
  • 阻塞并发队列一般用于实现:生产者 - 消费者模型

image
在《数据结构与算法之美》中我们讲到,队列可以分为无界队列和有界队列,无界队列指的是队列的大小没有限制,有界队列指的是队列的大小有限制

  • 对于有界队列:读、写均可以阻塞
  • 对于无界队列:读可阻塞,但写不会阻塞

JUC 提供的阻塞并发队列有很多
比如:ArrayBlockingQueue、LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue
接下来我们讲解一下:这些阻塞并发容器的用法和实现原理

方法 / 处理方式 抛出异常 返回特殊值 一直阻塞 超时退出
插入方法 add(e) offer(e) put(e) offer(e, time, unit)
移除方法 remove() poll() take() poll(time, unit)
检查方法 element() peek() 不可用 不可用

2、BlockingQueue

ArrayBlockingQueue、LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue 的实现原理类似
它们都是基于 ReentrantLock 锁来实现线程安全,基于 Condition 条件变量来实现阻塞等待
因此我们把这 4 个阻塞并发队列放在一起来讲解,并且拿其中的 ArrayBlockingQueue 重点讲解
对于剩下的 3 个阻塞并发容器,我们只讲解跟 ArrayBlockingQueue 有差异的地方

2.1、ArrayBlockingQueue 源码

ArrayBlockingQueue 是基于 "数组" 实现的 "有界阻塞并发队列"(循环队列),队列的大小在创建时指定
ArrayBlockingQueue 跟普通队列的使用方式基本一样,唯一的区别在于读写可阻塞,这里就不再举例了
接下来我们结合源码具体讲解它的实现原理,重点看下它是如何实现线程安全且可阻塞的,ArrayBlockingQueue 的部分源码如下所示

public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E> {
final Object[] items;
int takeIndex; // 下一次出队时, 出队数据的下标位置
int putIndex; // 下一次入队时, 数据存储的下标位置
int count; // 队列中的元素个数
final ReentrantLock lock; // 加锁实现线程安全
private final Condition notEmpty; // 用来阻塞读, 等待非空条件的发生
private final Condition notFull; // 用来阻塞写, 等待非满条件的发生
public ArrayBlockingQueue(int capacity) {
this(capacity, false);
}
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0) throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
// ... 省略核心函数 ...
}

从上述源码我们还可以发现,ArrayBlockingQueue 支持公平和非公平两种工作模式,默认为非公平模式,它的公平性依赖锁的公平性来实现
当线程竞争锁来执行读写操作时,如果此时 "锁未被持有" 且 "锁的等待队列不为空"

  • 对于非公平工作模式:线程可以 "插队" 竞争锁并执行后续读写操作
  • 对于公平模式:线程会进入等待队列排队等待获取锁

ArrayBlockingQueue 中提供的入队、出队函数有很多,我们重点看下支持阻塞写的 put() 函数和支持阻塞读的 take() 函数

2.2、put()

put() 函数的源码如下所示
put() 函数的代码实现是 Condition 条件变量的标准使用方法:先加锁,为了避免假唤醒,循环调用 await() 函数等待非满条件的发生,最后执行业务逻辑并解锁

public void put(E e) throws InterruptedException {
checkNotNull(e);
lock.lockInterruptibly();
try {
while (count == items.length) notFull.await(); // 阻塞, 等待队列非满
enqueue(e);
} finally {
lock.unlock();
}
}

上述 put() 函数中 enqueue() 函数的源码如下所示,在下面的代码中,putIndex 到达数组的最末尾之后,会重置为 0,重新指向数组的开头
因此我们可以得知,ArrayBlockingQueue 是一个循环队列(对循环队列实现原理不清楚的读者,请阅读《数据结构与算法之美》相关章节)
除此之外,put() 函数在调用 enqueue() 函数之前,就已经加了锁并且确保队列非满,因此 enqueue() 函数不需要处理线程安全问题以及队列满了的情况
enqueue() 函数执行完成之后,队列中添加了新的数据,于是就调用 notEmpty 条件变量上的 signal() 函数,唤醒其中一个执行阻塞读的线程

private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length) putIndex = 0;
count++;
notEmpty.signal(); // 唤醒执行阻塞读, 等待队列非空的线程
}

2.3、take()

接下来我们就看下出队操作是如何实现的,对应 take() 函数的源码如下所示

take() 函数代码结构跟 put() 函数类似
区别在于 take() 函数调用的是 notEmpty 条件变量上 await() 方法,等待非空条件的发生,并且在等到队列真正非空时,执行出队操作,也就是调用 dequeue() 函数

public E take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == 0) notEmpty.await(); // 阻塞, 等待队列非空
return dequeue();
} finally {
lock.unlock();
}
}

dequeue() 函数的源码如下所示,跟 enqueue() 函数类似,dequeue() 函数也不需要处理线程安全问题以及队列为空的情况
当执行完出队操作之后,dequeue() 函数调用 notFull 条件变量上的 signal() 函数,唤醒其中一个执行阻塞写的线程

private E dequeue() {
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length) takeIndex = 0;
count--;
notFull.signal(); // 唤醒执行阻塞写, 等待队列非满的线程
return x;
}

2.4、offer() 和 poll()

实际上除了实现支持阻塞的 put() 函数和 take() 函数之外,ArrayBlockingQueue 还实现了非阻塞的 offer() 函数和 poll() 函数
两个函数的代码实现如下所示,它们只通过 ReentrantLock 锁来保证线程安全,并没有通过条件变量来实现阻塞读写

public boolean offer(E e) {
checkNotNull(e);
lock.lock();
try {
if (count == items.length) {
return false;
} else {
enqueue(e);
return true;
}
} finally {
lock.unlock();
}
}
public E poll() {
lock.lock();
try {
return (count == 0) ? null : dequeue();
} finally {
lock.unlock();
}
}

2.5、总结

我们对 put() 函数和 take() 函数的实现原理做下总结:读操作和写操作互相等待,如下图所示

  • 读操作调用 notEmpty 上 await() 等待非空条件发生,执行完成之后,调用 notFull 上的 signal(),唤醒阻塞等待写的线程
  • 写操作调用 notFull 上的 await() 等待非满条件的发生,执行完成之后,调用 notEmpty 上的 signal(),唤醒阻塞等待读的线程

image

2.6、其它类

详细了解 ArrayBlockingQueue 的实现原理之后,我们再来看下跟它比较类似的 LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue

  • LinkedBlockingQueue 是基于 "链表" 实现的 "有界阻塞并发队列"
    默认大小为 Integer.MAX_VALUE,这个值非常大,实际上就相当于无界队列,当然我们也可以在创建对象时指定队列大小
  • LinkedBlockingDeque 跟 LinkedBlockingQueue 的区别在于:它是一个双端队列,支持两端读写操作
  • PriorityBlockingQueue 是一个 "无界阻塞并发优先级队列",底层基于 "支持扩容的堆" 来实现,因此:写操作永远都不需要阻塞,只有读操作会阻塞

这 3 个并发阻塞队列的实现方式,跟 ArrayBlockingQueue 的实现方式类似
也是使用 ReentrantLock 锁来实现读写操作的线程安全性,使用 Condition 条件变量实现读写操作的阻塞等待,这里就不再展示源码做讲解了
实际上从这 4 个并发阻塞队列的实现方式,我们也可以总结得到,利用锁和条件变量,我们可以实现任何类型的并发阻塞容器,比如:并发阻塞栈、并发阻塞哈希表等

3、DelayQueue

DelayQueue 为 "延迟阻塞并发队列",底层基于优先级队列 PriorityQueue 来实现,因为 PriorityQueue 支持动态扩容,因此 DelayQueue 是无界队列

3.1、Delayed 接口

DelayQueue 中存储的每个元素都必须实现 Delayed 接口,提供延迟被读取时间 delayTime,PriorityQueue 按照 delayTime 的大小将元素组织成小顶堆
也就是说:堆顶的元素是 delayTime 最小的元素,最先出队

public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}

3.2、示例

我们举个例子解释一下,示例代码如下所示

job1、job2、job3 的 delayTime 分别为 1s、2s、3s,线程 t1 和 t2 依次执行 take() 函数时,因为没有元素到期,所以均会被阻塞

  • 当时间过去 1s 之后,job1 到期,线程 t1 从阻塞中唤醒,读取到 job1
  • 当时间过去 2s 之后,job2 到期,线程 t2 从阻塞中唤醒,读取到 job2

这样就实现了一个简单的任务延迟执行框架

public class Job implements Delayed {
private String name;
private long endTime; // millisecond
public Job(String name, long delay) {
// delay 为延迟时间,System.currentTimeMillis() + delay 是终止时间
this.name = name;
this.endTime = System.currentTimeMillis() + delay; // 注意
}
public void run() {
System.out.println("I am " + name);
}
@Override
public long getDelay(TimeUnit unit) {
// 现在距离终止时间还差多少
long diff = endTime - System.currentTimeMillis(); // 注意
return unit.convert(diff, TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return (int) (this.getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS));
}
}
public class Demo {
private static class JobRunnable implements Runnable {
private DelayQueue<Job> jobs;
public JobRunnable(DelayQueue<Job> jobs) {
this.jobs = jobs;
}
@Override
public void run() {
try {
Job job = jobs.take();
job.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
DelayQueue<Job> jobs = new DelayQueue<>();
jobs.put(new Job("job1", 1000));
jobs.put(new Job("job2", 2000));
jobs.put(new Job("job3", 3000));
Thread t1 = new Thread(new JobRunnable(jobs));
Thread t2 = new Thread(new JobRunnable(jobs));
t1.start();
t2.start();
t1.join();
t2.join();
}
}

3.3、take()

因为 put() 函数不支持阻塞,实现比较简单,只是通过加锁保证线程安全,所以我们重点看下比较复杂的支持阻塞的 take() 函数,take() 函数的源码如下所示

public E take() throws InterruptedException {
lock.lockInterruptibly();
try {
// 自旋, 以免假唤醒
for (; ; ) {
E first = q.peek();
if (first == null) available.await(); // 条件变量: 可获得的, put() 函数会调用 signal() 唤醒它
else {
long delay = first.getDelay(NANOSECONDS);
if (delay <= 0) return q.poll(); // 元素到期被读取
if (leader != null) {
// 非 leader 线程
available.await();
} else {
// leader 线程
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
available.awaitNanos(delay); // 等待 delay 时间自动唤醒
} finally {
if (leader == thisThread) leader = null;
}
}
}
}
} finally {
// 唤醒非 leader 线程
if (leader == null && q.peek() != null) available.signal();
lock.unlock();
}
}

image

3.4、图示

实际上 take() 函数包含两部分独立的逻辑:针对 leader 线程的逻辑和针对非 leader 线程的逻辑,如下图所示
如果多个线程先后调用 take() 函数,那么第一个线程就是 leader 线程,剩下的线程为非 leader 线程
第一个线程执行读取操作完成之后,第二个线程便成为 leader 线程
非 leader 线程的处理逻辑比较简单,直接调用 await() 函数阻塞,等待 leader 线程读取完成之后调用 signal() 函数来唤醒

leader 线程的处理逻辑比较复杂,leader 线程读取的是队首的元素
如果队首元素的 delayTime > 0,那么 leader 线程会调用 awaitNanos() 阻塞 delayTime 时间,当 delayTime 时间过去之后,leader 线程自动唤醒
为了避免假唤醒(假唤醒来自于其他线程 "插队" 读取,待会讲解),leader 线程会检查队首元素的 delayTime 是否真正变为 <= 0
如果是则将队首元素出队,并且调用 signal() 唤醒第二个线程,第二个线程于是就成了 leader 线程,执行以上 leader 线程要执行的逻辑
image

实际上通过 take() 函数的源码,我们还可以发现:take() 函数的处理过程存在 "插队" 的行为
当一个线程执行 take() 函数时,如果检查发现队列不为空,并且队首元素的 delayTime <= 0
于是不管是否有其他线程在调用 await() 或 awaitNanos() 阻塞等待,这个线程都会直接读取队首元素并返回

4、SynchronousQueue

SynchronousQueue 是一个 "特殊的阻塞并发队列",用于两个线程之间传递数据
线程执行 put() 操作必须阻塞等待另一个线程执行 take() 操作,也就是说:SynchronousQueue 队列中不存储任何元素
因为在平时的开发中,SynchronousQueue 很少用到,所以我们不对其实现原理做深入分析,只对其用法做一个简单介绍,如下示例代码所示

// 先后输出: sleep done! take done! put done!
public class Demo {
public static void main(String[] args) throws InterruptedException {
SynchronousQueue<String> sq = new SynchronousQueue<>();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
sq.put("a");
System.out.println("put done!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
Thread.sleep(3000);
System.out.println("sleep done!");
sq.take();
System.out.println("take done!");
}
}

5、LinkedTransferQueue

LinkedTransferQueue 是一个基于 "链表" 实现的 "无界阻塞并发队列"
它是 LinkedBlockingQueue 和 SynchronousQueue 的综合体,既实现了 LinkedBlockingQueue 的功能,又实现了 SychronousQueue 的功能

LinkedTransferQueue 提供的 transfer() 函数,跟 SynchronousQueue 中的 put() 函数的功能类似,调用 transfer() 函数的线程会一直阻塞,直到数据被其他线程消费才会返回
在平时的开发中,LinkedTransferQueue 用到的也比较少,我们对其实现原理也不做深入分析,我们只简单介绍一下它的用法,示例代码如下所示

// 先后输出: put done! sleep done! take done! transfer done!
public class Demo {
public static void main(String[] args) throws InterruptedException {
LinkedTransferQueue<String> ltq = new LinkedTransferQueue<>();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
ltq.put("a"); // 不需要阻塞等待
System.out.println("put done!");
try {
ltq.transfer("b"); // 等待 b 被读取才返回
System.out.println("transfer done!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
Thread.sleep(3000);
System.out.println("sleep done!");
ltq.take(); // 读取 put() 写入的数据
ltq.take(); // 读取 transfer() 写入的数据
System.out.println("take done!");
}
}

6、课后思考题

请借鉴阻塞并发队列的实现方式,实现阻塞并发栈和阻塞并发哈希表

阻塞并发栈的代码实现如下所示

public class BlockingStack<E> {
private Object[] items;
private int top = 0;
private int capacity;
private ReentrantLock lock = new ReentrantLock();
private Condition notEmpty = lock.newCondition(); // 等待栈非空条件发生
private Condition notFull = lock.newCondition(); // 等待栈非满条件发生
public BlockingStack(int capacity) {
this.capacity = capacity;
this.items = new Object[capacity];
}
// 栈已满时,写入操作会被阻塞,直到栈有可写的空位为止
public void push(E elem) throws InterruptedException {
lock.lock();
try {
while (top == capacity) {
notFull.await();
}
this.items[top++] = elem;
notEmpty.signal();
} finally {
lock.unlock();
}
}
// 栈为空时,读取操作会被阻塞,直到栈有可读的数据为止
public E pop() throws InterruptedException {
lock.lock();
try {
while (top == 0) {
notEmpty.await();
}
return (E) items[--top];
} finally {
lock.unlock();
}
}
}

阻塞并发哈希表的代码实现如下所示

public class BlockingHashMap<K, V> {
private HashMap<K, V> hashMap = new HashMap<>();
private int capacity;
private int size = 0;
private ReentrantLock lock = new ReentrantLock();
private Condition notEmpty = lock.newCondition(); // 等待哈希表非空条件发生
private Condition notFull = lock.newCondition(); // 等待哈希表非满条件发生
public BlockingHashMap(int capacity) {
this.capacity = capacity;
}
// 哈希表已满时,写入操作会被阻塞,直到哈希表有可写的空位为止
public void put(K key, V value) throws InterruptedException {
lock.lock();
try {
while (size == capacity) {
notFull.await();
}
hashMap.put(key, value);
size++;
notEmpty.signal();
} finally {
lock.unlock();
}
}
// 哈希表为空时,读取操作会被阻塞,直到哈希表有可读的数据为止
public V get(K key) throws InterruptedException {
lock.lock();
try {
while (size == 0) {
notEmpty.await();
}
return hashMap.get(key);
} finally {
lock.unlock();
}
}
// 哈希表为空时,读取操作会被阻塞,直到哈希表有可读的数据为止
public V remove(K key) throws InterruptedException {
lock.lock();
try {
while (size == 0) {
notEmpty.await();
}
size--;
V value = hashMap.remove(key);
notFull.signal();
return value;
} finally {
lock.unlock();
}
}
}
posted @   lidongdongdong~  阅读(24)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开