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

Java并发--- 简单聊聊JDK中的七大阻塞队列

2018-10-26 14:28 656 查看

JDK中除了上文提到的各种并发容器,还提供了丰富的阻塞队列。阻塞队列统一实现了BlockingQueue接口,BlockingQueue接口在java.util包Queue接口的基础上提供了put(e)以及take()两个阻塞方法。他的主要使用场景就是多线程下的生产者消费者模式,生产者线程通过put(e)方法将生产元素,消费者线程通过take()消费元素。除了阻塞功能,BlockingQueue接口还定义了定时的offer以及poll,以及一次性移除方法drainTo。

//插入元素,队列满后会抛出异常
boolean add(E e);
//移除元素,队列为空时会抛出异常
E remove();

//插入元素,成功反会true
boolean offer(E e);
//移除元素
E poll();

//插入元素,队列满后会阻塞
void put(E e) throws InterruptedException;
//移除元素,队列空后会阻塞
E take() throws InterruptedException;

//限时插入
boolean offer(E e, long timeout, TimeUnit unit)
//限时移除
E poll(long timeout, TimeUnit unit);

//获取所有元素到Collection中
int drainTo(Collection<? super E> c);

JDK1.8中的阻塞队列实现共有7个,分别是ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue以及LinkedBlockingDeque,下面就来一一对他们进行一个简单的分析。

ArrayBlockingQueue

ArrayBlockingQueue是一个底层用数组实现的有界阻塞队列,有界是指他的容量大小是固定的,不能扩充容量,在初始化时就必须确定队列大小。它通过可重入的独占锁ReentrantLock来控制并发,Condition来实现阻塞。

//通过数组来存储队列中的元素
final Object[] items;

//初始化一个固定的数组大小,默认使用非公平锁来控制并发
public ArrayBlockingQueue(int capacity) {
this(capacity, false);
}

//初始化固定的items数组大小,初始化notEmpty以及notFull两个Condition来控制生产消费
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);//通过ReentrantLock来控制并发
notEmpty = lock.newCondition();
notFull =  lock.newCondition();
}

可以看到ArrayBlockingQueue初始化了一个ReentrantLock以及两个Condition,用来控制并发下队列的生产消费。这里重点看下阻塞的put以及take方法:

//插入元素到队列中
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly(); //获取独占锁
try {
while (count == items.length) //如果队列已满则通过await阻塞put方法
notFull.await();
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+1,当队列使用完后重置为0
putIndex = 0;
count++;
notEmpty.signal(); //队列添加元素后唤醒因notEmpty等待的消费线程
}

//移除队列中的元素
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly(); //获取独占锁
try {
while (count == 0) //如果队列已空则通过await阻塞take方法
notEmpty.await();
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+1,当队列使用完后重置为0
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
notFull.signal(); //队列消费元素后唤醒因notFull等待的消费线程
return x;
}

在队列添加和移除元素的过程中使用putIndex、takeIndex以及count三个变量来控制生产消费元素的过程,putIndex负责记录下一个可添加元素的下标,takeIndex负责记录下一个可移除元素的下标,count记录了队列中的元素总量。队列满后通过notFull.await()来阻塞生产者线程,消费元素后通过notFull.signal()来唤醒阻塞的生产者线程。队列为空后通过notEmpty.await()来阻塞消费者线程,生产元素后通过notEmpty.signal()唤醒阻塞的消费者线程。

限时插入以及移除方法在ArrayBlockingQueue中通过awaitNanos来实现,在给定的时间过后如果线程未被唤醒则直接返回。

public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
checkNotNull(e);
long nanos = unit.toNanos(timeout); //获取定时时长
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length) {
if (nanos <= 0) //指定时长过后,线程仍然未被唤醒则返回false
return false;
nanos = notFull.awaitNanos(nanos); //指定时长内阻塞线程
}
enqueue(e);
return true;
} finally {
lock.unlock();
}
}

还有一个比较重要的方法:drainTo,drainTo方法可以一次性获取队列中所有的元素,它减少了锁定队列的次数,使用得当在某些场景下对性能有不错的提升。

public int drainTo(Collection<? super E> c, int maxElements) {
checkNotNull(c);
if (c == this)
throw new IllegalArgumentException();
if (maxElements <= 0)
return 0;
final Object[] items = this.items;
final ReentrantLock lock = this.lock; //仅获取一次锁
lock.lock();
try {
int n = Math.min(maxElements, count); //获取队列中所有元素
int take = takeIndex;
int i = 0;
try {
while (i < n) {
@SuppressWarnings("unchecked")
E x = (E) items[take];
c.add(x); //循环插入元素
items[take] = null;
if (++take == items.length)
take = 0;
i++;
}
return n;
} finally {
// Restore invariants even if c.add() threw
if (i > 0) {
count -= i;
takeIndex = take;
if (itrs != null) {
if (count == 0)
itrs.queueIsEmpty();
else if (i > take)
itrs.takeIndexWrapped();
}
for (; i > 0 && lock.hasWaiters(notFull); i--)
notFull.signal(); //唤醒等待的生产者线程
}
}
} finally {
lock.unlock();
}
}

LinkedBlockingQueue

LinkedBlockingQueue是一个底层用单向链表实现的有界阻塞队列,和ArrayBlockingQueue一样,采用ReentrantLock来控制并发,不同的是它使用了两个独占锁来控制消费和生产。put以及take方法源码如下:

public void put(E e) throws InterruptedException {
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
//因为使用了双锁,需要使用AtomicInteger计算元素总量,避免并发计算不准确
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
while (count.get() == capacity) {
notFull.await(); //队列已满,阻塞生产线程
}
enqueue(node); //插入元素到队列尾部
c = count.getAndIncrement(); //count + 1
if (c + 1 < capacity) //如果+1后队列还未满,通过其他生产线程继续生产
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0) //只有当之前是空时,消费队列才会阻塞,否则是不需要通知的
signalNotEmpty();
}

private void enqueue(Node<E> node) {
//将新元素添加到链表末尾,然后将last指向尾部元素
last = last.next = node;
}

public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await(); //队列为空,阻塞消费线程
}
x = dequeue(); //消费一个元素
c = count.getAndDecrement(); //count - 1
if (c > 1) // 通知其他等待的消费线程继续消费
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity) //只有当之前是满的,生产队列才会阻塞,否则是不需要通知的
signalNotFull();
return x;
}

//消费队列头部的下一个元素,同时将新头部置空
private E dequeue() {
Node<E> h = head;
Node<E> first = h.next;
h.next = h; // help GC
head = first;
E x = first.item;
first.item = null;
return x;
}

可以看到LinkedBlockingQueue通过takeLock和putLock两个锁来控制生产和消费,互不干扰,只要队列未满,生产线程可以一直生产,只要队列不为空,消费线程可以一直消费,不会相互因为独占锁而阻塞。

看过了LinkedBlockingQueue以及ArrayBlockingQueue的底层实现,会发现一个问题,正常来说消费者和生产者可以并发执行对队列的吞吐量会有比较大的提升,那么为什么ArrayBlockingQueue中不使用双锁来实现队列的生产和消费呢?我的理解是ArrayBlockingQueue也能使用双锁来实现功能,但由于它底层使用了数组这种简单结构,相当于一个共享变量,如果通过两个锁,需要更加精确的锁控制,这也是为什么JDK1.7中的ConcurrentHashMap使用了分段锁来实现,将一个数组分为多个数组来提高并发量。LinkedBlockingQueue不存在这个问题,链表这种数据结构头尾节点都相对独立,存储上也不连续,双锁控制不存在复杂性。这是我的理解,如果你有更好的结论,请留言探讨。

PriorityBlockingQueue

PriorityBlockingQueue是一个底层由数组实现的无界队列,并带有排序功能,同样采用ReentrantLock来控制并发。由于是无界的,所以插入元素时不会阻塞,没有队列满的状态,只有队列为空的状态。通过这两点特征其实可以猜测它应该是有一个独占锁(底层数组)和一个Condition(只通知消费)来实现的。put以及take方法源码分析如下:

public void put(E e) {
offer(e);
}

public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
final ReentrantLock lock = this.lock;
lock.lock();
int n, cap;
Object[] array;
//无界队列,队列长度不够时会扩容
while ((n = size) >= (cap = (array = queue).length))
tryGrow(array, cap);
try {
//通过comparator来实现优先级排序
Comparator<? super E> cmp = comparator;
if (cmp == null)
siftUpComparable(n, e, array);
else
siftUpUsingComparator(n, e, array, cmp);
size = n + 1;
notEmpty.signal(); //和ArrayBlockingQueue一样,每次添加元素后通知消费线程
} finally {
lock.unlock();
}
return true;
}

public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
E result;
try {
while ( (result = dequeue()) == null)
notEmpty.await(); //队列为空,阻塞消费线程
} finally {
lock.unlock();
}
return result;
}

DelayQueue

DelayQueue也是一个无界队列,它是在PriorityQueue基础上实现的,先按延迟优先级排序,延迟时间短的排在前面。和PriorityBlockingQueue相似,底层也是数组,采用一个ReentrantLock来控制并发。由于是无界的,所以插入元素时不会阻塞,没有队列满的状态。能想到的最简单的使用场景一般有两个:一个是缓存过期,一个是定时执行的任务。但由于是无界的,缓存过期上一般使用的并不多。简单来看下put以及take方法:

private final transient ReentrantLock lock = new ReentrantLock();
private final PriorityQueue<E> q = new PriorityQueue<E>();//优先级队列

public void put(E e) {
offer(e);
}

public boolean offer(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
q.offer(e); //插入元素到优先级队列
if (q.peek() == e) { //如果插入的元素在队列头部
leader = null;
available.signal(); //通知消费线程
}
return true;
} finally {
lock.unlock();
}
}

public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
for (;;) {
E first = q.peek(); //获取头部元素
if (first == null)
available.await(); //空队列阻塞
else {
long delay = first.getDelay(NANOSECONDS); //检查元素是否延迟到期
if (delay <= 0)
return q.poll(); //到期则弹出元素
first = null; // don't retain ref while waiting
if (leader != null)
available.await();
else {
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
available.awaitNanos(delay); //阻塞未到期的时间
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
if (leader == null && q.peek() != null)
available.signal();
lock.unlock();
}
}

SynchronousQueue

SynchronousQueue相比较之前的4个队列就比较特殊了,它是一个没有容量的队列,也就是说它内部时不会对数据进行存储,每进行一次put之后必须要进行一次take,否则相同线程继续put会阻塞。这种特性很适合做一些传递性的工作,一个线程生产,一个线程消费。内部分为公平和非公平访问两种模式,默认使用非公平,未使用锁,全部通过CAS操作来实现并发,吞吐量非常高。这里只对它的非公平实现下的take和put方法做下简单分析:

//非公平情况下调用内部类TransferStack的transfer方法put
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
if (transferer.transfer(e, false, 0) == null) {
Thread.interrupted();
throw new InterruptedException();
}
}
//非公平情况下调用内部类TransferStack的transfer方法take
public E take() throws InterruptedException {
E e = transferer.transfer(null, false, 0);
if (e != null)
return e;
Thread.interrupted();
throw new InterruptedException();
}

//具体的put以及take方法,只有E的区别,通过E来区别REQUEST还是DATA模式
E transfer(E e, boolean timed, long nanos) {
SNode s = null; // constructed/reused as needed
int mode = (e == null) ? REQUEST : DATA;

for (;;) {
SNode h = head;
//栈无元素或者元素和插入的元素模式相匹配,也就是说都是插入元素
if (h == null || h.mode == mode) {
//有时间限制并且超时
if (timed && nanos <= 0) {
if (h != null && h.isCancelled())
casHead(h, h.next);  // 重新设置头节点
else
return null;
}
//未超时cas操作尝试设置头节点
else if (casHead(h, s = snode(s, e, h, mode))) {
//自旋一段时间后未消费元素则挂起put线程
SNode m = awaitFulfill(s, timed, nanos);
if (m == s) {               // wait was cancelled
clean(s);
return null;
}
if ((h = head) != null && h.next == s)
casHead(h, s.next);     // help s's fulfiller
return (E) ((mode == REQUEST) ? m.item : s.item);
}
}
//栈不为空并且和头节点模式不匹配,存在元素则消费元素并重新设置head节点
else if (!isFulfilling(h.mode)) { // try to fulfill
if (h.isCancelled())            // already cancelled
casHead(h, h.next);         // pop and retry
else if (casHead(h, s=snode(s, e, h, FULFILLING|mode))) {
for (;;) { // loop until matched or waiters disappear
SNode m = s.next;       // m is s's match
if (m == null) {        // all waiters are gone
casHead(s, null);   // pop fulfill node
s = null;           // use new node next time
break;              // restart main loop
}
SNode mn = m.next;
if (m.tryMatch(s)) {
casHead(s, mn);     // pop both s and m
return (E) ((mode == REQUEST) ? m.item : s.item);
} else                  // lost match
s.casNext(m, mn);   // help unlink
}
}
}
//节点正在匹配阶段
else {                            // help a fulfiller
SNode m = h.next;               // m is h's match
if (m == null)                  // waiter is gone
casHead(h, null);           // pop fulfilling node
else {
SNode mn = m.next;
if (m.tryMatch(h))          // help match
casHead(h, mn);         // pop both h and m
else                        // lost match
h.casNext(m, mn);       // help unlink
}
}
}
}

//先自旋后挂起的核心方法
SNode awaitFulfill(SNode s, boolean timed, long nanos) {
final long deadline = timed ? System.nanoTime() + nanos : 0L;
Thread w = Thread.currentThread();
//计算自旋的次数
int spins = (shouldSpin(s) ?
(timed ? maxTimedSpins : maxUntimedSpins) : 0);
for (;;) {
if (w.isInterrupted())
s.tryCancel();
SNode m = s.match;
//匹配成功过返回节点
if (m != null)
return m;
//超时控制
if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {
s.tryCancel();
continue;
}
}
//自旋检查,是否进行下一次自旋
if (spins > 0)
spins = shouldSpin(s) ? (spins-1) : 0;
else if (s.waiter == null)
s.waiter = w; // establish waiter so can park next iter
else if (!timed)
LockSupport.park(this); //在这里挂起线程
else if (nanos > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanos);
}
}

代码非常复杂,这里说下我所理解的核心逻辑。代码中可以看到put以及take方法都是通过调用transfer方法来实现的,然后通过参数mode来区别,在生产元素时如果是同一个线程多次put则会采取自旋的方式多次尝试put元素,可能自旋过程中元素会被消费,这样可以及时put,降低线程挂起的性能损耗,高吞吐量的核心也在这里,消费线程一样,空栈时也会先自旋,自旋失败然后通过线程的LockSupport.park方法挂起。

LinkedTransferQueue

LinkedTransferQueue是一个无界的阻塞队列,底层由链表实现。虽然和LinkedBlockingQueue一样也是链表实现的,但并发控制的实现上却很不一样,和SynchronousQueue类似,采用了大量的CAS操作,没有使用锁,由于是无界的,所以不会put生产线程不会阻塞,只会在take时阻塞消费线程,消费线程挂起时同样使用LockSupport.park方法。

LinkedTransferQueue相比于以上的队列还提供了一些额外的功能,它实现了TransferQueue接口,有两个关键方法transfer(E e)和tryTransfer(E e)方法,transfer在没有消费时会阻塞,tryTransfer在没有消费时不会插入到队列中,也不会等待,直接返回false。

private static final int NOW   = 0; // for untimed poll, tryTransfer
private static final int ASYNC = 1; // for offer, put, add
private static final int SYNC  = 2; // for transfer, take
private static final int TIMED = 3; // for timed poll, tryTransfer

//通过SYNC状态来实现生产阻塞
public void transfer(E e) throws InterruptedException {
if (xfer(e, true, SYNC, 0) != null) {
Thread.interrupted(); // failure possible only due to interrupt
throw new InterruptedException();
}
}
//通过NOW状态跳过添加元素以及阻塞
public boolean tryTransfer(E e) {
return xfer(e, true, NOW, 0) == null;
}

//通过ASYNC状态跳过阻塞
public void put(E e) {
xfer(e, true, ASYNC, 0);
}
//通过SYNC状态来实现消费阻塞
public E take() throws InterruptedException {
E e = xfer(null, false, SYNC, 0);
if (e != null)
return e;
Thread.interrupted();
throw new InterruptedException();
}

//生产消费调用同一个方法,通过e是否为空,haveData,how等参数来区分具体逻辑
private E xfer(E e, boolean haveData, int how, long nanos) {
if (haveData && (e == null))
throw new NullPointerException();
Node s = null;                        // the node to append, if needed

retry:
for (;;) {                            // restart on append race
//找出第一个可用节点
for (Node h = head, p = h; p != null;) { // find & match first node
boolean isData = p.isData;
Object item = p.item;
//队列为空时直接跳过
if (item != p && (item != null) == isData) { // unmatched
//节点类型相同,跳过
if (isData == haveData)   // can't match
break;
if (p.casItem(item, e)) { // match
for (Node q = p; q != h;) {
Node n = q.next;  // update by 2 unless singleton
if (head == h && casHead(h, n == null ? q : n)) {
h.forgetNext();
break;
}                 // advance and retry
if ((h = head)   == null ||
(q = h.next) == null || !q.isMatched())
break;        // unless slack < 2
}
LockSupport.unpark(p.waiter);
return LinkedTransferQueue.<E>cast(item);
}
}
Node n = p.next;
p = (p != n) ? n : (h = head); // Use head if p offlist
}
//插入节点或移除节点具体逻辑
//tryTransfer方法会直接跳过并返回结果
if (how != NOW) {                 // No matches available
if (s == null)
s = new Node(e, haveData);
Node pred = tryAppend(s, haveData); //加入节点
if (pred == null)
continue retry;           // lost race vs opposite mode
if (how != ASYNC)
//自旋以及阻塞消费线程逻辑,和SynchronousQueue类似,先尝试自选,失败后挂起线程
//transfer方法在没有消费线程时也会阻塞在这里
return awaitMatch(s, pred, e, (how == TIMED), nanos);
}
return e; // not waiting
}
}

LinkedBlockingDeque

LinkedBlockingDeque是一个有界的双端队列,底层采用一个双向的链表来实现,在LinkedBlockingQeque的Node实现多了指向前一个节点的变量prev。并发控制上和ArrayBlockingQueue类似,采用单个ReentrantLock来控制并发,这里是因为双端队列头尾都可以消费和生产,所以使用了一个共享锁。它实现了BlockingDeque接口,继承自BlockingQueue接口,多了addFirst,addLast,offerFirst,offerLast,peekFirst,peekLast等方法,用来头尾生产和消费。LinkedBlockingDeque的实现代码比较简单,基本就是综合了LinkedBlockingQeque和ArrayBlockingQueue的代码逻辑,这里就不做分析了。

总结

文章对JDK1.8中的7种阻塞队列都做了简单分析,帮助大家大致梳理的这7个队列的基本原理。总的来说每种阻塞队列都有它自己的应用场景,使用时可以先根据有界还是无界,然后在根据各自的特性来进行选择。

有界阻塞队列包括:ArrayBlockingQueue、LinkedBlockingQueue以及LinkedBlockingDeque三种,LinkedBlockingDeque应用场景很少,一般用在“工作窃取”模式下。ArrayBlockingQueue和LinkedBlockingQueue基本就是数组和链表的区别。无界队列包括PriorityBlockingQueue、DelayQueue和LinkedTransferQueue。PriorityBlockingQueue用在需要排序的队列中。DelayQueue可以用来做一些定时任务或者缓存过期的场景。LinkedTransferQueue则相比较其他队列多了transfer功能。最后剩下一个不存储元素的队列SynchronousQueue,用来处理一些高效的传递性场景。

http://mp.toutiao.com/preview_article/?pgc_id=6616546274206286350

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  Java JDK