当我们实现一个生产者-消费者模式时,我们需要一个存储资源的容器。JDK为我们实现了这样一个容器:阻塞队列BlockingQueue,我们只需要实现存,取操作而不必担心多线程环境下的线程安全问题。
接口BlockingQueue是Java util.concurrent(JUC)包下重要的数据结构,区别于普通的队列,BlockingQueue提供了线程安全的队列访问方式,并发包下很多高级同步类的实现都是基于BlockingQueue实现的。
阻塞队列提供了四组不同的方法用于插入、移除、检查元素:
方法\处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除方法 | remove() | poll() | take() | poll(time,unit) |
检查方法 | element() | peek() |
抛出异常:如果试图的操作无法立即执行,抛异常。当阻塞队列满时候,再往队列里插入元素,会抛出IllegalStateException(“Queue full”)异常。当队列为空时,从队列里获取元素时会抛出NoSuchElementException异常 。
返回特殊值:如果试图的操作无法立即执行,返回一个特殊值,通常是true / false。
一直阻塞:如果试图的操作无法立即执行,则一直阻塞或者响应中断。
超时退出:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功,通常是 true / false。
不能往阻塞队列中插入null,会抛出空指针异常。
由数组结构组成的有界阻塞队列。内部结构是数组,故具有数组的特性。
public ArrayBlockingQueue(int capacity, boolean fair){
//..省略代码
}
//ArrayBlockingQueue fairQueue = new ArrayBlockingQueue(1000,true);
可以初始化队列大小, 一旦初始化不能改变。构造方法中的fair表示控制对象的内部锁是否采用公平锁,默认是非公平锁。
由链表结构组成的有界阻塞队列。内部结构是链表,具有链表的特性。默认队列的大小是Integer.MAX_VALUE
,也可以指定大小。此队列按照先进先出的原则对元素进行排序。
该队列的元素只有在其指定的延迟时间到了,才能够从队列中获取到该元素 。放入其中的元素必须实现 java.util.concurrent.Delayed 接口。
DelayQueue是一个没有大小限制的队列,因此往队列中插入数据的操作永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
它是一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作, 否则不能继续添加元素。队列本身并不存储任何元素。
非常适合传递性场景。SynchronousQueue的吞吐量高于 LinkedBlockingQueue和ArrayBlockingQueue。
它支持公平访问队列。默认情况下线程非公平性访问队列。
一个支持优先级的无界阻塞队列。默认情况下元素采取自然顺序 升序排列,也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化 PriorityBlockingQueue时,指定构造参数Comparator来对元素进行排序。需要注意的该队列不能保证同优先级元素的顺序。
public class Test {
static PriorityBlockingQueue queue = new PriorityBlockingQueue<>();
static class User implements Comparable {
public User(int age,String name) {
this.age = age;
this.name = name;
}
int age;
String name;
@Override
public int compareTo(User o) {
return this.age > o.age ? -1 : 1;
}
}
public static void main(String[] args) throws InterruptedException{
queue.add(new User(1, "w1"));
queue.add(new User(66, "w66"));
queue.add(new User(55, "w55"));
queue.add(new User(77, "w77"));
queue.add(new User(3, "w3"));
queue.add(new User(9, "w9"));
for (User user : queue) {
System.out.println(queue.take().name);
}
/*输出:
w77
w66
w55
w9
w3
w1
*/
}
}
由于队列是无界的,因此PriorityBlockingQueue不会阻塞数据生产者,只会在没有可消费的数据时,阻塞消费者。
需要注意的是,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。对于使用默认大小的LinkedBlockingQueue也是一样的。
下面来看一下ArrayBlockingQueue 在JDK 1.8 的源码。
相关参数中除了有初始化队列的大小和是否是公平锁之外,还有 对同一个内部锁lock初始化的两个监视器notEmpty和notFull。
//数据元素数组
final Object[] items;
//下一个待取出元素索引
int takeIndex;
//下一个待添加元素索引
int putIndex;
//元素个数
int count;
//内部锁
final ReentrantLock lock;
//消费者监视器
private final Condition notEmpty;
//生产者监视器
private final Condition notFull;
public ArrayBlockingQueue(int capacity, boolean fair) {
//..省略其他代码
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
生产者线程尝试使用put方法放入资源:
(1)所有执行put操作的线程竞争lock锁,拿到lock锁的线程执行下一步骤,没有拿到锁的线程自旋竞争锁。
(2)判断阻塞队列是否满了:若已满,则调用await方法阻塞该线程,并标记为notFull(生产者)线程,同时释放lock锁,等待被消费者线程唤醒。
(3)若没有满,则调用enqueue方法将资源put进阻塞队列。
(4)唤醒一个标记为notEmpty(消费者)的线程。
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
// 1.自旋拿锁
lock.lockInterruptibly();
try {
// 2.判断队列是否满了
while (count == items.length)
// 2.1如果满了,阻塞该线程,并标记为notFull线程,
// 等待notFull的唤醒,唤醒之后继续执行while循环。
notFull.await();
// 3.如果没有满,则进入队列
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++;
// 4 唤醒一个等待的线程
notEmpty.signal();
}
消费者线程尝试使用take操作从队列中取资源:
(1)所有执行take操作的线程竞争lock锁,拿到lock锁的线程执行下一步骤,没有拿到锁的线程自旋竞争锁。
(2)判断阻塞队列是否为空,如果是空,则调用await方法阻塞这个线程,并标记为notEmpty(消费者)线程,同时释放lock锁,等待被生产者线程唤醒。
(3)如果没有空,则调用dequeue方法。
(4)唤醒一个标记为notFull(生产者)的线程。
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
// 1.自旋拿锁
lock.lockInterruptibly();
try {
// 2.判断队列是否为空:若空了,则阻塞该线程。
while (count == 0)
notEmpty.await();
//3.若没有空,则调用dequeue方法.
return dequeue();
} finally {
lock.unlock();
}
}
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
notFull.signal();
return x;
}
注意事项
在put操作和take操作中的第2点里,判断队列是否为空/满都是用while来执行语句。这是因为被唤醒的线程依然需要拿到锁才能继续执行,否则自旋拿锁。拿到锁需要判断队列是否可用,如果是用if语句,那么被唤醒且拿到锁的线程是不会再次检查队列是否可用,而是直接执行下一步骤,此时如果队列不可用会导致出错。
我们规定队列最多放入3个资源:
public class Test {
private int queueSize = 3;
ArrayBlockingQueue queue = new ArrayBlockingQueue<>(queueSize);
class Consumer extends Thread {
@Override
public void run() {
consume();
}
private void consume() {
while(true) {
try {
queue.take();
System.out.println("取出了一个元素,队列剩余" + queue.size() + "元素");
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Producer extends Thread {
@Override
public void run() {
produce();
}
private void produce() {
while(true) {
try {
queue.put(1);
System.out.println("生产了一个元素,队列还可以放入:"+(queueSize-queue.size()) + "个元素");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException{
Test test = new Test();
test.new Producer().start();
test.new Consumer().start();
}
}
输出结果如下。结果可能是有问题的,这是因为System.out.println语句没有锁,线程1执行完put/take操作后立即失去CPU时间片,然后转到线程2执行get/take操作,执行完后又回到线程1的输出语句并输出,此时队列的大小已被线程2修改,因此输出的size不是当时线程1执行完put/take操作后队列的size。不过我们可以确保的是队列的size不会超过3。
Java的线程池使用了阻塞队列来实现的,详情见此处。
http://concurrent.redspider.group/