众所周知,配置线程池时有任务队列这个参数,一般我们选用的就是ArrayBlockingQueue和LinkedBlockingQueue,今天一起来研究下他们的底层吧。
任务队列
- ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列(数组结构可配合指针实现一个环形队列)。
- LinkedBlockingQueue: 一个由链表结构组成的有界阻塞队列,在未指明容量时,容量默认为 Integer.MAX_VALUE。
- PriorityBlockingQueue: 一个支持优先级排序的无界阻塞队列,对元素没有要求,可以实现 Comparable 接口也可以提供 Comparator 来对队列中的元素进行比较。跟时间没有任何关系,仅仅是按照优先级取任务。
- DelayQueue:类似于PriorityBlockingQueue,是二叉堆实现的无界优先级阻塞队列。要求元素都实现 Delayed 接口,通过执行时延从队列中提取任务,时间没到任务取不出来。
- SynchronousQueue: 一个不存储元素的阻塞队列,消费者线程调用 take() 方法的时候就会发生阻塞,直到有一个生产者线程生产了一个元素,消费者线程就可以拿到这个元素并返回;生产者线程调用 put() 方法的时候也会发生阻塞,直到有一个消费者线程消费了一个元素,生产者才会返回。
- LinkedBlockingDeque: 使用双向队列实现的有界双端阻塞队列。双端意味着可以像普通队列一样 FIFO(先进先出),也可以像栈一样 FILO(先进后出)。
- LinkedTransferQueue: 它是ConcurrentLinkedQueue、LinkedBlockingQueue 和 SynchronousQueue 的结合体,但是把它用在 ThreadPoolExecutor 中,和 LinkedBlockingQueue 行为一致,但是是无界的阻塞队列。
BlockingQueue
阻塞队列就是基于这种模式实现的队列型容器。阻塞队列的一般实现是:我们创建队列时,指定队列的容量,当队列中元素的个数已经满时,向队列中添加元素的线程将被阻塞,直到队列不满才恢复运行,将元素添加进去;当队列为空时,向队列获取元素的线程将被阻塞,直到队列不空才恢复运行,从队列中拿出元素。
阻塞机制队列BlockingQueue
非阻塞机制队列CurrentLinkedQueue
特点:
- 通过在入队和出队加锁,保证了线程安全。
- 支持阻塞的入队和出队方法:当队列满时,会阻塞入队的线程,直到队列不满;当队列为空时,会阻塞出队的线程,直到队列中有元素。
常用于生产者-消费者模型。
五个实现类
ArrayBlockingQueue
成员变量
/** 一个数组,用来存储放入队列中的元素 */
final Object[] items;
/** 此变量用来记录下一次从队列中拿出的元素,它在数组中的下标,可以理解为队列的头节点 */
int takeIndex;
/** 此变量存储下一次往队列中添加元素时,这个元素在数组中的下标,也就是记录队列尾的上一个位置 */
int putIndex;
/** 记录队列中元素的个数 */
int count;
/** 一个锁,用来保证向队列中插入、删除等操作的线程安全 */
final ReentrantLock lock;
/** 用来在队列为空时阻塞获取元素的线程,也就是用来阻塞消费者,
* 这个变量叫notEmpty(不空),可以理解为队列空时将会被阻塞,不空时可以正常运行
*/
private final Condition notEmpty;
/** 用来在队列满时阻塞添加元素的线程,也就是用来阻塞生产者
* 这个变量叫notFull(不满),可以理解为队列满时将会阻塞,不满时可以正常运行
*/
private final Condition notFull;
/** 遍历使用的迭代器 */
transient Itrs itrs = null;
首先,ArrayBlockingQueue是基于数组实现的阻塞队列,由于队列是从头部获取元素,尾部添加元素,所以定义了两个变量分别记录队列头在数组中的下标,以及插入新元素时的下标。除此之外,我们可以看到,它是使用ReentrantLock来实现的线程同步,在这个lock上定义了两个Condition对象,分别用来阻塞生产者和消费者,这是生产者消费者模式非常基本的一种实现方式。
构造方法
// 仅仅指定队列容量的构造方法
public ArrayBlockingQueue(int capacity) {
// 调用下面那个构造方法,第二个参数默认为false,表示使用非公平锁
this(capacity, false);
}
/**
* 此构造方法接收两个参数:
* 1、capacity:指定阻塞队列的容量
* 2、fair:指定创建的ReentrantLock是否是公平锁
*/
public ArrayBlockingQueue(int capacity, boolean fair) {
// 容量必须大于0
if (capacity <= 0)
throw new IllegalArgumentException();
// 初始化存储元素的数组
this.items = new Object[capacity];
// 创建用于线程同步的锁lock,若fair为true,
// 则此时创建的将是一个公平锁,反之则是非公平锁
lock = new ReentrantLock(fair);
// 初始化notEmpty变量,用以阻塞和唤醒消费者线程
notEmpty = lock.newCondition();
// 初始化notFull变量,用以阻塞生产者线程
notFull = lock.newCondition();
}
上面的构造方法还是比较好理解的,唯一需要注意的地方就是用于线程同步的lock可以指定为公平锁,这也就意味着,线程的执行顺序将按时间排序,也就是先申请获取元素的线程,一定比后申请获取元素的线程,更先拿到元素,而向队列中放置元素的线程也是如此。如果我们需要这种先后顺序,可以将lock指定为公平锁,公平锁可以避免线程“饥饿”,但是公平锁比非公平锁的开销更大,因为强制要求每个线程排队,会导致阻塞和唤醒线程的次数大大增加,所以如果不是必要,最好还是使用非公平锁。
入队
接下来我们来看一看ArrayBlockingQueue的实现中,向队列中添加元素是如何实现的。ArrayBlockingQueue添加元素的方法有三个,分别是add,offer以及最重要的put。add和offer是Queue接口中定义的方法,任何一个实现了Queue接口的类都实现了这两个方法。但是put方法是阻塞队列才有的方法,它才是实现阻塞队列的核心方法之一。下面我们就先来分析看看put的实现:
public void put(E e) throws InterruptedException {
// 判断元素是否为null,若为null将抛出异常
checkNotNull(e);
// 获取锁对象lock
final ReentrantLock lock = this.lock;
// 调用lock的lockInterruptibly方法加锁,lockInterruptibly可以响应中断
// 加锁是为了防止多个线程同时操作队列,造成线程安全问题
lock.lockInterruptibly();
try {
// 如果当前队列中的元素的个数为数组长度,表示队列满了,
// 这时调用notFull.await()让当前线程阻塞,也就是让生产者阻塞
// 而此处使用while循环而不是if,是考虑到线程被唤醒后,队列可能还是满的
// 所以线程被唤醒后,需要再次判断,若依旧是满的,则再次阻塞
while (count == items.length)
notFull.await();
// 调用enqueue方法将元素加入数组中
enqueue(e);
} finally {
// 释放锁
lock.unlock();
}
}
/** 此方法将新元素加入到数组中 */
private void enqueue(E x) {
// 获得存储元素的数组
final Object[] items = this.items;
// 将新元素x放入到数组中,且放入的位置就是putIndex指向的位置
items[putIndex] = x;
// putIndex加1,如果超过了数组的最大长度,则将其置为0,也就是数组的第一个位置
if (++putIndex == items.length)
putIndex = 0;
// 元素数量+1
count++;
// 因为我们已经向队列中添加了元素,所以可以唤醒那些需要获取元素的线程,也就是消费者
// 之前说过,notEmpty就是用来阻塞和唤醒消费者的
notEmpty.signal();
}
// 判断元素是否为null
private static void checkNotNull(Object v) {
if (v == null)
throw new NullPointerException();
}
以上就是ArrayBlockingQueue中put方法的实现。读了它的源码后我们可以发现,put的工作工程就是:向队列中添加一个新元素,若队列已经满了,则当前线程被阻塞,等待队列不满时被唤醒;当前线程成功添加元素后,将唤醒正在等待的消费者线程(如果有的话),消费者线程则从队列中获取元素。除了put方法外,阻塞队列还有两个方用以添加元素,就是add以及offer,这是Queue接口中定义的方法,也就是说并不是阻塞队列所特有的,所以这两个方法比较普通,我们简单地看一看即可:
public boolean offer(E e) {
// 判断加入的元素是否为null,若为null将抛出异常
checkNotNull(e);
// 获取锁对象
final ReentrantLock lock = this.lock;
// 加锁防止线程安全问题,注意这里调用的是lock()方法,这个方法并不响应中断
// 而之前的put方法会响应中断,以为put会阻塞,为了防止它长期阻塞,所以需要响应中断
// 但是这个方法并不会被阻塞,所以不需要响应中断
lock.lock();
try {
// 若当前队列已满,则不进行添加,直接返回false,表示添加失败
if (count == items.length)
return false;
else {
// 若队列不满,则直接调用enqueue方法添加元素,并返回true
enqueue(e);
return true;
}
} finally {
// 解锁
lock.unlock();
}
}
public boolean add(E e) {
// 调用offer方法添加元素,若offer方法返回true表示添加成功,则此方法返回true
if (offer(e))
return true;
// 添加失败直接抛出异常
else
throw new IllegalStateException("Queue full");
}
可以看到,这两个方法的实现比较简单。offer方法在队列满时直接放弃添加,返回false,若添加成功返回true;add方法直接调用offer方法添加元素,若添加失败,将会抛出异常。除了上面两个方法外,ArrayBlockingQueue还有一个比较特殊的方法,也是用来添加元素,并且在队列满时也会进行等待,但是并不会一直等待,而是等待指定的时间,这个方法是offer的重载方法,其代码如下:
/**
* 此方法用来阻塞式地添加元素,但是需要指定阻塞的超时时间
* 1、timeout:需要阻塞时间的数量级,一个long类型的整数;
* 2、unit:用以指定时间的单位,比如TimeUnit.SECONDS表示秒,
* 若timeout为10,而unit为TimeUnit.SECONDS,则表示最多阻塞10秒
*/
public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
// 判断元素是否为null
checkNotNull(e);
// 获取线程需要阻塞的时间的纳秒值
long nanos = unit.toNanos(timeout);
// 获取锁对象
final ReentrantLock lock = this.lock;
// 加锁,并且lockInterruptibly方法会响应中断
lock.lockInterruptibly();
try {
// 若当前队列中元素已满
while (count == items.length) {
// 若等待的剩余时间小于0,表示超过了等待时间,则直接返回
if (nanos <= 0)
return false;
// 让当前线程等待指定的时间,使用notFull对象让线程等待一段时间
// 方法会返回剩余的需要等待的时间
nanos = notFull.awaitNanos(nanos);
}
// 调用enqueue方法将元素添加到数组中
enqueue(e);
// 返回true表示添加成功
return true;
} finally {
// 解锁
lock.unlock();
}
}
出队
和添加元素类似,元素移出队列也有三个方法,分别是remove、poll以及阻塞队列中最关键的两个方法之一的take(另一个关键方法是put)。我们就先来看看take方法的实现:
public E take() throws InterruptedException {
// 获取锁对象
final ReentrantLock lock = this.lock;
// 使用lock对象加锁,lockInterruptibly方法会响应中断
// 目的是防止线程一直在此处阻塞,无法退出
lock.lockInterruptibly();
try {
// 若当前队列中元素为0,则调用notEmpty对象的await()方法,
// 让当前获取元素的线程阻塞,也就是阻塞消费者线程,直到被生产者线程唤醒
while (count == 0)
notEmpty.await();
// 调用dequeue方法获取队投元素,并直接返回
return dequeue();
} finally {
// 解锁
lock.unlock();
}
}
/** 此方法用来获取队头元素,同时将它从数组中删除 */
private E dequeue() {
// 获取存储元素的数组
final Object[] items = this.items;
// takeIndex记录的就是队头元素的下标,使用变量x记录它
E x = (E) items[takeIndex];
// 将队头元素从数组中删除
items[takeIndex] = null;
// 队头元素删除后,原队头的下一个元素就成了新的队头,所以takeIndex + 1
// 若takeIndex加1后超过数组的范围,则将takeIndex置为0,也就是循环使用数组空间
// 为什么是加不是减,因为在数组中,队头在左边,队尾在右边
if (++takeIndex == items.length)
takeIndex = 0;
// 元素数量-1
count--;
// 这里是在干嘛我也没仔细研究,好像是和队列的迭代器有关
if (itrs != null)
itrs.elementDequeued();
// 当有元素出队后,队列不满,就可以被阻塞的生产者线程向队列中添加元素
notFull.signal();
// 返回获取到的元素值
return x;
}
take方法和put方法有很多的相似之处,理解了put方法,那take方法也很好理解:获得阻塞队列中队头的元素,若队列为空,则当前线程被阻塞,直到有线程向队列中添加了元素,获取成功后,将队头元素从队列中删除,然后唤醒一个被阻塞的生产者线程(如果有的话)。下面再来看看remove以及poll方法的实现:
public E poll() {
final ReentrantLock lock = this.lock;
// 获取元素前线加锁
lock.lock();
try {
// 若队列为空,直接返回null,否则调用dequeue获取队头元素;
return (count == 0) ? null : dequeue();
} finally {
// 解锁
lock.unlock();
}
}
// 此remove方法继承自父类
public E remove() {
// 调用poll获取并删除队头元素
E x = poll();
// 若获取成功直接返回
if (x != null)
return x;
// 获取失败抛出异常
else
throw new NoSuchElementException();
}
这两个方法实现非常简单,就不做过多解释了。下面我们再看看另一个获取元素的方法,这个方法获取元素时,需要指定超时时间,若队列为空,则当前线程将被阻塞,但是会在指定时间后返回,代码如下:
/**
* 方法参数timeout和unit的意义和之前指定超时时间的offer方法相同
*/
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
// 计算超时时间的纳秒值
long nanos = unit.toNanos(timeout);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 若队列为空,则进行等待
while (count == 0) {
// 若剩余等待时间小于0,则表示超时了,直接返回null
if (nanos <= 0)
return null;
// 线程等待,并返回剩余等待时间
nanos = notEmpty.awaitNanos(nanos);
}
// 若没有等待,或者在等待的过程中被唤醒,则调用dequeue方法获取队头元素
return dequeue();
} finally {
lock.unlock();
}
}
优缺点分析
缺点:
- ArrayBlockingQueue只有一把锁,无论是添加还是获取元素使用的都是同一个锁对象,这也就导致了添加和获取不能同时执行,所以性能低下。但是,实际情况下,添加元素操作的是队尾,而获取元素操作的是队头,它们之间发生线程冲突的概率比较小,所以使用一把锁并不是一种好的实现方式。
- ArrayBlockingQueue基于数组实现,数组并不适用于随机删除元素,因为如果删除数组中间的元素,则这之后的元素都需要向前移动一个位置。而ArrayBlockingQueue支持**remove(Object o)**方法,删除指定元素。当然,这严格来讲并不是一个缺点,毕竟队列就是尾进头出,随机删除元素的操作虽然支持,但是一般不使用。
LinkedBlockingQueue
成员变量
/** 记录阻塞队列允许的最大容量 */
private final int capacity;
/** 使用int的原子类记录队列中元素的个数 */
private final AtomicInteger count = new AtomicInteger();
/** LinkedBlockingQueue基于链表实现,head记录链表头节点 */
transient Node<E> head;
/** LinkedBlockingQueue基于链表实现,last记录链尾头节点 */
private transient Node<E> last;
/** ReentrantLock锁对象,用来保证获取元素时的线程同步 */
private final ReentrantLock takeLock = new ReentrantLock();
/** takeLock上创建的条件对象,在队列为空时,通过此对象来阻塞消费者线程 */
private final Condition notEmpty = takeLock.newCondition();
/** ReentrantLock锁对象,用来保证添加元素时的线程同步 */
private final ReentrantLock putLock = new ReentrantLock();
/** putLock上创建的条件对象,在队列满时,通过此对象来阻塞生产者线程 */
private final Condition notFull = putLock.newCondition();
LinkedBlockingQueue是基于链表实现的,且有capacity变量证明它是一个有界阻塞队列;成员变量中有两个lock对象,分别用来同步生产者线程和消费者线程,减小了锁的粒度,生产者和消费者可以同时运行,这两个锁对象使用默认构造函数创建,也就是说创建的是非公平锁。既然LinkedBlockingQueue是由链表实现,那我们就来看看链表的节点实现:
static class Node<E> {
// 节点值
E item;
// 下一个节点的引用
Node<E> next;
Node(E x) { item = x; }
}
构造方法
/** 带参构造方法,参数为阻塞队列的容量 */
public LinkedBlockingQueue(int capacity) {
// 容量必须大于0
if (capacity <= 0) throw new IllegalArgumentException();
// 记录容量
this.capacity = capacity;
// 初始化链表,创建一个无值的Node,头尾指针均指向它
last = head = new Node<E>(null);
}
// 默认构造方法
public LinkedBlockingQueue() {
// 调用带参构造方法,默认容量为int的最大值
this(Integer.MAX_VALUE);
}
唯一值得注意的是:若使用默认构造方法创建,则阻塞队列的默认容量为int的最大值。
入队
对于一个阻塞队列来说,最核心的就是它入队和出队的实现,下面我们就来分析一下LinkedBlockingQueue类中入队方法的实现。元素入队的方法有多个,我们先来分析其中最核心的方法——put方法:
public void put(E e) throws InterruptedException {
// 新元素不能为空
if (e == null)
throw new NullPointerException();
// 初始化一个c变量,后面用于记录插入新元素前,队列中元素的个数
int c = -1;
// 将新元素封装成一个Node
Node<E> node = new Node<E>(e);
// 因为是添加元素,所以这里使用put锁进行线程同步,先获取put锁
final ReentrantLock putLock = this.putLock;
// 获取记录元素个数的变量
final AtomicInteger count = this.count;
// 在正式操作前,先使用putLock加锁,调用的是lockInterruptibly方法
// 这个方法在在线程被阻塞时可以响应中断,使用它是防止线程一直无法添加成功,
// 长期被阻塞在此处
putLock.lockInterruptibly();
try {
// 添加元素前线判断队列中元素是否已经满了,
// 若满了则使用notFull对象,让当前线程阻塞,直到被另一个线程唤醒
// 使用while而不是if,目的是防止线程被唤醒时,
// 队列仍然是满的,所以需要重复判断
while (count.get() == capacity) {
notFull.await();
}
// 调用enqueue方法将新节点加入队列的尾部
enqueue(node);
// getAndIncrement方法返回count的旧值,然后让count+1
c = count.getAndIncrement();
/**************关键点1****************/
// c + 1就是插入当前这个节点后,队列中元素的数量
// 若插入这个元素后,队列依旧没有满,则唤醒一个生产者线程
// 也就是向队列中添加元素的线程(前提是有这么一个线程)。
// 为什么是在添加一个元素后,唤醒另外一个生产者线程,
// 而不是在有线程获取元素后,唤醒一个生产者线程呢,这不是才正常吗?
// 答案就是消费者线程使用的是take锁,而唤醒生产者线程需要的是put锁,
// 为了减少额外加锁解锁的次数,我们就可以在这里唤醒生产者线程,
// 因为这里在添加元素前,已经获取了put锁了,不需要重复获取。
if (c + 1 < capacity)
notFull.signal();
} finally {
// 解锁
putLock.unlock();
}
/**************关键点2****************/
// c表示插入之前,队列中元素的个数,若插入之前c == 0,
// 表示在插入前,队列是空,意味着很有可能存在正在等待的消费者线程
// 于是调用signalNotEmpty方法唤醒一个消费者线程。
// 可以看到,只有当添加元素之前,队列为空,这里才会唤醒一个消费者线程。
// 但是可能有多个消费者线程在等待,此时唤醒一个,那剩下的那些怎么办?
// 答案就是可以在获取元素的方法中唤醒它们,原理就是上面那段很长的注释
if (c == 0)
signalNotEmpty();
}
/** 此方法将节点加入到链表的末尾 */
private void enqueue(Node<E> node) {
/*
* 以下代码可以分解为:
* last.next = node;
* last = node
*/
last = last.next = node;
}
/** 此方法用于唤醒一个消费者线程 */
private void signalNotEmpty() {
// 因为消费者线程是获取元素,所以使用的是take锁
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
// notEmpty由take锁创建,所以需要先锁定take锁,再调用signal方法
// 否则将抛出异常
notEmpty.signal();
} finally {
takeLock.unlock();
}
}
以上就是put方法的实现,逻辑还是比较简单的,它的过程概括来说就是:将需要添加的元素封装成为一个Node,然后获取put锁,在添加前先判断当前队列是否已经满了,若满了,则会被阻塞等待,直到被其他线程唤醒;队列未满时,将元素添加到链表的末尾,若添加完后,队列依旧没有满,则再唤醒一个生产者线程。添加元素完成后,若判断添加前,队列为空,则很有可能有消费者线程在等待,于是唤醒一个消费者线程。put方法中,我用注释标记出了两个关键点,这两个关键点是作者对锁的一个优化,take方法中也有这两个关键点,它们相互对应,请结合take方法理解。下面我们再来看看另一个添加元素的方法offer:
/** 向队列中添加元素,但是不会阻塞 */
public boolean offer(E e) {
// 判空
if (e == null)
throw new NullPointerException();
// 获取记录元素数量的count变量
final AtomicInteger count = this.count;
// 如果当前队列已经满了,则直接返回false,不进行添加
if (count.get() == capacity)
return false;
// 此变量的作用于put方法中一致
int c = -1;
// 封装成Node
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
// 因为是添加元素,所以使用put锁进行锁定,这里调用的lock()方法
// lock方法不响应中断,这里不需要响应中断,所以选择使用lock,
// 不需要响应中断是因为这个方法并不会阻塞线程
putLock.lock();
try {
// 再次判断当前队列是否满了,为什么再次判断?
// 因为在上一次判断后,CPU可能暂停了当前线程,转而执行其他线程
// 在这个过程中可能有其他线程向队列中添加了元素
if (count.get() < capacity) {
// 调用enqueue方法将元素添加到队列的尾部
enqueue(node);
// 此处操作与put方法中相同,不重复描述
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
}
} finally {
// 解锁
putLock.unlock();
}
// 此处操作与put方法相同
if (c == 0)
signalNotEmpty();
// c记录的是添加前,队列中元素的个数,不可能出现负数,所以此处返回的一定是true
return c >= 0;
}
/**
* 此方法向队列中添加元素,若队列已经满了,则线程被阻塞,但是参数中限制了阻塞时间
* 若超时后,当前线程还没有添加成功,则不继续等待,直接返回
* 参数:
* 1、timeout:超时时间的数量级,一个long类型的整数
* 2、unit:timeout的单位,例如TimeUnit.SECOND表示的就是秒
*/
public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
if (e == null)
throw new NullPointerException();
// 将超时时间转换为纳秒
long nanos = unit.toNanos(timeout);
// 以下几句与put方法相同
int c = -1;
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
// 若当前队列已经满了,则线程需要进入等待
while (count.get() == capacity) {
// 判断等待的剩余时间是否<=0,若满足此条件,表示等待时间已经超时
if (nanos <= 0)
return false;
// 使用notFull对象让当前线程阻塞,传入需要阻塞的时间,但是这个方法并不精确
// 所以会返回剩余需要阻塞的时间,这也就是为什么上一句需要判断nanos <= 0
nanos = notFull.awaitNanos(nanos);
}
// 以下操作和put相同
enqueue(new Node<E>(e));
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
return true;
}
以上就是offer方法的实现,可以看到,offer方法被重载了两次,第一个是直接向队列中添加元素,不会被阻塞,添加成功返回true,失败则返回false;而另外一个offer方法,在无法添加时会被阻塞,但是限定了阻塞的超时时间,若超时还未添加成功,则不会继续等待。
出队
看完了入队的方法实现,下面再来看看出队的方法实现。出队的方法主要有两个,poll方法,以及阻塞队列的核心方法之一——take方法。下面我们就先来看看take方法:
public E take() throws InterruptedException {
E x;
// 此变量的作用与put方法中一致,用来记录插入前,队列中元素的个数
int c = -1;
final AtomicInteger count = this.count;
// 由于是向队列中获取元素,所以使用的是take锁
final ReentrantLock takeLock = this.takeLock;
// 实际操作前先锁定,调用lockInterruptibly锁定,且这个方法响应中断
// 此处需要响应中断,因为这个方法会阻塞线程
takeLock.lockInterruptibly();
try {
// 若队列为空,则当前线程需要等待,直到被其他线程唤醒
while (count.get() == 0) {
notEmpty.await();
}
// 调用dequeue获取队列的队头元素
x = dequeue();
// 获得count的值,然后count + 1
c = count.getAndDecrement();
/*************关键点1***************/
// 若队列中原来的元素数量>1,则表示当前线程拿走一个元素后,队列中还有元素
// 于是此处唤醒其他消费者线程,让他们获取元素
// 此处很关键,对应着put方法中的关键点2(注意是put,而不是此方法take),put方法中,
// 只有添加元素前,队列为空,才会唤醒一个消费者线程,而剩余的消费者线程在此处唤醒
// 因为这里已经拿到了take锁,不需要为了唤醒消费者线程再次获取take锁
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
/*******************关键点2*******************/
// 此处判断,若在获取元素之前,队列是满的,那说明很有可能有生产者线程在等待
// 因为这里拿走了一个元素,所以队列有空位了,于是就唤醒一个生产者线程,添加元素
// 值得注意的是,等待的生产者线程可能不止一个,这里只唤醒了一个,剩下的怎么办,
// 答案就在put方法的关键点2那里,由put方法唤醒了剩下的生产者线程
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;
}
以上方法的逻辑也是比较简单的,相信有了注释理解起来不会太难。上面的take方法中,需要中点关注的就是我用注释标记出的关键点1和关键点2,它们分别对应于put方法中的关键点2和关键点1,这样编写代码的意图就是为了尽量少获取锁,减少频繁获取和释放锁导致的资源消耗,提高性能。除了take方法,还有另外一个元素出队的方法poll,他被重载了两次,下面来看一看:
public E poll() {
// 获取元素数量数量
final AtomicInteger count = this.count;
// 若队列为空,则直接返回null,表示获取失败
if (count.get() == 0)
return null;
// 以下几句代码于take方法相同
E x = null;
int c = -1;
final ReentrantLock takeLock = this.takeLock;
// 调用lock()方法加锁,不响应中断,因为当前方法不会阻塞,所以可以不用响应中断
takeLock.lock();
try {
// 再次判断队列是否为空,因为在上一次判断之后,CPU可能暂停了这个线程,
// 转而执行其他线程,这个过程中可能有线程向队列中添加了元素
if (count.get() > 0) {
// 以下代码均与take方法中相同,不重复解释
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
}
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
/**
* 此方法向队列中获取元素,若队列为空线程将会被阻塞,但是需要指定超时时间,
* 超时后,线程还未获取元素,直接返回;
* 参数timeout和unit的含义与会超时的offer方法相同,
* 分别表示超时时间的数量级,已经时间的单位
*/
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
E x = null;
int c = -1;
// 将超时时间转换为纳秒
long nanos = unit.toNanos(timeout);
// 以下方法与take方法相同,不重复解释
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
// 若队列为空,则线程需要等待
while (count.get() == 0) {
// 判断等待的剩余时间,若剩余时间<=0,表示已经超时,直接返回null
if (nanos <= 0)
return null;
// 让当前线程在notEmpty中的等待nanos纳秒,因为awaitNanos方法不精确,
// 所以这个方法会返回一个值,表示剩余需要等待的时间,所以才有了上一句的if
nanos = notEmpty.awaitNanos(nanos);
}
// 以下代码与take相同,不重复解释
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
优缺点分析
相对于ArrayBlockingQueue的优势和劣势:
- LinkedBlockingQueue内部使用了两把锁进行线程同步,一把锁同步消费者线程,一把锁同步生产者线程,这也就意味着在添加元素时,不会影响获取元素,反之亦然。由于消费者操作的是队头,而生产者操作的是队尾,所以也不会发生线程安全问题,这样大大提高了队列的吞吐量。但是ArrayBlockingQueue内部只使用一把锁,生产者执行时,消费者也无法执行。
- LinkedBlockingQueue基于链表实现,所以如果我们要对队列进行随机删除操作,将会非常高效;但是ArrayBlockingQueue基于数组实现,随机删除操作的消耗会很高,以为需要重整元素在数组中的位置。当然,队列是一个尾进头出的容器,所以在使用时还是不要进行随机删除操作。
再说说它的劣势:
- LinkedBlockingQueue中的lock对象使用的是非公平锁,无法根据需求切换为公平锁;而ArrayBlockingQueue可以根据实际情况,选择是否使用公平锁;
DelayQueue
是一个支持延时获取元素的无界阻塞队列。队列使用 PriorityQueue 来实现。队列中的元素必须实 现 Delayed 接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才 能从队列中提取元素。我们可以将 DelayQueue 运用在以下应用场景:
- 缓存系统的设计:可以用 DelayQueue 保存缓存元素的有效期,使用一个线程循环查询 DelayQueue,一旦能从 DelayQueue 中获取元素时,表示缓存有效期到了。
- 定 时 任 务 调 度 : 使 用 DelayQueue 保 存 当 天 将 会 执 行 的 任 务 和 执 行 时 间 , 一 旦 从 DelayQueue 中获取到任务就开始执行,从比如 TimerQueue 就是使用 DelayQueue 实现的
SynchronousQueue
不是一个真正的队列,而是一种在线程之间移交的机制。要将一个元素放入 SynchronousQueue 中,必须有另一个线程正在等待接受这个元素。如果没有线程等待,并且线程池的当前大小小于最大值,那么线程池将创建一个线程,否则根据拒绝策略,这个任务将被拒绝。使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是被放在队列中,然后由工作线程从队列中提取任务。只有当线程池是无界的或者可以拒绝任务时,该队列才有实际价值。Executors.newCachedThreadPool使用了该队列。
LinkedBlockingQueue比ArrayBlockingQueue在插入删除节点性能方面更优,但是二者在put(), take()任务的时均需要加锁,SynchronousQueue使用无锁算法,根据节点的状态判断执行,而不需要用到锁,其核心是Transfer.transfer().
LinkedTransferQueue
是 一 个 由 链 表 结 构 组 成 的 无 界 阻 塞 TransferQueue 队 列 。 相 对 于 其 他 阻 塞 队 列 , LinkedTransferQueue 多了 tryTransfer 和 transfer 方法。
- transfer 方法: 如果当前有消费者正在等待接收元素(消费者使用 take()方法或带时间限制的 poll()方法时), transfer 方法可以把生产者传入的元素立刻 transfer(传输)给消费者。如 果没有消费者在等待接收元素, transfer 方法会将元素存放在队列的 tail 节点,并等到该元素 被消费者消费了才返回。
- tryTransfer 方法。则是用来试探下生产者传入的元素是否能直接传给消费者。如果没有消费者等待接收元素,则返回 false。和 transfer 方法的区别是 tryTransfer 方法无论消费者是否接收,方法立即返回。而 transfer 方法是必须等到消费者消费了才返回。对于带有时间限制的 tryTransfer(E e, long timeout, TimeUnit unit)方法,则是试图把生产者传 入的元素直接传给消费者,但是如果没有消费者消费该元素则等待指定的时间再返回,如果超时 还没消费元素,则返回 false,如果在超时时间内消费了元素,则返回 true。
LinkedBlockingDeque
是一个由链表结构组成的双向阻塞队列。所谓双向队列指的你可以从队列的两端插入和移出元素。 双端队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。相比其 他的阻塞队列, LinkedBlockingDeque 多了 addFirst, addLast, offerFirst, offerLast, peekFirst, peekLast 等方法,以 First 单词结尾的方法,表示插入,获取(peek)或移除双端队 列的第一个元素。以 Last 单词结尾的方法,表示插入,获取或移除双端队列的最后一个元素。另 外插入方法 add 等同于 addLast,移除方法 remove 等效于 removeFirst。但是 take 方法却等同 于 takeFirst,不知道是不是 Jdk 的 bug,使用时还是用带有 First 和 Last 后缀的方法更清楚。
在初始化 LinkedBlockingDeque 时可以设置容量防止其过渡膨胀。另外双向阻塞队列可以运用在“工作窃取”模式中。
ConcurrentLinkedQueue
ConcurrentLinkedQueue 是一个无边界、线程安全且无阻塞的队列
//可以直接创建
ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue();
//也可以通过现有集合创建
Collection<Integer> listOfNumbers = Arrays.asList(1,2,3,4,5);
ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>(listOfNumbers);
因为是非阻塞队列,所以即使队列为空也不会阻塞线程。虽然它是无界的,但如果没有额外的内存来添加新元素,它依旧会抛出 java.lang.OutOfMemory 错误。 除了非阻塞之外,_ConcurrentLinkedQueue_还有其他特性。 在任何生产者-消费者场景中,消费者都不会满足于生产者;但是,多个生产者将相互竞争:
int element = 1;
ExecutorService executorService = Executors.newFixedThreadPool(2);
ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>();
Runnable offerTask = () -> queue.offer(element);
Callable<Integer> pollTask = () -> {
while (queue.peek() != null) {
return queue.poll().intValue();
}
return null;
};
executorService.submit(offerTask);
Future<Integer> returnedElement = executorService.submit(pollTask);
assertThat(returnedElement.get().intValue(), is(equalTo(element)));
第一个任务 offerTask 向队列中添加元素,第二个任务 pollTask 从队列中检索元素。pollTask 首先检查队列中的元素,因为_ConcurrentLinkedQueue_是非阻塞的,并且可以返回_null_值。
相同点:
- 都 实现 Queue 接口
- 它们都使用 linked nodes 存储节点
- 都适用于并发访问场景
不同点: