引言
继上篇文章讲过了Java中的List之后,接下来我们会关注另外一种集合类型——Queue。
Queue,也就是队列,一种先进先出的数据结构。在Java中,从是否可以从尾部获取元素分成了普通队列以及双端队列。从是否会阻塞区分则分成了阻塞队列和非阻塞队列。这篇文章会从两个方面对队列进行介绍,第一部分主要介绍队列的特点,第二部分会针对Java中一些比较典型的实现进行具体分析。
有一个小细节需要注意的就是,目前队列里面一般是不允许添加null元素的。
队列简介
首先还是看一下队列的类图。
从类图中我们能够看到,一共有4个接口类,Queue
,BlockingQueue
,TransferQueue
以及Deque
。这四个类分别定了四种类型的队列的基本行为。首先我们会针对比较常用的Queue
以及BlockingQueue
来展开叙述。
Queue
Queue
是所有队列都必须实现的接口,在这个接口里面,定义了队列最基本的方法。
public interface Queue<E> extends Collection<E> {
boolean add(E e); //添加元素,元素不可为null,若添加失败,则抛出异常
boolean offer(E e); //添加元素,元素不可为null,添加失败,返回false,不抛出异常
E remove(); //移除队列首部元素,若队列为空,则抛出异常
E poll(); //移除队列首部,若队列为空,则返回null
E element();//获取队列首部元素,若队列为空,则抛出异常
E peek();//获取队列首部元素,若队列为空,则返回null
}
简单来看,队列里的接口主要定义了三个方法,添加元素,获取元素,删除元素,而又针对每个方法都定义了两种版本的(一种操作失败则返回异常,另外一种操作失败则返回null或者是false)。
BlockingQueue
BlockingQueue
则定义了一些会发生等待的场景。如在队列为空的时候进行元素获取或者是在队列已经满了的时候进行元素添加。
public interface BlockingQueue<E> extends Queue<E> {
boolean add(E e); //将元素添加到队列尾端,若添加失败,直接抛出异常
boolean offer(E e);//将元素添加到队列尾端,若添加失败,直接返回false
void put(E e) throws InterruptedException; //将元素添加到队列尾端,若队列已满则将线程挂起等待
boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException; //将元素添加到队列尾端,若队列已满,则等待一段时间,若超时后仍无空间,直接返回false
E take() throws InterruptedException;//获取队列首个元素,若队列为空,则将线程挂起进行等待
E poll(long timeout, TimeUnit unit)
throws InterruptedException; //获取队列首个元素,若队列为空,则等待一段时间,若超时后仍无空间,返回null
int remainingCapacity();//获取队列的剩余空间
boolean remove(Object o);//通过equal判断元素是否存在队列中,若存在,则将其移除
public boolean contains(Object o);//通过equal判断元素是否存在队列中,若存在,返回true
int drainTo(Collection<? super E> c);//将队列中所有元素转移到一个新的集合中,返回值为转移成功的元素数量
int drainTo(Collection<? super E> c, int maxElements);//将队列中所有元素转移到一个新的集合中,最多转移maxElements个,返回值为转移成功的元素数量
}
从方法定义上来看,由于BlockingQueue
要确保在高并发下的线程安全,因此它提供了更多的方法进行获取和删除队列元素。
具体实现
接下来还是针对具体的实现来研究具体的队列。
PriorityQueue
PriorityQueue
是一个优先队列。优先队列的意思就是,从队列里面获取元素的时候,会根据其优先级,从小到大进行获取,而非元素的添加顺序。这要求我们在使用优先队列的时候,要么使用实现了Comparable
的类进行元素添加,要么在初始化队列的时候指定Comparator
。
PriorityQueue
是基于堆排序算法来实现其优先顺序的。
首先我们来看下其内部成员变量
public class PriorityQueue<E> extends AbstractQueue<E>
implements java.io.Serializable {
private static final long serialVersionUID = -7720805057305804111L;//用于序列化
private static final int DEFAULT_INITIAL_CAPACITY = 11; //默认容量
transient Object[] queue; // 使用数组对元素进行存储
private int size = 0; //初始化的队列大小,为0
private final Comparator<? super E> comparator; //自定义的比较器
transient int modCount = 0; //版本号
}
然后再来看下几个关键的方法
offer
方法
public boolean offer(E e) {
if (e == null) //不能添加null元素
throw new NullPointerException();
modCount++;//版本号增加
int i = size;
if (i >= queue.length)
grow(i + 1);//扩容
size = i + 1;
if (i == 0)
queue[0] = e;
else
siftUp(i, e); //堆调整
return true;
}
扩容算法
private void grow(int minCapacity) {
int oldCapacity = queue.length;
//与队列不同,这里的扩容方式是,在小容量的情况下,每次扩容为原来的一倍,否则为原来的1.5倍
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
//同样需要判断是否已经超过了数组的最大容纳量
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
queue = Arrays.copyOf(queue, newCapacity);
}
堆调整算法
//添加时,元素是直接被添加到队列尾端的,也就是堆的底部(队列首端为堆顶),因此会从底部向上调整,直至这个元素找到合适的位置安放
private void siftUpComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
while (k > 0) {
int parent = (k - 1) >>> 1; //获取其父节点
Object e = queue[parent];
if (key.compareTo((E) e) >= 0)//若父节点比当前节点要小,则无需再进行调整
break;
queue[k] = e;//若父节点比当前节点大,则将当前节点的值调整到父节点处
k = parent;//父节点将继续进行调整
}
queue[k] = key; //最后安置元素
}
poll
方法
public E poll() {
if (size == 0)
return null;
int s = --size;
modCount++;
E result = (E) queue[0]; //获取首部元素
E x = (E) queue[s]; //获取队列尾部元素
queue[s] = null; //尾部位置置成空
if (s != 0)
siftDown(0, x); //堆调整,这次相当于将队列尾部的元素放到了堆顶,然后进行向下调整
return result;
}
//相当于从第n个元素开始,从上往下进行调整,直到元素x找到合适的位置
private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>)x;
int half = size >>> 1; // 只需要将非叶子结点遍历就够了
while (k < half) {
int child = (k << 1) + 1; // 首先,获得节点的左子节点
Object c = queue[child];
int right = child + 1; //同时也获取节点的右子节点
if (right < size &&
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
c = queue[child = right]; //若右子节点比左子节点小,则使用右子节点进行比较。这里使用较小值进行比较,是为了确保后续swap之后,在顶部的元素比子节点要小
if (key.compareTo((E) c) <= 0) //如果当前元素已经比子节点都要小了,直接退出循环
break;
queue[k] = c;//否则,进行交换
k = child;
}
queue[k] = key;//最后为确定位置的元素赋值
}
最后看一下其内部迭代器实现。
private final class Itr implements Iterator<E> {
private int cursor = 0; //游标指针,用于表示遍历到第几个
private int lastRet = -1; //上次返回的序号
private ArrayDeque<E> forgetMeNot = null; //若在遍历过程中发生了元素删除操作,堆调整后,元素可能会被放置在队列头部,为了防止漏掉这部分的元素遍历,这里再单独建立一个队列用于存储这些元素
private E lastRetElt = null; //上次返回的元素
private int expectedModCount = modCount;//在迭代过程中,若发生了版本号变更,则会导致ConcurrentModificationException的抛出
//是否有下一个元素,两个判断组成,首先当前游标小于队列长度,其次受到顺序影响的元素数量为0
public boolean hasNext() {
return cursor < size ||
(forgetMeNot != null && !forgetMeNot.isEmpty());
}
@SuppressWarnings("unchecked")
public E next() {
if (expectedModCount != modCount)
throw new ConcurrentModificationException();
if (cursor < size)//首先遍历队列中的元素
return (E) queue[lastRet = cursor++];
if (forgetMeNot != null) { //然后遍历顺序受影响的元素
lastRet = -1;//同时将这个标记为-1,代表元素是从受影响的队列中获取的
lastRetElt = forgetMeNot.poll();
if (lastRetElt != null)
return lastRetElt;
}
throw new NoSuchElementException();
}
//删除元素
public void remove() {
if (expectedModCount != modCount)
throw new ConcurrentModificationException();
//如果lastRet不为-1,代表目前遍历的元素是通过游标获取的,可以直接在相应的位置进行删除。
if (lastRet != -1) {
E moved = PriorityQueue.this.removeAt(lastRet);
lastRet = -1;
if (moved == null)
cursor--;
else {
if (forgetMeNot == null)
forgetMeNot = new ArrayDeque<>();
forgetMeNot.add(moved);
}
//如果lastRet == -1 且lastRetElt 不为null,则说明当前元素是从受影响队列中获取的,此时只能通过removeEq进行移除
} else if (lastRetElt != null) {
PriorityQueue.this.removeEq(lastRetElt);
lastRetElt = null;
} else {
throw new IllegalStateException();
}
expectedModCount = modCount;
}
}
最后了解一下removeAt
函数
private E removeAt(int i) {
modCount++;
int s = --size;
if (s == i) // 如果i和s相等,直接将队列最后一个元素置null即可。
queue[i] = null;
else {
//否则,就需要取出队列最后一个元素,并且将其放置在第i位上
E moved = (E) queue[s];
queue[s] = null;
//首先,向下调整
siftDown(i, moved);
//若向下调整时元素仍保持不变,说明元素已经是此子树上最小值,需要考虑进行向上调整
if (queue[i] == moved) {
siftUp(i, moved);
//如果向上调整,元素位置发生了变化,则将此元素返回(迭代器进行遍历的时候,无法通过游标遍历到了)
if (queue[i] != moved)
return moved;
}
}
return null;
}
列举一个会出现这种情况的例子
如上图所示,如果不存在forgetMeNot
保存被调整到前面的元素,那么在遍历的时候,就不能确保遍历队列的所有元素了。
PriorityBlockingQueue
PriorityBlockingQueue
是一种阻塞队列,它与PriorityQueue
一样是一个基于数组的二叉堆。对于所有的public操作如增删查,它使用一个锁进行控制,而在扩容的时候,会使用一个自旋锁进行控制(扩容时除了自旋锁之外没有别的锁,这样可以确保扩容时不影响take
操作)。又因为优先队列容量是没有限制的(最大为Integer.MAX_VALUE,超出会OOM),所以在put
操作中不需要进行等待。
public class PriorityBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
private static final long serialVersionUID = 5595510919245408276L; //用于序列化的UID
private static final int DEFAULT_INITIAL_CAPACITY = 11;//默认的数组容量
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; //最大数组容纳量,仍然是由于部分VM会将部分空间用作保留字
private transient Object[] queue; //使用数组来进行存放元素,这个数组会表示一个二叉堆
private transient int size; //队列大小
private transient Comparator<? super E> comparator;//比较器
private final ReentrantLock lock; //锁
private final Condition notEmpty; //等待信号,若队列为空,则将take操作的线程挂起等待
private transient volatile int allocationSpinLock; //用于扩容时的自旋锁
private PriorityQueue<E> q; //仍然保留了一个优先队列的对象,用于序列化以及反序列化
}
实际上通过其变量就大概能够知道,这里的操作,实际上只有在take
以及带超时时间的offer
操作中,是需要加锁进行的。具体细节就不在这里展开了。主要还是了解一下它的迭代器。
final class Itr implements Iterator<E> {
final Object[] array; // 保存了所有元素的数组
int cursor; // 游标
int lastRet; // 上次返回元素,实际上用于标记是否进行过删除操作,避免重复删除,以及在未开始遍历时的删除
Itr(Object[] array) {
lastRet = -1;
this.array = array;
}
public boolean hasNext() {
return cursor < array.length;
}
public E next() {
if (cursor >= array.length)
throw new NoSuchElementException();
lastRet = cursor;
return (E)array[cursor++];
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
removeEQ(array[lastRet]);
lastRet = -1;
}
}
从这个迭代器的实现中我们不难发现,这个迭代器并没有涉及到任何加锁的地方。实际上在初始化这个迭代器的时候,首先会对当前队列做一个SNAPSHOT,将所有元素复制一遍,并且将其赋值到迭代器中。这样,迭代器就不需要考虑并发场景下的遍历了,因为对它而言,它已经不需要保证线程安全了。
LinkedBlockingQueue
与前面提到的两个队列不同,LinkedBlockingQueue
是通过链表来实现队列的。
首先还是看一下其内部结构
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
private final int capacity; //可以为其设置一个最大容量,若不设置的话,默认就是Integer.MAX_VALUE
private final AtomicInteger count = new AtomicInteger();//记录当前队列中的元素数量
transient Node<E> head; //头指针
private transient Node<E> last;//尾指针
//读锁,写锁分开。以确保读写之间不会相互影响
private final ReentrantLock takeLock = new ReentrantLock();
private final Condition notEmpty = takeLock.newCondition();//使用take操作时,若当前队列为空,则进行等待
private final ReentrantLock putLock = new ReentrantLock();
private final Condition notFull = putLock.newCondition();//使用put操作时,若当前队列为空,则进行等待。
}
可以看到,由于在这里涉及到了头指针和尾指针,因此使用了两个锁分别对其进行控制,主要的原因还是为了避免在操作其中一个指针的时候,阻塞其它操作。如put操作以及poll操作,只是在操作过程中使用锁进行了条件控制。
这里主要了解一下元素的入对操作enqueue
以及出对操作dequeue
enqueue
private void enqueue(Node<E> node) {
last = last.next = node;//入队操作较为简单,只需要将node放到队列末端即可,此时这种操作的线程安全性由上层方法保证
}
dequeue
方法
private E dequeue() {
Node<E> h = head;
Node<E> first = h.next;
h.next = h; // 为了让其能够被回收,将已经废弃的head引用指向自身
head = first;
E x = first.item;
first.item = null; //为了表明这个元素已经被移除了,将其item置为null
return x;
}
重点还是放在其迭代器上
private class Itr implements Iterator<E> {
private Node<E> current;
private Node<E> lastRet;
private E currentElement;
Itr() {
fullyLock(); //在创建迭代器的时候,需要同时把头指针和尾指针都锁住。因为有可能头尾指针指向同一个元素
try {
current = head.next;
if (current != null)
currentElement = current.item;
} finally {
fullyUnlock();
}
}
public boolean hasNext() {
return current != null;
}
private Node<E> nextNode(Node<E> p) {
for (;;) {
Node<E> s = p.next;
if (s == p)//若s==p,则代表这个元素是被移除了的队列首部元素,因此在这个时候,直接返回最新的head.next即可
return head.next;
if (s == null || s.item != null) //若s为null,则意味着已经到队列的尾端了。若s.item不为null,则说明当前元素未被移除,仍然可以进行返回
return s;
p = s;
}
}
public E next() {
fullyLock();
try {
if (current == null)
throw new NoSuchElementException();
E x = currentElement;
lastRet = current;
current = nextNode(current);
currentElement = (current == null) ? null : current.item;
return x;
} finally {
fullyUnlock();
}
}
public void remove() {
if (lastRet == null)
throw new IllegalStateException();
fullyLock();
try {
Node<E> node = lastRet;
lastRet = null;
//这样做比直接调remove(Object)的好处是,避免了队列中有多个相等元素时的误删
for (Node<E> trail = head, p = trail.next;
p != null;
trail = p, p = p.next) {
if (p == node) {
unlink(p, trail);
break;
}
}
} finally {
fullyUnlock();
}
}
}
从其迭代器的实现上我们能够发现,迭代器并不会严格保证被删除的元素不会再被遍历到(每一次next操作会预先读取下一个元素的值),而且在迭代器中调用remove方法的时候,也是会遍历全部数组的。因此,最好的建议就是,避免既使用concurrent中的队列,又使用其迭代器进行遍历。因为迭代器遍历的,只能保证是某一时刻的的快照,既然是多线程读写,就可能会出现一些我们预料之外的状态。
ConcurrentLinkedQueue
ConcurrentLinkedQueue
是通过乐观锁形式来支持高并发队列的实现类,关于这个类的具体剖析更多的可以看这篇文章。这篇文章从状态机角度切入,分析了应该如何去实现一个高并发的类。
小结
这篇文章主要介绍了集合中的队列。并具体介绍了一个非并发队列——PriorityQueue
,三个支持并发的类,ConcurrentPriorityQueue
,LinkedBlockingQueue
以及使用乐观锁的ConcurrentLinkedQueue
。并且对部分关键操作进行了分析。这些分析主要关注具体实现背后的数据结构,以及相关操作具体的性能。